Separation of Concerns
Transport, business logic, persistence, formatting and infrastructure change for different reasons, so mixing them is expensive. Adding a layer for each of them anyway is a different and equally expensive mistake.
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.
Which concerns genuinely deserve to be separated, and how do I tell that apart from adding layers because separation sounds like a virtue?
A pricing rule has to change. The rule turns out to be written inside an HTTP handler, alongside JSON parsing, a currency format string, a SQL query and a retry loop — so changing it means understanding all five.
Keep it in one handler. Everything about this endpoint is in one place, you can read it top to bottom, and there is no indirection at all — which is genuinely the best possible local reasoning for someone reading it once (Local Reasoning).
The rule cannot be reused by the batch job, the CSV export or the admin screen, so it is reimplemented in each of them, and now the invariant "shown equals charged" depends on four implementations agreeing (Duplicate Knowledge).
- The rule cannot be reused by the batch job, the CSV export or the admin screen, so it is reimplemented in each of them, and now the invariant "shown equals charged" depends on four implementations agreeing (Duplicate Knowledge).
- It cannot be tested without HTTP and a database, so the pricing tests are slow, flaky and few — which means the rule with the most business consequence has the weakest verification (Testing as Design Feedback).
- A transport change drags business logic with it. Adding a GraphQL endpoint or a queue consumer means either duplicating the rule or refactoring under deadline.
- A currency format change requires editing the file that charges cards. The blast radius of a cosmetic change is identical to the blast radius of a financial one.
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 handler works and is well tested at the HTTP level, so any restructuring must keep those tests passing unchanged.
- The team has been burned by a previous "clean architecture" attempt that produced seven layers and a class per layer per entity; the word "layer" is currently unpopular.
- The service is deployed as one process. There is no distribution requirement and no plan for one (Designing a Monolith).
- The price a customer is shown equals the price they are charged, regardless of which transport asked for it.
- Money is never represented as a floating-point number anywhere in the flow (Primitive Obsession).
- A retry of a failed charge never produces a second charge (Idempotency by Design).
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- Transport owns the wire: parsing, status codes, content negotiation, auth token extraction. It changes when the protocol or the client changes (Transport Validation).
- Business logic owns the rules and the invariants. It changes when the business changes, and it is the only concern with no technology in it.
- Persistence owns storage and retrieval. It changes when the schema or the store changes.
- Formatting owns presentation: currency symbols, locales, date rendering, rounding for display. It changes when design or a market changes (Timezones and Locale Formatting).
- Infrastructure owns retries, timeouts, connection pools, logging and metrics. It changes when operations change (Designing for Failure).
- The seams fall between those five because each has a different external trigger and a different rate of change, which is the only justification for a boundary that survives contact with a real backlog (Finding Seams).
- The strongest single boundary is around business logic, because it is the one whose isolation makes it testable without infrastructure. If you only get one boundary, take that one.
- A boundary is not a folder. It is a stated interface plus a rule about direction: business logic must not import transport, persistence or formatting, and that rule should be enforced by a lint check rather than by good intentions (Dependency Direction).
- Formatting and transport can share a boundary in a small service without much loss. Persistence and business logic cannot, because that is the pair whose fusion makes testing expensive.
Five concerns in one function, and what each one costs to leave there
The example below is compressed but not exaggerated; handlers like it exist in every codebase that grew quickly. Read it looking for the pricing rule, and notice how much unrelated context you had to hold to find it.
The extraction that matters is a single one: the rule becomes a function from values to values. Everything else — where the retry lives, whether formatting is its own module — is secondary, and treating it as equally important is how the five-way split turns into a seven-layer ritual.
- Transport changes when a client or protocol changes — a different requester, a different release cadence.
- Business logic changes when the business changes, and it is the only one of the five that should contain no technology at all.
- Persistence changes when the schema does. Its fusion with business logic is the expensive one, because it is what makes rules untestable (Choosing a Data Access Layer).
- Formatting changes when design or a market changes, which is frequently and cheaply — so it should never be on the same page as a payment call.
- Infrastructure — retries, timeouts, logging — cuts across the other four, and belongs in a wrapper rather than a layer (Retries Are a Property of the Operation).
1app.post('/quote', async (req, res) => {2 const body = JSON.parse(req.body) // transport3 const rows = await db.query( // persistence4 'SELECT * FROM items WHERE id = ANY($1)', [body.ids])5 6 let total = 07 for (const r of rows) {8 total += r.price_cents * (body.qty ?? 1)9 if (body.country === 'DE') total *= 1.19 // business rule10 }11 if (total > 50_00) total *= 0.9 // business rule12 13 for (let i = 0; i < 3; i++) { // infrastructure14 try { await audit.write({ total }); break } catch {}15 }16 17 res.json({ total: '$' + (total / 100).toFixed(2) }) // formatting18})The pricing rule is four lines out of eighteen, and it is the only part anyone will be asked to change. It cannot be run without a database, cannot be reused by the batch job, and the hardcoded $ means the currency question was decided by whoever wrote the response line.
The layer that passes data through unchanged
The reaction to a fused handler is usually to add layers, and the reaction to that is a codebase where a one-field change touches seven files and no file made a decision. This is the more expensive of the two failures because it is defended: nobody calls a fused handler good design, and plenty of people call seven layers clean.
The test is blunt and works. Take one layer away and ask what became impossible. If the answer is "nothing, the call is just shorter", that layer was not separating anything.
looks like A class whose methods have the same names as the layer beneath, take the same arguments, return the same values, and contain a single line that forwards the call. Often a Manager between a Service and a Repository, or a DTO that is a field-for-field copy of the entity.
suggests The layer was added because the architecture diagram had a box, not because a decision needed a home. Cost is paid on every change: each new field is edited in every layer, and each layer brings a test file that asserts the forwarding works.
fix Delete the layer and inline the call. If someone objects on principle, ask which future change it contains and how likely that change is — the same question you would ask of any abstraction (Premature Abstraction).
How much separation to buy
The choice is not between "one handler" and "clean architecture". It is a spectrum, and the useful question is how many boundaries this system can pay for out of the changes it will actually receive.
The scores below are a way of comparing shapes, not a measurement of anything. The row that matters most for most teams is the middle one, and the reason is unglamorous: it captures nearly all of the testability benefit for a fraction of the navigation cost.
| Option | Simplicity | Flexibility | Testability | Migration cost | Operational | Note |
|---|---|---|---|---|---|---|
| One handler, everything inline | Best possible first-read comprehension and the right answer for a genuinely small, short-lived service. Business rules are untestable without infrastructure, and a second transport means duplication. | |||||
| Logic extracted, everything else inline | One boundary: pure functions for the rules, everything else stays in the handler. Captures most of the testability benefit at the cost of one extra file, and is the default this lesson recommends. | |||||
| Transport / logic / persistence | The classic three. Worth it once there is a second transport or a second team, and unremarkable to navigate. Requires an enforced import rule or it decays into folders. | |||||
| Ports, adapters, DTOs, mappers per entity | Genuinely buys the ability to swap infrastructure and to enforce direction mechanically. Charges a file per entity per layer, and in systems that never swap anything it is the seven-layer pass-through with better vocabulary (Clean Architecture, and Where It Is Overused). |
caveat The scores compare shapes for one service with one transport and no distribution requirement; they are an Engineer Atlas model, not a measurement, and they say nothing about the variable that dominates in practice — whether your team already knows the idiom. A team fluent in ports and adapters pays much less for the bottom row than these numbers imply, and a team meeting it for the first time pays much more. "Simplicity" here also means simplicity for a reader, not for the author, and those diverge: the bottom row is often faster to write and slower to read.
How to build it
Most important first.
- Separate the concern that is expensive to mix, not every concern you can name. Business logic mixed with I/O is expensive; formatting mixed with transport usually is not (Over-Decomposition).
- Make the business layer take and return values, with no framework types in its signatures. That single rule produces most of the benefit attributed to elaborate architectures (Functional Core, Imperative Shell).
- Push effects to the edges. Read what you need, compute, then write — rather than interleaving reads, decisions and writes (Effect Boundaries).
- Enforce direction mechanically. An import-boundary rule in the linter or build is worth more than a diagram, because it survives the author leaving (Circular Dependencies).
- Add a layer only when you can name what it decides. A layer whose methods have the same names as the layer below it and which transforms nothing is a tax with no service attached (Speculative Generality).
- Count the layers a single request crosses. Three is usually plenty inside one process; beyond four, each additional one needs its own justification rather than inheriting the previous one's (Package Design).
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.
- Next change: "prices in Japan round to whole yen". With formatting separated, one module and one test. Mixed, it is an edit inside the handler that charges cards, reviewed by whoever is available.
- Next change: "the same pricing must be available to a nightly batch job". Separated: import the business module, no new logic. Mixed: reimplement, then keep two implementations in agreement forever.
- Next change: "add one field to the response". Under three layers, one or two files. Under seven pass-through layers, seven files, seven DTO mappings and seven tests — and that is the cost that turns a team against separation entirely, which is why the excess matters as much as the deficit.
- Separated concerns mean more files and more indirection for someone reading a flow once. The mixed handler genuinely wins on first-read comprehension, and pretending otherwise loses the argument with people who have felt it.
- A value-in, value-out business layer means mapping at the edges, and mapping code is boring, voluminous and a real source of bugs.
- Enforced direction occasionally makes a legitimately convenient shortcut illegal — the business layer that would like to log something, the query that would be one join. Those are genuine costs paid to keep the rule credible.
What can go wrong
- The pass-through layer:
ControllercallsServicecallsManagercallsRepository, and three of those four only forward arguments. Every new field is a four-file change and no layer decided anything (Shotgun Surgery). - The concerns are separated in folders and fused in types: the business layer takes an ORM entity, so the schema is still part of the business contract (Leaky Abstractions).
- Business logic is separated but the invariant stays behind — validation remains in the transport layer, so the queue consumer that bypasses HTTP bypasses the rule too (Invariant Leaks).
- The mitigation fails when the lint rule is added with forty existing violations and an allowlist that nobody ever shrinks. A rule with a growing exception list is documentation, not enforcement.
- Transport, persistence and formatting all depend on business logic. Business logic depends on none of them, which is what makes the arrangement worth having (Dependency Inversion).
- Infrastructure concerns cut across everything, which is why they belong in wrappers and middleware rather than in a layer of their own (What Belongs in the Pipeline).
- Every layer added is a dependency added, and dependencies compose: a change that crosses four layers has four chances to require a signature change and four sets of tests to update.
- "Each extra layer means better separation." No. A layer that transforms nothing separates nothing and costs a file per change forever; the number of *decisions* is what matters, not the number of hops (Over-Decomposition).
- "So we need hexagonal architecture." You need business logic that does not import the framework. Hexagonal is one way to get that, with a specific cost profile, and it is not the only one (Hexagonal Architecture (Ports and Adapters)).
- "Separation of concerns means one class per concern per entity." That is how a five-entity system becomes thirty-five classes with no additional containment (How SOLID Gets Misused).
- "Everything technical goes in
infrastructure/." That folder becomes the dumping ground within a year, for the same reasonutils/does — it is defined by what it is not (The Utility Dumping Ground).
- shotgun-surgery
- divergent-change
- utility-dumping-ground
Testing it, and how it ages
- Business logic gets fast, plentiful tests with no doubles: it takes values and returns values (What a Unit Is).
- Transport gets a small number of tests about status codes, content types and error mapping — not about business rules (Error Boundaries).
- Persistence gets tests against a real database, because the things that break there are the things a fake would not model (Test Against the Real Database).
- One end-to-end test proves the layers are wired. If you need many, the boundaries are not doing their job (Where a Test Must Be Real).
- The business module accumulates most of the future edits and stays free of technology, which is the sign the split was drawn correctly rather than merely drawn.
- A second transport — a queue consumer, a CLI, an internal API — is the moment the separation visibly pays, and it is worth waiting for that second transport rather than predicting it (The Rule of Three).
- It stops fitting when the "business logic" module becomes the new god object because everything that is not I/O was pushed into it. At that point the interesting decomposition is *inside* it, by domain rather than by technology (Package by Feature).
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 business rules mixed with I/O are expensive to test and reuse follows from what testing and reuse require, so it holds across languages and paradigms — although what counts as a "layer" varies from a class to a module to a package.
- CONTESTEDThe strongest opposing view is that layering is mostly cargo cult: the honest observation is that a single well-named handler is easier to read, easier to change and easier to delete than five files, and that most systems never get the second transport that supposedly justifies the split. Proponents point at codebases where the "clean" version is objectively slower to work in. The position taken here concedes the empirical point about excess and holds only the narrow line — keep business rules out of I/O — because that specific separation is the one whose benefit shows up in test speed immediately rather than in a hypothetical future.
- SCALE-SPECIFICA 500-line service with one transport genuinely does not need five separated concerns; two — logic and everything else — is the right answer and adding more is loss. The five-way split starts paying somewhere around several transports, several teams, or a codebase large enough that nobody reads the whole request path.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — System Design — the same concern split reappears as tiers, where crossing a boundary costs a network hop and the pass-through layer becomes a pass-through service.
- — Programming Languages & Runtime Internals — how strongly a language can enforce an import direction (modules, visibility, crates, packages) decides whether a boundary is a rule or a hope.