Over-Design and Under-Design
Two opposite failures with the same cause — structure chosen without reference to expected change. The symptoms are recognisable, and the right amount is a function of how complex the domain is and how often it moves.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
One reviewer says this is over-engineered and the author says the last feature built the simple way took three days to change. How do I decide who is right?
The reporting service needs scheduled delivery: a report, a schedule, a destination. Today the only destination is email; the team has been asked twice about S3 and once about a webhook.
Build it the way the last thing was built. If the codebase is abstract, add another abstraction; if it is direct, add another controller method. Consistency is a design principle.
Consistency propagates whichever mistake is already there. A codebase with an interface per class gets another one; a codebase with everything in controllers gets six hundred more lines.
- Consistency propagates whichever mistake is already there. A codebase with an interface per class gets another one; a codebase with everything in controllers gets six hundred more lines.
- Both directions have the same underlying error — structure chosen by habit, taste or local convention rather than by what is expected to change — so "how much structure" cannot be answered by looking at the neighbouring file.
- The symptoms diverge but the remedy question is identical: name the change you expect, and ask whether the structure makes that one cheap.
- And the two failures are not symmetric in cost. Under-design is loud — everything takes longer, everyone can feel it. Over-design is quiet — the codebase looks professional, the extra cost is spread thinly across every reader, and nobody can point at the file that is wrong (Speculative Generality).
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- Three engineers own this service and also own two others, so anything they build here is read by people who visit it monthly.
- The service is four years old and has both kinds of code in it already: a well-factored query layer and a controller with six hundred lines of delivery logic.
- Reporting is not the product's differentiator, but it is the thing enterprise customers ask about in every renewal.
- A scheduled report is delivered at most once per scheduled run, whatever the destination.
- A delivery failure is visible — no schedule silently stops running.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- Delivery owns getting a rendered report to a destination. That is one responsibility and it currently has exactly one implementation.
- Scheduling owns when. Rendering owns what. Keeping those three apart is the structure that is justified by the requirement as stated, and nothing beyond it is (Designing by Responsibility).
- Whoever proposes additional structure owns naming the change it makes cheap and how likely that change is (The Cost of Change).
- Three seams, because there are three genuinely different reasons to change: a new destination, a new schedule kind, a new report. Any seam that does not correspond to a reason to change is decoration.
- A seam is a named function or module, not necessarily an interface. The cheapest useful boundary is a function with an honest name and no second implementation (Finding Seams).
- Boundaries that pass data through unchanged are the clearest evidence of over-design: a layer whose every method forwards to the next layer is a layer whose only effect is to be read (Package by Layer).
Two failures, side by side
Both of these are recognisable from the outside, and neither is diagnosed by counting files. What identifies them is the relationship between the structure and the changes the team actually makes.
The most useful column is the last one. In an over-designed codebase, adding a field is expensive and adding a variant is cheap; in an under-designed one, adding a field is cheap and everything else is expensive. Ask a team which of those they recognise and you get an answer in one sentence.
- Over-design is quiet and spread thin; under-design is loud and concentrated. Teams therefore fix under-design and tolerate over-design, which is why most mature codebases have both.
- Neither list mentions line counts, file counts or pattern names, because none of those distinguish the two (Long Functions).
| Where to look | Over-design looks like | Under-design looks like |
|---|---|---|
| Interfaces and abstractions | Interfaces with exactly one implementation; a factory that always returns the same class; a strategy with one strategy (Interface Versus Implementation) | Business rules written inline in request handlers, with no name for the rule anywhere (Designing by Responsibility) |
| Extension points | A plugin system with no third-party plugins; an event bus carrying events with one synchronous subscriber in the same process | Every variation handled by a conditional added to the same function, now eleven branches deep |
| Module structure | Layers that pass data through unchanged — DTO to mapper to entity to DTO, with no decision made in between (Package by Layer) | No modules at all: a services folder, a helpers folder, and everything importing everything (The Utility Dumping Ground) |
| State and data | Configuration for values that have never differed between environments | Global mutable state, because passing it would have meant a signature change (Hidden Global State) |
| Where knowledge lives | One rule, correctly owned, wrapped in three layers of indirection to reach it | One rule, stated in five places, with two of them out of date (Duplicate Knowledge) |
| The change that hurts | Adding a field: eight files that each forward it unchanged | Adding anything: every change ripples, and nobody can predict how far (Change Amplification) |
Pricing the same requirement under both
The way to settle the reviewer's argument is not to characterise the design but to price two changes — the one the structure was built for, and one it was not. A structure that makes both expensive is simply wrong; a structure that makes one cheap and one expensive is a bet, and then the question is whether the bet matches what the team is actually asked for.
Scheduled delivery has real evidence on one axis: two requests for S3 and one for a webhook. It has no evidence on the schedule axis and none on the report-format axis, so a design that is flexible about destinations and rigid about everything else is the one the evidence supports.
Requirement A: deliver scheduled reports to S3 as well as by email. Requirement B: add a generatedAt timestamp to every report.
Requirement A costs about six files, and the delivery rule ends up stated twice because two of the three call sites get updated and the third is found later. Requirement B is genuinely cheap here — one query, one template — which is the part that makes under-design comfortable for a surprisingly long time.
Requirement A is two files and reads beautifully. Requirement B costs eight: domain type, persistence entity, mapper, transport DTO, serializer, two adapters and the plugin interface — each one forwarding a timestamp it does not use.
Destination function type, one resolver, no manifest and no bus — makes Requirement A cost two files and Requirement B cost two, and the reason it is not the default is that it requires someone to have judged the destination axis worth a seam and the rest not. That judgement is the work; neither extreme requires making it (Choosing the Model).How much structure this codebase has earned
The input both reviewers were arguing from, without saying so, is a pair of estimates: how complex is this domain going to get, and how often does this code change. Writing those two down turns the argument from a clash of tastes into a disagreement about a claim, which can be checked in six months.
Scored options make the shape of the trade visible. What they cannot do is tell you which row you are in — that is the judgement, and the numbers below are a way of writing down an opinion about it, not a measurement of anything.
| Option | Simplicity | Flexibility | Testability | Operational | Migration cost | Note |
|---|---|---|---|---|---|---|
| Inline in the job (under-designed) | Fastest to write and the easiest thing in the world to read once. The second destination duplicates the delivery rule, and the job cannot be tested without a mailer, a storage client and a database. | |||||
| One destination function type, resolved by name | Three seams matching the three named reasons to change. No interface ceremony, no registry, no manifest — a function type and a map. Becomes an interface in an afternoon if a destination ever needs lifecycle or configuration. | |||||
| Plugin registry, manifest, event bus (over-designed) | Excellent at the one change it was built for. Every field change crosses four layers, the bus makes failures asynchronous and harder to trace, and the manifest format is now a contract with its own consumers (Plugin Architecture). |
caveat These digits compare three sketches against one team's current evidence — two S3 requests and one webhook — and they would move wholesale if that evidence changed. Nothing here was measured, and no single number should be read as a quality score: the middle row scores well because the evidence points at one axis of variation, and if reporting became a platform with external integrators, the bottom row would stop being over-design and start being the product (Extensibility).
How to build it
Most important first.
- Start from the observed variation. Two requests for S3 and one for a webhook is evidence about the destination axis and no evidence at all about the schedule axis — so the destination seam is justified and a pluggable scheduler is not (The Rule of Three).
- Give each expected change a home, and give nothing else one. The count of seams should equal the count of reasons to change you can name out loud.
- Prefer the structure you can add later cheaply. A named function becomes an interface in an afternoon; a plugin registry with a lifecycle and a manifest format does not become a function again (Reversible and Irreversible Decisions).
- For under-designed code, extract along the change that is currently hurting, not along a diagram. The six-hundred-line controller gets split where the last three tickets touched it (Extract Function).
- Write the expected-complexity judgement down, because it is the input that both reviewers were arguing from without stating: how complex is this domain, and how often does it move (Decision Records).
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Under-designed: "deliver to S3 as well as email" touches the six-hundred-line controller, two other controllers that also send reports, the job runner and the test file that covers all of them — roughly six files and a full regression, and the delivery rule ends up stated twice.
- Right-sized: the same change is one new
Destinationimplementation and one line where destinations are resolved — two files, one new test. - Over-designed: the same change is also about two files, which is exactly why over-design is hard to argue against. The cost shows up on the *other* change: "add a
generatedAtfield to every report" touches the DTO, the mapper, the domain type, the transport type, the two adapters and the serializer — eight files that each pass the field along unchanged. - That asymmetry is the whole lesson. A structure makes the change it was designed for cheap and every other change slightly more expensive, so being wrong about which change is coming is expensive in both directions (The Cost of Change).
- Matching structure to observed variation means being late by one case, every time. The first S3 request is served by a change that would have been free if the seam existed already.
- Requiring a named change before adding structure biases towards under-design in domains where the changes are known to be coming but nobody has written them on a roadmap.
- Making the expected-complexity judgement explicit invites a debate about the future that nobody can settle, and some teams will spend more on that debate than on either design.
What can go wrong
- A team recognises over-design, removes the abstractions, and lands in under-design within a year because nothing replaced the judgement that produced either.
- "Over-engineered" is used as a review verdict with no named alternative, which makes it indistinguishable from a preference and unanswerable by the author (Tone, Disagreement and Receiving Review).
- Structure is added to under-designed code without tests, so the extraction changes behaviour and the team concludes that refactoring is dangerous (Refactoring Without Tests).
- The judgement is made once and never revisited, so a design that was right for a simple domain stays in place through three years of the domain getting complicated (Revisit Triggers).
- Over-design multiplies dependencies inward: every layer depends on the one beneath it and on a set of shared types, so a change to a type crosses all of them (Change Amplification).
- Under-design concentrates them: one controller depends on the mailer, the storage client, the template engine and the database, which is why it cannot be tested without all four (Testing as Design Feedback).
- Both directions make dependency direction hard to see, which is why either extreme is a bad place to start a migration from (Dependency Direction).
- "Under-designed means we need more layers." Layers are not the unit of design and adding one to a codebase whose problem is a scattered rule adds a hop without giving the rule an owner. The unit is the responsibility (Package by Layer).
- "Over-designed means too many files." A codebase with many small, well-named modules along real axes of change is not over-designed; a codebase with three interfaces that each have one implementation is, regardless of file count (Over-Decomposition).
- "Just build it simple and refactor when it hurts." Reasonable as a default, and it fails on exactly the changes that are catastrophic to retrofit — stored data shapes, identity, tenancy (YAGNI, With Its Bill Attached).
- "Follow the existing style." Consistency is worth something, but a codebase is allowed to contain both a well-factored module and a bad one, and copying the bad one to be consistent is how it spreads (What Technical Debt Actually Is).
- speculative-generality
- god-object
- utility-dumping-ground
Testing it, and how it ages
- Count the test doubles a single test needs. One or two is a design with contained dependencies; six is under-design telling you the unit does six things (Testing as Design Feedback).
- Look at what breaks when you rename an internal type. If tests across four modules fail, the layers are not hiding anything from each other and the structure is not buying isolation (What a Unit Is).
- For the extraction itself, characterization tests first — this is a change to code that works, made on a bet about the future (Characterization Tests).
- The right amount moves. A domain that grows genuinely complex justifies structure that would have been over-design two years earlier, and the same structure in a domain that stayed simple becomes dead weight.
- Over-design ages worse than under-design in one specific way: the abstractions get consumers, and consumers make removal a migration rather than a deletion (Stability and Dependency Direction).
- Under-design ages worse in the other: the knowledge spreads, and by the time someone attacks it the rule is in nine places and three are subtly different (Duplicate Knowledge).
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- DOMAIN-SPECIFICThe right amount is a function of how complex the business rules genuinely are. In a domain with dozens of interacting rules — insurance, payroll, logistics — a rich model repays itself quickly; in a domain that is mostly forms over data, the same model is the textbook over-design and a transaction script is the correct answer (Transaction Script).
- SCALE-SPECIFICWith three engineers who all read all the code, coordination is cheap and structure buys less; with sixty engineers across eight teams, the structure is what lets them change things without talking, and the same abstraction flips from over-design to necessary infrastructure (Internal Module Contracts).
- CONTESTEDThe strongest opposing view holds that this framing is unfalsifiable in practice: since nobody can predict which changes arrive, "structure matched to expected change" is hindsight dressed as method, and the only robust strategy is to keep everything as direct as possible and pay the refactor when reality shows up. That position has the better track record on greenfield products and the worse one on long-lived systems with regulatory rules, where the changes genuinely are predictable and the refactor window never opens.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — System Design — the same pair of failures appears one level up as premature service extraction against a single process doing everything, with the added twist that the over-designed version is also the one with network partitions in it.