Essential and Accidental Complexity
Some difficulty is the business rule itself and cannot be deleted, only moved somewhere honest. The rest is your encoding of it — and that part is negotiable.
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.
This feature felt ten times harder than the rule it implements. Which part of that difficulty was the problem, and which part did we build ourselves?
A customer asks: "when a subscription is paused, stop billing it, but hold the seat for thirty days." One sentence. The pull request is forty files.
Some features are just big. Forty files is what a real billing change costs in a real codebase, and asking why is the kind of question people ask before they have shipped one.
It is not one number, it is two. Three of the forty files encode the rule — what "paused" means, when the seat expires, what happens to an in-flight invoice. The other thirty-seven map a field through a DTO, add it to a serializer, add a column, add it to an admin form, add it to two export jobs and thread a flag through four call sites.
- It is not one number, it is two. Three of the forty files encode the rule — what "paused" means, when the seat expires, what happens to an in-flight invoice. The other thirty-seven map a field through a DTO, add it to a serializer, add a column, add it to an admin form, add it to two export jobs and thread a flag through four call sites.
- The next requirement in this area — "pauses can be cancelled early" — pays the thirty-seven again. That ratio is stable across features, which is what makes it a design property rather than an accident of this ticket.
- The three essential files are also the ones that got the least attention in review, because they were buried in a diff that looked like plumbing.
- Teams that notice the forty and not the split reach for the wrong fix: they rewrite the framework layer, keep the rule scattered, and the ratio does not move.
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.
- The framework, the ORM and the message bus are already chosen and the team is productive in them; none of that is being replaced this quarter.
- Billing runs nightly against a schedule table that four other features also read.
- The rule genuinely has cases: annual plans, mid-cycle pauses, and pauses that expire while an invoice is already in flight.
- A paused subscription is never charged, including by a job that started before the pause was recorded.
- The held seat is released exactly once at day thirty, even if the release job runs twice.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- One module owns what a pause *means* — the transitions, the dates, the effect on an invoice. That is the essential part and it should be readable by someone who does not know the framework.
- The transport, persistence and admin layers own carrying data, and are responsible for knowing nothing about when a seat expires.
- Nobody owns "accidental complexity" as a work item, which is why it accumulates. It has to be attached to a feature that is already paying for it.
- The seam that matters is between the rule and its encoding. If you cannot point at a file whose contents a domain expert could check, the essential part has no address (Local Reasoning).
- Framework code belongs on the outside of that seam, so that an upgrade or a replacement is a change to the shell and not to the rule (What a Framework Charges).
- Not every plumbing file is a boundary failure: a serializer that carries one more field is doing its job. The finding is the *count*, not the existence.
Split the forty files
The argument about whether a feature was "too big" goes nowhere because both sides are talking about the same number. Splitting it takes twenty minutes with the file list open, and the split is what tells you which fix is available.
The test for each file is simple and mechanical: if the business rule changed, would this file change? If the framework changed, would this file change? Files that answer yes to the second and no to the first are carrying your rule, not stating it.
- Three files were the feature. Reviewers spent most of their attention on the other thirty-seven, because that is where the diff was.
- The recurring accidental cost is the one worth a project; the one-off awkwardness is not (When Design Does Not Pay).
- Storage, migration and deployment complexity are essential to *running* software, even when they are accidental to the rule. Counting them as waste leads to designs that ignore how change reaches production.
| What the forty files did | Essential or accidental | What removing it would take |
|---|---|---|
| Decide what "paused" means for an in-flight invoice, an annual plan and a mid-cycle date | Essential — the customer would recognise every case | Nothing removes it. It can be gathered into one policy file where six cases read as six cases. |
Add pausedUntil to the entity, the DTO, the API response, the admin form and two exports | Mostly accidental — the field exists five times because five layers each hold their own shape | Fewer serialization boundaries, or generating the shapes from one source. Both are real projects, not a cleanup. |
Thread an includesPaused flag through four query call sites | Accidental, and the recurring kind | Move the decision into the query owner so callers ask for "billable subscriptions" and never see the flag (Cost-Aware Interfaces). |
| Add a migration, backfill, and a nullable column that becomes non-null later | Essential to the fact that data is stored and running, not to the pause rule | Nothing. This is the price of a live system and it is paid per schema change (Expand and Contract). |
| Handle the nightly job racing the pause write | Essential — it is the invariant, and a distributed one | Nothing, but it belongs next to the rule rather than inside the job (Idempotency by Design). |
The accidental complexity you added yourself
It is comfortable to locate accidental complexity in the framework, because that makes it someone else's decision. Most of it in a mature codebase is home-grown: a layer that exists so another layer has something to call, a configuration switch nobody has ever set to false, an event that one handler consumes synchronously.
The pattern is recognisable. Each piece was added for a reason that was true once, none of them is individually unreasonable, and together they mean a one-line rule has to be expressed in six places before it reaches a database.
class PauseSubscriptionCommand { constructor(readonly id: string, readonly until: Date) {} }
class PauseSubscriptionHandler {
handle(cmd: PauseSubscriptionCommand) {
this.bus.publish(new SubscriptionPauseRequested(cmd.id, cmd.until))
}
}
class SubscriptionPauseRequestedListener {
on(e: SubscriptionPauseRequested) { this.service.pause(e.id, e.until) }
}
// three types, one in-process call, and the rule is still in service.pause// billing/pause-policy.ts — no framework types in this file
export function pause(sub: Subscription, until: Date, now: Date): PauseResult {
if (sub.status !== 'active') return refuse('only active subscriptions pause')
if (sub.invoiceInFlight) return pauseAfter(sub.periodEnd) // case 2
return { status: 'paused', until, seatHeldUntil: addDays(now, 30) }
}The first version has three types and a bus hop that buy nothing, because nothing else subscribes and the call is synchronous anyway — and after all that indirection the six real cases are still somewhere else. The second puts the cases where a reviewer and a domain expert can both read them. This is not an argument against command buses; it is an argument against paying for one before a second consumer exists (YAGNI, With Its Bill Attached).
What you can actually do about each kind
The two halves of the split have entirely different remedies, and applying the wrong remedy is how teams spend a quarter and end up with the same files-per-feature number they started with.
The one move that is always available, and is usually skipped, is to make the essential part legible. It does not reduce complexity by a single case, but it changes who can check the cases — and a rule a domain expert can read is a rule that gets corrected before it ships rather than after.
Which half is the cost in, and does it recur?
when The six cases of the pause rule live across a service, a job and a template.
cost Gather them into one owner. Costs a risky refactor of working code and buys a rule that can be reviewed and tested in one place (Extract Module).
when One file, six cases, and it is genuinely difficult.
cost Nothing to do structurally. Spend the effort on naming and tests instead, and resist the abstraction that would hide the cases from the next reader (Naming and Domain Language).
when Every feature threads a field through five shapes.
cost Worth a real project — collapse a boundary, generate the shapes, or delete a layer. It competes with features for time, and should be argued for with the per-feature number, not with taste.
when One awkward adapter you touch twice a year.
cost Leave it. Cleanup here is pure spend, and the risk of changing working code is not zero (When Design Does Not Pay).
when The ORM, the migration tool, the framework's request lifecycle.
cost Not a defect. Write down what it buys so that the next person to propose removing it argues against the benefit rather than against the ceremony (Decision Records).
How to build it
Most important first.
- Split the diff before arguing about it. List every file and mark it "encodes the rule" or "carries the rule somewhere". The two lists are different problems with different fixes.
- Put the essential complexity in one place and make it look complicated. A pause policy with six cases should read as six cases; hiding them behind a clever abstraction does not remove them, it relocates them into the reader's head (What an Abstraction Actually Is).
- Attack accidental complexity that recurs, not accidental complexity that is ugly. Thirty-seven files that repeat per feature are worth a week; one awkward class you pass through twice a year is not (When Design Does Not Pay).
- Prefer removing a step to abstracting a step. A DTO layer that exists to be mapped from another DTO layer is accidental complexity that an abstraction will make cheaper to tolerate and harder to delete (Over-Design and Under-Design).
- Accept that some accidental complexity is bought deliberately: the ORM, the framework, the schema migration tool all charge ceremony in exchange for things you want. Name what you are getting, then it is a trade rather than a tax (What a Framework Charges).
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.
- Today: "pauses can be cancelled early" costs three rule edits plus roughly thirty plumbing edits, and the plumbing edits are where the mistakes happen because they are boring and reviewers skim them.
- After the split: the same requirement costs three rule edits, one focused test file, and whatever plumbing genuinely carries a new field — typically five to eight files rather than thirty, because most of the thirty were threading a flag that the rule module now owns.
- What does not get cheaper: the six genuine cases of the pause rule. Any change to them is a change to six cases. That is the essential part, and no structure reduces it — the best you get is that all six are in one file where a reviewer can see them.
- Pulling the rule out of the framework means new engineers have one more concept to find, and the framework's conventions no longer tell them where things live.
- Domain code that refuses framework types often re-implements small things the framework already had — a validation helper, a date parser — which is real duplication traded for real independence.
- The split takes review effort on a diff that already looks large, and it is easy to sell as "we made the PR bigger to make future PRs smaller", which is exactly the kind of promise this domain treats as a bet (The Cost of Change).
What can go wrong
- "Accidental" becomes a label for whatever the speaker dislikes, and the term stops carrying information (What Technical Debt Actually Is).
- The team deletes accidental complexity that was doing something — the mapping layer was also where a field was redacted — and finds out in production (Characterization Tests).
- The essential rule is extracted into a module and the old inline copies stay, so now the complexity is essential *and* duplicated (Duplicate Knowledge).
- A rewrite is justified as removing accidental complexity, and reproduces most of it because the new stack has its own ceremony that nobody counted yet (Refactor or Rewrite).
- The rule module depends on nothing framework-shaped — no request object, no ORM entity, no clock it did not get handed (Time as a Dependency).
- Everything else depends inward on the rule, which is the only direction that lets the outside be replaced (Dependency Direction).
- The ratio of essential to accidental is itself a function of your dependencies: each framework, each serialization boundary and each service hop adds files per feature forever.
- "Accidental complexity means bad code." It means complexity that comes from the solution rather than the problem, and a great deal of it sits in code that is individually well written. The mapping layer is not badly written; there is just a lot of it.
- "So we should remove the framework." Frameworks trade ceremony for productivity, and the trade is often good. The point is to know what you bought, not to buy nothing (Library or Framework).
- "Essential complexity can be abstracted away." It can only be moved. An abstraction that hides six cases still has six cases inside it, and now the caller cannot see them (Leaky Abstractions).
- "Brooks proved there is no silver bullet, so tooling does not help." His claim was about order-of-magnitude gains against essential complexity, not about the very real gains tools have delivered against the accidental part.
- shotgun-surgery
- duplicate-knowledge
Testing it, and how it ages
- Test the pause rule with no database, no HTTP and a fixed clock. If that is hard, the rule is not actually separated yet, whatever the folder structure says (Testing as Design Feedback).
- One integration test that a paused subscription is not charged by the nightly job, because that is the invariant and it crosses the seam.
- Characterize the mapping layers before deleting any of them, since undocumented behaviour hides in exactly the code everyone calls boilerplate (Characterization Tests).
- Essential complexity grows with the business and never shrinks: each new plan type, tax regime or contract term is a permanent addition. Designs that assume it will simplify age badly.
- Accidental complexity grows with your stack. Adding a service hop, a schema registry or a second serialization format multiplies files-per-feature immediately and silently (Change Amplification).
- The ratio is the number to watch across a year. If a one-sentence requirement still costs forty files after four features, the split never actually happened.
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.
- GENERALThat difficulty splits into the problem and your encoding of it holds in any language and any paradigm, because it is a statement about the gap between a specification and an implementation rather than about a technology.
- CONTESTEDThe strongest objection is that the distinction is not operational: you never observe essential complexity directly, only your best current encoding, so "essential" ends up meaning "complexity I do not currently know how to remove" — and calling it essential stops people trying. Practitioners who have watched a supposedly irreducible rule collapse after a better domain model appeared are right to be suspicious of the label; the defence is that the split is useful as a question, not as a verdict.
- DOMAIN-SPECIFICIn genuinely complex domains — tax, clinical coding, derivatives — the essential share is high and the payoff is making it visible and reviewable. In CRUD-shaped products the essential share is small and almost all the cost is encoding, so the same analysis points at the stack rather than at the domain model (When Domain-Driven Design Does Not Pay).
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Programming Languages & Runtime Internals — a language's expressiveness moves the essential/accidental line directly: what needs a builder, a visitor and a null check in one language is a pattern match in another.