الدرس 2 من 2
Making Layers Hold in a Real Codebase
Where transactions, validation and authorisation belong; why layering degrades quietly; and the mechanical checks that keep it honest.
Layering fails silently. There is no outage the day someone imports a repository into a component; the code works, the tests pass, and the erosion is discovered a year later when a schema change breaks a page nobody connected to it. Keeping layers honest is therefore a question of mechanics, not of intent.
Deciding where a responsibility belongs
- 1
Input validation: at the outer edge, then again as invariants
Shape and type validation belongs where untrusted data arrives — the request handler — so malformed input never reaches business code. Business rules such as "a discount may not exceed the order total" belong in the domain, expressed so an invalid object cannot be constructed. These are different jobs and doing both is not duplication.
- 2
Authorisation: at the point of use, from a central policy
Whether this principal may perform this operation on this resource needs both the principal and the resource, which usually means the application layer. The rule itself lives in one policy function; the layer decides where it is called, not what it says.
- 3
Transactions: the application layer, one per business operation
A use case is the natural transaction boundary, because it is the unit that must succeed or fail as a whole. Repositories opening their own transactions produce nesting, partial commits and behaviour that depends on call order.
- 4
Business rules: the domain, with no framework in sight
If a rule needs a request object, a session or a database connection to be expressed, it has been written in the wrong layer. The test is whether it can be exercised by a plain unit test with plain values.
- 5
Mapping: at each boundary you have decided to protect
And only there. A mapper between two shapes that are always changed together is pure cost — that is connascence with high degree and no benefit.
// application layer
export async function applyDiscount(input: unknown): Promise<Result> {
// 1. Shape validation at the edge: nothing untyped goes further.
const command = applyDiscountSchema.parse(input);
// 2. Authorisation at the point of use, from the central policy.
const principal = await authorize('order.discount', {
organizationId: command.organizationId,
});
// 3. One transaction per business operation.
return withTransaction(async (tx) => {
const orders = orderRepository(tx);
const order = await orders.byId(command.orderId);
if (!order) return { ok: false, reason: 'not_found' } as const;
// 4. The business rule lives in the domain and is testable with values.
const discounted = order.withDiscount(command.discount, principal.id);
await orders.save(discounted);
return { ok: true, total: discounted.total } as const;
});
}
// domain layer — no framework, no transaction, no request.
withDiscount(discount: Money, appliedBy: UserId): Order {
if (isGreaterThan(discount, this.subtotal)) {
throw new DomainError('Discount cannot exceed the order subtotal');
}
return new Order({ ...this.state, discount, discountAppliedBy: appliedBy });
}| Erosion | How it starts | Mechanical defence |
|---|---|---|
| Presentation imports persistence types | A quick page that "just needs the row" | A build-time import rule between the two directories |
| Domain imports the ORM | One method needs a query the repository does not have | A dependency check that fails the build, as this platform uses |
| Repositories open transactions | A repository method that must write two tables | Repositories accept a transaction handle; they never create one |
| Business rules in the presentation layer | A conditional in a component to hide a button | Return a capability from the server rather than a role, so the rule is not re-derived |
A pull request adds `import { prisma } from "@/server/db"` to a page component
As a developer
Notes it works, the query is small, and going through the repository would mean three files for a two-line read. Approves with a comment suggesting a refactor later.
As an architect
Treats it as the first instance of an erosion pattern, and knows that "later" does not arrive. The response is not a stricter review culture, which decays; it is a rule in the build that makes the import impossible, plus an honest look at whether the repository path is so heavy that people will keep trying to route around it. If developers repeatedly bypass a layer, the layer has an ergonomics problem and the bypass is a symptom.
In practice
Choosing layers deliberately for a regulated back office
A team of nine was rebuilding a claims back-office system for an insurer. Requirements were stable and well documented, throughput was modest, and every calculation had to be explainable to a regulator. They considered vertical slices and a layered structure.
Constraints
- Roughly 60 concurrent internal users, no scale pressure
- Every payout calculation must be reproducible and auditable
- High staff turnover in the team historically
- Existing rules documented as a hierarchy of policies
Decision
A four-layer structure with a strict, build-enforced rule that the domain layer has no imports outside itself, plus a dedicated read path for the twelve list screens.
Why
The dominant quality attributes were auditability and comprehensibility by a rotating team. A pure domain layer meant every payout rule could be exercised by a plain test with plain numbers, and printed as evidence for the regulator. Predictable code placement is worth more than usual when the team's composition changes every year, and there was no scaling or independent-deployment pressure that would have argued for another structure.
What it cost
They accepted the mapping ceremony — four representations of a claim — as the price, and mitigated it by generating two of the mappers. They also accepted that a feature touching several layers takes longer to write than in a vertically sliced codebase, and recorded that this trade would be wrong for a product doing rapid experimentation, where the same ceremony would slow down the thing that mattered most.
A repository method needs to write to two tables atomically. A developer opens a transaction inside the repository. What is wrong with that, and what would you do instead?RevealHide
It moves the transaction boundary below the layer that knows what a complete business operation is. Two consequences follow. First, when a use case needs to call two repository methods atomically, either you get nested transactions with implementation-dependent semantics, or the second call silently commits on its own. Second, the transaction boundary is now invisible from the application layer, so whether an operation is atomic depends on which repository methods it happened to call. The fix is that repositories accept a transaction handle they did not create, and the application layer opens exactly one transaction per business operation — which is also the only layer that can decide what "the whole operation" means.
Key takeaways
- Edge validation and domain invariants protect different boundaries; keep both.
- One transaction per business operation, opened in the application layer and passed down.
- Layering erodes silently, so defend it with build-time rules rather than review discipline.
- Repeated bypassing of a layer is evidence about that layer's ergonomics, not only about the developer.
- Layers organise a deployable unit; using them to split a system into services produces the worst kind of distributed monolith.