Enforcing Modular Boundaries with Import Linter in Python

Part of the Python in Production: Architecture, Robustness, Async & FFI series

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 PatternTechnical CauseOperational Consequence
Shortcut ImportsDirect imports of concrete adapters bypassing portsTightly couples layers and prevents swapping adapters
Circular ImportsMutual imports across different architectural layersTriggers ImportError on startup and blocks unit testing
Accidental CouplingCore domain importing infrastructure librariesForces 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.

Domain Layer Zero outbound dependencies (shop.domain) Ports Layer (shop.ports) Application Layer (shop.application) Adapters Layer (shop.adapters) Allowed Direction INWARD Rule: imports flow INWARD only (↓) Outward imports (↑) are FORBIDDEN tests/ lives outside shop/ (can import any layer).

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 broken

The 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 = repo

After 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:

Terminal window
uv add --dev import-linter

Add 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 = true

Next, 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 = false
unmatched_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 = false

Finally, 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 layers contract enforces a strict top-down structure where adapters depend on application, ports, or domain modules, application depends on ports or domain, and so on. With exhaustiveness = true, adding a new top-level module under shop/ is itself a violation, preventing silent boundary creation.
  • The forbidden contracts prevent reverse dependencies. With as_packages = false, the rule applies to the exact module, not the whole package; with unmatched_ignore_imports_alerting = "warn", a stale ignore_imports entry becomes a warning rather than silently passing.
  • The independence contract enforces that adapters cannot depend on one another (the database adapter must not import the Stripe adapter).
  • The protected contract 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_siblings contract 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:

Terminal window
lint-imports

If 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 to src/.
  • Whitelisting tests inside package contracts: this uses the ignore_imports configuration option inside pyproject.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_CHECKING guard imports types purely for static analysis.
  • Setting exclude_type_checking_imports = true instructs the linter to ignore imports inside type-checking blocks.
shop/ports/processor.py
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.

ProjectLoCFilesWhy it earned a slot
janbjorge/pgqueuer10k70A textbook hexagonal layout. Small enough that you can read every contract by hand, fast enough that any regression is obvious.
wemake-services/django-modern-rest20k150Eight 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/posthog200k6kThe “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:

Terminal window
uv venv .bench-venv
source .bench-venv/bin/activate
uv pip install import-linter

For 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:

Terminal window
git clone --depth=1 https://github.com/janbjorge/pgqueuer.git
cd pgqueuer
uv pip install -e .[dev]

I used hyperfine to time the linter because it’s written in rust it runs warmups and reports standard deviation:

Terminal window
hyperfine --warmup 1 --runs 5 'lint-imports'

Here are the benchmarks on an Apple M1 Pro:

ProjectLoCFilesImport edgesContractsTime (median)RAM
pgqueuer10k701754 forbidden0.155s36 MB
django-modern-rest20k1504008 (3 layers + 1 forbidden + 4 independence)0.167s39 MB
PostHog/posthog200k6k10k1 forbidden0.460s65 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_imports whitelists.
  • Tracking the ignore_imports count over time surfaces architectural debt before boundaries erode.
  • Resolving type hint cycles with TYPE_CHECKING guards 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_points group, 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_CHECKING import 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 pandas ETL helper, a matplotlib report 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