LayersSCALE-SPECIFICGENERALLANGUAGE-SPECIFIC

Transport, Application, Domain, Infrastructure

Four layers, one rule that matters — dependencies point inward — and a scale at which the whole thing is overhead.

What actually happensHow to build it

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.

The question

What are the layers of a backend service, and which rule about them is load-bearing?

The requirement

The codebase is nine months old and four engineers deep. "Where does this go?" comes up in every review, and the answers disagree.

The obvious build

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.

Why it breaks

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.

How it breaks in production
  • 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.
  • A model imports the HTTP status-code enum because a validation error needed a 422. Business logic now cannot run outside a web request.
  • The services folder holds both OrderService (business logic) and S3Service (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.
RequirementAPI ContractApplication LogicData AccessExternal DepsConcurrencyFailureSecurityObservabilityDeploymentScale

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 Money is, 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.

Dependencies point inward; infrastructure implements interfaces the inside declares
commandsame commandrulesdepends onimplementsimplementsimplementsHTTP handlersQueue consumerPostgres adapterQueue adapterPayment SDK adapterApplication — use cases, transactionsDomain — rules, types, invariantsInterfaces declared inside
UserLLMAgentToolDataDecisionHumanGuardrail

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.

LayerMay depend onMust not know aboutThe leak, when it happens
TransportApplication, domain typesWhich database, which SDKA handler builds a SQL string or calls Stripe directly (Fat Controllers)
ApplicationDomain, declared interfacesHTTP, status codes, sessions, the ORM's concrete clientA service returns res.status(422) or imports express
DomainItself, and the standard libraryEverything else, including the clocknew Date() inside a rule, so the test is flaky at midnight
InfrastructureInterfaces it implements, SDKsUse cases, business rulesA repository decides whether to send the email
ConfigNothingEverythingA domain module reads process.env at import time (Configuration: Separating Code From Environment)
utilsIt exists. Split it into domain, infrastructure, or a library

When four layers are three too many

SCALE-SPECIFICEvery row is a size mismatch, not a wrong pattern. The first row is correct structure at the wrong team size; the second is the same structure missing at the size where it pays.

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.

Structure applied at the wrong size
TriggerSymptomCauseResponse
Four layers, two engineers, month threeA one-field change is a five-file diffMapping between representations that are all identicalCollapse to transport + logic; keep the direction rule (Alternatives to Layering)
No layers, twelve engineers, year twoEvery change causes a conflict; nobody can predict blast radiusThe import graph is a mesh, so everything depends on everythingIntroduce the direction rule and enforce it in CI before adding folders
Layers by convention only, no toolingDirection erodes within two quartersReview catches the obvious violations and misses the small onesAn import boundary lint rule; make the wrong direction fail the build
Domain package importing the ORM entityA migration breaks business-rule testsThe schema became the domain modelMap explicitly at the infrastructure boundary, or drop the pretence of a domain layer (Three Models, Not One)
A "temporary" utils moduleIt is imported by all four layers and cannot be movedIt has no owner and no defined directionDelete 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 utils as a destination. Something in utils either 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

Failure modes
  • 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 types package 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 require inside 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).
What can race
  • 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).
Security
  • 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).
Misreads
  • "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

How you see it in production
  • 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?).
What changes at 10x and 100x
  • 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).
What this costs
  • 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.

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