Code Quality: Common Mistakes Every Team Makes (and How to Detect Them Automatically)

Code quality is one of those terms that every team claims to care about and few can actually measure. Ask five engineers what "good quality" means and you'll get five different answers — clean formatting, good test coverage, meaningful variable names, low cyclomatic complexity. All true, all incomplete. Quality is the aggregate of structural decisions made (or avoided) over months. The problem is that structural degradation is invisible until it isn't: the codebase that felt fine six months ago now takes three engineers to understand a single change.

This article covers the six anti-patterns that cause most of that degradation, why dependency cycles are a separate category of dangerous, how to score module health, and how to automate all of this so you catch problems early rather than explaining them in a post-mortem.

Why Quality Degrades Silently

Structural problems don't announce themselves. They accumulate through decisions that each looked reasonable at the time: adding one more method to the class that already does too much, importing a module that creates a subtle cycle, duplicating a constant "just this once". Each individual decision has a plausible justification. The compound effect — measured across months — is a codebase where changes are unpredictable, refactoring feels risky, and onboarding takes weeks instead of days.

The silent accumulation problem: No single commit breaks a codebase. Anti-patterns emerge from a pattern of small, locally reasonable decisions that are globally destructive. This is why code reviews alone are insufficient — a reviewer approving a single commit can't see the structural trend that commit participates in.

The Six Most Destructive Anti-Patterns

1. God Class

A God Class is a class (or module, or service) that knows too much and does too much. It accumulates responsibilities over time because it already exists, already has the database connection, already imports the right utilities — so new behavior lands there instead of in a proper home. The result is a class with hundreds of methods, thousands of lines, and intimate knowledge of every other domain in the system.

// God Class signals: // • More than ~300 lines and growing // • Methods that span unrelated domains (payments AND emails AND reports) // • Every other module imports it // • Changing it requires understanding the whole system class OrderService { createOrder() { ... } sendConfirmationEmail() { ... } // deveria ser EmailService generateInvoicePDF() { ... } // deveria ser InvoiceService updateInventory() { ... } // deveria ser InventoryService applyDiscountCode() { ... } // deveria ser PromotionService calculateShipping() { ... } // deveria ser ShippingService notifyWarehouse() { ... } // deveria ser NotificationService // ... 40 more methods }

The fix is decomposition by Single Responsibility: each class should have exactly one reason to change. An OrderService should handle the order lifecycle. Emails, invoices, inventory, and shipping belong in their own services with their own boundaries.

2. Feature Envy

Feature Envy describes a method that is more interested in the data of another class than its own. The giveaway is a method that repeatedly calls getters on an external object to compute something. The method belongs with the data it's consuming — it just ended up in the wrong place.

// Feature Envy — this method belongs in Order, not OrderReportService class OrderReportService { calculateTotal(order: Order): number { const subtotal = order.getItems() .reduce((sum, item) => sum + item.getPrice() * item.getQuantity(), 0); const discount = order.getCoupon()?.getDiscountRate() ?? 0; const tax = order.getAddress().getState().getTaxRate(); return subtotal * (1 - discount) * (1 + tax); } }

Feature Envy is often a symptom of an anemic domain model: all the data lives in POJOs (plain objects with only getters/setters) while all the behavior ends up scattered in service classes. Moving behavior closer to the data it operates on reduces coupling and improves cohesion simultaneously.

3. Shotgun Surgery

Shotgun Surgery is the inverse of God Class: a single logical change requires modifications to many unrelated files. Adding a new user field forces changes in the model, the DTO, the mapper, the validator, the API schema, the frontend form, the email template, and three test fixtures. No single file is too large — but every change is expensive because the concern is scattered everywhere.

// Shotgun Surgery — adding "phone" to User requires changing 7 files // user.model.ts, user.dto.ts, user.mapper.ts, user.validator.ts, // user.schema.ts (OpenAPI), user-form.component.ts, user-email.template.html // ✓ After consolidation: one Zod schema is the canonical source of truth const UserSchema = z.object({ id: z.string().uuid(), name: z.string().min(2), email: z.string().email(), phone: z.string().optional(), // added once — derived everywhere }); type User = z.infer<typeof UserSchema>; // model derived type UserDTO = z.infer<typeof UserSchema>; // DTO is the same // Validation: UserSchema.parse(input) — no separate validator // OpenAPI: generated from schema via @asteasolutions/zod-to-openapi

