Skip to content

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-linter builds 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.ini
  • lint-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, but import-linter docs typically show a standalone ini)

In practice:

  • Prefer an explicit config file and invoke with --config to 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 = layers
layers =
your_top_level_package.presentation
your_top_level_package.application
your_top_level_package.domain
your_top_level_package.infrastructure

Meaning:

  • 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 infrastructure
type = forbidden
source_modules =
your_top_level_package.domain
forbidden_modules =
your_top_level_package.infrastructure

Good 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 independent
type = independence
modules =
your_top_level_package.billing
your_top_level_package.shipping
your_top_level_package.identity

Good 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__.py re-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