Layered Architecture

When Everything Was in One Module

Before I understood layered architecture, my code looked like this in practice: a function that accepted HTTP parameters, validated them, ran a database query, formatted the result, and returned the response β€” all in the same block. Changing the database schema broke the HTTP layer. Changing the response format required touching the query logic.

Layered architecture gave me the first vocabulary for talking about what belonged where, and β€” more importantly β€” the rule about which direction dependencies were allowed to flow.

Table of Contents


What Is Layered Architecture?

Layered architecture organises code into horizontal layers stacked on top of each other. Each layer:

  • Has a specific responsibility

  • Can only call layers below it (dependencies flow downward)

  • Exposes a well-defined interface to the layer above it

  • Is unaware of the layer above it

spinner

From top to bottom:

  • Presentation knows about HTTP; knows nothing about business rules

  • Application orchestrates use cases; knows nothing about HTTP or SQL

  • Domain contains the business rules; knows nothing about persistence

  • Infrastructure handles technical concerns; knows nothing about use cases


The Dependency Rule

The key constraint: dependencies only point downward. The Presentation layer can depend on Application; Application can depend on Domain; Domain can depend on Infrastructure.

What it prevents:

  • Business logic importing from router/controller code

  • Domain entities importing SQLAlchemy models

  • Use cases hard-coding HTTP calls to external services

The rule makes each layer independently testable and replaceable.


Standard Layers

Presentation Layer

Handles I/O with the outside world. For a web service, this is the HTTP boundary β€” routing, request parsing, response serialisation, HTTP status codes, authentication middleware.

The presentation layer should contain zero business logic. If a controller function does anything beyond parsing input, calling a service, and formatting output, business logic has leaked.

Application Layer

Orchestrates use cases β€” sequences of operations that fulfil a specific business goal. An application layer function like create_order() calls domain services, coordinates repositories, and publishes events. It does not know whether the request came from HTTP or a message queue.

Domain Layer

The heart of the application. Contains entities, value objects, domain services, and business rules. In a pure layered model, this layer has no knowledge of infrastructure β€” it does not import SQLAlchemy, httpx, or any external library.

Infrastructure Layer

Implements the technical concerns that the domain layer defines abstractly: repositories that read/write to a database, HTTP clients, email senders, file storage. The infrastructure layer implements interfaces defined in the domain or application layer.


The Risk: Lasagna Code

The failure mode of layered architecture is lasagna code: so many layers of abstraction for simple operations that making any change requires touching five files, each adding no real value.

I have written lasagna code. A GET /products endpoint that went through:

  1. ProductController β†’ 2. ProductApplicationService β†’ 3. ProductDomainService β†’ 4. ProductRepository β†’ 5. ProductDataMapper β†’ database

For a simple query, that is too much ceremony. Three layers is usually sufficient. Add a fourth only when it genuinely contains distinct logic.


When Layers Are Enough

Layered architecture is sufficient when:

  • The application logic is straightforward and does not change frequently

  • There is one primary interface (HTTP API) β€” no need to support multiple adapters

  • The team is small and grasps the entire codebase

  • Domain rules are not complex enough to warrant domain-centric designs

When the domain becomes complex, consider Onion Architecture or Ports & Adapters which give the domain stronger isolation guarantees.


Practical Example: Inventory Module

Notice: the Product domain model has no SQLAlchemy imports. It is a plain dataclass. The ProductRepository lives in Infrastructure and handles the ORM mapping. The ReserveStockUseCase in Application orchestrates the operation. The router in Presentation handles HTTP concerns.


Comparison with Onion and Hexagonal

Aspect
Layered
Onion
Hexagonal (Ports & Adapters)

Dependency direction

Downward through all layers

Inward toward domain

Inward toward core, via ports

Domain position

Middle layer

Centre

Core

Infrastructure position

Bottom layer

Outermost ring

Adapter (pluggable)

Primary benefit

Separation of concerns

Domain isolation

Multiple adapters (UI, API, CLI)

Test strategy

Mock lower layers

Mock infrastructure

Mock adapters via port interfaces

Layered architecture is the simplest starting point. Onion and Hexagonal express the same dependency direction more rigorously and make the domain's independence more explicit.


Lessons Learned

  • The main rule is one rule: dependencies flow in one direction. Everything else follows.

  • Separate the domain model from the persistence model. A SQLAlchemy ORM class is infrastructure. A domain entity is not. Merging them exposes domain code to database concerns.

  • Thin presentation layer, thin application layer, rich domain. Complexity belongs in the domain, not in the router.

  • Layered architecture does not mean every layer must always exist. Simple CRUD endpoints do not need a domain layer with rich entities. Add complexity proportional to the actual domain complexity.

Last updated