The Senior Engineer Who Spent Three Days Finding Where the Email Gets Sent
The onboarding task was straightforward: add a confirmation field to the user registration email.
The new engineer — not a junior, a senior with eight years of experience — opened the codebase on Monday morning. By Wednesday afternoon, she had traced the execution path through an EmailDispatchCoordinator, a NotificationStrategyFactory, a MessageTemplateResolver, an AbstractBaseTransportHandler, a ConcreteSmtpImplementation, a TemplateContextBuilder, a UserEventPublisher, and four separate configuration files spread across three modules before she found the actual string that became the subject line of the email.
She changed two words. The PR was four lines.
The codebase had been built by a team that was proud of its architecture. Every pattern had a reason. Every abstraction had been introduced to solve a real problem someone had encountered, or anticipated encountering, at some point. The abstractions were not random — they were deliberate, documented, and defended.
They were also making the codebase nearly impossible to work in.
This is the central paradox of over-engineered software: every individual decision that produced the complexity was rational. The complexity itself is irrational. And unlike most engineering problems, it gets worse as more talented engineers work on it — because talented engineers are the ones most likely to add another abstraction layer to solve the current problem elegantly.
Da Vinci's observation was about mastery, not laziness. The genius of simplicity is that it requires more skill to achieve than complexity, not less. This article is about what that actually means in a production codebase — and how to build the judgment to distinguish necessary abstraction from the kind that costs three days to trace through.
What Over-Abstraction Actually Looks Like in Production Code
Over-abstraction is not the same as bad code. It often coexists with clean syntax, comprehensive tests, and excellent documentation. That is precisely what makes it difficult to identify and harder to argue against in code review.
The pattern has three recognisable signatures:
Signature 1: Abstraction without multiplicity
An abstraction that exists in anticipation of multiple implementations — but has only ever had one — is almost always premature. The AbstractBaseEmailSender with a single concrete subclass SmtpEmailSender has solved a problem that never existed. It has also created a problem that does exist: anyone reading the code must now understand both the abstraction and the implementation, plus the interface contract between them, to understand something that could have been a function.
The tell: interfaces, base classes, and factory patterns with exactly one implementation. If there is no second implementation and no credible near-term plan to add one, the abstraction is doing negative work.
Signature 2: Indirection without information
A function that calls another function that calls another function, where each layer adds no new information, no transformation, and no decision — only delegation — is pure indirection. The reader follows the call chain through four hops to arrive at code that could have lived at the first hop.
This is distinct from legitimate layering. A repository pattern that separates business logic from database access is adding information at each layer — the business layer doesn't need to know SQL exists, the repository layer doesn't need to know business rules exist. The layers are independent and each one has a coherent scope.
Indirection without information is the chain of functions that are just pass-throughs, wrappers around wrappers, whose individual existence cannot be justified independently.
Signature 3: Configuration as complexity
Systems that externalise every parameter to configuration files, environment variables, feature flags, and runtime-injectable values — even parameters that have never changed and have no legitimate reason to change — trade code clarity for theoretical flexibility. When a boolean that has been true in every environment since 2019 is a runtime-configurable feature flag, the code that reads it, the tests that mock it, the documentation that explains its valid states, and the debugging process that checks its value in production are all more expensive than the flag's optionality is worth.
The Origin Story: How Codebases Accumulate Abstraction Layers
No codebase starts with seventeen abstraction layers. They accumulate — one reasonable decision at a time, over months or years, each one invisible to the engineers making subsequent decisions because the context that made the prior decision reasonable is no longer visible.
Understanding how this happens is prerequisite to knowing how to prevent it.
Stage 1: The legitimate abstraction
At some point, there is a genuine reason to abstract. The application starts supporting multiple payment providers — Stripe and PayPal. A PaymentGateway interface with two implementations is the right call. The abstraction is earned. It corresponds to real multiplicity that exists now and will expand.
Stage 2: The anticipatory extension
Six months later, someone adds a PaymentGatewayFactory because "we might need to dynamically select the gateway at runtime based on user geography." There's no current requirement for dynamic selection. There might be in the future. The factory is added. It adds complexity to every payment interaction going forward.
Stage 3: The consistency pattern
A new engineer joins the team. She sees the factory pattern for payment processing. She adds a factory for email sending, even though there is one email provider and no planned second. "We should be consistent with the payment architecture," she notes in the PR description. The PR is approved.
Stage 4: The configuration defensive layer
A production incident occurred six months ago where a hardcoded configuration value caused a deployment issue. The team adds a configuration layer to externalise previously hardcoded values — all of them, including ones that have no reason to vary. The configuration layer wraps existing code. Abstractions accumulate on top of abstractions.
Stage 5: The framework migration residue
Two years in, the application migrated from Flask to FastAPI. The migration was partial — some old patterns remained for legacy routes. The new and old patterns are now both present. The codebase has two ways of doing almost everything. Engineers learn the patterns for the areas they work in and avoid the rest.
At each stage, the decision was defensible. The cumulative result is the codebase the senior engineer spent three days navigating.
The Cost of Complexity That Nobody Puts on a Roadmap
Abstraction complexity has costs that don't appear in sprint velocity, bug counts, or system performance metrics. They appear in the places that are hardest to measure: onboarding time, debugging duration, confidence threshold for making changes, and the category of bugs introduced by misunderstanding an abstraction layer.
Onboarding time as a complexity metric
How long does it take a senior engineer with strong fundamentals to become independently productive in a new codebase? In a well-structured codebase with appropriate abstraction depth, this is typically one to two weeks. In a heavily over-abstracted codebase, it is four to eight weeks — and "productive" is a generous characterisation of what happens at week four, because the engineer is still discovering new abstraction layers when they encounter new feature areas.
This cost is real and significant. At a fully-loaded engineering cost of $150,000–$250,000 per year, six extra weeks of onboarding per senior hire represents $17,000–$29,000 in reduced productivity per person. Multiply by team size and annual hiring rate.
The confidence threshold for changes
Engineers who don't fully understand a system — because the abstraction layers have made the system too expensive to understand fully — make one of two choices: they spend disproportionate time understanding the system before making a change, or they make the change with incomplete understanding and rely on tests to catch the consequences.
Both choices are rational. Both are more expensive than a simpler system where the engineer can read the relevant code, understand it completely, make the change, and commit.
The second choice — change with incomplete understanding — is the mechanism by which over-abstracted codebases generate bugs at higher rates than simpler codebases. Not because the engineers are less careful, but because the system is less legible.
The abstraction tax on debugging
A bug in a system with two abstraction layers between the symptom and the cause requires the engineer to traverse two layers to find the root cause. A bug in a system with seven layers requires traversal of up to seven layers. In practice, engineers don't always traverse all layers systematically — they guess, make assumptions, and occasionally fix the wrong layer. The bug recurs. The fix was at the wrong level of abstraction.
The Real-World Scenario: The Startup That Architected Itself to Death
Here is a scenario that plays out regularly across the industry — specifically in well-funded startups where early engineering decisions are made by experienced engineers who have worked at large companies and are applying enterprise architecture patterns to a codebase that doesn't need them yet.
A Series A fintech startup has twelve engineers and a payment processing product. The founding engineer came from a major bank's engineering team. He built the initial codebase with production-grade abstractions: domain-driven design with bounded contexts, a CQRS (Command Query Responsibility Segregation) pattern separating read and write models, an event-sourcing architecture for the transaction ledger, a hexagonal architecture with ports and adapters isolating the domain from infrastructure, and a microservices decomposition that split the application into nine services on day one.
Every pattern was appropriate for a system at scale. Every pattern was premature for a twelve-person team with five hundred customers.
What went right:
The architecture was genuinely well-designed. It would scale. It separated concerns cleanly. The bounded contexts were accurately identified. The team could add engineers without stepping on each other's work.
What went wrong:
- A feature that took three days at a competitor's equally small team took three weeks at this startup, because every feature touched multiple services, required event schema updates, required CQRS read model projection updates, and required ports and adapters updates in at least two bounded contexts
- Three of the nine engineers were working primarily on infrastructure and architecture maintenance rather than user-facing features
- When a critical bug was discovered in production, the CEO called the on-call engineer, who had to ask two colleagues to explain which service owned the relevant business logic before beginning to diagnose
- New engineer onboarding took ten weeks before anyone was independently productive — which meant the startup was burning recruiter fees and salary for ten weeks before seeing returns
The company raised a Series B. They hired a new VP of Engineering. Her first action was to propose collapsing nine services into three, removing the CQRS pattern (the read and write models were identical in six of nine services), and simplifying the event sourcing to a conventional transaction log for everything except the core ledger.
The team estimated it would take six months to implement the simplification. A senior engineer who had been there from the beginning objected: "We'll lose all the architectural benefits we built."
The VP's response was precise: "We'll lose architectural benefits we don't currently need, in exchange for the ability to ship features at a competitive pace. We can re-add complexity when we have the scale that justifies it. Right now, the complexity is costing us more than it's protecting us."
The simplification took seven months. In the twelve months following completion, the team shipped more features than in the prior twenty-four months combined.
The YAGNI Principle and Why Senior Engineers Violate It Most
YAGNI — You Aren't Gonna Need It — is one of the oldest principles in software engineering and one of the most consistently violated by experienced practitioners.
The reason for the violation is counterintuitive: YAGNI is violated more often by senior engineers than junior ones, because senior engineers have seen the consequences of not having an abstraction when it was needed. They've been the one called at 2am because a hardcoded configuration value couldn't be changed without a deployment. They've merged a feature that required extracting an interface from a concrete class mid-implementation. They've lived the future that YAGNI allows.
So they add the abstraction layer early. To avoid past pain. Based on patterns from systems that were at a different scale, with different team sizes, with different constraints.
The correct application of YAGNI is not "never abstract" — it is "don't abstract until the second use case exists." The rule of three is a useful heuristic:
- First use: write the simplest code that works
- Second use: notice the duplication, resist the urge to abstract, the pattern isn't clear yet
- Third use: now the pattern is clear enough to abstract meaningfully
An abstraction built for three confirmed use cases will be shaped correctly for the use cases that exist. An abstraction built for one current use case and one anticipated future use case will often be shaped for the wrong future — the anticipated use case that didn't materialise, not the actual ones that did.
The real cost of premature abstraction:
When the anticipated use case doesn't arrive — which happens more often than senior engineers estimate — the abstraction is stranded. It cannot be removed easily because code has been written to depend on it. It cannot be properly used because it was designed for a use case that doesn't exist. It sits in the codebase, adding overhead to every interaction with it, maintaining its existence through inertia rather than value.
This is the abstraction layer nobody added intentionally. It arrived when the anticipated future didn't materialise, leaving its scaffolding behind.
How to Argue for Simplicity in a Code Review
This is where the principle meets the political reality of software teams. Abstract architecture is easier to defend than simple architecture, because it anticipates problems and appears thorough. Simple architecture requires arguing that anticipated problems are not worth the cost of solving them in advance — which sounds like arguing for technical debt, even when it isn't.
The arguments that work in code review:
Argument 1: The testability inversion
Over-abstracted code requires more complex tests — mocks of interfaces, test implementations of abstract base classes, factory stubs. Simpler code can be tested with simpler tests. The test suite for a simpler codebase is itself simpler, which means tests are more likely to be written, more likely to be read, and more likely to catch real bugs rather than verify that the abstraction plumbing works correctly.
When a PR adds an abstraction that requires three new mock classes to test, that is a quantifiable complexity increase. Point to the test complexity as evidence of the production complexity.
Argument 2: The deletion asymmetry
It is significantly easier to add an abstraction layer when you discover you need one than to remove one that turns out to be premature. Adding an interface takes an afternoon. Removing an interface that ten other classes depend on takes a week — if the team can justify the time at all, which they usually can't.
This asymmetry means the cost of waiting to abstract is lower than the cost of abstracting prematurely. The engineer who argues for the abstraction in the PR review should be the one who estimates the removal cost if the abstraction turns out to be unnecessary.
Argument 3: The new engineer test
For any abstraction being proposed, ask: how long will it take a new senior engineer on the team to understand why this abstraction exists and what problem it solves? If the answer is more than ten minutes without documentation, the abstraction is adding cognitive overhead that will compound over every future engineer who interacts with it.
Ask for an explanation that would fit in a code comment. If the explanation requires more words than fit in a comment, the abstraction is complex enough to deserve the scrutiny.
Argument 4: The current benefit test
Ask for a concrete, current use case that benefits from the proposed abstraction — not a hypothetical future benefit, but a demonstrable present benefit. If the answer is "we'll need it when X happens," ask when X is expected to happen and what the plan is for removing the abstraction if X doesn't happen.
Most anticipatory abstractions are never explicitly removed. They persist through the obsolescence of the use case that never arrived and the team that added them. Making the removal plan explicit at addition time changes the quality of the justification.
The Refactoring That Requires Courage, Not Cleverness
The hardest part of addressing over-abstraction in an existing codebase is not the technical work. The technical work is straightforward: find the abstraction layers that don't earn their keep, collapse them into simpler structures, update the call sites, run the tests.
The hard part is convincing a team — particularly one that built the abstractions and takes professional pride in them — that the simplification is an improvement rather than a regression.
The three moves that make this conversation possible:
Move 1: Measure before you argue
Before proposing simplification, gather data. How many files does an engineer touch to add a typical feature? How long does it take a new team member to make their first meaningful contribution? How many abstraction layers does a specific request traverse from HTTP handler to database? How many mock objects does the average test require?
Data removes the aesthetics from the argument. "Our abstractions are elegant" is a values statement. "The average feature touches fourteen files and requires four interface updates" is a measurement.
Move 2: Propose a pilot simplification
Choose one area of the codebase — ideally one that has caused recent pain — and simplify it. Measure the before and after: files touched per feature, lines of code per change, test complexity, time to understand. Present the comparison to the team.
A concrete before-and-after in a familiar part of the codebase is more persuasive than any architectural argument. Engineers who can see the simplified version and recognise that it does the same work with less code are in a better position to update their prior beliefs than engineers who are asked to accept the argument abstractly.
Move 3: Reframe complexity as technical debt
Technical debt is a concept the engineering community accepts. Over-abstraction is a category of technical debt — specifically, the category that taxes every future development against a speculative flexibility investment that may never pay off.
Framing over-abstraction as technical debt gives it a place in the roadmap conversation that "our architecture is too complex" doesn't have. It can be prioritised, estimated, and addressed in dedicated cycles. It has a name and a cost model. It competes with other technical debt for prioritisation rather than competing with new features — where simplification always loses.
The Simplicity Spectrum: Where Abstraction Earns Its Keep
This is not an argument against abstraction. Abstraction is one of the most powerful tools in software engineering. The repository pattern, the service layer, the domain model, the API contract — these abstractions are earning their keep constantly. They are the reason large teams can work in the same codebase without constant collision.
The question is not "should we use abstractions" but "is this specific abstraction earning its cost?"
An abstraction earns its keep when it satisfies at least one of three conditions:
Condition 1: It hides genuine complexity. The database query layer hides the complexity of SQL from the business logic layer. The HTTP client hides the complexity of the network protocol from the service that makes API calls. The complexity being hidden is real, significant, and something the layer above legitimately doesn't need to know about.
Condition 2: It enables real multiplicity. The payment gateway interface enables the application to work with Stripe, PayPal, and Razorpay through the same code. All three exist. The interface earns its keep daily.
Condition 3: It enforces a meaningful boundary. The domain model layer prevents database concerns from leaking into business logic and prevents HTTP concerns from touching data access. The boundary is maintained actively and protects the codebase from coupling that would be genuinely harmful.
When none of these three conditions is satisfied, the abstraction is a candidate for removal. Not automatically a removal — but a candidate for the question: "What would the code look like without this? Would it be harder to understand, harder to change, or more brittle?" If the answer is no, the abstraction is doing negative work.
The simplicity spectrum is not a spectrum from "no abstraction" to "maximum abstraction" where the correct position is somewhere in the middle. It is a spectrum from "abstractions that earn their keep" to "abstractions that don't." The goal is to have only the former.
Da Vinci's Standard, Applied to Software
When Da Vinci said simplicity is the ultimate sophistication, he was describing the mastery required to reduce a complex subject to its essential form without losing anything important.
That mastery is significantly harder to demonstrate in a codebase than complexity is.
Adding an abstraction layer is easy. Writing code that handles five different future scenarios in a single clean interface is impressive in a PR review. It signals experience, forethought, and architectural sophistication.
Writing code that handles the current requirements with the minimum structure necessary — and only the minimum — requires a different kind of discipline. It requires resisting the patterns you've learned. It requires trusting that you can add complexity when it's needed rather than accumulating it ahead of time. It requires accepting that the code you write today will be read by engineers who deserve to understand it quickly.
The seventeen abstraction layers in the codebase are not evidence that the engineers who built them lacked skill. They are evidence that skill was applied to the wrong objective — building impressive architecture rather than legible software.
The senior engineer who spent three days finding where the email gets sent is not a failure of the documentation, the onboarding, or the team culture. It is a measurement of the cumulative cost of seventeen decisions, each made by a competent engineer, each of which made the system slightly harder to navigate than it needed to be.
Simplicity is the harder standard. It is also, eventually, the more durable one.
Where This Fits in the Larger Engineering Skill Set — and What You Need to Learn Next
Over-abstraction is one failure mode in the broader discipline of software design — and understanding it changes how you think about the decisions that precede and follow it in a practitioner's development.
The questions that naturally follow: How do you conduct a meaningful code review that evaluates architectural decisions rather than just syntax and functionality — and what does a review process look like that consistently catches premature abstraction before it accumulates? How do you approach system design interviews and real architecture decisions with a framework that accounts for both current requirements and realistic future ones — without defaulting to either under-engineering or over-engineering? How do you refactor a genuinely over-abstracted production codebase while the system is running, maintaining stability during a simplification that touches dozens of files across multiple services?
Each of these is a practitioner-level skill that separates engineers who can write good code from engineers who can make good systems — systems that teams can work in efficiently, that new engineers can learn quickly, and that stay maintainable as the product and organisation scale.
At Meritshot, the Full Stack Development programme is built around exactly these system-level engineering decisions — taught by practitioners who've navigated the startup that architected itself to death, rebuilt the codebase that accumulated seventeen abstraction layers, and learned through real production systems what simplicity actually requires as a discipline. Students work through architecture decision exercises, real refactoring challenges, and system design frameworks that ground every structural choice in measurable cost and benefit — not in pattern recognition alone. If this article made you reconsider the last abstraction layer you added or the next one you're planning, that instinct is the beginning of the harder, better kind of engineering judgement. Meritshot is where that judgement gets built into a consistent practice.





