Transport, Application, Domain, Infrastructure
Four layers, one rule that matters — dependencies point inward — and a scale at which the whole thing is overhead.
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 has a problem.
What are the layers of a backend service, and which rule about them is load-bearing?
The codebase is nine months old and four engineers deep. "Where does this go?" comes up in every review, and the answers disagree.
Make folders called controllers, services, models and utils, and put things where they seem to fit. Everyone knows this layout, so it needs no explanation.
utils becomes the layer. It ends up containing date formatting, the Stripe client, an email template and the tax rule, and it is imported by everything, so nothing can be moved.
utilsbecomes the layer. It ends up containing date formatting, the Stripe client, an email template and the tax rule, and it is imported by everything, so nothing can be moved.- A
modelimports the HTTP status-code enum because a validation error needed a 422. Business logic now cannot run outside a web request. - The
servicesfolder holds bothOrderService(business logic) andS3Service(an SDK wrapper). They are different kinds of thing with the same suffix, so the dependency graph is a mesh. - A change to the JSON response shape breaks a domain test, because the domain object *is* the response body (Schema Leakage).
- Nobody can run the business rules in a script, because importing one of them pulls in the web framework, which reads config at import time and exits.
What is actually happening
- Transport adapts a protocol to a call: parse, authenticate, map to a command, map the result to a status code and a body. HTTP, gRPC, a queue consumer and a CLI are four transports over the same application (What a Handler Is Responsible For).
- Application orchestrates a use case: order of steps, transaction boundary, which failure means what. It knows the domain and the infrastructure *interfaces* (The Service Layer).
- Domain holds the rules and the types: what a valid
Moneyis, which order state transitions are legal, what an invariant is. It ideally imports nothing from the other three. - Infrastructure is everything that talks to the outside: database, cache, queue, SDKs, the filesystem, the clock. Implementations, not policy (Calling Something You Do Not Control).
- The only rule that carries weight is dependency direction: transport may depend on application, application on domain, and infrastructure implements interfaces the inner layers declare. Nothing inner imports anything outer. Every other rule about layers is a convention.
- The direction is enforceable mechanically — an import lint rule, a module boundary check, separate packages — which is what makes it a rule rather than an aspiration.
One rule, drawn
The diagram is worth more than the vocabulary. Transport and infrastructure are both *outside* — they are adapters to things you do not control, on two different sides. Application and domain are inside, and they are the part that could, in principle, be run by a test with no network at all.
The arrows that are missing are the content of the design: no arrow from domain to infrastructure, none from application to transport. When someone asks "can I import this from here", the diagram answers it without a discussion.
The layer test, applied to real files
Vocabulary is easy to agree on and hard to apply. The useful form is a table you can check a file against: what may it know, what must it not know, and what does it look like when the boundary has already been crossed.
The third column is the one to memorise. Layer violations are not announced; they arrive as an import that seemed harmless because the thing being imported was small.
| Layer | May depend on | Must not know about | The leak, when it happens |
|---|---|---|---|
| Transport | Application, domain types | Which database, which SDK | A handler builds a SQL string or calls Stripe directly (Fat Controllers) |
| Application | Domain, declared interfaces | HTTP, status codes, sessions, the ORM's concrete client | A service returns res.status(422) or imports express |
| Domain | Itself, and the standard library | Everything else, including the clock | new Date() inside a rule, so the test is flaky at midnight |
| Infrastructure | Interfaces it implements, SDKs | Use cases, business rules | A repository decides whether to send the email |
| Config | Nothing | Everything | A domain module reads process.env at import time (Configuration: Separating Code From Environment) |
utils | — | — | It exists. Split it into domain, infrastructure, or a library |
When four layers are three too many
This is the part usually left out. A two-person team shipping a first product does not benefit from a domain package with no rules in it, a repository interface with one implementation, and a mapping function per representation. The coordination that layering buys has no one to coordinate.
The version that survives at every size is much smaller: keep transport separate from logic, and never let logic import transport. That is one rule and one boundary, it costs nothing, and it is the boundary you will need first when a queue consumer or a CLI shows up.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| Four layers, two engineers, month three | A one-field change is a five-file diff | Mapping between representations that are all identical | Collapse to transport + logic; keep the direction rule (Alternatives to Layering) |
| No layers, twelve engineers, year two | Every change causes a conflict; nobody can predict blast radius | The import graph is a mesh, so everything depends on everything | Introduce the direction rule and enforce it in CI before adding folders |
| Layers by convention only, no tooling | Direction erodes within two quarters | Review catches the obvious violations and misses the small ones | An import boundary lint rule; make the wrong direction fail the build |
| Domain package importing the ORM entity | A migration breaks business-rule tests | The schema became the domain model | Map explicitly at the infrastructure boundary, or drop the pretence of a domain layer (Three Models, Not One) |
A "temporary" utils module | It is imported by all four layers and cannot be moved | It has no owner and no defined direction | Delete the name; every item in it belongs to exactly one layer |
How to build it
Most important first.
- Enforce direction with tooling, not review. One lint rule that fails a build beats twenty comments that do not.
- Name folders after the layer's job and forbid
utilsas a destination. Something inutilseither belongs to the domain, is infrastructure, or is a library. - Put the interface of an infrastructure dependency where it is used and the implementation where it is built. That is what makes the direction hold with a real database on the other side (Dependency Management Without the Container).
- Keep the clock, randomness and id generation behind an interface. They are infrastructure, and they are the ones that make domain tests flaky.
- Let the domain layer be thin or absent when the domain is thin. A reporting service whose "domain" is three enums does not need a domain package; say so rather than creating an empty one.
- Prefer fewer, real layers over four ceremonial ones. Two layers you enforce beat four you do not.
What can go wrong
- Anemic layering: every layer forwards to the next with no behaviour, so a one-field change is a four-file change and none of the four does anything (When the Repository Is Just Indirection).
- A shared
typespackage imported by every layer, which becomes the coupling point that layering was meant to prevent. - Circular imports between application and infrastructure, resolved with a lazy
requireinside a function — the cycle is still there, now invisible. - The domain imports the ORM entity because "it is already the right shape". The schema is now the domain model and a migration is a domain change (Schema Migrations from the Application Side).
- Layer-crossing exceptions: an infrastructure error type caught and rethrown at every level, arriving at transport with the original cause lost (Error Boundaries: Three Translations, Not One).
- Layering does nothing about concurrency, and can hide it: a read in the application layer and a write in the domain layer are still a check-then-act gap across two files (Backend Races).
- A transaction opened in the application layer and used by infrastructure calls must be threaded explicitly. If it is carried in ambient context, a call that escapes the context runs outside the transaction and nothing in the layer structure will say so (Where the Transaction Boundary Goes).
- Authentication belongs in transport; authorization belongs where the object is known, which is usually application or domain. Putting both in transport is the standard way to get an object-level vulnerability (Authentication vs Authorization, Object-Level Authorization).
- Layering makes the audit question answerable: "which code can issue an outbound HTTP call?" should have one directory as its answer, which is what makes SSRF review tractable (SSRF — When the Backend Fetches a URL).
- Secrets belong to infrastructure construction, not to business code. A domain function reading an API key from the environment is both untestable and a leak path into logs (Secrets in Logs).
- "Four layers is the right number." The number is not the point; the direction is. Two layers with an enforced direction beat four with imports going both ways.
- "Layered means slow." The function calls are free. What can be slow is mapping large payloads between representations, and that is measurable rather than assumed.
- "Layering is the same as microservices." Layers are a compile-time structure inside one process. Turning a layer boundary into a network boundary imports an entirely different set of problems (Microservices).
- "The domain layer must be pure." A useful goal, not a law. Pulling a domain rule that needs a database lookup into the application layer is a normal, correct outcome.
- "If we layer properly we can replace the database." You can replace an *implementation* of an interface. Nothing about layering makes two engines behave alike (When the Repository Is Just Indirection).
Operating it
- Instrument at layer boundaries: one span for the handler, one for the use case, one per infrastructure call. A trace then reads as the layer diagram, and an unexpected call from an unexpected layer is visible (Tracing From the Backend's Side).
- Import-graph checks in CI. The metric that matters is number of edges pointing the wrong way, and it should be zero and stay zero.
- When latency moves, the layer-shaped trace tells you whether the extra time is orchestration or a dependency, which is the first fork in most investigations (Why Is My API Slow?).
- At 10x traffic layering is neutral. It adds function calls, which are not your bottleneck.
- At 10x team size it is close to essential: it is the vocabulary that makes "where does this go" answerable without the person who wrote the module.
- If the service is ever split, layer boundaries are the only candidate seams that already exist. That is a real benefit and still not a reason to split (The Modular Monolith).
- Below about three engineers the enforcement cost exceeds the coordination it saves, and the honest answer is fewer layers (Alternatives to Layering).
- More files, more indirection, more names to agree on, and a longer path from "I read the ticket" to "I found the code".
- Interfaces in the inner layers add a level of indirection whose only justification is direction. If there is exactly one implementation and there will only ever be one, that indirection is bought on credit.
- Strict layering pushes toward mapping between representations — request DTO, command, domain object, row, response DTO. Each mapping is code that can be wrong, and on hot paths it is measurable CPU (What Serialization Costs).
Where this applies
Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.
- SCALE-SPECIFICFlips on team size and codebase lifetime, not request rate. Below roughly three engineers on a codebase under a year old, four enforced layers cost more in navigation and mapping than they save in coordination — one transport file and one logic file is the correct answer, and the direction rule still applies. Above roughly ten engineers, or once more than one team edits the same module, the enforced direction is what stops the import graph from becoming a mesh, and adding it later is a multi-week refactor rather than a convention change.
- GENERALThe dependency-direction rule itself — inner layers do not import outer ones — holds at every size, including in a single-file service where it is enforced by attention rather than tooling.
- LANGUAGE-SPECIFICEnforcement differs sharply: Go packages and Java/Kotlin modules can make a wrong-direction import a compile error; TypeScript needs an ESLint boundary rule or project references, which are advisory; Python needs an import-linter configuration, and its dynamic imports can route around any of them.
Where the depth lives
This domain teaches the application-side mechanism and hands the rest off.
- — Programming Languages & Runtime Internals — what a module boundary actually is at compile time, and why some languages can enforce direction while others can only lint it.