← Back to the section

You've split the service into layers: core/ with the business logic, adapters/ with the database and HTTP, app/ with the entry point. It all looks right — until someone in a hurry writes from sqlalchemy.orm import ... right inside core/. Python will execute this without error. In a year there will be twenty such imports, and the layers will cease to exist.

In Java you're protected from this by the module system or by separate Gradle subprojects — they won't let a wrong import compile. In Python there's no such protection. You need a separate tool — import-linter.

What import-linter does

import-linter is a static analyzer that builds a dependency graph between packages and checks whether the specified rules are violated. It's run with the lint-imports command. If a violation is found, it exits with a non-zero code and prints exactly which import is forbidden.

The equivalent in the Java world is ArchUnit, except it works not with bytecode but with source files.

Installation:

pip install import-linter

The configuration lives in pyproject.toml under the [tool.importlinter] section.

Entry point: root_package

The first thing you need to specify is the service's root package. It's the single point from which analysis starts:

[tool.importlinter]
root_package = "order_service"
include_external_packages = true

include_external_packages = true is needed so that forbidden contracts can check imports from third-party libraries (FastAPI, SQLAlchemy, etc.).

If you specify too narrow a package (for example, only order_service.core), violations coming from adapters/ won't be visible.

Three kinds of contracts

The layers contract: the overall layer hierarchy

The main rule: a layer may depend on the ones below it, but not on the ones above. The dependency axis: app → adapters → core.

[[tool.importlinter.contracts]]
name    = "hexagonal-layers"
type    = "layers"
layers  = [
    "order_service.app",
    "order_service.adapters",
    "order_service.core",
]

This contract catches the most common violation: core/ importing something from adapters/.

The forbidden contract: external libraries in core/

The layers contract checks only packages inside the project. FastAPI and SQLAlchemy are external libraries, and it doesn't see them. For them you need a separate forbidden contract:

[[tool.importlinter.contracts]]
name        = "core-no-framework"
type        = "forbidden"
source_modules = ["order_service.core"]
forbidden_modules = [
    "fastapi",
    "sqlalchemy",
    "httpx",
    "pydantic",
]

pydantic is forbidden deliberately: a Pydantic BaseModel is an HTTP DTO, its place is in the adapter, not in the core. Domain objects are written with @dataclass without Pydantic.

An example of what this contract catches:

# order_service/core/order/aggregate.py
from sqlalchemy.orm import DeclarativeBase  # lint-imports fails

class Order:
    ...

The independence contract: adapters don't know about one another

Adapters depend on core/, but must not depend on each other. The HTTP adapter doesn't import from the database adapter, the Sber adapter doesn't import from the Kafka adapter:

[[tool.importlinter.contracts]]
name = "adapters-independence"
type = "independence"
modules = [
    "order_service.adapters.in_.http",
    "order_service.adapters.out.persistence",
    "order_service.adapters.out.sber",
]

When a new adapter is added, it's enough to add a line to this list.

An example of a violation:

# order_service/adapters/out/sber/sber_payment_adapter.py
from order_service.adapters.out.persistence.order_repository import SqlAlchemyOrderRepository

class SberPaymentAdapter:
    def __init__(self, repo: SqlAlchemyOrderRepository): ...  # wrong — coordination in the handler

Coordination between adapters is done by the handler, not by the adapters themselves.

The full configuration for order_service

[tool.importlinter]
root_package = "order_service"
include_external_packages = true

[[tool.importlinter.contracts]]
name    = "hexagonal-layers"
type    = "layers"
layers  = [
    "order_service.app",
    "order_service.adapters",
    "order_service.core",
]

[[tool.importlinter.contracts]]
name    = "core-no-framework"
type    = "forbidden"
source_modules    = ["order_service.core"]
forbidden_modules = ["fastapi", "sqlalchemy", "httpx", "pydantic"]

[[tool.importlinter.contracts]]
name    = "adapters-independence"
type    = "independence"
modules = [
    "order_service.adapters.in_.http",
    "order_service.adapters.out.persistence",
    "order_service.adapters.out.sber",
]

For a service with a Kafka adapter, add aiokafka to forbidden and the new modules to independence:

[[tool.importlinter.contracts]]
name    = "core-no-framework"
type    = "forbidden"
source_modules    = ["customer_service.core"]
forbidden_modules = ["fastapi", "sqlalchemy", "httpx", "pydantic", "aiokafka"]

[[tool.importlinter.contracts]]
name    = "adapters-independence"
type    = "independence"
modules = [
    "customer_service.adapters.in_.http",
    "customer_service.adapters.in_.kafka",
    "customer_service.adapters.out.persistence",
    "customer_service.adapters.out.sber",
]

The check in CI

lint-imports only works if it's run automatically. Code review doesn't replace the check: a single forbidden import easily slips through in a large PR, especially if the reviewer doesn't know the rule by heart.

GitHub Actions configuration:

jobs:
  architecture-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install import-linter
      - run: lint-imports

In the repository settings, architecture-check is added as a required status check in branch protection. The PR is not merged until the check has passed.

What this gives you:

  • Instant feedback. CI immediately shows: ImportBoundaryViolation: order_service.core imports sqlalchemy — it's clear what to fix.
  • No exceptions. You can't agree "just for this PR" — the contract is either satisfied or not.
  • Boundaries don't degrade over time. Every change goes through the same check.

Common mistakes

Only layers, without forbidden. The layers contract doesn't see external libraries. Without forbidden, FastAPI and SQLAlchemy calmly end up in core/ — and the contract never finds out.

Different root_package for different contracts. Each contract scans the package anew. A single root_package for the whole configuration means one pass, no duplication.

lint-imports only in pre-commit, not in CI. Pre-commit can be bypassed (git commit --no-verify). Only a CI check with a required status gives a real guarantee.

No independence for adapters. Without this contract, adapters quietly start depending on each other — and changing one breaks the other.

In short

  • Python has no compile-time package isolation — you need import-linter as an explicit tool for protecting boundaries.
  • The configuration is in pyproject.toml, run with the lint-imports command.
  • Three kinds of contracts: layers (the overall hierarchy), forbidden (external libraries in core/), independence (adapters don't know about one another).
  • include_external_packages = true — without it, forbidden doesn't see FastAPI, SQLAlchemy and other external packages.
  • A single root_package for the whole configuration means one scanning pass.
  • lint-imports must be a mandatory CI step with a required status check — code review doesn't replace it.
  • When adding a new adapter: add a line to independence and, if necessary, update forbidden.