Skip to content
← Back to explainers

Python explainer

Split Code into Domain, Application and Adapter Layers

About 4 min to read. Allow extra time to work through examples.

Keep business rules apart from database and network code, let each use case state the Protocols it needs, and make every import point inward.

This article uses Python. You can make it your practice mode to browse related content across the site.

You want to test a currency conversion. The function reads the exchange rate from a database, so the test needs a database. Yet the rule itself is one multiplication.

Layers fix this by keeping rules away from the code that does I/O. Split the code into three groups:

  • The domain holds business rules with no I/O, such as a Money value and the rule that amounts round to two decimal places.
  • The application holds the steps of a use case, and it states the Protocols it needs. An example is convert(amount, base, quote, rates), which asks for a RateSource.
  • The adapters talk to a database, the network or files. An example is SqliteRates, which reads rates from a table.

A fourth group, the entrypoints layer, starts the program. It is a main function or a web handler that builds the adapters and passes them in.

Imports point inward

Layer Holds May import
domain Business rules and values domain
application Use cases and the Protocols they need application, domain
adapters Database, network and file code adapters, application, domain
entrypoints Startup and wiring any layer

Each layer imports only itself and the layers inside it. The domain imports none of the others, so you can test it with no database installed. The exercise checks this rule.

Third-party packages are a separate choice. A database client belongs in an adapter, but a math library can be fine in the domain.

The application states what it needs

Here are the use case, its Protocol and a test stand-in, in one file:

from collections.abc import Mapping
from decimal import Decimal
from typing import Protocol


# application: the use case and the Protocol it needs
class RateSource(Protocol):
    def rate(self, base: str, quote: str) -> Decimal: ...


def convert(
    amount: Decimal, base: str, quote: str, rates: RateSource
) -> Decimal:
    return amount * rates.rate(base, quote)


# a test stand-in with the same method
class FixedRates:
    def __init__(self, values: Mapping[tuple[str, str], Decimal]) -> None:
        self._values = values

    def rate(self, base: str, quote: str) -> Decimal:
        return self._values[(base, quote)]


rates = FixedRates({("USD", "GBP"): Decimal("0.80")})
assert convert(Decimal("10"), "USD", "GBP", rates) == Decimal("8.00")

In a project, RateSource and convert live in the application layer. An adapter, SqliteRates, has a rate method that runs a query. The entry point builds a SqliteRates and passes it to convert, and a test passes a FixedRates. Neither class inherits from RateSource, because a Protocol needs only the matching method. A plain dict of rates does not fit, since it has no rate method.

Check your understanding Why does RateSource live in the application layer, not next to the database adapter? Show answer

The application is the code that uses it. If RateSource lived in the adapters, convert would have to import from an outer layer, which breaks the rule. In the application layer, the adapter does not need to import it at all. It only needs a matching rate method.

Check the rule automatically

Folders do not enforce the rule, because any module can still import any other. Add a check that runs on every change, so an import that points outward fails the build. The exercise below is a small version of that check. It takes the direct imports between modules as data, and it reports each one that points from an inner layer to an outer one. Import Linter, covered in a later article, does the same job on a real project, and it also follows indirect imports.

Keep your behavior tests too. Test the rules with stand-ins, and test each adapter against the real database.

Check your understanding When should a small script get layers? Show answer

When you have a concrete reason. Maybe a rule is hard to test without a service, storage code needs replacing, or calls to a vendor's API are spread across the code. Pull out that one boundary first. A small script may need only one function parameter or one Protocol, not four packages.

Going further

The Import Linter documentation describes its layers contract. mypy's protocol guide covers how mypy matches a class to a Protocol.

step 13 of 41

Modules, Packaging and Tooling

Up next Check That Imports Follow Your Layers