Дисклеймер. Это не «единственно верный путь», а конкретный приём на слоистом бэкенде. Структура в примере такая:
domainс бизнес-правилами,adaptersдля БД, Redis и внешних API,applicationс общими прикладными интерфейсами и типами,presentorsдля REST и брокера на входе (слой назван так исторически; в новом коде я бы назвал егоpresentationилиentrypoints). Стек — Litestar + SQLAlchemy + Postgres. Все примеры — из моего публичного репозитория-образца example-web-service.Если вы считаете, что ваша архитектура более чистая или более гексагональная, то пожалуйста, не претендую на идеал. Здесь больше про сам подход как следить за слоями.
Обычный летний день на проекте: ruff зелёный, mypy зелёный, тесты проходят. А domain при этом тихо импортирует adapters. Слоистая архитектура, которую рисовали на старте, теперь осталась только на схеме.
Линтер ловит неиспользуемый импорт и кривой отступ. Типизатор — что вместо int передали str. А то, что бизнес-логика потащила в себя SQLAlchemy или Redis, не видит никто. Кроме ревьюера. У которого в пятницу вечером открыто восемь PR.
Ниже — как отдать эту проверку машине. Инструмент называется import-linter, ставится за пять минут и встаёт в один ряд с ruff и mypy.
История из жизни: пять импортов и полдня работы
Самый частый способ размыть слой — поймать инфраструктурное исключение там, где его быть не должно. У меня в сервисе на FastAPI в domain/services однажды появилась строчка:
from sqlalchemy.exc import IntegrityError
В PR разработчику было проще обработать ошибку уникального индекса прямо в бизнес-логике: поймал IntegrityError, кинул через raise ... from ... доменное исключение, поехали дальше. Логика работает, тесты прошли, на ревью пропустили — выглядит безобидно.
Через два месяца таких импортов в домене было уже пять: DataError, IntegrityError, DBAPIError, UniqueViolationError (из asyncpg) и т. д. SQLAlchemy протекла в самое ядро: домен теперь знал про конкретную ORM, про имена constraint’ов, про то, что хранилище вообще на SQL (и в частности postgresql). Поменять ORM стало дорого. Протестировать домен без живой базы — тоже. Полдня я вырезал эти импорты и переносил обработку в адаптеры, где ей и место. И тогда я задумался, а есть ли возможность это отлавливать не руками, а автоматически? И такая возможность есть — import-linter. Один контракт в import-linter остановил бы первый такой импорт ещё до ревью.
Что такое архитектурный тест?
Обычный тест проверяет поведение: дал вход, получил ожидаемый выход. Архитектурный тест проверяет структуру: кто на кого имеет право ссылаться.
«Домен не знает про инфраструктуру» — это не философия, а проверяемое правило. Зависимость одного модуля от другого в коде — это import. Представьте проект графом: узлы — модули, рёбра — импорты. Тогда правило «domain не импортирует adapters» — это «в графе нет ребра из domain в adapters». Такое уже не обсуждают на ревью, это проверяет программа.
Так и работает import-linter: строит граф импортов пакета (статически, не запуская код) и проверяет его на ваши правила, которые тут называются контрактами. Нарушил контракт — сборка красная, как от упавшего теста.
Почему именно в эту сторону? Инверсия зависимостей
А почему правило направлено именно так: domain нельзя импортировать adapters, а обратно можно? Почему не симметрично?
Потому что это инверсия зависимостей, DIP — буква D в SOLID. На коде это видно лучше, чем в определениях.
Доменному сервису нужно хранилище пользователей. Напрашивается заимпортить SQLAlchemy-реализацию. Но домен импортирует не её, а интерфейс, объявленный тут же, в домене:
# library/domain/services/user.pyfrom library.domain.interfaces.storages.user import IUserStorage
IUserStorage — это Protocol, просто список методов (fetch_user_by_id, create_user, …) без упоминания базы. Домен говорит: мне нужно вот такое хранилище. Чем оно будет внутри (AsyncSession, IntegrityError, SQL-таблицы), решает адаптер library/adapters/database/storages/user.py — он подходит под интерфейс по сигнатурам.
Про
Protocolпротивabc.ABC. Здесь интерфейс —Protocol, но это не догма. Сabc.ABCи явным наследованием (class UserStorage(IUserStorage)) связь видна прямо в коде: адаптер объявляет, что реализует интерфейс домена, появляется честный импортadapters → domain(контрактами разрешён).Protocolсвязывает структурно, по сигнатурам, без наследования и импорта — удобно для чужих типов.ABCпрозрачнее по зависимостям,Protocolгибче; для слоёв годятся оба.
Вот это и есть инверсия. Стрелка зависимости развёрнута: не «домен → конкретное хранилище», а «домен → свой интерфейс ← адаптер подстраивается». Контракт диктует домен, инфраструктура подгоняется. Не наоборот.
С DI не путать. DI (в проекте — dishka) — про то, как подсунуть конкретный UserStorage в рантайме. DIP — про то, куда смотрят зависимости. Первое — проводка, второе — направление. import-linter следит за вторым.
И тут вся инверсия держится на одном импорте. Напишешь в доменном сервисе from library.adapters... — стрелка развернулась обратно, инверсии нет, абстракция протекла. А ruff, mypy и тесты по-прежнему зелёные: направление импортов им безразлично. Контракт domain ↛ adapters, который мы сейчас пропишем, — та же инверсия, только сломать её незаметно уже не выйдет.
import-linter за пять минут
Ставим:
pip install import-linter# или, если используете uv:uv add --dev import-linter
Конфиг живёт в pyproject.toml (ещё понимает setup.cfg и отдельный .importlinter). Вот рабочий конфиг из репозитория-образца, четыре правила:
[tool.importlinter]root_package = "library"include_external_packages = true[[tool.importlinter.contracts]]name = "Production must not import tests"type = "forbidden"source_modules = ["library"]forbidden_modules = ["tests"][[tool.importlinter.contracts]]name = "Domain must not import adapters"type = "forbidden"source_modules = ["library.domain"]forbidden_modules = ["library.adapters"][[tool.importlinter.contracts]]name = "Domain must not import presentors"type = "forbidden"source_modules = ["library.domain"]forbidden_modules = ["library.presentors"][[tool.importlinter.contracts]]name = "Adapters must not import presentors"type = "forbidden"source_modules = ["library.adapters"]forbidden_modules = ["library.presentors"]
Читается как написано. domain не имеет права тянуть adapters и presentors, adapters — presentors, а боевой код (library) — тесты. forbidden — самый прямой тип: вот этим нельзя импортировать вот это.
Запуск — одна команда без аргументов, конфиг находит сам:
lint-imports
---------Contracts---------Analyzed 137 files, 344 dependencies.-------------------------------------Production must not import tests KEPTDomain must not import adapters KEPTDomain must not import presentors KEPTAdapters must not import presentors KEPTContracts: 4 kept, 0 broken.
137 файлов, 344 зависимости — тот самый граф, который в голове не удержишь. Код возврата 0, всё чисто. Это не тест в смысле pytest, а статическая проверка, но в CI ведёт себя так же: ненулевой код возврата роняет сборку. Теперь сломаем.
Ломаем слой
Допишу в доменный сервис ту же строчку, с которой начиналась история. Только теперь импорт не внешней библиотеки, а своего адаптера:
# library/domain/services/user.pyfrom library.adapters.database.storages.user import UserStorage
Прогоняю lint-imports ещё раз:
Analyzed 137 files, 345 dependencies.-------------------------------------Production must not import tests KEPTDomain must not import adapters BROKENDomain must not import presentors KEPTAdapters must not import presentors KEPTContracts: 3 kept, 1 broken.----------------Broken contracts----------------Domain must not import adapters-------------------------------library.domain is not allowed to import library.adapters:- library.domain.services.user -> library.adapters.database.storages.user(l.1)
Код возврата 1. Линтер не просто сказал «нельзя», а показал конкретную цепочку: library.domain.services.user -> library.adapters.database.storages.user, строка 1. На большом графе, где зависимость идёт через пять промежуточных модулей, он распечатает всю цепочку, и будет видно, каким путём слой протёк. Глазами на ревью такое почти не найти.
Импорт я тут же убрал, это была демонстрация. Но так выглядел бы тот PR из истории, если бы контракт стоял с самого начала: красная сборка вместо двух месяцев техдолга.
Контракт против внешних библиотек
Тут легко обмануться. Четыре контракта выше запрещают домену тянуть ваши собственные adapters и presentors. Но первая строчка из истории, from sqlalchemy.exc import IntegrityError — это внешняя библиотека, не ваш пакет. Контракты про внутренние слои её не поймают.
Чтобы домен не знал и про сторонние зависимости, нужен отдельный контракт. Ровно для этого в конфиге стоит include_external_packages = true:
[[tool.importlinter.contracts]]name = "Domain must not import SQLAlchemy"type = "forbidden"source_modules = ["library.domain"]forbidden_modules = ["sqlalchemy"]
Теперь бизнес-логика не знает, на чём работает хранилище. Захотите завтра уехать с SQLAlchemy на что-то другое — домен этого не заметит. Тот PR из истории такой контракт остановил бы на первой строке.
Остальные типы контрактов
forbidden закрывает большинство бытовых случаев, но import-linter умеет больше. Что есть под рукой:
-
layers— самый удобный для слоистой архитектуры. Вместо россыпиforbiddenодин раз описываете порядок слоёв, и каждый верхний слой может зависеть только от нижних:[[tool.importlinter.contracts]]name = "Layered architecture"type = "layers"layers = [ "library.presentors", "library.adapters", "library.domain",]Порядок в
layersзначим: верхние элементы списка — верхние слои, им можно импортировать нижние, но не наоборот. В этой схемеpresentorsзнают проadaptersиdomain,adapters— проdomain, аdomainпро слои выше не знает. Пример укорочен: в реальном проекте есть ещёapplication, так что порядок слоёв подбирайте под свою схему, а не копируйте механически.Одна запись заменяет три моих
forbiddenпро слои. Станет слоёв больше — не нужно дописывать каждый запрет руками,layersсам закрывает все направления снизу вверх. Если архитектура слоистая, начинайте сразу сlayers. -
independence— набор модулей не должен зависеть друг от друга (например, бизнес-модулиbilling,catalog,deliveryживут изолированно и общаются только через общий слой). -
protected— модуль нельзя импортировать ниоткуда, кроме явного списка разрешённых. -
acyclic siblings — запрет циклов между соседними пакетами.
-
custom — свой тип контракта на Python, если штатных не хватило.
Начинать стоит с forbidden или layers, этого обычно хватает на моих проектах.
А когда import-linter избыточен: на скрипте из пяти файлов он не нужен. Инструмент раскрывается там, где есть слои, несколько команд, долгоживущий код и риск, что договорённости забудутся через пару месяцев.
Бонус: граф можно нарисовать
Помните «137 файлов, 344 зависимости»? Этот граф можно нарисовать — у import-linter есть отдельная команда. import-linter drawgraph library отдаёт граф в формате DOT, скармливаем его graphviz и получаем картинку:
import-linter drawgraph library | dot -Tpng -o graph.png
Вот верхний уровень моего проекта (палитру подкрутил, рёбра — дословно из вывода инструмента):
presentors наверху, application — общий слой прикладных абстракций, куда сходятся разрешённые зависимости. Теперь главное: посмотрите на library.domain (зелёный). Из него выходит ровно одна стрелка, в library.application. Ни в adapters, ни в presentors.
Вторая команда, import-linter explore library, поднимает интерактивную схему в браузере, по большому графу так ходить удобнее (для неё нужны отдельные UI-зависимости). А drawgraph умеет подписать рёбра числом импортов (--show-import-totals) или подсветить те, что разрывают циклы (--show-cycle-breakers).
Где это должно жить: pre-commit мало, нужен CI
Базово как и любой другой линтер — в pre-commit. При чем его можно поставить как встроенным в проект pre-commit hook, а можно вызовом из .venv/bin . Я предпочитаю такой вариант, т. к. в этом случае версия, которая используется в CI и в pre-commit будет 100% одинаковая.
- repo: local hooks: - id: lint_imports name: "Lint imports" entry: .venv/bin/lint-imports language: system pass_filenames: false
Удобно: проверка бежит локально перед каждым коммитом. Но pre-commit легко обойти. Но любой git commit --no-verify проходит мимо него. Да и поставить его надо локально — у нового человека в команде хук может быть просто не включён. (Конечно, мы оставляем инструкции в Makefile, как развернуть окружение локально одной командой, но на это рассчитывать не стоит).Поэтому оставить проверку только тут это понадеяться на разработчика.
Архитектурный контракт будет жить только в pre-commit и до CI не доходит. Проверка есть, но не там, где её нельзя обойти. Сам пару раз так оставлял.
Поэтому обязательно добавляем в команду линтеров для вашего CI:
lint-ci: ruff mypy import-linter ##@Linting Run all linters in CI
И все:pre-commit даёт быструю обратную связь локально, CI не пускает красный импорт в основную ветку, как бы его ни проталкивали.
Про скорость можно не волноваться: import-linter кеширует разобранный граф (каталог .import_linter_cache), повторные прогоны на неизменившихся импортах почти бесплатны.
Что в итоге?
-
Слои существуют ровно настолько, насколько их кто-то проверяет. Человек на ревью устаёт и торопится, машина — нет.
-
import-linterпревращает «домен не знает про инфраструктуру» из пожелания в красную сборку. Ставится за пять минут. -
Начните с
forbiddenилиlayers. Не забудьте про внешние библиотеки иinclude_external_packages. -
Главное — занесите проверку в CI, а не только в
pre-commit. Иначе её можно обойти.
import-linter не сделает архитектуру хорошей. Но не даст незаметно сломать ту, что уже есть.
А что сторожит слои у вас, человек или машина? Если человек — прикиньте, таких импортов протекло за последний год. Попробуйте поставить import-linter и сохраните нервы и время себе и вашим коллегам.
ссылка на оригинал статьи https://habr.com/ru/articles/1053430/