Every architecture document ever written has been outlived by the code it described. The diagram is right on the day it is drawn and wrong three months later, and nobody notices because nothing checks.
A fitness function is the check: an automated, objective assertion about a structural property of the system, run on every commit. Not “the domain should not depend on the database” in a wiki, but a test that fails the build when it does.
Four kinds are worth having, in ascending order of what they cost to write.
1. Import-graph contracts
import-linter reads a contract file — conventionally in your
pyproject.toml — and fails if the graph disagrees:
[tool.importlinter]
root_package = "pricing"
[[tool.importlinter.contracts]]
name = "Layers"
type = "layers"
layers = ["pricing.entrypoints", "pricing.adapters", "pricing.application", "pricing.domain"]
[[tool.importlinter.contracts]]
name = "Domain is pure"
type = "forbidden"
source_modules = ["pricing.domain"]
forbidden_modules = ["sqlite3", "urllib", "httpx"]
[[tool.importlinter.contracts]]
name = "Adapters do not know about each other"
type = "independence"
modules = ["pricing.adapters.sqlite", "pricing.adapters.http"]
Three contract types cover almost everything: layers (an ordering,
dependencies point one way), forbidden (this may never reach that), and
independence (these siblings may not reach each other, in either
direction).
The critical property, and the reason a grep cannot do this job, is that
contracts are transitive. domain → helpers → sqlite3 is a violation
even though no line in domain mentions sqlite3. A tool that reports the
chain tells you where to cut; a tool that reports “somewhere in here”
does not.
ruff analyze graph emits the dependency graph as JSON if you would rather
write the assertion yourself — which is what the problem below has you do.
💡Your contract passes. A colleague adds import sqlite3 inside a function in domain/order.py rather than at module top level. Does the contract still pass?
click to reveal
It depends on the tool, and the honest answer is: often yes, which is exactly why a function-local import is not a free workaround.
Static import-graph tools read the AST. Some count every Import node
wherever it appears; others only consider module-level imports. If yours does
the latter, the function-local import is invisible to it and the contract
goes green on a genuine violation.
Two defences. Check what your tool actually does — write the violating file, run the contract, and see. And add the sabotage test below, which does not care where the import is written, because it fails at execution.
The general lesson: a fitness function you have never seen fail is a fitness function you have no evidence about. Break it on purpose once.
2. Environment sabotage
The strongest structural test there is, because it cannot be fooled by where an import is written:
code = (
"import sys, os\n"
"sys.modules['sqlite3'] = None\n"
"sys.modules['httpx'] = None\n"
"os.environ = {}\n"
"import pricing.domain\n"
"print(pricing.domain.total(...))\n"
)
subprocess.run([sys.executable, "-P", "-c", code], check=True)
A fresh subprocess, with the adapters’ dependencies poisoned and the
environment removed, imports the inner layer and computes with it. If
pricing.domain reads an environment variable, opens a database, or imports
an HTTP client anywhere on that path — top level or function local — this
fails loudly.
Note the -P: without it the working tree is on the path and you may not be
testing the installed artefact at all.
3. Module-namespace assertions
Cheap and surprisingly effective:
def test_public_surface_is_exact() -> None:
import pricing
assert set(pricing.__all__) == {"quote", "Money", "Order", "RateSource"}
def test_importing_the_package_does_not_cost_a_database_driver() -> None:
code = "import pricing, sys; assert 'sqlite3' not in sys.modules"
subprocess.run([sys.executable, "-P", "-c", code], check=True)
The first turns “we accidentally made something public” into a failing test
and a deliberate decision. The second is an import-cost budget: it forces
the adapter import to be deferred into the function that needs it, and it
fails the moment somebody adds a convenient top-level re-export. Pair it with
python -X importtime when it fires, to see what pulled the driver in.
4. AST fitness functions
When the property is about how the code is written rather than what it
imports — no bare except:, no set_start_method anywhere in a library, no
pool constructed at import time — the check is an ast walk. That is the
mechanism in item 12.20, and it is the one to reach for last, because it is
the easiest to get subtly wrong.
The exercise that ties it together
The most valuable thing you can do with this material is not a puzzle; it is
a refactor with a contract attached. Take a real 180-line module that mixes
environment variables, SQL, an HTTP call, four business rules and an audit
write, and split it into a four-file package — domain, application
(holding the Protocols), adapters, entrypoints — with:
- the same golden cases passing through in-memory adapters;
-
the sabotage test above, proving
domainimports and computes withsqlite3,httpxandos.environunavailable; - an import-linter contract with zero violations;
-
__all__exact; -
"sqlite3" not in sys.modulesafterimport yourpkg; - a differential test running the same cases through the SQLite adapter and the in-memory one, asserting they agree.
💡Of that list, which single check gives the most information about whether the abstraction is honest? click to reveal
The differential test.
Every other check is about shape: the imports point the right way, the
namespace is what you said, nothing heavy loads. Shape can be correct while
the abstraction is a lie — a RateSource Protocol whose in-memory
implementation quietly behaves differently from the SQL one (rounding, null
handling, ordering, what happens on a missing key) still satisfies every
structural assertion.
Running the same forty cases through both adapters and asserting identical results is what proves the port is not leaky. It is also the check that catches the most common real defect: the fake is easier to satisfy than reality, so tests pass against the fake and production does something else.
The sabotage test is a close second, because it is the only one that cannot be satisfied by moving an import around.