We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← Capstones step 2 of 5
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.
sqlite3andhttpxare 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.domainmeanspricing.domainandpricing.domain.rulesand 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).
forbidden — source_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:
-
contract["layers"]type-checks only inside the branch wherecontract["kind"] == "layers". -
Ending the chain with
assert_never(contract)means that the day someone adds a fourth contract type,check_contractsfails to type-check rather than silently returning an empty violation list for it. A fitness function that silently passes is worse than none. -
parse_contractis a boundary function in the sense of the whole of T13:Mapping[str, object]in,Contractout. 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 withsqlite3,jsonandurllibpoisoned insys.modules, andos.environreplaced 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.modulesafterimport 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.
Stuck?
Python reference solution
Sign in to attempt this problem and reveal the reference solution.