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-linteras an explicit tool for protecting boundaries. - The configuration is in
pyproject.toml, run with thelint-importscommand. - Three kinds of contracts:
layers(the overall hierarchy),forbidden(external libraries incore/),independence(adapters don't know about one another). include_external_packages = true— without it,forbiddendoesn't see FastAPI, SQLAlchemy and other external packages.- A single
root_packagefor the whole configuration means one scanning pass. lint-importsmust 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
independenceand, if necessary, updateforbidden.
What to read next
- Core layer of Hexagonal (Python) — exactly what
core-no-frameworkforbids: pure Python without FastAPI and SQLAlchemy. - Ports (Python) — why a port is a
Protocol, not a class, and how that relates to testability. - Adapters in (Python) — how a FastAPI router passes a request into a UseCase.
- Adapters out (Python) — the symmetric rule: out-adapters don't depend on each other.
- Module structure (Python) — the
core/adapters/apppackage layout.