← назад к разделу

Вы разбили сервис на слои: 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: общая иерархия слоёв

layers app → adapters → core ловит core, который импортирует adapters forbidden core не импортирует fastapi, sqlalchemy, pydantic нужен include_external_packages independence adapters.in_.http и adapters.out.sber не импортируют друг друга

Три контракта закрывают три разные дыры: порядок слоёв, внешние библиотеки в ядре и связь между адаптерами.

Главное правило: слой может зависеть от нижних, но не от верхних. Ось зависимостей: 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 - вернуть смену цены так, чтобы архитектурный тест остался зелёным.

Что почитать дальше