This website uses cookies

Read our Privacy policy and Terms of use for more information.

Open any backend repo started in the last twenty years and you will find the same skeleton. A controller that parses the request. A service that holds the logic. A repository that talks to the database. Three boxes, arrows pointing down, the diagram you could draw blindfolded. It is so common that it barely registers as a decision. It is just what a backend looks like.

This is the layered architecture, also sold as N-tier, and it has a reputation problem. In architecture circles it is the thing you graduate from. The blog posts that matter are about hexagonal, clean, onion, event-driven. Layered is what you draw on a whiteboard before someone senior says "but have you considered ports and adapters." It is treated as the architecture of people who have not read enough architecture.

I want to defend it, but not the way you think. The thesis is this: the layered default works, and almost every failure blamed on it is actually a failure of one specific thing, the direction and discipline of dependencies between the layers. Get that one thing right and layered will carry a service for years. Get it wrong and no amount of hexagonal ceremony will save you, because you will build the same mess with more folders.

What the pattern actually is

The canonical version separates an application into presentation, business logic, and data access. Martin Fowler describes it in Presentation Domain Data Layering: a web layer that knows about HTTP and rendering, a domain layer that holds validation and calculation, and a data layer that manages persistence. He has used it for decades and still recommends it. The advantage he names is not elegance. It is that you can think about each topic in relative isolation, which shrinks how much you have to hold in your head at once.

The first thing people get wrong is conflating layers with tiers. They are not the same word with different spelling. Microsoft's own .NET architecture guidance is blunt about it: layers are a logical separation inside the application, while tiers are physical deployment targets. A three-layer application deployed as a single process is one tier and three layers. You can have a beautifully layered monolith and a horrifyingly tangled set of microservices. The layer count tells you nothing about how you ship. Conflate the two and you will think the cure for spaghetti is more network calls, which is how a tangled monolith becomes a distributed tangled monolith, the most expensive shape in software.

So when this essay says "layered," it means the logical structure: presentation on top, domain in the middle, data underneath, dependencies pointing down. Whether that runs in one container or twelve is a separate question.

The dependency rule is the whole game

A layer is "closed" when a request must pass through it to reach the next one down. The presentation layer cannot reach into data access directly. It goes through the domain. Mark Richards, in O'Reilly's Software Architecture Patterns, calls this layers of isolation: a change in one closed layer does not ripple into the others, because nobody is allowed to skip past it.

Closed layers are also where the most cited complaint comes from, the architecture sinkhole anti-pattern. A request arrives, the controller hands it to the service, the service hands it straight to the repository, and nothing happens in between except forwarding. The work is a pass-through. Here is the sinkhole in the flesh:

# presentation
@router.get("/users/{user_id}")
def get_user(user_id: int):
    return user_service.get_user(user_id)

# domain
def get_user(user_id: int):
    return user_repository.find_by_id(user_id)

# data
def find_by_id(user_id: int):
    return db.query(User).get(user_id)

The service layer earns nothing here. It exists so the diagram stays symmetrical. Critics point at this and conclude the whole pattern is ceremony.

Richards' answer is the one most people skip: count them. Some pass-throughs are fine. His rule of thumb is roughly 80-20. If about 20 percent of your requests are simple forwarding and 80 percent do real work in each layer, the structure is paying for itself. If that ratio inverts and most requests are pure pass-through, the architecture has become a tax. The fix is not to abandon layering. It is to open the layers that add nothing, letting some requests reach the data layer directly, while accepting that you have traded a little isolation for a lot less boilerplate. The mistake teams make is leaving every layer closed out of dogma, then blaming the pattern for the boilerplate they chose to keep.

The real failure mode is leakage

The sinkhole is annoying. Leakage is what actually kills layered systems, and it gets discussed far less.

Leakage is when one layer's concerns bleed into another. The classic case: your ORM entity travels all the way up and out through the JSON response.

@router.get("/orders/{order_id}")
def get_order(order_id: int):
    order = order_service.get_order(order_id)
    return order  # this is a SQLAlchemy model

This looks clean. It is a trap. You have just coupled your HTTP contract to your database schema. Rename a column and you break API clients. Add a lazily loaded items relationship and your JSON serializer triggers a query outside the database session, which throws in production and nowhere else. Worse, the serializer walks every relationship it can reach and fires the N+1 query storm that shows up as a mysterious latency cliff under load. The data layer's shape has leaked through the domain and out the presentation layer, and now a schema migration is an API breaking change.

