Package by Layer
controllers / services / repositories. It is genuinely good at cross-cutting technical change and at being guessable, and it scatters every feature across every folder.
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 a layer-first layout actually buy, and what does it charge?
The team has to add a "pause subscription" capability. The work is spread over a controller, two services, a repository, three DTOs, a mapper and an event publisher, in seven different top-level folders, and the pull request is hard to review because no reviewer can see the feature in one place.
This is the standard layout, everyone knows it, and the alternative is a matter of taste. Feature-first is just fashion.
It is not taste, and both halves of that sentence deserve to be taken seriously. Layering really is better for technical change — swapping the ORM touches one folder — and really is worse for requirement-shaped change, which touches all of them.
- It is not taste, and both halves of that sentence deserve to be taken seriously. Layering really is better for technical change — swapping the ORM touches one folder — and really is worse for requirement-shaped change, which touches all of them.
- The scattering is measurable rather than aesthetic: the pause-subscription change touches seven folders and the pull request has no natural reading order, which is why reviewers approve it without understanding it (Review Size).
- Because a feature has no home, nothing owns it. Two engineers can implement overlapping subscription logic in two services without either noticing (Duplicate Knowledge).
- Layers tend to multiply. Once the scheme is "one folder per technical role", adding a mapper layer or a DTO layer is the obvious move, and each one adds a hop that passes data along unchanged (Speculative Generality).
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 framework generates this layout and its tooling assumes it, so leaving it is not free (What a Framework Charges).
- Twenty engineers know where things are under the current scheme; a change costs all of them their navigation habits at once.
- The persistence library is under discussion for replacement next year, which is exactly the change this layout is best at.
- Dependencies point one way through the layers. A repository that calls a controller has destroyed the only guarantee the scheme provides (Dependency Direction).
- A layer boundary is crossed by data, not by knowledge: a repository does not know what a subscription pause means.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- Each layer owns one technical concern and owns no domain knowledge from another layer.
- The service layer, in practice, owns whatever did not fit elsewhere — which is why it is the layer that rots first and deserves the most scrutiny (God Object).
- Somebody has to own a feature end to end even though the layout does not express one; if nobody does, review is the only place the whole feature is ever visible at once.
- The boundaries are technical: transport, application coordination, domain, persistence. They are real boundaries — each side genuinely can change without the other — which is why the scheme survives.
- They are the wrong *primary* boundaries when requirements arrive shaped like capabilities, because no capability lives inside one of them (Finding Seams).
- The most defensible version keeps layers *inside* capability packages, so both boundaries exist and the primary axis is the one requirements respect (Package by Feature).
What the layout looks like, and where one feature lives
The tree below is not a caricature; it is what a well-run layer-first repository looks like, and it has genuine virtues. Every file is where you would guess. Nothing is ambiguous. A new engineer can place their first file correctly without knowing what the product does.
The annotation is the cost. One capability — pause a subscription — is marked, and it appears in seven places. Nobody chose that; it follows from grouping by technical role.
1src/2 controllers/3 SubscriptionController.ts <- pause endpoint4 services/5 SubscriptionService.ts <- pause orchestration6 BillingService.ts <- proration on pause7 domain/8 Subscription.ts <- the state transition9 repositories/10 SubscriptionRepository.ts <- persist paused_at11 dto/12 PauseSubscriptionRequest.ts13 SubscriptionResponse.ts <- new field14 mappers/15 SubscriptionMapper.ts16 events/17 SubscriptionPaused.ts18 197 folders, 9 files, one capability.Compare this with the ORM swap, which touches only repositories/ and nothing else in the tree. Both facts are consequences of the same grouping, and an argument that mentions one without the other is not describing the layout (Package by Feature).
What layering is genuinely good at
This is the half of the argument that usually gets skipped, and skipping it is why the debate never resolves. Layering solves real problems, and the problems it solves are exactly the ones that a capability layout makes worse.
Read the table as a set of bets rather than a verdict. If your last year of work looks like the top rows, layering is costing you. If it looks like the bottom rows, it is paying.
| Change | Under layer-first | Why | Verdict |
|---|---|---|---|
| Add a capability | Seven folders, no single reviewable unit | A capability is orthogonal to every layer | Layering loses, and this is most of the work in most products. |
| Add a field to an existing capability | One edit per layer, all mechanical | The layers pass the field along | Layering loses mildly. The edits are low-risk; the cost is proportional to layer count, which is an argument against extra layers rather than against layers. |
| Replace the ORM or the database driver | One folder | Persistence is exactly one layer | Layering wins decisively. A capability layout repeats this change per capability. |
| Add tracing, auth or rate limiting to every inbound call | One place, in the layer everything passes through | The concern is technical and uniform | Layering wins. This is the canonical case for a technical boundary (What Belongs in the Pipeline). |
| Onboard an engineer who has never seen the codebase | They can place a file correctly on day one | The scheme requires no domain knowledge | Layering wins, and this benefit grows with team size and turnover. |
| Find everything that knows about subscriptions | A search, and no guarantee you found it all | Nothing groups by subject | Layering loses, and this is what makes duplicate business logic easy to create without noticing. |
Pricing the two schemes against each other
A scored comparison is worth making, provided it is read as a summary of the argument above and not as a measurement. The axes that matter here are not the usual ones: what decides this choice is navigability, change locality, and how much coordination a change costs across a team.
The most important row is the last one, and it is the one most often left out: the hybrid. Capability packages with layers inside them are what most mature codebases converge on, because they take the primary axis from requirements and the secondary axis from technical role.
| Option | Simplicity | Flexibility | Testability | Operational | Migration cost | Note |
|---|---|---|---|---|---|---|
| Layer-first | Simplest to adopt and to teach: zero judgement per file, framework tooling assumes it, and any engineer can navigate it cold. Inflexible where it matters most — a capability has no home — and expensive to leave later, because leaving means moving every file in the repository at once. | |||||
| Feature-first | Changes land in one place and a package can be tested as a unit. Costs judgement on every file placement, makes cross-cutting technical change N times more expensive, and gives shared concepts no obvious home — which is where it most often fails in practice (Vertical Slices). | |||||
| Capability packages with layers inside | The primary axis matches how requirements arrive; the secondary axis keeps the technical seams that make the ORM swap and the domain-purity tests possible. Costs a two-level convention people must be taught, and requires an explicit decision about where shared concepts live rather than letting them fall into a folder (The Modular Monolith). |
caveat These scores compare layouts for a domain-heavy product with a team large enough that navigation matters. For a thin-domain system the first row's flexibility score is simply wrong — layering is flexible along the axis that system actually changes on. Nothing here measures anything: the numbers summarise the arguments above so they can be compared at a glance, and the one that should decide your case is the migration column combined with your own merge history, not the totals.
How to build it
Most important first.
- Keep it if your changes are genuinely technical and your domain is thin. A reporting service, an ETL pipeline, a CRUD admin tool over a stable schema — layering is a good fit and switching would be waste (Transaction Script).
- Enforce the direction with a tool rather than a review habit, since the one guarantee layering offers is worthless if it is violated quietly (Stable Dependencies).
- Do not add a layer that only maps: if a DTO is a rename of the domain object, the layer is a hop and should be removed (Speculative Generality).
- Keep the domain layer free of framework types, because that is where the value of the scheme concentrates and it is the layer most often contaminated (Leaky Abstractions).
- If you are leaving it, leave it capability by capability with both schemes live, rather than as a repository-wide move (Incremental Migration).
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.
- A technical change — new ORM, new HTTP framework, adding tracing to every inbound call — costs one folder. This is the scheme working exactly as intended and it is a real, repeatable saving.
- A capability change costs seven folders and a review nobody can read as a unit. That cost recurs on every feature, which is most of the work most teams do.
- Adding a field to an existing feature costs one edit per layer — mechanical, low-risk, and multiplied by however many layers you have. This is where layer proliferation is paid for.
- Leaving the scheme later costs a repository-wide file move, conflicts on every open branch, and a period where nobody can find anything — which is why the decision is much cheaper to revisit at ten engineers than at fifty.
- Predictability is a genuine benefit that this domain tends to undervalue: any engineer can guess where a file goes without knowing the domain, and at large scale with high turnover that is worth a lot.
- It is the layout every framework tutorial uses, so onboarding is faster and tooling fits — real costs to giving it up.
- It makes the domain implicit, and a system whose structure never names its business concepts makes those concepts harder to discuss as well as harder to find (Ubiquitous Language).
What can go wrong
- The service layer becomes a dumping ground, since anything that is not transport and not persistence lands there by elimination.
- Layer purity is enforced by ceremony rather than by tools, and after a year there are repositories that know about HTTP status codes.
- A feature's logic gets split across two services because no one folder owned it, and the two copies drift (Divergent Change).
- The mitigation fails on its own terms: a team adds a "domain" layer to hold business rules, but the rules stay in the services and the new layer holds anaemic data classes that pass through unchanged (The Anemic Domain Model).
- Each layer depends only on the one beneath it, which is the property that makes the ORM swap cheap.
- Every feature depends on every layer, which is the property that makes the feature change expensive. These are the same fact seen from two directions.
- The framework is a dependency of the outer layers by design; the moment it appears in the domain layer, the scheme has stopped paying (Volatile Dependencies).
- "Layers are bad." They are the correct primary axis for a genuinely technical system and the correct *secondary* axis for almost everything else. What is being criticised is layers as the top-level split in a domain-heavy system (Package by Feature).
- "More layers mean better separation of concerns." Layers separate concerns only when each one makes a decision. A layer that renames fields and forwards them adds a hop and separates nothing (Separation of Concerns).
- "Clean or hexagonal architecture is just this with more folders." Those styles are about dependency direction — the domain depending on nothing — which is a different claim from grouping files by technical role, and one is achievable under either layout (Hexagonal Architecture (Ports and Adapters)).
- "Our controllers are thin, so the layering is working." Thin controllers say nothing about where the domain knowledge went. Usually it went into services, which is the layer with no definition (Fat Controllers is the visible version of the same problem).
- divergent-change
- shotgun-surgery
- god-object
Testing it, and how it ages
- Test the domain layer with no framework, no database and no HTTP. If that is hard, the layers are nominal (What a Unit Is).
- Add a dependency-direction test that fails when an inner layer imports an outer one — cheap, and it protects the only guarantee the scheme makes.
- Write at least one test per feature that runs through all the layers, because otherwise nothing anywhere asserts that the feature works end to end (Where a Test Must Be Real).
- Layer-first layouts age by accumulating layers, not by losing them: a mapper here, a facade there, each individually reasonable.
- They age well in systems whose domain stays thin and badly in systems whose domain grows, and nobody notices the transition because it happens one feature at a time (Domain Modeling).
- The signal to change is that most pull requests touch most folders. That is measurable from merge history and is a far better trigger than an opinion about layouts (Change Amplification).
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.
- GENERALThat grouping by technical role makes technical change local and requirement-shaped change distributed is a structural consequence of the grouping, independent of language or framework.
- DOMAIN-SPECIFICFor a thin-domain system — reporting, ETL, an admin CRUD tool over a stable schema — most changes really are technical, and layering is the right primary axis rather than a compromise. For a system whose business rules are the product, the same layout distributes every requirement across every folder, so identical advice is right in one domain and wrong in the other.
- SCALE-SPECIFICAt five engineers the scattering is cheap because everyone has read everything, and the predictability buys little. At fifty engineers with turnover, the predictability becomes genuinely valuable — a new hire can place a file on day one — while the scattering becomes genuinely expensive, because a feature crossing seven folders now crosses several review queues. Both effects grow, and which dominates depends on how domain-heavy the work is.
- CONTESTEDThe strongest case for layer-first, made by people who have run large engineering organisations: a uniform, framework-standard layout means every engineer can navigate every service on their first day, code review has a predictable shape, tooling and generators work out of the box, and the layout survives reorganisations because it does not encode a domain model that will be renamed next year. Feature-first layouts, on this view, degrade into inconsistent per-team conventions and put shared concepts nowhere. That is an accurate description of how feature layouts fail; the counter is that it optimises for reading unfamiliar code over changing familiar code, and most engineering time is the second.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Testing & Reliability Engineering — a layer boundary is also a test boundary, and how much confidence a layer-scoped test actually gives you is the question that decides whether the layering is real or nominal.