Вы разбили сервис на слои: core/ с бизнес-логикой, adapters/ с базой и HTTP, app/ с точкой входа. Всё выглядит правильно — пока кто-то в спешке не пишет from sqlalchemy.orm import ... прямо в core/. Python выполнит это без ошибки. Через год таких импортов будет двадцать, и слои перестанут существовать.
В Java от этого защищает система модулей или отдельные Gradle-подпроекты — они не дадут скомпилировать неправильный импорт. В Python такой защиты нет. Нужен отдельный инструмент — import-linter.
Что делает import-linter
import-linter — это статический анализатор, который строит граф зависимостей между пакетами и проверяет, не нарушены ли заданные правила. Запускается командой lint-imports. Если нарушение найдено — выходит с ненулевым кодом и печатает, какой именно импорт запрещён.
Аналог в мире Java — ArchUnit, только работает не с байткодом, а с исходными файлами.
Установка:
pip install import-linter
Конфигурация живёт в pyproject.toml в секции [tool.importlinter].
Точка входа: root_package
Первое, что нужно указать — корневой пакет сервиса. Это единственная точка, с которой начинается анализ:
[tool.importlinter]
root_package = "order_service"
include_external_packages = true
include_external_packages = true нужен, чтобы forbidden-контракты умели проверять импорты из сторонних библиотек (FastAPI, SQLAlchemy и т.д.).
Если указать слишком узкий пакет (например, только order_service.core), нарушения со стороны adapters/ не будут видны.
Три вида контрактов
Контракт layers: общая иерархия слоёв
Главное правило: слой может зависеть от нижних, но не от верхних. Ось зависимостей: app → adapters → core.
[[tool.importlinter.contracts]]
name = "hexagonal-layers"
type = "layers"
layers = [
"order_service.app",
"order_service.adapters",
"order_service.core",
]
Этот контракт поймает самое частое нарушение: core/ импортирует что-то из adapters/.
Контракт forbidden: внешние библиотеки в core/
layers-контракт проверяет только пакеты внутри проекта. FastAPI и SQLAlchemy — внешние библиотеки, их он не видит. Для них нужен отдельный контракт forbidden:
[[tool.importlinter.contracts]]
name = "core-no-framework"
type = "forbidden"
source_modules = ["order_service.core"]
forbidden_modules = [
"fastapi",
"sqlalchemy",
"httpx",
"pydantic",
]
pydantic запрещён намеренно: Pydantic BaseModel — это HTTP-DTO, его место в адаптере, не в ядре. Доменные объекты пишут на @dataclass без Pydantic.
Пример того, что поймает этот контракт:
# order_service/core/order/aggregate.py
from sqlalchemy.orm import DeclarativeBase # lint-imports падает
class Order:
...
Контракт independence: адаптеры не знают друг о друге
Адаптеры зависят от core/, но не должны зависеть друг от друга. HTTP-адаптер не импортирует из адаптера к базе данных, Sber-адаптер не импортирует из адаптера к Kafka:
[[tool.importlinter.contracts]]
name = "adapters-independence"
type = "independence"
modules = [
"order_service.adapters.in_.http",
"order_service.adapters.out.persistence",
"order_service.adapters.out.sber",
]
При добавлении нового адаптера достаточно добавить строку в этот список.
Пример нарушения:
# 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): ... # не так — координация в handler
Координацию между адаптерами делает handler, не сами адаптеры.
Полная конфигурация для 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",
]
Для сервиса с Kafka-адаптером добавляем aiokafka в forbidden и новые модули в 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",
]
Проверка в CI
lint-imports работает только если запускается автоматически. Ревью кода не заменяет проверку: один запрещённый импорт легко проскользнёт в большом PR, особенно если ревьюер не знает правило наизусть.
Конфигурация GitHub Actions:
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
В настройках репозитория architecture-check добавляется как required status check в branch protection. PR не объединяется, пока проверка не прошла.
Что это даёт:
- Мгновенная обратная связь. CI сразу покажет:
ImportBoundaryViolation: order_service.core imports sqlalchemy— понятно, что исправить. - Нет исключений. Нельзя договориться «для этого PR можно» — контракт либо выполнен, либо нет.
- Границы не деградируют со временем. Каждое изменение проходит через ту же проверку.
Частые ошибки
Только layers, без forbidden. layers-контракт не видит внешние библиотеки. Без forbidden FastAPI и SQLAlchemy спокойно попадают в core/ — и контракт об этом не узнает.
Разные root_package для разных контрактов. Каждый контракт заново сканирует пакет. Единый root_package на всю конфигурацию — один проход, нет дублирования.
lint-imports только в pre-commit, не в CI. Pre-commit можно обойти (git commit --no-verify). Только CI-проверка со статусом required даёт настоящую гарантию.
Нет independence для адаптеров. Без этого контракта адаптеры незаметно начинают зависеть друг от друга — и при изменении одного ломается другой.
Коротко
- В Python нет compile-time изоляции пакетов — нужен
import-linterкак явный инструмент защиты границ. - Конфигурация в
pyproject.toml, запуск командойlint-imports. - Три вида контрактов:
layers(общая иерархия),forbidden(внешние библиотеки вcore/),independence(адаптеры не знают друг о друге). include_external_packages = true— без негоforbiddenне видит FastAPI, SQLAlchemy и другие внешние пакеты.- Единый
root_packageна всю конфигурацию — один проход сканирования. lint-importsдолжен быть обязательным шагом CI с required status check — ревью кода этого не заменяет.- При добавлении нового адаптера: добавить строку в
independenceи при необходимости обновитьforbidden.
Что почитать дальше
- Core-слой Hexagonal (Python) — что именно запрещает
core-no-framework: чистый Python без FastAPI и SQLAlchemy. - Ports (Python) — почему port — это
Protocol, не класс, и как это связано с тестируемостью. - Адаптеры in (Python) — как роутер FastAPI передаёт запрос в UseCase.
- Адаптеры out (Python) — симметричное правило: out-адаптеры не зависят друг от друга.
- Структура модулей (Python) — пакетная раскладка
core/adapters/app.