The discipline that prevents this is unglamorous and load-bearing: each layer owns its own data shape and translates at the boundary.

# presentation owns the response shape
class OrderResponse(BaseModel):
    id: int
    total: Decimal
    item_count: int

# domain returns a domain object, not a row
def get_order(order_id: int) -> Order:
    row = order_repository.find_by_id(order_id)
    return Order(id=row.id, total=row.total, items=row.items)

# presentation translates domain -> response
@router.get("/orders/{order_id}", response_model=OrderResponse)
def get_order_endpoint(order_id: int):
    order = order_service.get_order(order_id)
    return OrderResponse(id=order.id, total=order.total,
                         item_count=len(order.items))

Yes, it is more code. That extra translation is the actual product of layering. It is the seam that lets you change the database without changing the API, and change the API without touching SQL. Skip it and you do not have a layered architecture. You have three folders sharing one giant coupled object, which is a big ball of mud with good lighting.

The reason this matters more than the sinkhole: a sinkhole costs you a few redundant function calls, measurable and cheap. Leakage costs you the one benefit you adopted layering for, the ability to change one concern without disturbing the others. A system can have perfect-looking folders and zero real isolation because the same User object flows through all of them untranslated.

Enforcing the rule when nobody is looking

Architecture diagrams do not enforce themselves. The presentation-imports-repository shortcut gets added at 5pm on a Friday because it was faster, and six months later half the codebase routes around the domain. Discipline that lives only in a wiki page is already dead.

This is why the dependency direction should be a test, not a convention. On the JVM, ArchUnit makes it a unit test that fails the build:

layeredArchitecture().consideringAllDependencies()
  .layer("Controllers").definedBy("..controller..")
  .layer("Services").definedBy("..service..")
  .layer("Persistence").definedBy("..repository..")

  .whereLayer("Controllers").mayNotBeAccessedByAnyLayer()
  .whereLayer("Services").mayOnlyBeAccessedByLayers("Controllers")
  .whereLayer("Persistence").mayOnlyBeAccessedByLayers("Services");

The day someone wires a controller straight into a repository, CI goes red with a precise message. Other ecosystems have their own version of this: ESLint boundary plugins in TypeScript, module visibility in Go and Java, internal packages, dependency-cruiser. The tool is incidental. The point is that the one rule that determines whether layered works for you needs a machine guarding it, because humans under deadline will always find the shortcut.

Yes, but isn't this just an excuse for a monolith?

The sharpest objection is that "layered architecture" is a polite name for not having an architecture. You drew three boxes, called it a design, and shipped a monolith.

There is truth in it, and it is worth conceding cleanly. Layering is a weak constraint. It tells you which direction dependencies flow, and nothing else. It says nothing about how you slice the domain, where transactional boundaries sit, or whether your OrderService has quietly grown to four thousand lines. You can satisfy every layer rule and still have an unmaintainable system, because the rot moved sideways within a layer instead of across them.

But that is an argument for adding constraints, not for throwing layering out. The honest move when the domain logic gets hard to test, or when you need to swap an external dependency without rewriting the core, is to invert a dependency: make the domain define an interface and have the data layer implement it, so the arrow that used to point down now points up. The moment you do that deliberately, you have started building hexagonal architecture, the subject of the next letter in this series. Hexagonal is not the opposite of layered. It is layered with the dependency direction inverted at the boundaries that need it. You graduate to it from a specific, measured pain, not from a blog post telling you layered is for beginners.

What to do Monday

Default to layered. On a new service it is the right first move, not a placeholder you are embarrassed by. Then spend your discipline on the two things that actually decide the outcome.

First, give every layer its own data shape and translate at the boundary, even when it feels redundant on day one. That translation is the entire value proposition. The day a schema migration does not break your API is the day it pays you back, with interest.

Second, make the dependency direction a failing test, not a paragraph in a README. Add ArchUnit, a lint boundary rule, module visibility, whatever your stack supports. A rule a machine checks on every commit is real. A rule in a wiki is a wish.

And when you hit the wall where the domain needs to call outward and the layer rule fights you, do not conclude that layered failed. Conclude that you have found the one boundary that needs inverting, and invert it on purpose. That is not abandoning the default. That is the default doing its job, which was to be obvious enough that the one place you need something cleverer is obvious too.

Sources

Keep Reading