Clean Architecture, and Where It Is Overused
Policy inward, details outward. One approach among several — and the one most often adopted whole, at a ceremony cost nobody prices before committing.
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.
What does "policy inward, details outward" actually buy, and when is the ceremony it charges not repaid?
A new team standard is proposed: every service adopts entities, use cases, interface adapters and frameworks-and-drivers, with a request model, a response model and a mapper at each crossing. The first service to adopt it is a CRUD API over eight tables.
Adopt it everywhere. It is the most complete published answer to structuring a codebase, it is internally consistent, and a uniform standard removes the argument from every code review.
It breaks first on the CRUD service, where the eight tables acquire eight entities, eight request models, eight response models, eight persistence models and thirty-two mapping functions, and not one of them expresses a rule (The Anemic Domain Model).
- It breaks first on the CRUD service, where the eight tables acquire eight entities, eight request models, eight response models, eight persistence models and thirty-two mapping functions, and not one of them expresses a rule (The Anemic Domain Model).
- It breaks again on change cost: adding a field to that service now touches five representations of the same concept, so the most common change in the product has been multiplied by five (Change Amplification).
- It breaks socially. Engineers who see the ceremony produce nothing conclude that structure is theatre, and the next genuinely useful boundary is argued down because it looks like more of the same.
- And it breaks quietly in the service where it *does* fit, because uniformity means nobody had to argue for it there either — so the team never learns which part was load-bearing (Decomposition by Folder).
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 team has eleven services with a wide spread of complexity, from a billing engine with genuine rules to three services that read a table and return it.
- A standard has to be uniform enough to review, which is exactly what makes a bad fit expensive: it applies everywhere or nowhere.
- New hires arrive roughly monthly, and each must be productive without a week of architecture onboarding.
- Business rules are enforced in one place and cannot be bypassed by adding a new entry point (Enforcing Invariants).
- Whatever the layering, the observable API of each service stays compatible while the change lands (Backward Compatibility as a Constraint).
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- Entities own enterprise-wide rules — the ones that would still be true if this application did not exist (Entities).
- Use cases own application-specific sequences: what happens, in what order, for one operation (Domain Services).
- Interface adapters own translation between the use case's vocabulary and whatever is outside — controllers, presenters, gateways (Boundary Adapters).
- Frameworks and drivers own the outermost detail: the web framework, the ORM, the vendor SDK. They are the parts the design assumes are replaceable.
- Nobody owns "the mapper between two representations that are identical", which is why those mappers are where the ceremony accumulates.
- The one boundary that is genuinely load-bearing is between the rules and everything with an external cause to change. That boundary is worth drawing in any style, and it is the part of Clean Architecture that transfers (Stable Boundaries).
- The boundary between entity and use case is much softer. In many systems the "use case" is a single call into an entity, and the extra type is a file with a name and no content.
- The rule about crossing — that data crosses as a simple structure the inner layer defines — is what forces the mapping layers into existence. It is a real protection against ORM types leaking inward, and it is also the single largest cost of the style.
Policy inward, details outward
The core claim is a dependency rule: source dependencies point only inward, toward higher-level policy. Entities know nothing about use cases; use cases know nothing about controllers; controllers know nothing about the framework that instantiated them. Where control must flow outward, an interface is declared in the inner ring and implemented in the outer one.
That rule is genuinely valuable and it is the part worth keeping. What is contested is everything wrapped around it: that there should be four named rings, that data must cross each boundary as a structure the inner side defines, and that this should be applied uniformly regardless of what the service does.
- The rule that transfers: rules do not import infrastructure. It is worth applying in a codebase with no rings at all.
- The ring count does not transfer. Four is a teaching choice, not a finding (Onion Architecture).
- The crossing convention is what generates the mapping layers, and therefore most of the cost.
- Nothing in the rule requires a class per use case; that is convention, and it is where pass-through code comes from.
outermost ┌──────────────────────────────────────────────┐
│ Frameworks & drivers │
│ web server · ORM · vendor SDKs · the DB │
│ ┌────────────────────────────────────────┐ │
│ │ Interface adapters │ │
│ │ controllers · presenters · gateways │ │
│ │ ┌──────────────────────────────────┐ │ │
│ │ │ Use cases │ │ │
│ │ │ "place an order", step by step │ │ │
│ │ │ ┌────────────────────────────┐ │ │ │
│ │ │ │ Entities │ │ │ │
│ │ │ │ rules true with or │ │ │ │
│ │ │ │ without this application │ │ │ │
│ │ │ └────────────────────────────┘ │ │ │
│ │ └──────────────────────────────────┘ │ │
│ └────────────────────────────────────────┘ │
└──────────────────────────────────────────────┘
source dependencies: ──────────────────────────────> inward only
control flow: crosses outward via an interface declared insideThe DTO that exists only to satisfy the diagram
Here is the concrete cost, in the form it actually takes. A single concept — an order line — acquires one representation per ring, plus a mapper per crossing. When those representations differ because their reasons to change differ, that is a boundary doing its job. When they are the same fields with different names, the mappers are pure ceremony and they are where the field-mapping bugs live.
The test is not "does a DTO exist here". It is: when this field changes, do all these representations change together? If the answer is always yes, they are one thing in four costumes, and the crossings are protecting nothing (Duplicate Knowledge).
looks like A function whose entire body is a field-for-field copy between two types with the same field names and the same field types, and which is edited in the same commit as both of its neighbours, every time.
suggests A boundary was declared where there is no difference in reason to change. The crossing was created to satisfy a diagram rather than to protect anything, and it now multiplies the cost of the most common change in the system (Change Amplification).
fix Delete the crossings whose sides always change together, keeping the entity and the outermost contract. Then re-derive the rule: a representation exists because something about it must be free to change independently, and if nobody can name that something, it does not exist (YAGNI, With Its Bill Attached).
1// transport2class CreateOrderRequest { sku!: string; qty!: number }3// interface adapters4class CreateOrderCommand { sku: string; qty: number }5// use case boundary6class OrderLineInput { sku: string; qty: number }7// entity8class OrderLine { constructor(readonly sku: Sku, readonly qty: Quantity) {} }9// persistence10class OrderLineRow { sku!: string; qty!: number }11 12// Four mappers. Adding "purchaseOrderRef" is five edits and13// four mapper changes, none of which a reviewer will read.14 15// Which of these is load-bearing? OrderLine — because Sku and16// Quantity are parsed there, so an invalid line cannot exist.17// And OrderLineRow, because the table's shape must be free to18// change without breaking the API. The middle two are copies.The two that survive are the ones whose sides have genuinely different reasons to change: the entity enforces an invariant the others cannot, and the row shields the API from a schema migration. The middle two move whenever either neighbour moves, which means they are not a boundary (Making Illegal States Unrepresentable).
Two services, one standard, opposite results
The uniform-standard decision is where this goes wrong, so price it on both services at once. The same structure, applied to a billing engine and to a CRUD API, produces a clear win and a clear loss — which is the argument for an exception path rather than for abolition.
In billing: "late fees are waived for customers on an annual plan in their first 90 days." In the CRUD service: "orders now carry a purchase-order reference."
Billing: the waiver rule has to be added in four places that each compute lateness slightly differently, and every test needs a database because the rule lives next to the query. Roughly a week, with real risk of the four disagreeing. CRUD: three edits, one hour, no drama.
Billing: one edit in one entity, a test that runs in a millisecond, and the rule now provably applies to every path. This is the style at its best and it is a large win. CRUD: five representations and four mappers for a field that has no rule attached to it — the same standard, four times the work, and nothing protected.
How to build it
Most important first.
- Take the rule and leave the diagram. "Business rules do not import infrastructure" is the whole benefit; four named rings with mandatory crossings are one way of achieving it and not the cheapest (Dependency Direction).
- Apply it where the rules are. A service with genuine domain complexity earns the inner rings; a service that reads and returns rows does not, and the standard should say so out loud rather than leaving it to individual judgement under review pressure (When Domain-Driven Design Does Not Pay).
- Delete every mapping whose two sides are structurally identical and change together. A mapper between two copies of the same shape does not protect a boundary — both sides move for the same reason, so nothing was decoupled (Duplicate Knowledge).
- Keep the DTO only where the two shapes have different reasons to change: an external API contract that must stay stable while the domain moves is a real one; an internal command object that is the entity with the same fields is not (Versioned Interfaces).
- If you adopt it as a standard, adopt it with an explicit exception path and a written trigger for using it, so the CRUD service does not have to litigate (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.
- Adding a business rule: one edit in the entity, one test, applies everywhere. This is the change the style is genuinely excellent at, and in a rules-heavy system it arrives constantly.
- Adding a field end to end: five representations, four mappers, one migration, and a test for each layer that touches it. In a CRUD-shaped service that is the change you get every week, and the style has made it about four times more expensive than a single-model approach.
- Replacing the web framework or the ORM: contained to the outer ring, genuinely cheap, and something most teams do roughly once per decade. Pricing the weekly change against the decadal one is the entire argument.
- You buy independence from frameworks and excellent testability of the rules, and you pay in types, mappers and onboarding time — every week, whether or not the independence is used.
- Uniformity buys reviewability and costs fit. A standard applied to eleven services of differing complexity is wrong for some of them by construction.
- The indirection is real for readers. Following one request through four rings and two mappers is slower than reading one function, and most engineering time is reading (Local Reasoning).
What can go wrong
- Mapping code becomes the bulk of the diff on every feature, and reviewers stop reading it — which is where field-mapping bugs live, silently, because the compiler is satisfied and nothing is asserted (Shotgun Surgery).
- Use cases turn into one class per endpoint that forwards a call, and the layer that was supposed to hold application logic is a directory of pass-throughs.
- The ORM entity is used as the domain entity "just for now", collapsing two rings into one while the folder structure claims otherwise. The style now costs its full ceremony and delivers none of its protection (Invariant Leaks).
- The mitigation fails on its own terms: a mapping library that generates the mappers removes the typing effort but also removes the review, so a renamed field silently maps to nothing.
- All source dependencies point inward. Where control has to flow outward — a use case needs the database — the interface is declared inside and implemented outside (Dependency Inversion).
- The framework becomes a dependency of the outermost ring only, which is the strongest genuine claim of the style: framework churn stops reaching the rules (What a Framework Charges).
- Against that: the number of types the team maintains grows roughly linearly with fields multiplied by crossings, and that dependency on the team's patience is real and rarely counted.
- "Clean Architecture is best practice." It is one approach with a specific and substantial cost, best suited to systems with real domain rules and long lives. Presenting it as the default is the single most common error in this part of the profession (How SOLID Gets Misused).
- "More layers means better separation." The mapping layers in a CRUD service separate nothing — both sides change together, for the same reason, in the same commit. Separation is measured by whether a change is contained, not by how many files it passes through.
- "The critique means the dependency rule is wrong." It is not. The rule is the good part and it survives the critique intact; what is being questioned is the mandatory ring count and the mandatory crossing ceremony (Dependency Direction).
- "We are doing Clean Architecture because we have those four folders." The folders are free. The rule is the thing, and it is not enforced by naming (Decomposition by Folder).
- "A DTO always protects the boundary." Only when the two shapes have different reasons to change. A DTO that is regenerated whenever the entity changes is a copy with an extra file (Speculative Generality).
- shotgun-surgery
- duplicate-knowledge
Testing it, and how it ages
- Entities and use cases should require no doubles: construct, call, assert. That is the style's clearest payoff and the fastest way to check whether the rings are real (What a Unit Is).
- Test each mapper explicitly, including the round trip, because mappers are where silent field loss happens and where reviewers stop paying attention.
- Add one dependency-direction test that asserts the inner packages import nothing from the outer ones — otherwise the structure is enforced only by the reviewer who cares (Design Review).
- The style ages well in the services that needed it: the inner rings accumulate rules and stay testable, and framework upgrades stop being events.
- It ages badly where it did not fit. Mapping layers are never removed, because removing them is work with no visible feature, so the cost compounds for the life of the service (What Technical Debt Actually Is).
- The usual real-world equilibrium is partial adoption: the dependency rule survives, the mandatory rings do not, and the mapping is kept only at the external contract. That end state is more defensible than either extreme, and teams reach it by attrition rather than by decision.
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.
- CONTESTEDStated at its strongest, the defence is this: teams consistently underestimate how long code lives and how much frameworks churn, and the ceremony is a cheap insurance premium against a rewrite that costs a year. The mapping layers that look like waste in month three are what let a ten-year-old billing system move off a dead ORM in a sprint, and the discipline of a uniform standard is what keeps the rule alive after its advocate leaves. That is a serious argument and it is right about long-lived, rules-heavy systems. The critique here is not that the style is wrong, but that it is applied uniformly to systems whose expected lifetime and rule density do not justify the premium.
- DOMAIN-SPECIFICPays in proportion to domain rule density. In billing, insurance, trading or clinical systems the inner rings hold real content within months. In a CRUD API, a reporting service or an integration shim, the same rings hold data classes and forwarding calls, and the identical structure produces the opposite result.
- LIFETIME-SPECIFICThe framework-independence benefit is realised on a decade timescale. For a service with a known three-year life, or one expected to be replaced when the product pivots, the premium is paid and the payout never arrives — which flips the recommendation rather than merely weakening it.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Testing & Reliability Engineering — the claim that inner-ring code needs no test doubles is checkable, and checking it is the fastest way to find out whether the rings are real or decorative.
- — System Design — the framework-independence argument is a bet on system lifetime, which is the same class of bet that decides whether to build or buy at the system grain.