One Responsibility per Layer, and the Discipline It Takes

Everyone nods along to layering, and nearly every project still gets it wrong. The wrongness comes in two shapes: either you over-abstract, stacking three layers for a requirement that hasn’t happened yet, or you don’t abstract at all and cram I/O, business logic, and presentation into one function. Both charge you the same fee. When something breaks, you’re guessing across several layers to find where. My test is plain: a layer should own one thing you can state clearly, and if you can’t state it clearly, you haven’t separated it yet.

Blur the Responsibility and Debugging Becomes Archaeology

The value of layering isn’t that it “looks architected.” It’s that when something breaks, you know which layer to open. If you can describe each layer’s job in a single sentence, then the moment a bug shows up you roughly know where it lives. A system with muddy responsibilities is the opposite: debugging it feels like archaeology, digging layer by layer, because you genuinely don’t know where the data got corrupted or where the logic turned wrong.

Under-abstraction is the usual starting point. A handler function that parses params, opens a DB connection and queries, applies business rules to compute a result, and stitches HTML back at the end. It feels great while you’re writing it, everything visible in one file, small changes lightning fast. The trouble is a function like that eventually seizes up, because when you want to change a pricing rule you’re forced to confront SQL and HTML in the same breath, and one slip anywhere can surface somewhere you never expected. I’ve inherited code like this, and just working out where a single field got overwritten ate an entire afternoon. With responsibilities uncut, the blast radius of every change is the whole function, not the few lines you touched.

Overcorrecting, though, is harder to recover from.

Over-Abstraction Is Paying Today’s Tax for a “Maybe Later”

Over-abstraction has a recognisable look. Because “we might one day support another payment provider / another data source / another notification channel,” you go and extract a PaymentProvider interface, a Repository abstraction, a Notifier factory, and the whole project ends up with exactly one implementation of each. Every time you want to change something trivial, you tunnel through the interface, through the factory, through some registry, before you reach the twenty lines actually doing the work. That was my SvelteKit SSR episode: Vite’s transform was slow in that environment, and to handle it “properly” I stacked middleware to wrap the problem, until every debugging session was a crawl through a tunnel. I tore it out, went to a plain SPA, hand-wrote eighty lines of hash router, and suddenly located problems in five minutes.

The instinct that matters here is anti-additive: with no second caller, don’t extract the interface yet. An interface is for converging duplication that already exists, not for reserving duplication that might. Write the direct version first, lay the logic out honestly in the open, and when a second and third use genuinely appear, the shape of the repetition surfaces on its own, and that’s when you extract it well. Interfaces pulled out early almost always guess the boundary wrong, because you’re imagining the commonality with no second case to check against, and the commonality you imagine is usually mistaken, so every implementation ends up routing around it.

Over-abstraction carries a quieter cost too: it erodes your feel for what’s underneath. After enough wrapping, the person writing the code gradually stops knowing what a request actually touches, where HTTP ends, where data lands. When something breaks, they’re debugging the abstraction they stacked, not the system that’s really running.

Good Layering Means Swapping Just One Layer

There’s a practical test for whether your layers are cut well: when you replace something, how many layers do you have to touch. Move your DB off PostgreSQL onto something else, and if data access sits behind one layer, ideally you change that layer alone and the business logic above doesn’t move a line. Swap the tracing backend, swap the cache, swap out some external API, same story. Being able to change just one layer means that layer’s responsibility really is cleanly contained. This is interface-driven design said another way: the interface is “I only promise this layer’s outward behaviour, and how I swap the inside is my business.”

Note, though, that this benefit is an outcome, not a starting point. You don’t extract that layer because you “want easy DB swaps later.” You extract it because data access and business logic are different responsibilities and belong apart. Easy swapping is just the dividend you collect once the responsibilities are cleanly cut. Run it the other way, abstracting hard for some imagined replaceability, and you fall straight back into the pit from the section above.

To close. More layers isn’t better, and fewer isn’t either. Clearer responsibility per layer is what’s better. A three-layer system with each layer’s job distinct is far easier to maintain than a five-layer one whose duties overlap at every seam, and far easier than one function holding the lot. What you’re after isn’t some elegant layer count, it’s every layer able to state in one sentence what it does, and what it doesn’t.

Key Takeaways

  • A layer should own one thing you can state clearly; if you can’t, it isn’t separated yet.
  • Under-abstraction spreads a single function’s change across everything; over-abstraction makes a simple fix tunnel through layers.
  • With no second caller, don’t extract an interface; converge once the shape of the repetition has surfaced.
  • The mark of good layering is “swap just one layer,” which is a dividend of clean responsibilities, not the goal of abstracting.
  • Aim not for an elegant layer count but for each layer stating in one sentence what it does and what it doesn’t.

Sheng’s take, drafted with Claude · part of the 2026-06-13 blog renovation, paint still drying.