Designing clean modular boundaries is only half the battle. The real challenge is preventing them from eroding as a codebase scales. Without automated guardrails and static contracts, boundary leakage inevitably creeps in.
The previous posts already covered the strategic case for Bounded Contexts and how to express them in code; this post is about enforcing these boundaries.
Modularity decay in growing Python codebases
In dynamic, interpreted languages like Python, the runtime executes imports at startup. Python has no language-enforced visibility layers1
such as Java’s package-private visibility, C++‘s namespace restrictions, or C#‘s internal keyword , relying entirely on developer discipline and convention. If a module resides on the Python path, any other module can import it.
This means that modular boundaries exist only as conceptual agreements between developers. A single import statement in the wrong module can silently break architectural contracts, and no built-in tooling raises an alarm. The problem compounds as teams grow. New contributors unfamiliar with the intended layer separation introduce imports that shouldn’t be allowed.
As codebases grow, several modularity decay patterns emerge.
| Decay Pattern | Technical Cause | Operational Consequence |
|---|---|---|
| Shortcut Imports | Direct imports of concrete adapters bypassing ports | Tightly couples layers and prevents swapping adapters |
| Circular Imports | Mutual imports across different architectural layers | Triggers ImportError on startup and blocks unit testing |
| Accidental Coupling | Core domain importing infrastructure libraries | Forces domain changes during infrastructure package updates |
Developers often introduce shortcut imports when sibling layers bypass defined ports and import concrete adapters directly. The shortcut circumvents the port-adapter contract, making it difficult to swap implementations (e.g., swapping a database client) and causing compile-time or testing-time dependencies to leak across boundaries. In practice, shortcut coupling results in test suites requiring fully configured database connections just to verify simple application behavior.
Circular dependencies emerge when modules across different layers import each other, creating execution cycles during package initialization. Because Python executes import statements dynamically at runtime, these cycles cause ImportError crashes at application startup. Operationally, circular imports force developers to use localized or late imports, obscure the dependency graph, and make it hard to isolate a single component in a unit test.
Accidental coupling happens when the core domain layer imports external libraries2
like psycopg2, redis, or boto3 or low-level implementation details, often because an adapter has leaked into a domain model. When the domain layer directly couples to third-party dependencies, any upgrade or modification to those libraries forces changes inside the core business logic. This tight coupling defeats the purpose of clean architecture, which dictates that business logic must remain stable and unaffected by infrastructure changes.
A static analysis tool that treats architectural modular boundaries as first-class constraints solves this problem. The linter fails the build immediately if the source code violates a contract.
Enforcing modular boundaries with Import Linter
To prevent modularity decay, establish strict rules for dependency direction and package layout. As discussed in the previous article on Hexagonal Architecture,3 Ports and Adapters the core domain and application layers reside at the center. Outward adapters depend on inward ports.
Inbound driving flows occur when external drivers4
e.g., HTTP controllers, CLI commands, AMQP event consumers call the Application layer through driving ports. These drivers reside in the adapters module.
Outbound driven flows occur when the core application calls external services5
e.g., database engines, payment APIs, message brokers through driven ports, and the concrete implementations live in the same adapters module.
The central business domain remains isolated and maintains zero outbound dependencies.
From leaky imports to clean contracts
Before configuring Import Linter, consider what a typical boundary violation looks like in practice. A common shortcut is importing a concrete adapter directly inside the application layer:
# shop/application/use_cases.py: BEFORE (leaky)from shop.adapters.db_adapter import ( PostgresOrderRepository,)
class OrderProcessor: def __init__(self) -> None: self._repo = PostgresOrderRepository( "postgresql://localhost/shop" )The application layer now depends directly on the Postgres adapter. Swapping the database requires modifying business logic code. Running the linter against this code surfaces the violation immediately:
$ lint-imports=============Import Linter=============
----- Forbidden import -----
shop.application.use_cases -> shop.adapters.db_adapter
Contracts: 1 brokenThe fix introduces a port abstraction. The application layer depends on the protocol interface, and the concrete adapter is injected at the composition root:
# shop/application/use_cases.py: AFTER (clean)from shop.ports.repositories import ( OrderRepositoryPort,)
class OrderProcessor: def __init__( self, repo: OrderRepositoryPort ) -> None: self._repo = repoAfter applying the fix, the linter passes. The application layer no longer knows which database driver backs the repository.
Automating enforcement with Import Linter contracts
Enforcing the architecture automatically requires installing import-linter and writing rules inside your pyproject.toml configuration file.
Install the package via your package manager:
uv add --dev import-linterAdd the following blocks to the configuration file to define your contracts. Start with the root config and the layers contract that enforces the top-down hierarchy:
[tool.importlinter]root_package = "shop"exclude_type_checking_imports = true
# Layers contract: enforce a strict top-down dependency hierarchy.# exhaustiveness = true means adding a new top-level module under shop/# (one not in the layers list) is itself a contract violation (preventing# silent boundary creation in new code).[[tool.importlinter.contracts]]name = "Hexagonal layer hierarchy"type = "layers"layers = [ "adapters", "application", "ports", "domain",]exhaustiveness = trueNext, the forbidden contracts that prevent reverse dependencies. as_packages = false applies the rule to the exact module, not the whole package; unmatched_ignore_imports_alerting warns when an ignore_imports entry is no longer needed (catches stale exceptions):
[[tool.importlinter.contracts]]name = "Domain layer isolation"type = "forbidden"source_modules = ["shop.domain"]forbidden_modules = [ "shop.ports", "shop.application", "shop.adapters",]as_packages = falseunmatched_ignore_imports_alerting = "warn"
[[tool.importlinter.contracts]]name = "Ports depend only on domain"type = "forbidden"source_modules = ["shop.ports"]forbidden_modules = [ "shop.application", "shop.adapters",]as_packages = falseFinally, the three sibling-group contracts. independence keeps sibling adapters from importing each other; protected marks a module as a public surface that anyone can import but whose internals must not leak back; acyclic_siblings enforces that a flat group of plugins has no cycles between them:
[[tool.importlinter.contracts]]name = "Adapter sibling independence"type = "independence"modules = [ "shop.adapters.db_adapter", "shop.adapters.stripe_adapter",]
[[tool.importlinter.contracts]]name = "Framework public API"type = "protected"include_modules = [ "shop.framework.api",]expose_modules = [ "shop.domain",]
[[tool.importlinter.contracts]]name = "No cycles between plugins"type = "acyclic_siblings"modules = [ "shop.plugins.auth", "shop.plugins.billing", "shop.plugins.notifications",]The configured contracts enforce seven distinct architectural rules:
- The
layerscontract enforces a strict top-down structure where adapters depend on application, ports, or domain modules, application depends on ports or domain, and so on. Withexhaustiveness = true, adding a new top-level module undershop/is itself a violation, preventing silent boundary creation. - The
forbiddencontracts prevent reverse dependencies. Withas_packages = false, the rule applies to the exact module, not the whole package; withunmatched_ignore_imports_alerting = "warn", a staleignore_importsentry becomes a warning rather than silently passing. - The
independencecontract enforces that adapters cannot depend on one another (the database adapter must not import the Stripe adapter). - The
protectedcontract marks a module as a public framework surface: anyone can import it, but it cannot import the rest of the codebase. Useful for the “stable API” of a framework, an SDK entry point, or a plugin host. - The
acyclic_siblingscontract enforces that a group of sibling modules has no import cycles between them. Useful for a flat package of plugins that must remain independent.
For multi-package monorepos with uv / hatch / pdm workspaces, set root_package to a list of workspace members:
[tool.importlinter]root_package = ["shop", "shop_admin", "shop_worker"]Execute the linter in the terminal:
lint-importsIf a violation is present, the command returns a non-zero exit code and prints a traceback of the violating import path.
Managing test suites and type hint cycles
Integrating architectural contracts into real-world codebases introduces practical challenges around unit testing exceptions and cyclic type annotations.
Excluding test suites from import contracts
Unit tests and integration tests frequently need to import both core logic and concrete adapters. For instance, an integration test needs to instantiate a PostgresOrderRepository to verify the execution of an OrderProcessorService. If test files live within the main package,6
e.g. shop.adapters.tests or shop.domain.tests the linter analyzes them by default and raises errors for violating layers.
To handle test suites cleanly, choose between two main structures:
- Moving tests outside the package root: this structures your project so that the
tests/directory is a sibling tosrc/. - Whitelisting tests inside package contracts: this uses the
ignore_importsconfiguration option insidepyproject.toml.
Moving tests outside the package root
Structuring the project so that the tests/ directory is a sibling to src/, and not a child of the shop/ package, allows the Import Linter to ignore the tests. This external directory structure is the recommended approach because root_package = "shop" limits analysis to the source tree, eliminating configuration noise.
Whitelisting tests inside package contracts
If tests must reside inside the package, you can whitelist specific test imports in each contract:
[[tool.importlinter.contracts]]name = "Domain layer isolation"type = "forbidden"source_modules = ["shop.domain"]forbidden_modules = ["shop.adapters"]ignore_imports = [ "shop.domain.tests.test_models -> shop.adapters.db_adapter",]While ignore_imports is useful, this approach introduces configuration bloat over time. Move tests outside the package folder whenever possible.
Resolving type hint cycles
A frequent issue with static import contracts is importing classes strictly for type annotations. For example, a port often needs to type-hint a domain parameter, and importing the domain module can cause a circular import without proper handling.
To resolve these cycles cleanly, you can use two approaches:
- Wrapping imports in a
TYPE_CHECKINGguard imports types purely for static analysis. - Setting
exclude_type_checking_imports = trueinstructs the linter to ignore imports inside type-checking blocks.
from typing import TYPE_CHECKING, Protocol
if TYPE_CHECKING: from shop.domain.models import Order
class OrderProcessorPort(Protocol): async def process( self, order: Order ) -> bool: ...Benchmarking Import Linter on real codebases
I benchmarked lint-imports against three open source projects that already use it in production, with their real contract configurations in place, to see how this tool performs in different situations.
| Project | LoC | Files | Why it earned a slot |
|---|---|---|---|
| janbjorge/pgqueuer | 10k | 70 | A textbook hexagonal layout. Small enough that you can read every contract by hand, fast enough that any regression is obvious. |
| wemake-services/django-modern-rest | 20k | 150 | Eight contracts across three sub-trees (dmr, dmr.streaming, dmr.openapi), with 189 TYPE_CHECKING imports spread over 93 files. Useful for isolating the effect of the exclude_type_checking_imports toggle. |
| PostHog/posthog | 200k | 6k | The “large codebase” anchor. 6k files, 10k import edges, and a real forbidden contract over products.*.backend.presentation with 80+ allowlisted ignore_imports entries. |
A clean venv keeps the import graph from picking up unrelated site-packages:
uv venv .bench-venvsource .bench-venv/bin/activateuv pip install import-linterFor a project that already declares import-linter in pyproject.toml, install it in editable mode so the linter picks up the real root_package and contracts:
git clone --depth=1 https://github.com/janbjorge/pgqueuer.gitcd pgqueueruv pip install -e .[dev]I used hyperfine to time the linter because it’s written in rust it runs warmups and reports standard deviation:
hyperfine --warmup 1 --runs 5 'lint-imports'Here are the benchmarks on an Apple M1 Pro:
| Project | LoC | Files | Import edges | Contracts | Time (median) | RAM |
|---|---|---|---|---|---|---|
| pgqueuer | 10k | 70 | 175 | 4 forbidden | 0.155s | 36 MB |
| django-modern-rest | 20k | 150 | 400 | 8 (3 layers + 1 forbidden + 4 independence) | 0.167s | 39 MB |
| PostHog/posthog | 200k | 6k | 10k | 1 forbidden | 0.460s | 65 MB |
The PostHog figure is the most representative for “large codebase” expectations. Even with 6k files and 10k import edges, the linter finishes in under half a second, and the entire graph fits in 65 MB of RAM.
Three findings worth pinning down
1. The exclude_type_checking_imports toggle matters when you have many type-only imports. On django-modern-rest (189 TYPE_CHECKING imports across 93 files), toggling exclude_type_checking_imports = true reduced runtime from 0.250s to 0.167s, a 33% drop. The effect on PostHog was negligible (0.470s → 0.460s) because the contract there is a single forbidden that doesn’t traverse the type-checking subgraph. Apply the toggle when your contracts include layers or independence over a codebase with heavy TYPE_CHECKING usage; skip it otherwise.
2. Memory scales sub-linearly. grimp (the underlying import-graph library that import-linter wraps) reuses the same Python process across contracts within a single lint-imports invocation, so adding contracts adds little memory. The 6,059-file PostHog run peaked at 65 MB, while the 70-file pgqueuer run peaked at 36 MB, only 1.8× more for an 86× increase in file count.
3. Contract type matters less than the graph size on small codebases. Toggling between three layers contracts and one forbidden contract on django-modern-rest (20k LOC) produced no measurable difference (both ~0.16s), because the import graph build dominates. The pattern may matter more once graphs cross 100k+ modules; on a 200k LOC codebase the layers contract would still finish in single-digit seconds.
Key practices for enforcing modular boundaries
- Import Linter automates boundary enforcement by treating architectural rules as build errors.
- Keeping test suites external to the source package simplifies contracts and eliminates the need for
ignore_importswhitelists. - Tracking the
ignore_importscount over time surfaces architectural debt before boundaries erode. - Resolving type hint cycles with
TYPE_CHECKINGguards keeps the linter happy without introducing runtime overhead.
What about dynamic imports?
Import Linter handles static import analysis comprehensively, but it can’t detect importlib.import_module calls or inline imports hidden inside function bodies. In most cases, static contracts are sufficient. However, there are some cases where dynamic imports are useful:
- Optional dependencies with hard fallbacks. A driver package that supports multiple backends keeps the imports inside the dialect functions so the package can be imported without the optional library installed.
- Plugin discovery and entry points. Loading a list of installed plugins from a package’s
entry_pointsgroup, or from a config file that names a module path, is dynamic by construction: the project does not know at build time which plugins are installed. - Circular-import workarounds that have no static equivalent. Sometimes, a deferred import inside a function is the only way to break the cycle. The
TYPE_CHECKINGimport is preferred when it works; the inline import is the fallback when it does not. - Heavy or rarely-used modules. Importing a large third-party library (a
pandasETL helper, amatplotlibreport generator) at the top of a module pulls it into every process that touches the module, including CLI tools and web request handlers that never need it. Deferring the import until the function that actually uses it keeps the cold-start path cheap and the dependency graph clean.
In the core domain layer, none of these use cases apply: they all belong to the infrastructure layer.
If you need to detect dynamic imports in the domain layer, then an AST scanning tool can be the way to go, especially in codebases where dynamic import bypasses have been observed in code reviews or where the contributor base is large enough that convention alone is insufficient.
References and additional resources
- Import Linter Documentation for detailed contract specifications.
- Python ast module documentation to understand abstract syntax trees.