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

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

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