Your pricing package keeps its calculation rules in pricing.domain and
its database code in pricing.adapters.sqlite. The domain must never import
the database code. Nothing stops a teammate from adding that import next
month, and code review can miss it.
Turn the rule into a check that runs in CI. CI, or continuous integration, is the set of checks that run on every push or pull request. A broken boundary then fails the build with a message that names it. Tool docs call an automated check of a design rule like this a fitness function.
Check the import graph with Import Linter
Import Linter
reads your source, builds a graph of which modules import which, and checks
it against rules in pyproject.toml. Run it with lint-imports in the
project's environment:
[tool.importlinter]
root_package = "pricing"
include_external_packages = true
[[tool.importlinter.contracts]]
name = "Dependencies follow the layers"
type = "layers"
layers = ["pricing.entrypoints", "pricing.adapters", "pricing.application", "pricing.domain"]
[[tool.importlinter.contracts]]
name = "Domain does not import database or HTTP libraries"
type = "forbidden"
source_modules = ["pricing.domain"]
forbidden_modules = ["sqlite3", "urllib", "httpx"]
[[tool.importlinter.contracts]]
name = "Adapters are independent"
type = "independence"
modules = ["pricing.adapters.sqlite", "pricing.adapters.http"]
The layers contract lists layers from outermost to innermost, the reverse of
layer_order in the layering exercise. A
layer may import the layers listed after it, and not the ones before it.
The forbidden contract stops pricing.domain from importing the listed
packages, and by default it checks indirect imports too. If pricing.domain
imports pricing.helpers, and pricing.helpers imports sqlite3, the
contract fails.
For a package outside your project, such as sqlite3, set
include_external_packages = true and list the package's top-level name.
The independence contract stops the two adapters from importing each other.
Check your understanding
Does moving import sqlite3 inside a function make the forbidden contract pass?
Show answer
Hide answer
No. Import Linter reads every import statement in the source, including the
ones inside functions. Moving the import changes when Python runs it, not
whether the module depends on sqlite3.
Import Linter cannot see a module name built at runtime, such as
importlib.import_module(name). To test your setup, add a known bad import on
a branch and check that CI fails.
Check that a function runs without a dependency
A test can block a module and then call your code in a fresh Python process.
Suppose the installed pricing/domain.py holds:
from collections.abc import Sequence
def total(prices_in_cents: Sequence[int]) -> int:
return sum(prices_in_cents)
Its pricing/__init__.py re-exports total and lists it in __all__.
Install a built wheel of the package into a clean virtual environment, then
run this test with that environment's Python:
import subprocess
import sys
code = '''
import os
import sys
sys.modules["sqlite3"] = None
sys.modules["httpx"] = None
os.environ.clear()
from pricing.domain import total
assert total([250, 175]) == 425
import pricing
assert all(hasattr(pricing, name) for name in pricing.__all__)
'''
subprocess.run([sys.executable, "-I", "-c", code], check=True)
A None entry in sys.modules makes any later import of that name fail,
and os.environ.clear() removes every environment variable in the child.
Importing pricing.domain runs pricing/__init__.py first, so the test also fails if that file
loads sqlite3. The test calls total instead of only importing it,
because a function can import a module inside its body. The last two lines
check that every name in __all__ exists. -I keeps the child from reading your current folder,
PYTHON* environment variables and your per-user site-packages, so it imports
the installed copy. Install the wheel, not an editable install, which would
point back at your source.
The test covers only the code that this one call runs. Another branch in
total can still import a blocked module.
Check that adapters behave the same
Import rules cannot tell you whether two adapters do the same thing. Run the same cases through the in-memory adapter and the real database adapter, and compare the results and errors. Include missing keys, ordering, rounding and null values where your Protocol's rules cover them.
Check your understanding Both adapters fit the same Protocol. Why compare their outputs? Show answer Hide answer
A Protocol fixes method names and types. Two adapters can fit it and still disagree about a missing key or a rounding rule. Running both on the same inputs shows those differences for the cases you test.
Start with one rule
Pick the one boundary that matters most in your project. Add an import contract for it, a behavior test, and a known bad example that proves the check fails. Write each check so that its failure message tells you what to fix.
Going further
A small ast check can enforce source rules that no import contract covers,
such as a ban on bare except:. Rules about particular function calls need
more care, because a simple name match misses aliases, wrappers and dynamic
calls. Test such a check with examples that should pass and fail.