Опирается на правила:
R-HEX-TEST-1…R-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— намеренно: PydanticBaseModelкак 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-layers | layers | core → adapters, adapters → app |
core-no-framework | forbidden | FastAPI / SQLAlchemy / httpx / Pydantic в core/ |
adapters-independence | independence | adapters/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-X1 | lint-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, не в CI | R-HEX-TEST-2 | Required status check в branch protection |
Контракт только для layers, без independence | R-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.