Architecture Boundaries
Domain, application, infrastructure and transport is a useful model of where the seams fall inside a codebase. It is one model, not universal truth, and saying so is the lesson.
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.
Where should the boundaries inside a single codebase fall, and does the four-role model actually answer that?
A team of six has one service that has grown to ninety thousand lines. Every feature now touches files nobody expected, and someone has proposed "doing proper architecture" before the next big feature starts.
Split it into controllers, services, repositories and entities. That is what architecture means, every framework tutorial does it, and it gives everyone an unambiguous answer to "where does this file go".
The unambiguous answer is the problem. "Where does this file go" is answered instantly and "where does this *change* go" is not — a new business rule lands in a controller, a service and a repository, so the boundary that was supposed to contain change is crossed by every change (Package by Layer).
- The unambiguous answer is the problem. "Where does this file go" is answered instantly and "where does this *change* go" is not — a new business rule lands in a controller, a service and a repository, so the boundary that was supposed to contain change is crossed by every change (Package by Layer).
- After two years the service layer is where everything ended up, because it was the only role with no rule about what it may not contain. The layering is intact on the folder listing and absent in the code (God Object).
- Adding a layer to fix this makes it worse and feels like progress. Four files in a diff instead of three is not better separation; it is more places for the same knowledge to live (Change Amplification).
- And the model is silent on the question the team actually has. It says which technical kind a file is; it says nothing about which *feature* owns it, which is what determines whether tomorrow's change is local (Package by Feature).
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.
- Nothing can stop for a restructure; the roadmap is committed for the quarter and the restructure has to happen alongside it.
- Six engineers, none of whom has worked in a codebase with explicit boundaries, so any model has to be obvious enough that a reviewer can apply it without a meeting.
- The language has no enforced module system beyond folders and imports, so every boundary is a convention until something checks it.
- Wherever the boundary ends up, the business rules it protects must still be enforced on every path — moving a rule across a seam must not move it out of enforcement (Where Invariants Live).
- Observable behaviour does not change. A restructure that alters behaviour is a rewrite wearing a refactor's name (What Refactoring Actually Is).
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- Domain owns the rules and the vocabulary: what a valid order is, what a price is, which transitions a subscription may make. It owns no I/O and knows no framework.
- Application owns the use case — the sequence of steps for "place an order", including which rules to consult and which effects to fire. It orchestrates; it does not decide business questions.
- Infrastructure owns everything that talks to something outside the process: the database, the payment provider, the mail service, the clock (Time as a Dependency).
- Transport owns the shape of a request and a response for one protocol — HTTP, a queue consumer, a CLI. It translates and it validates the wire, and it owns nothing else (Transport Validation lives in Backend; here it is simply "not a business rule").
- The seam that earns its keep is the one between code that decides and code that talks to the world, because those two change for entirely different reasons and at entirely different rates (Divergent Change).
- The seams *between* transport and application, and between application and domain, are much weaker. In a system whose use cases are thin they are often the same file, and splitting them buys nothing (Over-Decomposition).
- A boundary is only real if crossing it is visible. Four folders with unrestricted imports between them are a naming scheme, not a boundary (Internal Module Contracts).
Four roles, and the one rule underneath them
The four-role model says a codebase contains code that decides (domain), code that sequences (application), code that talks to the outside (infrastructure) and code that speaks a protocol (transport). It is a useful vocabulary and it is worth learning. It is also a model — one of several — and the reason to be careful about it is that it is usually taught as a fact about software rather than as a hypothesis about which changes are coming.
Underneath the diagram there is exactly one rule, and it is the part that generalises: decisions must not import details. A pricing rule must not import the Stripe SDK. A subscription lifecycle must not import the ORM session. Everything else — how many circles you draw, what you call them, whether the interface lives in the inner ring or the outer one — is presentation of that single dependency-direction constraint (Dependency Direction).
If you keep the rule and throw away the four names, you lose very little. If you keep the four names and lose the rule, you have four folders and a codebase that imports the payment SDK from a controller.
- Domain — no I/O, no framework types, no vendor types. If it cannot be tested by calling a function, it is not domain code.
- Application — one function per use case. It may sequence and it may not decide business questions (Domain Services).
- Infrastructure — implements interfaces it did not define. Every arrow points inward from here (Dependency Inversion).
- Transport — owns status codes, serialisation and wire validation. It owns no rule that a second protocol would also need.
- Wiring — the composition root is the one place allowed to know every concrete type, and keeping it to one place is what makes the rest testable (Wiring and the Composition Root).
Price two changes, not one — that is where the model shows its shape
Any structure looks good if you choose the change that flatters it. The four-role model is usually sold with "swap the database", which it handles beautifully and which almost nobody does. Price a second change alongside it and the actual trade appears.
The change that flatters it is replacing an external dependency. The change that exposes it is adding one field end to end — which, in most products, is the change that arrives every week.
Change one: replace the payment provider. Change two: add a "purchase order number" that the customer types, the system validates, stores, and shows on the invoice.
Provider swap: seven call sites, found by grep, each with slightly different error handling that has to be re-derived. Field addition: three edits, all in the same folder, done in an hour. The design is terrible at the rare change and fine at the common one.
Provider swap: one new adapter satisfying an existing interface, one wiring line, and the compiler enumerates anything missed. Field addition: transport DTO, application command, domain type, infrastructure mapper, migration — five edits in four folders, plus two mapping functions that exist only to carry the field across a seam.
Choosing a model, including the option of not having one
The four roles are not the only cut, and for many codebases they are not the best one. The genuinely useful skill is picking a boundary model from what the system is, rather than adopting one because it is what architecture is understood to mean.
Two questions do almost all of the work. How much real business rule is there — enough that a domain layer has something to hold? And how much does the outside vary — enough that a port has something to abstract? If both answers are "not much", the honest recommendation is one module, good names, and the discipline to revisit (Revisit Triggers).
Ninety thousand lines, six engineers, one deployment. Which cut do we make?
when The domain has rules worth naming and at least one external dependency you expect to replace. Also when the team is new to boundaries and needs an unambiguous rule they can apply in review.
cost Every feature change crosses every folder, and you acquire mapping code proportional to the number of fields in the system. The application layer will absorb everything unless something prevents it.
when Most changes are feature-shaped — a field, a rule, a screen — and features rarely share logic. The common case for product codebases.
cost Cross-cutting concerns and shared rules have no obvious home, so duplication appears between slices and you have to decide each time whether it is the same knowledge (Duplicate Knowledge).
when Exactly one dependency is volatile — a payment provider, a search engine — and the rest of the system is unremarkable. Buy the boundary where the variation is.
cost Looks inconsistent to a reader expecting a uniform style, and someone will eventually propose "finishing" it everywhere (Hexagonal Architecture (Ports and Adapters)).
when Small, short-lived, or genuinely mostly I/O. A prototype, a pipeline, an internal tool with three users.
cost You are betting the code stays small or dies young. When that bet loses, the boundary has to be retrofitted into working code with real usage, which is the expensive path (Finding Seams).
How to build it
Most important first.
- Start from the one rule that all of these styles are drawings of: decisions must not import details. Business rules do not import the database driver, the HTTP framework or the vendor SDK. Everything else in the four-role model is presentation (Dependency Direction).
- Draw the boundary where the rates of change differ. A payment provider changes every few years; a pricing rule changes every few weeks; an HTTP framework changes once a decade. Those are three different reasons to change and therefore three candidate seams (Designing by Responsibility).
- Make the direction enforceable, not aspirational — an import lint rule, a package boundary, a separate build unit. An unenforced direction reverts within a quarter (Stable Dependencies).
- Cut the roles you do not need. A CRUD service with no interesting rules has no domain layer worth having; giving it one produces an anemic set of classes that only forward calls (The Anemic Domain Model).
- Slice by feature first and by role second, so that the primary folder answers "which change lands here" and the secondary one answers "what kind of code is this" (Vertical Slices).
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 no boundary: replacing the payment provider costs an audit of every file that imports the vendor SDK, and the cost is proportional to how many that is — typically dozens, discovered one grep at a time. The rule-change cost is the same shape: find every site, decide whether each is the same rule.
- Under the four-role boundary: replacing the payment provider costs one new class in infrastructure and one wiring change, and the compiler lists what is left. Adding a business rule costs one file in domain plus one test.
- What did not get cheaper: adding a *field* that the user types, the domain validates, the database stores and the API returns still costs four edits in four folders. That is the change the four-role model is worst at, and it is also the most common change in most products. This is the honest reason vertical slices exist (Vertical Slices).
- Every boundary buys change locality along one axis and charges indirection along every other. The four-role model buys locality for "swap a detail" and charges it for "add a field", and most products do far more of the second.
- It costs vocabulary. Six engineers have to agree what "application" means, and the argument recurs at every hire.
- Enforcement costs tooling and friction. Unenforced, the model costs almost nothing and delivers almost nothing.
What can go wrong
- The layering is enforced only by review, so it survives exactly as long as the reviewer who cares about it (Bus Factor).
- The domain becomes a bag of data classes and every rule lives in the application layer, which is the four-role model producing precisely the structure it was meant to prevent (The Anemic Domain Model).
- The seam is drawn but nothing is moved: an interface is introduced with one implementation and the same code on both sides, so the indirection is paid and no variation is bought (Speculative Generality).
- The mitigation itself fails: an import lint rule that everyone bypasses with a per-file suppression is worse than no rule, because now the violations are documented and permanent.
- The intended direction is inward: transport depends on application, application depends on domain, and infrastructure depends on domain by implementing interfaces the domain declares (Dependency Inversion).
- The domain depends on the language and on nothing else — no ORM annotations, no framework base classes, no vendor types (Volatile Dependencies).
- In practice the ORM leaks upward in almost every real codebase, because the entity the domain wants and the row the mapper wants are close enough that keeping them separate feels like waste. Naming that as a deliberate trade rather than a violation is more honest than pretending it does not happen.
- "So every codebase should have these four layers." No. This is one model, and it is a good one for systems with real business rules and volatile external dependencies. It is a poor one for a data pipeline, a thin CRUD API or a prototype (When Design Does Not Pay).
- "More layers means better separation." A layer that every change passes through has separated nothing and added a hop. Separation is measured by whether a change is contained, not by how many files it visits (Separation of Concerns).
- "The domain layer is the important one, so put everything in it." A domain layer that knows about HTTP status codes and database columns is the same god object with a better folder name (God Object).
- "We have the folders, so we have the boundary." Folders are a naming scheme. A boundary exists when crossing it is prevented or at least visible (Decomposition by Folder).
- divergent-change
- god-object
Testing it, and how it ages
- Domain code should be testable with no test doubles at all — construct, call, assert. If it needs a mock, the boundary is in the wrong place and the test is telling you so (Testing as Design Feedback).
- Application use cases are tested against in-memory implementations of the interfaces they declare, which is a design test as much as a behaviour test: if the interface is awkward to fake, it is awkward to change (Test Doubles, Precisely).
- Infrastructure is tested against the real thing — a real database, a provider sandbox — because a mocked adapter tests your understanding of the vendor rather than the vendor (Where a Test Must Be Real).
- Add one test on the dependency direction itself: an assertion that nothing in the domain package imports infrastructure. It is the cheapest architecture test there is and it catches the drift that review misses.
- The first year the four roles hold because someone is watching. The second year the application layer swells, because it is the role with no prohibition, and that is the signal to slice by feature rather than to add a fifth role.
- When a feature's domain rules grow past what one person can hold, the role split stops being the useful axis and the module split starts — which is the transition into a modular monolith (The Modular Monolith).
- The model stops applying when the system is genuinely mostly I/O. A service that reads a queue, transforms and writes has no domain layer to protect, and forcing one on it is the most common form of architecture theatre in this domain.
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.
- CONTESTEDThe strongest opposing case: boundaries between technical roles are the wrong cut entirely, and a codebase organised into vertical feature slices — each holding its own handler, rules and queries — has better change locality for the changes teams actually get, with no cross-folder ceremony. That position is right about the common case and understates what happens when one external dependency has thirty call sites and has to be replaced. Both cuts are defensible; the four-role model is not the default.
- DOMAIN-SPECIFICWorth its cost in proportion to how much genuine business rule the system contains. In insurance, payroll or billing the domain layer earns its keep within months; in a reporting API whose logic is a SQL query, the same structure produces classes that only forward, and the honest design there is a transaction script (Transaction Script).
- SCALE-SPECIFICAt one team the boundary is a convention that a single reviewer can hold in their head, so enforcement tooling is overhead. At six teams the convention has already decayed and only a build-level check survives; advice about boundaries written at large organisations is mostly about coordination, not about code.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Testing & Reliability Engineering — the fastest honest check on a boundary is what it takes to test the code inside it; if a unit test needs a database, the seam is not where the folder says it is.
- — System Design — at the system grain the same question becomes where the service boundaries fall, where the trade is deployment independence rather than import direction.