Кто-то в вашей команде только что импортировал pg в src/domain/invoice.ts. PR компилируется. Тесты проходят. Код-ревью — триста строк, и никто не замечает.
Три месяца спустя вы хотите вынести доменную логику в отдельный пакет. Не получится. Она зависит от типов Postgres, логики пулов соединений и кастомной обёртки драйвера, которая существует только в монолите. Диаграмма на доске с концентрическими кругами теперь — шутка.
Это стандартный паттерн разложения чистой архитектуры без автоматизированного принуждения. Архитекторы рисуют стрелки внутрь. Разработчики пишут импорты наружу. Компилятору плевать на ваши слои.
Что на самом деле означает «домен не импортирует инфраструктуру»
В многослойной архитектуре зависимости текут внутрь. Доменный слой, в центре, определяет сущности, бизнес-правила и юзкейсы. Он ничего не знает о HTTP, SQL, очередях или файловых системах.
Инфраструктура живёт на внешнем кольце. Она реализует интерфейсы, которые определяет домен. Интерфейс репозитория живёт в домене. Реализация Postgres живёт в инфраструктуре. Домен вызывает save(invoice). Он никогда не вызывает pool.query().
Когда доменный код напрямую импортирует инфраструктуру, происходят две вещи. Во-первых, домен становится невозможно протестировать без запущенной базы данных. Во-вторых, вы никогда не сможете заменить Postgres на DynamoDB без переписывания бизнес-логики. Весь смысл слоистости, тестируемости и заменяемости испаряется.
Почему код-ревью недостаточно
В каждой команде есть сеньор-инженер, который ловит плохие импорты на ревью. Этот инженер также бывает в отпуске, болеет или ревьюит пулреквест из пятидесяти файлов в пять вечера пятницы.
Люди — распознаватели паттернов с ограниченной оперативной памятью. Автоматические правила зависимостей — детерминированные валидаторы с бесконечным терпением. Правильное место для принуждения архитектурных границ — там же, где вы принуждаете синтаксические ошибки: в пайплайне сборки. Если код компилируется, но нарушает граф зависимостей, он должен падать.
Как dependency-cruiser принуждает границы слоёв
Для кодовой базы на TypeScript и JavaScript dependency-cruiser превращает вашу архитектурную диаграмму в тестируемый набор правил. Он парсит граф импортов и ломает сборку, когда появляется запрещённое ребро.
Установите его:
npm install --save-dev dependency-cruiser
npx depcruise --init
Скрипт инициализации генерирует конфиг .dependency-cruiser.js. Вы добавляете правило, запрещающее домену трогать инфраструктуру:
// .dependency-cruiser.js
module.exports = {
forbidden: [
{
name: "domain-cannot-import-infrastructure",
comment:
"Domain code must not depend on infrastructure. Move the import to an adapter or repository.",
severity: "error",
from: {
path: "^src/domain",
},
to: {
path: "^src/infrastructure",
},
},
{
name: "domain-cannot-import-node-modules",
comment:
"Domain code must not depend on external I/O libraries.",
severity: "error",
from: {
path: "^src/domain",
},
to: {
dependencyTypes: ["npm"],
// Allow only pure logic libraries like date-fns or lodash
pathNot: "^(date-fns|lodash|ramda)",
},
},
],
options: {
doNotFollow: {
path: "node_modules",
},
tsPreCompilationDeps: true,
tsConfig: {
fileName: "tsconfig.json",
},
},
};
Это правило говорит: любой файл в src/domain, который импортирует что-либо из src/infrastructure, ломает сборку. Второе правило ловит импорт pg или axios, который прилетает через node_modules.
Подключите его в CI:
# .github/workflows/ci.yml
jobs:
architecture:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- run: npx depcruise src
Теперь импорт pg в src/domain/invoice.ts падает ещё до того, как человек откроет PR.
Как выглядит падение
Когда разработчик нарушает правило, он получает вывод примерно такой:
error domain-cannot-import-infrastructure: src/domain/invoice.ts → src/infrastructure/database/pool.ts
Domain code must not depend on infrastructure. Move the import to an adapter or repository.
error domain-cannot-import-node-modules: src/domain/invoice.ts → pg
Domain code must not depend on external I/O libraries.
✖ 2 dependency violations (2 errors, 0 warnings). Showing only the first 2.
Сообщение об ошибке говорит ему, что делать. Перенесите вызов базы данных в репозиторий на слое инфраструктуры. Передайте репозиторий в юзкейс как интерфейс. Доменный код остаётся чистым.
Альтернативы для других экосистем
Не все используют TypeScript. Принцип везде одинаковый. Меняется только инструментарий.
Java: ArchUnit — золотой стандарт. Вы пишете тест, а не конфиг:
@ArchTest
static final ArchRule domain_should_not_access_infrastructure =
noClasses()
.that()
.resideInAPackage("..domain..")
.should()
.dependOnClassesThat()
.resideInAPackage("..infrastructure..");
Это запускается как тест JUnit, поэтому интегрируется с Maven и Gradle без дополнительных телодвижений в CI.
C#: NetArchTest предоставляет тот же стиль API для .NET. Напишите юнит-тест, запустите его в CI.
Go: Зрелого аналога нет. Большинство команд принуждают это простым shell-скриптом:
#!/bin/bash
# scripts/check-domain-imports.sh
if grep -r "infrastructure/" internal/domain/ ; then
echo "Domain code imports infrastructure. Fix the dependency direction."
exit 1
fi
Это грубо, но работает. Многие Go-команды также используют кастомные анализаторы go vet или пакет go/ast для построения полноценных проверок.
Системы сборки: Bazel, Gradle и Nx из коробки поддерживают графы зависимостей модулей. Если вы определите домен и инфраструктуру как отдельные модули с явными декларациями зависимостей, инструмент сборки бесплатно принуждает границу. Подвох в том, что сначала нужно купиться на саму систему сборки.
Компромиссы: ложные срабатывания, боль миграции и трения в команде
Автоматизированные правила — не бесплатно. Нужно знать издержки, прежде чем включать их.
Ложные срабатывания случаются, когда у вас есть легитимные общие утилиты, которые сидят между слоями. Слагификатор строк или кастомный базовый класс ошибки может жить в src/shared. Если и домен, и инфраструктура импортируют его, правило в порядке. Если вы положите его в src/infrastructure/utils, а домен импортирует, dependency-cruiser пожалуется. Исправление обычно заключается в перемещении общего кода, что зачастую и есть правильный шаг.
Боль миграции реальна. Если ваша кодовая база уже нарушает правило в тридцати местах, вы не можете щёлкнуть выключателем без героического рефакторинга. Прагматичный подход — начать с предупреждений, исправить существующие нарушения за спринт, а потом повысить до ошибок. Либо используйте исключения pathNot для известных легаси-файлов и запрещайте новые нарушения с первого дня.
Трения в команде — скрытая издержка. Разработчики, которые никогда не работали со строгим принуждением слоёв, будут сопротивляться. Они будут утверждать, что один маленький импорт безвреден, что правило бюрократично, что оно их тормозит. Это культурный сигнал, а не технический. Те же разработчики будут импортировать express в доменную модель в 11 вечера, чтобы затащить фичу. Правило существует потому, что люди под давлением идут на компромиссы.
Как внедрить это в существующей кодовой базе
Вам не нужен большой рерайт. Вам нужна граница и способ её измерить.
-
Нарисуйте слои. Решите, какие директории — домен, приложение и инфраструктура. Запишите это в
ARCHITECTURE.md. -
Добавьте dependency-cruiser (или эквивалент для вашей экосистемы) с одним правилом: домен не может импортировать инфраструктуру.
-
Запустите его. Посчитайте нарушения. Если число мало, исправьте их до слияния конфига. Если велико, добавьте легаси-пути в исключения
pathNot. -
Заставьте его ломать CI. Не опционально. Не предупреждение. Ошибка, которая блокирует мерж.
-
Каждый раз, когда разработчик натыкается на ошибку, помогите ему перенести импорт. Не просто говорите, что сборка красная. Объясняйте, где должен жить код и почему.
За две недели команда перестанет пытаться импортировать pg в доменные файлы. Архитектурная диаграмма на доске наконец будет соответствовать коду в репозитории.
FAQ
Может ли слой приложения импортировать инфраструктуру?
Обычно да. Слой приложения оркестрирует юзкейсы. Он может знать о репозиториях и внешних сервисах, но должен зависеть от интерфейсов, а не конкретных реализаций. Если хотите строгое принуждение, добавьте второе правило: application не может импортировать реализации инфраструктуры, только их интерфейсы.
А как насчёт доменных событий?
Доменные события — распространённая лазейка. Доменное событие — чистые данные, поэтому оно принадлежит доменному слою. Инфраструктурная шина событий, которая его публикует, принадлежит инфраструктуре. Слой приложения подписывает обработчики доменных событий на шину. Сам домен никогда не знает о существовании шины.
Можно ли использовать это с Nx или Turborepo?
Да. В Nx из коробки есть правила границ модулей на уровне проектов. Если ваш домен и инфраструктура — отдельные библиотеки Nx, вы можете принудить правило без дополнительных инструментов. То же самое работает для Bazel и Gradle.
А если мне нужна утилита из инфраструктуры?
Вам не нужна. Перенесите утилиту в общий пакет common или kernel, который не имеет зависимостей от инфраструктуры. Если ей по-настоящему нужна инфраструктура, это не утилита. Это инфраструктура.
Суть
Чистая архитектура без автоматизированного принуждения — джентльменское соглашение. Джентльменские соглашения не переживают продакшн-дедлайны.
Dependency-cruiser, ArchUnit или даже shell-скрипт с grep превращают вашу архитектуру из рекомендации в гарантию. Домен остаётся чистым. Инфраструктура остаётся подменяемой. И в следующий раз, когда кто-то попытается импортировать pg в бизнес-правило, сборка упадёт ещё до того, как PR попадёт к человеку-ревьюеру.
Начните с одного правила. Сделайте его красным в CI. Исправьте первое нарушение. Остальное последует.