The root cause is usually missing abstraction: the same concept is represented multiple times in different forms, and there's no single authoritative source that the others derive from. The fix is consolidation — identifying the canonical representation and generating or deriving the others from it.

4. Data Clump

Data Clumps are groups of data items that always appear together but haven't been named as a concept. If three or more fields always travel together — street, city, postalCode; or startDate, endDate, timezone — that's an object waiting to be born. Leaving them as loose parameters means every function that uses them must accept all three, every validation must repeat itself, and refactoring one means touching every call site.

// Data Clump — these three always appear together function scheduleReport(startDate: Date, endDate: Date, timezone: string) { ... } function fetchMetrics(startDate: Date, endDate: Date, timezone: string) { ... } function exportData(startDate: Date, endDate: Date, timezone: string) { ... } // After extracting the concept: class DateRange { constructor(start: Date, end: Date, timezone: string) { ... } } function scheduleReport(range: DateRange) { ... }

5. Dead Code

Dead code is code that is never executed: unreachable branches, unused exports, variables that are assigned but never read, functions that are defined but never called. Beyond the obvious problem of wasted space, dead code creates a maintenance trap — future developers can't tell if it's intentionally unused (a toggle, a migration path) or accidentally orphaned. Every hour spent reading dead code is an hour not spent understanding live code.

The aggressive fix is deletion. If it's truly dead and there's no planned use, remove it — that's what version control is for. The conservative fix is a clear comment marking the intent, but this approach compounds: every "will be used later" comment that ages becomes noise.

6. Primitive Obsession

Primitive Obsession is the habit of representing domain concepts with raw primitives instead of typed objects. User IDs as plain strings, monetary amounts as floating-point numbers, email addresses as unvalidated strings. The code compiles and runs — until someone passes a product ID where a user ID was expected, or adds two monetary amounts in different currencies, or stores an email without validation.

// Primitive Obsession — easy to misuse, no domain protection function chargeUser(userId: string, amount: number, currency: string) { ... } chargeUser(productId, 9.99, "USD"); // compiles fine, wrong at runtime // With value objects — the type system catches the mistake at compile time function chargeUser(userId: UserId, amount: Money) { ... } chargeUser(productId, Money.usd(9.99)); // type error — ProductId ≠ UserId

Value objects — thin wrappers around primitives that enforce invariants — are the standard fix. They make invalid states unrepresentable and push validation to the boundary where data enters the system.

Dependency Cycles: A Separate Category of Dangerous

Circular dependencies deserve their own section because they're qualitatively different from the anti-patterns above. A cycle doesn't degrade performance or readability directly — it destroys your ability to reason about the system independently. When module A depends on B, and B depends on A, you cannot understand, test, or deploy either one without the other. Both must change together, be tested together, and be reasoned about together. The modules are coupled in the most fundamental sense.

Order Service
imports User Service
↑ also imports
imports ↓
User Service
imports Order Service
→
✗ Can't test A without B
✗ Can't deploy A without B
✗ Changes ripple in both directions
✗ No clear layer boundary
Circular dependencies merge separate modules into a single indivisible unit — defeating the purpose of modularity

Cycles are insidious because they're hard to spot in code review. A single import statement looks harmless. The cycle only becomes visible when you draw the dependency graph — which almost no one does manually. Automated detection is the only practical approach at scale.

The standard fix is extraction: identify the subset of behavior that both modules need, extract it into a third module with no dependencies on either, and have both import from there. Or introduce an abstraction (interface, event) that breaks the direct import relationship.

Scoring Module Health: Cohesion, Coupling, and Size

Individual anti-patterns are useful to name. But a single number — a health score — is useful for ranking. When you have 15 modules and limited refactoring budget, you need to know which three to fix first. A 0–100 score per module, broken down by three dimensions, gives you that ranking:

