Вы разбили сервис на слои: core/ с бизнес-логикой, adapters/ с базой и HTTP, app/ с точкой входа. Всё выглядит правильно — пока кто-то в спешке не пишет from sqlalchemy.orm import ... прямо в core/. Python выполнит это без ошибки. Через год таких импортов будет двадцать, и слои перестанут существовать.
В языках с компиляцией и системой модулей такой импорт просто не соберётся. В Python такой защиты нет. Нужен отдельный инструмент — import-linter.
Что делает import-linter
import-linter — это статический анализатор, который строит граф зависимостей между пакетами и проверяет, не нарушены ли заданные правила. Запускается командой lint-imports. Если нарушение найдено — выходит с ненулевым кодом и печатает, какой именно импорт запрещён.
Работает он с исходными файлами, код при проверке не выполняется.
Установка:
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 можно» — контракт либо выполнен, либо нет.
- Границы не деградируют со временем. Каждое изменение проходит через ту же проверку.
Как выглядит падение
Нарушение контракта в выводе lint-imports читается без расшифровки: имя контракта, откуда и куда идёт запрещённый импорт, и цепочка, если импорт косвенный:
=============
Import Linter
=============
Contracts
---------
Analyzed 48 files, 112 dependencies.
core-no-framework BROKEN
hexagonal-layers KEPT
adapters-independence KEPT
Contracts: 2 kept, 1 broken.
----------------
Broken contracts
----------------
core-no-framework
-----------------
order_service.core is not allowed to import sqlalchemy:
- order_service.core.order.aggregate -> sqlalchemy.orm (l.3)
Строка с номером показывает, куда идти; косвенный импорт через соседний модуль ядра будет показан цепочкой из двух-трёх стрелок.
Внедрение на существующем проекте
На живом проекте первый запуск находит десятки нарушений, и чинить их все перед включением проверки никто не будет. import-linter позволяет заморозить известные нарушения списком ignore_imports: они не ломают сборку, а каждое новое нарушение ломает. Список сокращают по мере рефакторинга, и его длина это измеримый долг:
[[tool.importlinter.contracts]]
name = "core-no-framework"
type = "forbidden"
source_modules = ["order_service.core"]
forbidden_modules = ["fastapi", "sqlalchemy", "httpx", "pydantic"]
ignore_imports = [
"order_service.core.order.aggregate -> sqlalchemy.orm", # долг: переезд на чистый dataclass
"order_service.core.shared.money -> pydantic", # долг: убрать BaseModel из value object
]
Правило работы со списком: записи не добавляют, только убирают; каждая запись с комментарием, что именно и когда переедет. Так проверка включается в первый же день, а долг гасится по одной записи в неделю, не блокируя остальную работу.
Частые ошибки
Только 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 для адаптеров. Без этого контракта адаптеры незаметно начинают зависеть друг от друга — и при изменении одного ломается другой.
Глубже: тесты адаптеров: out через respx, in через подмену, persistence через контейнеррасширенное
Раздел учит проверять границы. Три статьи повторяют, что пакеты нужны ради изоляции тестов, и ни одна не показывает сами тесты адаптеров; вот они, по одному на вид.
Out-адаптер к HTTP. Тест поднимает respx вместо партнёра и проверяет две вещи, за которые отвечает адаптер: что доменная команда превратилась в правильный запрос (путь, тело в копейках, заголовок с ключом идемпотентности) и что ответы партнёра превратились в правильные доменные результаты и исключения: 200 с orderStatus: 1 даёт RegisterResult, 500 даёт SberError, таймаут тоже SberError. Бизнес-правил в этих тестах нет.
In-адаптер. Роутер тестируют через TestClient с подменённым Dispatcher, который записывает полученную команду и отдаёт заготовленный результат. Проверяют маппинг: JSON запроса стал командой с нужными типами, результат стал схемой ответа, доменная ошибка стала нужным статусом. Домен в этом тесте не участвует.
Persistence. Единственный адаптер, который тестируют против настоящей базы: testcontainers поднимает PostgreSQL, миграции применяются, и тест делает круг «сохранить агрегат, прочитать, сравнить»: маппер домен-ORM-домен не теряет полей, уникальные ограничения дают нужные доменные исключения. Это те же контрактные тесты, что гоняют против поддельной реализации в памяти.
Запускаются они с разной частотой: первые два на каждый коммит за секунды, третий тоже на каждый коммит, но в отдельном шаге, потому что ему нужен Docker.
Коротко
- В 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. - Адаптеры проверяют по видам: HTTP-клиент через
respxна маппинг запроса и ошибок, роутер черезTestClientс подменённымDispatcher, persistence кругом «сохранить и прочитать» на Testcontainers; бизнес-правил в этих тестах нет.
Что пощупать
В каталоге практикума remodov/marketplace-system-python инварианты из этой статьи проверяет tests/test_architecture.py: он разбирает импорты файлов ядра через ast и падает на fastapi, sqlalchemy, pydantic или boto3, смотрит, что входные адаптеры не импортируют выходные, а выходные не знают друг о друге. Вторая проверка - runtime_checkable-протоколы: каждый адаптер обязан удовлетворять своему порту.
Код: test_architecture.py, port/out.py.
Сделаем сами
Ветка step-07-adult-catalog - вернуть смену цены так, чтобы архитектурный тест остался зелёным.
Что почитать дальше
- Core-слой Hexagonal (Python) — что именно запрещает
core-no-framework: чистый Python без FastAPI и SQLAlchemy. - Ports (Python) — почему port — это
Protocol, не класс, и как это связано с тестируемостью. - Адаптеры in (Python) — как роутер FastAPI передаёт запрос в UseCase.
- Адаптеры out (Python) — симметричное правило: out-адаптеры не зависят друг от друга.
- Структура модулей (Python) — пакетная раскладка
core/adapters/app.