BoundariesCONTESTEDDOMAIN-SPECIFICSCALE-SPECIFIC

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.

The question

Where should the boundaries inside a single codebase fall, and does the four-role model actually answer that?

The requirement

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.

The obvious build

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".

Why it breaks

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).

How it breaks as requirements change
  • 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).
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

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.

Constraints
  • 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.
Invariants
  • 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.

Responsibilities
  • 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").
Boundaries
  • 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).
Four roles, and the direction that has to hold
callsconsultsdeclaresimplements — this is the inversionconstructsinjectsTransport — HTTP handler, queue consumer, CLIWiring — the only place that knows bothApplication — the use case, step by stepInfrastructure — DB, payment SDK, mailer, clockDomain — rules, vocabulary, invariantsInterface declared by the domain
UserLLMAgentToolDataDecisionHumanGuardrail

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.

Two changes, priced under the same design
The change

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.

No boundary — the vendor SDK and the ORM are imported wherever they are convenient
CheckoutHandlerRefundHandlerSubscriptionRenewalAdminRefundToolReconciliationJobInvoiceBuilderWebhookReceiver
testscheckout_testrefund_testrenewal_testadmin_testreconciliation_testinvoice_testwebhook_test
7 modules · 7 test files

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.

Four roles, with the payment port declared by the domain
payments/StripeAdapter (replaced)wiring
testspayment_adapter_contract_testcheckout_use_case_test
2 modules · 2 test files

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.

what it cost The four-role design made the rare change cheap by making the common change more expensive, and that is the trade nobody states out loud. It also created two mapping layers whose only job is to move data across a boundary, and those mappings are pure change-amplification for every field the product ever adds. Whether the trade is good depends entirely on the ratio of provider swaps to field additions in your actual history — which you can look up.

Choosing a model, including the option of not having one

CONTESTEDPractitioners disagree sharply about whether the four-role model should ever be a default. The strongest case for it: teams do not reliably invent boundaries under delivery pressure, and a mediocre model applied consistently beats a good model applied by whoever reviewed the PR. The strongest case against: the model optimises for a change most products get once every few years and taxes the change they get weekly, and it is adopted far more often out of professional identity than out of measured need. Both are correct about different failure modes.

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).

Which boundary model fits this codebase?

Ninety thousand lines, six engineers, one deployment. Which cut do we make?

Four technical roles (layered)

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.

Vertical slices by feature

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).

Ports and adapters at one seam only

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)).

One module, no declared boundaries

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.

Cost of the next change
  • 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).
What the recommended approach costs
  • 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

Failure modes
  • 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.
Dependencies, and their direction
  • 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.
Misreads
  • "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).
Smells this explains
  • divergent-change
  • god-object

Testing it, and how it ages

What to test, and at which boundary
  • 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.
How this design ages
  • 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.

Domains that do not exist yet
  • 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.