Cohesion
Do the responsibilities in this module belong together? High cohesion = single clear purpose. Low cohesion = God Class signal.
Coupling
How many other modules does this one depend on? High coupling = changes here cascade everywhere. Low coupling = module is independently changeable.
Size
File count and line count relative to the rest of the codebase. Outliers (too large or surprisingly small) are signals worth investigating.
Score = weighted average of cohesion, coupling, and size — sorted worst-first to prioritize refactoring effort

A module scoring 30/100 on coupling with 80/100 on cohesion tells a different story than a module scoring 30/100 on cohesion with 80/100 on coupling. The first is tightly integrated with the system but internally well-organized — the fix is decoupling. The second is loosely connected but internally a mess — the fix is decomposition. Knowing which dimension is failing tells you what kind of refactoring to apply.

Cyclomatic Complexity: The Metric With a Number

Cyclomatic complexity measures the number of linearly independent paths through a function. Each if, else, for, while, case, &&, and || adds one to the count. A function with complexity 1 has a single path; a function with complexity 15 has fifteen. The number tells you directly how many test cases you need to achieve full branch coverage — and indirectly how hard the function is to understand, modify, and debug.

1–4
Simple
Easy to test and understand
5–10
Moderate
Review suggested, test all branches
11–20
High risk
Refactoring strongly recommended
>20
Untestable
Must be split before any change
Cyclomatic complexity thresholds — the industry standard ceiling for a single function is 10
// Cyclomatic complexity = 9 — one path per branch combination function applyDiscount(order: Order, user: User): number { let discount = 0; if (user.isPremium) { // +1 if (order.total > 100) { // +1 discount = 0.20; } else { discount = 0.10; } } else if (user.isLoyalty) { // +1 discount = 0.05; } if (order.hasPromoCode) { // +1 discount += order.promoCode.value; } if (discount > 0.30) { // +1 — cap at 30% discount = 0.30; } return order.total * (1 - discount); } // Refactored: each piece is complexity 1–2, composable and individually testable function getUserDiscount(user: User, orderTotal: number): number { if (user.isPremium) return orderTotal > 100 ? 0.20 : 0.10; if (user.isLoyalty) return 0.05; return 0; } function applyDiscount(order: Order, user: User): number { const discount = Math.min(getUserDiscount(user, order.total) + getPromoDiscount(order), 0.30); return order.total * (1 - discount); }

Enforce it in CI: eslint --rule 'complexity: [error, 10]'. Functions exceeding the limit fail the build, which prevents new complexity from being merged without explicit justification. Start lenient (15–20) if your codebase is currently high, then tighten over sprints.

Technical Debt Has Four Types — and Only Two Are Worth Paying

Martin Fowler's Technical Debt Quadrant is the most useful framework for deciding which debt to tackle. It maps debt along two axes: deliberate vs. inadvertent, and reckless vs. prudent. Teams that treat all debt as equivalent waste effort paying down debt that was intentional and managed, while leaving genuinely destructive debt untouched.

Reckless Prudent
Deliberate
"We don't have time for design" Avoid — this creates debt with no plan to repay
"We must ship now, refactor next sprint" Acceptable — conscious decision, tracked, has a repayment plan
Inadvertent
"What's layering?" Most dangerous — team doesn't know they're creating debt
"Now we know how we should have done it" Natural — discovered through learning; track and address in future sprints
Fowler's Technical Debt Quadrant — only the two left cells represent debt worth actively hunting down and eliminating

The practical implication: when you find debt, ask which quadrant it came from before deciding how urgently to fix it. Reckless debt (both types) needs systematic remediation — it's symptomatic of a team culture problem, not just a code quality problem. Prudent deliberate debt needs a tracked repayment plan. Prudent inadvertent debt is a learning outcome — fix it when the refactoring cost is low.

Static Analysis: The First Line of Defense

Anti-pattern detection tools like archlens-ai operate at the architectural level. But the first line of defense is simpler: static analysis that runs on every file save and every commit. These tools catch the low-hanging fruit — unused variables, unreachable code, overly complex functions, missing null checks — before a human reviewer ever sees the code.

