Hexagonal-архитектура разделяет код на три зоны: core/ (доменная логика), adapters/ (всё внешнее) и app/ (сборка). Идея хорошая, но без инструмента она держится только на доброй воле разработчика.
Почему одного ревью недостаточно
В Java границы слоёв защищены компилятором: если в core/build.gradle.kts нет зависимости на Spring, импорт org.springframework.* просто не скомпилируется. Node устроен иначе — весь код живёт в одном node_modules, и TypeScript спокойно позволит core/ импортировать @nestjs/common или typeorm. Единственный способ поймать такое нарушение — специальный инструмент.
Без него один нечаянный import { Injectable } from '@nestjs/common' в core/ — и граница сломана незаметно. Ревьюер не всегда заметит, а накопленные нарушения потом дороги в исправлении.
dependency-cruiser
dependency-cruiser — инструмент, который читает реальные импорты в TypeScript и JavaScript и проверяет их по правилам из конфига. Если импорт нарушает правило — выход с ошибкой.
Конфиг называется .dependency-cruiser.cjs и лежит в корне проекта. Формат CommonJS (.cjs), потому что dependency-cruiser загружает его через require() даже в ESM-проектах.
<project-root>/
.dependency-cruiser.cjs
src/
core/<bc>/
adapters/in/http/
adapters/out/persistence/
adapters/out/<system>/
app/
Запуск:
npx depcruise --validate .dependency-cruiser.cjs src
Три базовых правила
Минимальный конфиг для Hexagonal-проекта:
// .dependency-cruiser.cjs
module.exports = {
forbidden: [
{
name: 'core-pure',
severity: 'error',
from: { path: '^src/core' },
to: {
path: '^(src/(adapters|app)|node_modules/(@nestjs|typeorm|class-validator|axios|kafkajs))',
},
},
{
name: 'adapters-independent',
severity: 'error',
from: { path: '^src/adapters/in' },
to: { path: '^src/adapters/out' },
},
{
name: 'nobody-depends-on-app',
severity: 'error',
from: { path: '^src/(core|adapters)' },
to: { path: '^src/app' },
},
],
options: {
tsConfig: { fileName: 'tsconfig.json' },
enhancedResolveOptions: { exportsFields: ['exports'] },
},
};
Что проверяет каждое правило:
core-pure—core/не импортирует ничего инфраструктурного: ни@nestjs/*, ниtypeorm, ниaxios, ни другие внешние зависимости. Доменный слой должен быть чистым TypeScript без фреймворка.adapters-independent— входящие адаптеры (adapters/in/*) не знают про исходящие (adapters/out/*). Контроллер HTTP не должен напрямую брать репозиторий из базы данных.nobody-depends-on-app—app/это точка сборки, а не библиотека. Никто на неё не опирается.
Дополнительные правила по мере роста
Когда в проекте появляется несколько bounded contexts или несколько out-адаптеров, добавляют уточняющие правила:
{
name: 'adapters-out-independent',
severity: 'error',
from: { path: '^src/adapters/out/([^/]+)/' },
to: { path: '^src/adapters/out/(?!$1/)' },
},
{
name: 'core-no-cross-bc',
severity: 'warn',
from: { path: '^src/core/([^/]+)/' },
to: { path: '^src/core/(?!$1/|shared/)' },
},
Первое правило запрещает адаптерам adapters/out/* ссылаться друг на друга. Если SberPaymentAdapter вызывает NotificationsAdapter — это работа для хендлера в core/, а не прямой импорт. Второе предупреждает, когда один bounded context напрямую импортирует агрегаты другого — взаимодействие должно идти через port-интерфейс.
Типичные нарушения
@Injectable() в core/
// core/order/usecases/create-order.handler.ts — неверно
import { Injectable } from '@nestjs/common';
@Injectable()
export class CreateOrderHandler { ... }
Доменный хендлер — это обычный TypeScript-класс. Аннотация @Injectable() нужна только в адаптерах. Биндинг переносят в useFactory-провайдер в app/order.module.ts.
Контроллер берёт репозиторий напрямую
// adapters/in/http/order.controller.ts — неверно
import { TypeOrmOrderRepository } from 'src/adapters/out/persistence/...';
Контроллер должен знать только о Dispatcher или use case port из core/. Репозиторий — детали реализации, скрытые за port-интерфейсом.
TypeORM-декораторы в доменном классе
// core/order/aggregate/order.ts — неверно
import { Entity, Column } from 'typeorm';
@Entity()
export class Order { ... }
Доменный объект — plain TypeScript-класс. @Entity() и @Column() живут только в ORM-entity внутри adapters/out/persistence/.
Out-адаптер вызывает другой out-адаптер
// adapters/out/sber/sber-payment.adapter.ts — неверно
import { NotificationsAdapter } from 'src/adapters/out/notifications/...';
Координация — задача хендлера. Хендлер инжектирует PaymentPort и NotificationsPort через Symbol-токены, а не конкретные адаптеры.
Обязательная проверка в CI
Запускать проверку вручную недостаточно: если она не блокирует слияние, нарушения откладываются «на потом» и накапливаются. Нужен обязательный статус-чек.
GitHub Actions:
# .github/workflows/ci.yml
jobs:
architecture-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx depcruise --validate .dependency-cruiser.cjs src
GitLab CI:
architecture-check:
image: node:22-alpine
script:
- npm ci
- npx depcruise --validate .dependency-cruiser.cjs src
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
В настройках ветки architecture-check отмечают как required status check. Без этого конфиг остаётся просто документацией.
Как выглядит вывод при нарушении
error core-pure: src/core/order/usecases/create-order.handler.ts →
node_modules/@nestjs/common/index.js
1 error, 0 warnings
Сообщение показывает конкретный файл и конкретный импорт — понятно, что исправлять.
Визуализация графа зависимостей
dependency-cruiser умеет строить граф всех импортов в виде SVG — удобно при знакомстве с проектом или при разборе сложного нарушения:
npx depcruise --validate .dependency-cruiser.cjs \
--output-type dot src | dot -T svg -o arch.svg
Альтернатива: eslint-plugin-boundaries
Если ESLint уже настроен в проекте, eslint-plugin-boundaries решает ту же задачу в рамках одного инструмента:
// eslint.config.mjs
import boundaries from 'eslint-plugin-boundaries';
export default [
{
plugins: { boundaries },
settings: {
'boundaries/elements': [
{ type: 'core', pattern: 'src/core/**' },
{ type: 'adapters-in', pattern: 'src/adapters/in/**' },
{ type: 'adapters-out', pattern: 'src/adapters/out/**' },
{ type: 'app', pattern: 'src/app/**' },
],
},
rules: {
'boundaries/element-types': ['error', {
default: 'disallow',
rules: [
{ from: 'core', allow: [] },
{ from: 'adapters-in', allow: ['core'] },
{ from: 'adapters-out', allow: ['core'] },
{ from: 'app', allow: ['core', 'adapters-in', 'adapters-out'] },
],
}],
},
},
];
Если проект новый — удобнее начать с dependency-cruiser: один .cjs-файл без дополнительной настройки линтера. Если ESLint уже есть и хочется единый инструмент — eslint-plugin-boundaries встраивается в существующий процесс.
Коротко
- В Node нет compile-time изоляции слоёв — правила границ требуют отдельного инструмента.
- dependency-cruiser читает реальные импорты и сравнивает с правилами в
.dependency-cruiser.cjs. - Три базовых правила:
core/не импортирует фреймворк, входящие адаптеры не знают исходящих, никто не зависит отapp/. - Конфиг один в корне проекта, скан
src/— не разрозненные файлы по папкам. - Без обязательного CI-чека конфиг не работает: нарушения обходят или откладывают.
- eslint-plugin-boundaries — альтернатива, если ESLint уже настроен.
Что почитать дальше
- Core слой — почему
@Injectable()запрещён вcore/и как устроен чистый доменный слой. - Ports — Symbol-токены и интерфейсы, через которые
core/общается с адаптерами. - Bootstrap / Composition root — как
app/собирает всё вместе без утечки зависимостей. - Когда переходить на Hexagonal — архитектурные тесты оправданы только при достаточной сложности проекта.