Опирается на правила: R-HEX-TEST-1R-HEX-TEST-3 и R-HEX-TEST-X1 из Hexagonal Style Guide → раздел 8. Архитектурные тесты.

Важно знать

  • import-linter — единственный enforcement границ в Python. В отличие от Java, где multi-module Gradle даёт compile-time изоляцию, Python не запрещает импорт ничего ниоткуда — нужен явный инструмент.
  • Конфигурация живёт в pyproject.toml (секция [tool.importlinter]), запускается командой lint-imports.
  • Минимальный обязательный контракт — type = "layers" с осью app → adapters → core; при необходимости дополняется forbidden и independence.
  • Required CI check. PR не мерджится, если lint-imports падает (R-HEX-TEST-2).
  • Единый root_package для всех контрактов — один scan, нет дублей (R-HEX-TEST-3).
  • Контракт independence между adapters/out/<system>/ пакетами — ловит запрещённую координацию адаптеров друг с другом (R-HEX-AOUT-X4).
  • Только code-review без lint-imports — антипаттерн: один SQLAlchemy-импорт в core/ проскользнёт через ревью в большом PR и останется там навсегда.

В Python нет compile-time изоляции пакетов. Ничто не мешает написать from service.adapters.out.persistence import OrderModel прямо в core/order/aggregate.py — Python выполнит импорт без ошибки. Единственный механизм, который enforcement'ит границы, — статический анализ импортов. import-linter запускается в CI как lint-imports, проверяет граф зависимостей между пакетами и падает, если нарушена граница слоя. Это аналог ArchUnit в Java — только работает на уровне импортов, а не байткода.

Где живёт конфигурация

R-HEX-TEST-1 / R-HEX-TEST-3: вся конфигурация в pyproject.toml, единый root_package.

src/order_service/
  core/order/{aggregate,port,usecase}/
  adapters/in/http/
  adapters/out/persistence/
  adapters/out/sber/
  app/
pyproject.toml
[tool.importlinter]
root_package = "order_service"
include_external_packages = true

root_package — единственная точка скана. Все контракты ниже работают с этим корнем. Если указать пакет слишком узко (например, только order_service.core), контракт не увидит нарушения со стороны adapters/.

Обязательные контракты

Слои (layers contract)

Главный контракт: ось app → adapters → core. Слой может зависеть от нижних, но не от верхних.

[[tool.importlinter.contracts]]
name    = "hexagonal-layers"
type    = "layers"
layers  = [
    "order_service.app",
    "order_service.adapters",
    "order_service.core",
]

Этот контракт ловит самое частое нарушение: core/ импортирует что-то из adapters/ (R-HEX-MOD-X2).

Forbidden-импорты в core (forbidden contract)

R-HEX-CORE-X1 / R-HEX-CORE-X2: core/ не должен знать о FastAPI, SQLAlchemy, Pydantic-HTTP-DTO, httpx. layers-контракт это не ловит — fastapi и sqlalchemy внешние пакеты, не пакеты проекта. Нужен forbidden:

[[tool.importlinter.contracts]]
name        = "core-no-framework"
type        = "forbidden"
source_modules = ["order_service.core"]
forbidden_modules = [
    "fastapi",
    "sqlalchemy",
    "httpx",
    "pydantic",       # разрешены только доменные dataclass/VO, не BaseModel из HTTP
]

pydantic в forbidden — намеренно: Pydantic BaseModel как HTTP-DTO в core/ — антипаттерн (R-HEX-CORE-X5). Если в проекте применяются доменные dataclass-VO без Pydantic, этот запрет тривиален; если VO на @dataclass — конфликта нет.

Независимость адаптеров (independence contract)

R-HEX-AIN-X4 / R-HEX-AOUT-X4: адаптеры зависят от core/, не друг от друга. Пакеты adapters/in/ и adapters/out/<system>/ — независимы.

[[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_consumer, s3) — добавить строку в список. Контракт падает, если sber-адаптер импортирует что-то из persistence/, или in_http — из sber/.

Что проверяется — сводная таблица

КонтрактТипЧто ловит
hexagonal-layerslayerscoreadapters, adaptersapp
core-no-frameworkforbiddenFastAPI / SQLAlchemy / httpx / Pydantic в core/
adapters-independenceindependenceadapters/in/*adapters/out/*, out-адаптеры друг с другом

Пример нарушения, которое поймает core-no-framework:

# order_service/core/order/aggregate.py — НАРУШЕНИЕ R-HEX-CORE-X2
from sqlalchemy.orm import DeclarativeBase  # lint-imports: FAIL

class Order:
    ...

Пример нарушения, которое поймает adapters-independence:

# order_service/adapters/out/sber/sber_payment_adapter.py — НАРУШЕНИЕ R-HEX-AOUT-X4
from order_service.adapters.out.persistence.order_repository import SqlAlchemyOrderRepository  # FAIL

class SberPaymentAdapter:
    def __init__(self, repo: SqlAlchemyOrderRepository): ...  # координация — в handler, не здесь

Required CI check

R-HEX-TEST-2: lint-imports — обязательный шаг CI, не опциональный.

# .github/workflows/ci.yml
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

В branch protection: architecture-check — required status check. PR не мерджится при падении.

Зачем именно required:

  • Без CI границы деградируют. PR с SQLAlchemy-импортом в core/ пройдёт ревью, если ревьюер устал или не знает правила.
  • Мгновенная обратная связь. Разработчик видит в CI: ImportBoundaryViolation: order_service.core imports sqlalchemy — и знает, что исправить, без дополнительных объяснений.
  • Договорённостей «на этот раз можно» нет. lint-imports не знает об исключениях — либо контракт выполнен, либо нет.

Пример полного pyproject.toml для Order-сервиса

[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",
]

Для сервиса CustomerService с адаптером к Sber и Kafka:

[tool.importlinter]
root_package = "customer_service"
include_external_packages = true

[[tool.importlinter.contracts]]
name    = "hexagonal-layers"
type    = "layers"
layers  = [
    "customer_service.app",
    "customer_service.adapters",
    "customer_service.core",
]

[[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",
]

Что запрещено

АнтипаттернПравилоЧто взамен
Только code-review для enforcement границR-HEX-TEST-X1lint-imports как required CI check
Нет контракта forbidden для внешних пакетовR-HEX-CORE-X1 / R-HEX-CORE-X2Добавить forbidden с FastAPI / SQLAlchemy / httpx
Разные root_package для разных контрактовR-HEX-TEST-3Единый root_package на всю конфигурацию
lint-imports только в pre-commit, не в CIR-HEX-TEST-2Required status check в branch protection
Контракт только для layers, без independenceR-HEX-AOUT-X4Добавить independence для out-адаптеров

Куда дальше

  • Core слой — что именно запрещает контракт core-no-framework: чистый Python без FastAPI и SQLAlchemy.
  • Ports — почему port — Protocol, не класс, и как это связано с testability.
  • Adapters in — как роутер FastAPI маппит Pydantic request-DTO в UseCase command.
  • Adapters out — симметричное правило: out-адаптеры независимы и не знают друг о друге.
  • Bootstrap — app/: create_app(), container, lifespan, Dockerfile.
  • Структура модулей — пакетная раскладка core/adapters/app и import-linter-контракт.
  • Когда переходить — когда Hexagonal оправдан для Python-сервиса, а когда это ceremony.