# Recommended static analysis stack (works across most languages) # TypeScript / JavaScript ESLint # style, complexity, dead code, import order TypeScript # strict mode: noImplicitAny, strictNullChecks, noUnusedLocals Knip # unused exports, dead files, unused dependencies # Python Ruff # fast linter + formatter, replaces flake8+isort+pyupgrade mypy # static type checking # Java / Kotlin Checkstyle # style and complexity rules SpotBugs # bug pattern detection SonarQube # cross-language: complexity, coverage, smell density # Pre-commit hook — catches issues before they reach CI npx lint-staged # runs linters only on staged files — fast

The Boy Scout Rule in practice: "Leave the code cleaner than you found it." When a PR touches a file, the author is responsible for leaving it better — not necessarily for a full refactor, but for removing obvious dead code, extracting a clearly misnamed variable, or breaking a function that grew past its complexity limit. Applied consistently, this turns every PR into an incremental quality improvement rather than a neutral or negative event.

Automating Architecture Quality Analysis

All of the above — anti-pattern detection, cycle detection, module health scoring — is tractable to automate. You don't need to audit your architecture manually. Tools exist that can scan your codebase, build the dependency graph, identify the patterns, and score each module in seconds.

One such tool is archlens-ai — a CLI that points at any codebase and generates a self-contained HTML report with an interactive architecture graph, per-module health scores, detected anti-patterns with severity ratings, dependency cycle paths, and concrete refactoring suggestions. It runs on top of Claude Code and supports TypeScript, JavaScript, Python, Go, Rust, and Java/Kotlin.

# Install once npm install -g archlens-ai # Analyze any project — report opens in your browser automatically archlens-ai analyze ./my-project # Deep mode reads key file contents for more accurate analysis archlens-ai analyze ./my-project --depth deep # Security-focused analysis (OWASP Top 10) archlens-ai analyze ./my-project --profile security

The report surfaces exactly the patterns covered in this article: God Classes with responsibility breakdowns, Feature Envy signals, Shotgun Surgery indicators, Data Clumps, Dead Code candidates, and Primitive Obsession instances — each with the module it lives in, its severity, and a concrete suggestion for addressing it.

What a real report looks like: Running archlens-ai on a mid-size TypeScript monorepo (130 files) produced a health score of 44/100 with 12 identified modules, 7 architectural suggestions, and 3 circular dependency paths — all in under 30 seconds, with an interactive graph showing which modules were critical, at-risk, or healthy. The worst-scoring module had cohesion 28/100 and coupling 71/100, immediately pointing to decomposition as the right intervention.

Quality Gates in CI

Detecting anti-patterns is valuable. Blocking merges when quality drops below a threshold is how you prevent them from accumulating in the first place. A CI quality gate turns the health score from a diagnostic into a policy.

# .github/workflows/architecture.yml # Blocks merge if health score drops below 70 - name: Architecture quality gate run: npx archlens-ai analyze . --min-score 70 --no-open --output /tmp/archlens.html - name: Upload report as artifact uses: actions/upload-artifact@v3 with: name: architecture-report path: /tmp/archlens.html

The --min-score flag exits with code 1 when the health score falls below the threshold, making it a standard blocking CI check. The report is uploaded as an artifact so the team can review exactly which modules and anti-patterns triggered the failure — not just that the build failed.

A few practical notes on thresholds: don't set the initial threshold at your current score minus one. Set it at a defensible target (70 is a reasonable baseline for most teams) and accept that you'll need to refactor to reach it. The gate only creates value if it occasionally blocks — a gate that never triggers is not a gate, it's decoration.

Building the Habit: Architectural Review Cadence

A CI gate catches regressions. But catching a problem the day it's introduced is better than catching it in the CI gate. The teams that maintain quality over years tend to have a lightweight review habit: a short architectural review in sprint retrospectives, or a monthly "architecture health" meeting where the score trend is reviewed alongside new anti-pattern findings.

The key insight is that quality is a trend, not a snapshot. A module that scores 60/100 today but has been improving from 40 for three months is in a better position than a module that scores 75/100 but has been declining from 90. The score at any point in time is less informative than the direction it's moving.

Key Takeaways

Back to all articles