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. This post walks through the main pitfalls of enforcing imports boundaries and how to prevent them with Import Linter.


Modularity Decay in Growing Python Codebases

In dynamic, interpreted languages like Python, the runtime executes imports at startup. Python has no language-enforced visibility layers.1 such as Java’s package-private visibility, C++‘s namespace restrictions, or C#‘s internal keyword If a module resides on the Python path, any other module can import it.

Compiled languages offer package-private visibility or namespace restrictions that block unauthorized access at compile time. Python provides no equivalent mechanism, relying entirely on developer discipline and convention.

This lack of compiler enforcement 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 shortcut imports under time pressure.

As codebases grow, several modularity decay patterns emerge. You must identify and eliminate them to maintain a clean codebase architecture.

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 prevent unit tests from isolating specific components.

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.

# Example of boundary leakage in shop/domain/models.py
from shop.adapters.db_adapter import PostgresOrderRepository # Leaked

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, you must establish strict rules for dependency direction and package layout. Once you design these boundaries, you can automate their enforcement using Import Linter contracts.

Before configuring automated checks, establish the allowed direction of dependencies. 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.

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

Inbound vs. Outbound Boundary Flows

  • 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. The concrete implementations reside in the 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
pip install import-linter

Add the following blocks to the configuration file to define your contracts:

[tool.importlinter]
root_package = "shop"
exclude_type_checking_imports = true
[[tool.importlinter.contracts]]
name = "Domain layer isolation"
type = "forbidden"
source_modules = [
"shop.domain",
]
forbidden_modules = [
"shop.ports",
"shop.application",
"shop.adapters",
]
[[tool.importlinter.contracts]]
name = "Ports depend only on domain"
type = "forbidden"
source_modules = [
"shop.ports",
]
forbidden_modules = [
"shop.application",
"shop.adapters",
]
[[tool.importlinter.contracts]]
name = "Hexagonal layer hierarchy"
type = "layers"
containers = ["shop"]
layers = [
"adapters",
"application",
"ports",
"domain",
]
[[tool.importlinter.contracts]]
name = "Adapter sibling independence"
type = "independence"
modules = [
"shop.adapters.db_adapter",
"shop.adapters.stripe_adapter",
]

The configured contracts enforce four distinct architectural rules:

  • The domain layer isolation contract prevents domain models from importing anything from outer layers.
  • The “Ports depend only on domain” contract prevents protocol definitions from importing business use cases or adapters.
  • The “Hexagonal layer hierarchy” contract enforces a strict top-down structure where adapters can import from application, ports, or domain modules.
  • The “Adapter sibling independence” contract enforces that adapters cannot depend on one another, meaning the database adapter must not import the Stripe adapter.

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. You must handle unit testing exceptions and cyclic type annotations without eroding boundaries.

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 might need to type-hint a domain parameter, but importing the domain module could lead to 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
python3 -m venv .bench-venv
source .bench-venv/bin/activate
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
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 with import-linter 2.13 and Python 3.14:

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 (21k 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 213k LOC codebase the layers contract would still finish in single-digit seconds.


Key Practices for Enforcing Modular Python Boundaries

  • Import Linter automates boundary enforcement by treating architectural rules as builds 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.

Detecting Dynamic Import Bypasses with AST Scanning

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 well-maintained codebases, static contracts are sufficient. However, in legacy projects with accumulated technical debt, or in repositories with many contributors where coding conventions vary widely, developers may inadvertently (or deliberately) bypass static linters using dynamic imports.

For these scenarios, a custom Abstract Syntax Tree (AST) checker serves as a last line of defense. The following script scans all Python files in the domain and application layers, reporting any inline imports or import_module calls it finds:

import ast
import sys
from pathlib import Path
class ImportScanner(ast.NodeVisitor):
def __init__(self, path: Path) -> None:
self.path = path
self._in_function = False
self.violations: list[str] = []
def visit_FunctionDef(
self,
node: ast.FunctionDef | ast.AsyncFunctionDef,
) -> None:
self._in_function = True
self.generic_visit(node)
self._in_function = False
visit_AsyncFunctionDef = visit_FunctionDef
def visit_Import(
self, node: ast.Import | ast.ImportFrom
) -> None:
if self._in_function:
self.violations.append(
f"{self.path}:{node.lineno}"
" - Inline import forbidden"
)
return
self.generic_visit(node)
visit_ImportFrom = visit_Import

The scanner walks each file’s AST, tracking whether the current node is inside a function body. When visit_Import fires while _in_function is True, the script records an inline-import violation on the visitor’s violations list and returns without descending further into that subtree. Letting the visitor accumulate findings produces a complete report in a single pass.

A second visitor method catches importlib.import_module calls:

def visit_Call(self, node: ast.Call) -> None:
if (
isinstance(node.func, ast.Attribute)
and node.func.attr == "import_module"
):
self.violations.append(
f"{self.path}:{node.lineno}"
" - import_module forbidden"
)
return
self.generic_visit(node)

To run the scanner, add a driver script that iterates over the source tree:

if __name__ == "__main__":
total = 0
for p in Path("./src").rglob("*.py"):
scanner = ImportScanner(p)
scanner.visit(ast.parse(p.read_text()))
for v in scanner.violations:
print(v, file=sys.stderr)
total += len(scanner.violations)
if total:
print(
f"{total} dynamic import(s) detected.",
file=sys.stderr,
)
sys.exit(1)
print("No dynamic imports detected.")

This AST scanning script can be useful 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.

Running the scanner against the three benchmarked projects returned several dynamic imports in all of them!


References and Additional Resources