100%

Lesson 2 of 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.

11 min read

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. 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. 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. 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. 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. 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.

One use case, showing each responsibility in its layer. The domain method has no idea a transaction exists.typescript
// 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 });
}
ErosionHow it startsMechanical defence
Presentation imports persistence typesA quick page that "just needs the row"A build-time import rule between the two directories
Domain imports the ORMOne method needs a query the repository does not haveA dependency check that fails the build, as this platform uses
Repositories open transactionsA repository method that must write two tablesRepositories accept a transaction handle; they never create one
Business rules in the presentation layerA conditional in a component to hide a buttonReturn a capability from the server rather than a role, so the rule is not re-derived
The four erosion patterns, in the order they usually appear in a codebase.

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?Reveal

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.