Skip to content
← All articles

Layered architecture: domain, application, adapters

The alternative is a codebase where every module can import every other, so every change has unbounded blast radius, every test needs a database, and nobody can reason locally.

Ask what happens in your codebase when somebody changes one function. If the honest answer is “we run the whole suite and hope”, you do not have an architecture — you have a directory.

The cost of no structure is not aesthetic. It is:

  • Unbounded blast radius. Any module may import any other, so the set of things a change can break is the whole repository.
  • Every test needs a database. Business rules that call the ORM directly cannot be tested without one, so the fast tests are slow and the slow tests are the only tests.
  • No local reasoning. To answer “what does this do”, you have to read everything it can reach, which is everything.

Four layers and one arrow

entrypoints/   the CLI, the HTTP handlers, the worker main().
               The ONLY place concrete adapters get constructed.
adapters/      the ONLY place a third-party import lives:
               sqlalchemy, httpx, boto3, redis.
application/   use cases. Depends on Protocols, never on adapters.
domain/        pure. Imports nothing from your other layers.

One rule: dependencies point inward. entrypoints may import anything; adapters may import application and domain; application may import domain; domain imports nothing of yours. Same-layer imports are fine.

The direction is what buys you everything else. domain has no third-party imports, so it loads in microseconds and tests without fixtures. application depends on a RateSource Protocol rather than on SqliteRateSource, so the test supplies a dict.

Where the inversion happens

If application needs to read exchange rates, and the code that reads them lives in adapters, the import would have to point outward. It does not, because the Protocol is declared in the layer that consumes it:

# application/ports.py — the inner layer states its requirement
from typing import Protocol

class RateSource(Protocol):
    def rate(self, base: str, quote: str) -> Decimal: ...


# application/quote.py
def quote(order: Order, rates: RateSource) -> Money: ...


# adapters/sqlite_rates.py — the outer layer satisfies it, structurally
class SqliteRateSource:
    def rate(self, base: str, quote: str) -> Decimal: ...

adapters imports application; nothing imports back. SqliteRateSource does not inherit from anything and does not know RateSource exists — that is structural typing doing the work that an ABC would need an import for.

💡Why put the Protocol in application/ rather than in adapters/ next to the class that implements it? click to reveal

Because where the Protocol lives determines which way the import arrow points, and that is the entire mechanism.

If RateSource lives in adapters/, then application/quote.py must from adapters.ports import RateSource — an outward import. You have written an interface and gained nothing: the inner layer still cannot be imported, tested or reasoned about without the outer one.

With the Protocol in application/, the dependency inverts. The inner layer states its requirement and the outer layer happens to satisfy it. application can be imported with sqlite3 poisoned in sys.modules and it still works, which is a test you can actually write.

The slogan is “the consumer owns the interface”, and the test of whether you got it right is: can the inner layer be imported alone?

Enforcement is the point

A layout with no enforcement is decoration. Within a month somebody adds from adapters.db import session to a domain module because it was convenient, review does not catch it, and the structure is gone. Three mechanisms, cheapest first:

  • Ruff flake8-tidy-imports, banned-api, per-module. Fast, runs in the editor, but it is a name-matching rule rather than a graph one.
  • An import-linter contract in pyproject.tomllayers, forbidden, independence. It understands transitive chains, which is the thing name matching cannot do.
  • A test that walks the import graph, which is what the problem below builds. No new dependency, and it fails on the branch that introduced the violation.

The honest limit

For a 400-line tool this is overhead. Four packages, a ports.py and a contract file, to wrap a script that reads a CSV and prints a total, is ceremony that makes the code harder to read, not easier.

The transferable lesson is not “always use four layers”. It is where the seam belongs, and the answer is: at every boundary you will want to test or replace, and nowhere else. A seam you never test and never swap is a layer of indirection you are paying for and not using.

💡Your codebase has one layer and one entry point, and someone proposes the full four-package split. What do you ask before agreeing? click to reveal

Which boundary hurts today.

Not “which boundaries could exist” — every function is a potential boundary. The useful question is which of these is currently true: we cannot test the business rules without a database; we need to swap the storage backend; the third-party client leaks into forty call sites so upgrading it is a month. Each of those names one seam worth having.

Introduce that seam, alone. A Protocol and one adapter is a two-file change and pays for itself the first time you write a test with a dict instead of a container. If a second boundary starts hurting later, add it then — with evidence rather than with a diagram.

The failure mode of the full split adopted on day one is that the layers encode a guess about which axis will change, the guess is wrong, and the team is left routing around their own architecture.