Why Your Codebase Is Harder to Understand Than It Needs to Be
Why Your Codebase Is Harder to Understand Than It Needs to Be
Fred Brooks distinguished between essential complexity and accidental complexity in 1986. Essential complexity comes from the problem itself: financial regulations are complex, real-time systems are complex, distributed computing is complex. Accidental complexity comes from the tools and choices we make.
After auditing 23 codebases over the past five years, I can tell you that the ratio is roughly 30/70. Thirty percent of the complexity in a typical codebase comes from the problem domain. Seventy percent is self-inflicted.
That's not a criticism of the developers who wrote the code. I've written plenty of unnecessarily complex code myself. The point is that most of the difficulty we face when understanding a codebase is fixable. Here are the seven patterns I see most often.
Pattern 1: Premature Abstraction
This is the number one offender. A developer sees a pattern twice and immediately creates an abstraction. Three months later, the abstraction has 14 configuration parameters, handles 7 different use cases, and nobody can remember what it does.
// The abstraction that tried to do everything
class DataProcessor<T, U, V> {
constructor(
private fetcher: DataFetcher<T>,
private transformer: DataTransformer<T, U>,
private validator: DataValidator<U>,
private enricher: DataEnricher<U, V>,
private persister: DataPersister<V>,
private config: ProcessorConfig,
private hooks: ProcessorHooks<T, U, V>,
) {}
// 400 lines of orchestration code
}
// What it replaced: two simple functions
async function importUsers(csv: string): Promise<User[]> { /* 40 lines */ }
async function importProducts(json: string): Promise<Product[]> { /* 35 lines */ }I was wrong about DRY for most of my career. I treated "Don't Repeat Yourself" as a law. It's not. It's a guideline, and it has a counterweight: "Avoid Hasty Abstractions" (AHA). Duplication is cheaper than the wrong abstraction. Two functions with 20 lines of duplicated code are easier to understand and modify than one generic function with 200 lines of configuration.
The fix: Apply the Rule of Three. Don't abstract until you have three concrete use cases. And when you do abstract, make sure the abstraction simplifies the common case, not just the framework author's mental model.
Pattern 2: Inconsistent Patterns
Nothing slows down code comprehension like inconsistency. When one module uses the repository pattern, another uses direct database calls, and a third uses an ORM's active record pattern, every module feels like a different codebase.
I audited a TypeScript monorepo last year that had four different ways to make HTTP requests: fetch, axios, a custom wrapper around fetch, and got. Each was introduced by a different developer who preferred their tool. None was wrong individually. Together, they created a comprehension nightmare because you couldn't transfer patterns from one module to another.
The fix: Document your conventions explicitly. Create a CONVENTIONS.md or equivalent. For each category (HTTP client, error handling, validation, testing), specify the one approved approach. Enforce it in code review. New code uses the convention. Old code gets updated when you touch it for other reasons.
Pattern 3: Invisible Control Flow
Event emitters, pub/sub, middleware chains, decorators, dependency injection containers. These patterns have legitimate uses. They also make control flow invisible.
// Where does this go after calling save()?
// Good luck finding out without searching the entire codebase
@EventHandler("order.created")
class OrderCreatedHandler {
handle(event: OrderCreatedEvent) {
// This triggers InventoryHandler, NotificationHandler,
// AnalyticsHandler, and BillingHandler.
// But you'd never know from reading this file.
}
}When a function call doesn't go where you expect, when the stack trace jumps through three frameworks before hitting your code, when the only way to understand execution order is to add console.log statements and watch the output, you have an invisible control flow problem.
The fix: Use direct function calls as your default. Reserve events and pub/sub for genuinely decoupled concerns (like analytics or audit logging). When you do use events, maintain a registry document that lists every event and its subscribers. And please, put a comment at the emission point listing the known handlers.
Pattern 4: Leaky Abstractions at the Wrong Layer
Every abstraction leaks, but some leak in the wrong direction. A common pattern: the business logic layer knows about HTTP status codes. Or the API layer knows about database column names. Or the frontend knows about backend implementation details.
// Business logic shouldn't know about HTTP
class OrderService {
createOrder(data: OrderInput): OrderResult {
if (!data.items.length) {
throw new HttpException(400, "No items"); // Why HTTP here?
}
// ...
}
}When abstractions leak in the wrong direction, changes ripple unpredictably. Renaming a database column breaks the API. Changing an HTTP status code requires modifying business logic. Each leak is small. Together, they create a system where nothing can be changed in isolation.
The fix: Enforce unidirectional dependencies. UI depends on Business Logic depends on Data Access. Never the reverse. Each layer should only know about its own concepts. Business logic throws domain errors (InsufficientInventory, InvalidOrder). The API layer translates those to HTTP status codes.
Pattern 5: Naming That Requires a Decoder Ring
I've seen a codebase where ctx meant five different things depending on the module. I've seen a function called process that appeared 47 times across the codebase, each doing something completely different. I've seen a variable called data used for everything from user input to database results to API responses.
Bad naming forces every reader to build a mental translation table. Good naming eliminates the need for one.
The fix: Use specific names. Not process, but calculateShippingCost. Not data, but userProfileResponse. Not ctx, but requestContext or authContext or databaseTransaction, depending on what it actually is. Longer names are fine. Autocomplete exists. The cost of typing extra characters is near zero. The cost of misunderstanding a variable's purpose is hours of debugging.
Pattern 6: The God Module
Every codebase has one. The file that everything depends on. The service that does everything. The utils folder that's become a junk drawer of unrelated functions.
I worked on a codebase where UserService had 3,200 lines and 67 methods. It handled authentication, profile management, billing, notification preferences, team membership, API key management, and audit logging. Want to understand how authentication works? You have to read a file that's also about billing.
The fix: Split along responsibility lines, not arbitrary size limits. UserService should become AuthenticationService, UserProfileService, BillingService, NotificationPreferenceService, and so on. Each service should have a single reason to change. If you can't describe what a module does in one sentence without using the word "and," it's doing too much.
Pattern 7: Dead Code and Vestigial Features
Dead code is the codebase equivalent of an appendix. It serves no purpose, but it takes up space and occasionally causes problems. The real cost isn't storage. It's comprehension. Every dead function, unused import, and commented-out block adds noise that readers have to filter through.
I measured this once. A codebase had 18% dead code by line count. Removing it reduced the time for new developers to complete their first code comprehension exercise by 23%. Not because the code was hard. Because there was simply less to read.
The fix: Delete dead code aggressively. Don't comment it out. Don't wrap it in a feature flag "just in case." Delete it. Git has your history. If you need it back, it's one git log away. Run coverage tools, identify unreachable code, and remove it in dedicated cleanup PRs.
The Complexity Audit Checklist
Run this audit on your codebase quarterly. Score each item from 1 (not a problem) to 5 (severe problem). A total score above 20 means you have significant accidental complexity.
| Item | Score (1-5) |
|---|---|
| Abstractions created before 3 use cases exist | |
| Inconsistent patterns across modules | |
| Event-driven code without a subscriber registry | |
| Business logic coupled to infrastructure concerns | |
| Generic names (process, handle, data, ctx) in core modules | |
| Files over 500 lines | |
| Dead code percentage (estimate) | |
| Total |
The Uncomfortable Truth
Here's my contrarian take: most accidental complexity exists because it was faster to write than to prevent. Creating an abstraction feels productive. Documenting a convention feels bureaucratic. Cleaning up dead code feels like busywork. So the complexity accumulates, one reasonable shortcut at a time.
The fix isn't heroic refactoring. It's changing what "done" means. A feature isn't done when it works. It's done when a stranger can read the code and understand it without asking questions. That's a higher bar, and it takes longer. But the time you invest pays dividends every time someone reads that code, which, if the research is right, is about 10 times for every 1 time someone writes it.
Write code for the reader, not the writer. Your codebase will thank you.
$ ls ./related
Explore by topic