import-linter - Architecture enforcement
import-linter enforces architectural import rules (“layers”, “boundaries”, “contracts”) so your codebase doesn’t
silently become a dependency graph mess.
It fails if modules import things they shouldn’t (e.g., domain importing infra, or api reaching into db).
It’s most useful once a repo has multiple “areas” (packages, layers, bounded contexts) and refactors are frequent.
When it’s worth using
- Monorepo or “modular monolith” with multiple packages.
- Clear layering intent you want to keep stable over time.
- You’ve felt pain from:
- circular imports due to messy layering
- “just import that helper” spreading dependencies everywhere
- infra leaking into business logic
What it is not
- Not a style linter (ruff/flake8).
- Not a type checker (mypy).
- Not a runtime import cycle detector (though it can expose cycles indirectly).
- Not a replacement for good module boundaries (it enforces them; it can’t invent them).
Core mental model
- You define a set of modules/patterns (often Python packages).
- You define contracts that specify allowed vs forbidden import relationships.
import-linterbuilds an import graph and checks these contracts.
The key question each contract answers:
- “Can code in set A import code in set B?”
Installation and invocation
Install (typical):
pip install import-linter- or with poetry:
- add as dev dependency
Run:
lint-imports
Common flags:
lint-imports --config importlinter.inilint-imports --verbose(helpful when debugging why a rule fails)
Configuration files and precedence
Canonical config is usually an .ini file:
importlinter.ini(common convention)- or
setup.cfg(also supported by many tools, butimport-linterdocs typically show a standalone ini)
In practice:
- Prefer an explicit config file and invoke with
--configto avoid ambiguity.
Minimal, good starter configuration
Create importlinter.ini:
[importlinter]root_package = your_top_level_package# If you have multiple top-level packages, you’ll typically pick the “main” one# and use explicit module paths/patterns in contracts.
[importlinter:contract:layers]name = Enforce layering (presentation -> application -> domain -> infrastructure)type = layerslayers = your_top_level_package.presentation your_top_level_package.application your_top_level_package.domain your_top_level_package.infrastructureMeaning:
- presentation may import anything below it.
- application may import domain and infrastructure below it (but not presentation).
- domain may import only things below it (ideally nothing, depending on your structure).
- infrastructure is bottom: should not import “up”.
If you want “domain must not import infrastructure”, the above already enforces it if domain is above infrastructure.
Common contract types and patterns
Layers contract (type = layers)
Use when you have a strict stack (top depends on bottom, never vice versa).
Good for:
- api/web → service → domain → db/infra
Pitfall:
- Too strict early on; you’ll either fight the tool or weaken layers until it becomes meaningless.
Forbidden contract (type = forbidden)
Use when there are specific “nope” edges.
Example: “domain must never import infrastructure”:
[importlinter:contract:no_domain_infra]name = Domain must not depend on infrastructuretype = forbiddensource_modules = your_top_level_package.domainforbidden_modules = your_top_level_package.infrastructureGood for:
- Preventing a single architectural sin without defining a full layer stack.
Pitfall:
- Can become a whack-a-mole list of exceptions if the architecture isn’t clear.
Independent / bounded contexts (type = independence)
Use when packages must not import each other (mutual independence).
Example: “contexts are independent”:
[importlinter:contract:contexts_independent]name = Bounded contexts must be independenttype = independencemodules = your_top_level_package.billing your_top_level_package.shipping your_top_level_package.identityGood for:
- Enforcing “talk via interfaces/events, not direct imports”.
Pitfall:
- Sometimes you legitimately need shared code; extract it into your_top_level_package.shared rather than allowing cross-imports.
Best practices
Start with 1–2 high-signal contracts
Begin with:
- a layers contract for a coarse architecture, OR
- a single forbidden contract for a critical boundary.
Add more only after the team agrees they reflect real intent.
Keep “shared” truly shared
If multiple areas need the same code:
- extract shared abstractions into a dedicated shared package
- keep it small and stable
Avoid “shared” becoming a dumping ground:
- enforce “shared must not import from contexts” using another contract.
Decide where interfaces live
A common stable rule:
- domain defines interfaces/ports
- infrastructure implements them
Then enforce:
- domain does not import infrastructure
- infrastructure may import domain
Prefer dependency inversion over exceptions
If you find yourself wanting to allow a forbidden import:
- that often indicates a missing interface or a misplaced responsibility.
Common pitfalls (and what to do instead)
“It flags imports that are only for typing”
Python typing imports can create unwanted edges.
Prefer:
from __future__ import annotations(py>=3.11 always-on behavior, but still useful below)if TYPE_CHECKING:blocks- string annotations (“SomeType”)
Also consider placing types in modules that don’t violate layering.
“My tests violate boundaries”
Options:
- Exclude tests by structure (keep tests outside root package), or
- Create a specific contract for tests, or
- Move test-only helpers to a testing support package.
If tests import internals intentionally (common), decide explicitly:
- either allow it via a contract (with intent)
- or make the tested behavior accessible via public interfaces
“It’s noisy because of incidental imports”
Incidental imports often come from:
- huge init.py re-exporting many things
- “barrel modules” that pull dependencies upward
Fix:
- reduce re-exports
- keep init.py light
- import from the defining module, not package root
“It’s slow / too heavy for local runs”
Import graph building is non-trivial on big repos.
Strategy:
- keep contracts limited
- run it in CI or as an explicit local command (not necessarily on every save)
- avoid pathological patterns like scanning massive vendor dirs (keep venv out of repo)
Architecture patterns you can enforce (practical recipes)
Recipe: Keep domain pure
Goal:
- domain depends on nothing (or only domain + stdlib)
Contracts:
- forbidden domain → infrastructure
- optionally forbid domain → application/presentation too (if you place orchestration outside domain)
Recipe: Prevent “API reaches into DB models”
Goal:
- presentation must not import infrastructure.db or ORM models directly
Contract:
- forbidden presentation → infrastructure (or specifically presentation → infrastructure.db)
Recipe: Enforce “contexts communicate only via shared”
Goal:
- billing and shipping don’t import each other
Contract:
- independence across contexts
- allow both to import shared
Debugging: how to understand a failure quickly
When a contract fails, identify:
- What exact module import edge triggered it?
- Is it:
- a real boundary violation, or
- an accidental re-export / typing-only import?
Checklist:
-
Look for:
__init__.pyre-exports that pull dependencies “up”- imports used only for typing
- circular dependencies hidden by lazy imports
-
Fix by:
- moving an interface upward (dependency inversion)
- extracting shared abstractions
- replacing runtime imports with TYPE_CHECKING + forward refs
- removing re-exports and importing from leaf modules