Skip to content
← All articles

Enforcing structure: import-graph fitness functions

A layout without enforcement is decoration. The enforcement artefact — a contract file, a namespace assertion, an import-cost budget — is the part that survives the people who wrote it.

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 domain imports and computes with sqlite3, httpx and os.environ unavailable;
  • an import-linter contract with zero violations;
  • __all__ exact;
  • "sqlite3" not in sys.modules after import 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.