Skip to content

← Capstones step 2 of 5

Hard End-to-End

Capstone: break the monolith, then enforce the layering

You have already split the monolith. Four modules, a domain with no I/O, adapters behind ports. It is beautiful, and in four months it will be gone, because the next person under deadline will import sqlite3 in the domain layer and no automated check will notice.

A layout without enforcement is decoration. The enforcement artefact — a set of contracts that fail the build — is the transferable deliverable, and it is what you build here. This is the engine behind import-linter, reimplemented so that you know exactly what its three contract types mean and, more importantly, what they do not catch.

def parse_contract(raw: Mapping[str, object]) -> Contract: ...
def check_contracts(graph: Graph, contracts: Sequence[Contract]) -> list[Report]: ...

The graph

Graph = Mapping[str, Sequence[str]] — module name to the modules it imports. A real one comes from ruff analyze graph or from walking the ASTs. Two properties matter:

  • Not every node is a key. sqlite3 and httpx are imported and never import anything, so they appear only in the values. Build your node set from both sides or you will silently pass every contract that mentions a leaf.
  • A spec covers its descendants. pricing.domain means pricing.domain and pricing.domain.rules and everything below. That is the only reading under which a layer is a layer rather than a single file.

The three contract types

layers — the list is ordered highest first. A higher layer may import a lower one; a lower layer importing a higher one is a violation. Check every ordered pair (lower, higher).

forbiddensource_modules must not reach forbidden_modules. Check every (source, forbidden) pair.

independence — none of the listed modules may reach any other, in either direction. Check every ordered pair.

In all three, “reach” means transitively. This is the part people get wrong when they hand-roll a checker with a regex over import statements: the interesting violations are never direct. pricing.domain does not import sqlite3 — it imports an adapter that does, which is exactly how the layering rots without anyone writing an obviously bad line.

Reporting

A violation is the import chain, as a list of module names. Which chain? There may be thousands, so the answer must be canonical:

the shortest chain; among equally short chains, the lexicographically smallest sequence.

Breadth-first search, visiting each node’s imports in sorted order and keeping the first path that reaches a node, produces exactly that. Seed the queue with every source module, in sorted order, so the search is multi-source and still deterministic.

One chain per pair, then deduplicated and sorted — two different pairs can legitimately produce the same chain, and a report that lists it twice is noise. Contracts are reported in the order given.

The typing is the other half

Contract is a tagged union of TypedDicts discriminated by a Literal kind. That buys three things:

  1. contract["layers"] type-checks only inside the branch where contract["kind"] == "layers".
  2. Ending the chain with assert_never(contract) means that the day someone adds a fourth contract type, check_contracts fails to type-check rather than silently returning an empty violation list for it. A fitness function that silently passes is worse than none.
  3. parse_contract is a boundary function in the sense of the whole of T13: Mapping[str, object] in, Contract out. The contract file is untrusted input like any other — the last test case feeds it a string where a list belongs.

What this does not check, and how the full capstone gets there

A graph assertion is one mechanism of several, and the others are worth naming because you will want them on a real codebase:

  • Layering by sabotage. python -c "import pricing.domain" in a fresh subprocess with sqlite3, json and urllib poisoned in sys.modules, and os.environ replaced by a mapping that raises. If the domain still imports and computes, it really is pure — no graph analysis required.
  • Module-namespace assertions. pricing.__all__ exact, and "sqlite3" not in sys.modules after import pricing, which forces the adapter import to be deferred.
  • Differential testing. Run the same forty golden cases through the SQLite adapter and the in-memory one. If the results differ, the port is leaky and the abstraction was a lie regardless of what the import graph says.

The graph contract is the one that runs in a second on every commit. The others are what you reach for when it passes and the design is still wrong.