Опирается на правила:
R-HEX-TEST-1…R-HEX-TEST-3иR-HEX-TEST-X1из Hexagonal Style Guide → раздел 8. Архитектурные тесты.
Важно знать
- В Node нет compile-time изоляции модулей сборки — dependency-cruiser (или eslint-plugin-boundaries) — единственный способ machine-enforce правил границ.
- Конфиг
.dependency-cruiser.cjsв корне проекта; запускается какdepcruise --validate .dependency-cruiser.cjs src.- Три обязательных правила:
core/не импортирует@nestjs/*/typeorm/class-validator;adapters/in/*не импортируетadapters/out/*;core/иadapters/*не импортируютapp/.- Required CI check. PR не мерджится, если
depcruiseпадает.- Единый корень скана —
src/— в одном конфиге, не разрозненные правила по папкам.- Только code-review без инструмента — антипаттерн: один нечаянный
import { Injectable } from '@nestjs/common'вcore/— и граница сломана незаметно.- dependency-cruiser дополняет unit- и интеграционные тесты; не заменяет их.
В Java core/ изолирован compile-time: если в core/build.gradle.kts нет зависимости на Spring, импорт org.springframework.* не скомпилируется. В Node такого нет — весь код в одном node_modules, и TypeScript спокойно позволит adapters/out/persistence/ импортировать core/, а core/ — @nestjs/common. Без инструмента исполнение правил держится только на дисциплине ревьюера, а её недостаточно. Раскрытие правил R-HEX-TEST-* ниже.
Где живёт конфиг
R-HEX-TEST-3: единый корень скана — src/ в одном конфиге.
<project-root>/
.dependency-cruiser.cjs # единственный конфиг, скан src/
src/
core/<bc>/
adapters/in/http/
adapters/in/http-admin/
adapters/out/persistence/
adapters/out/<system>/
app/
Конфиг — CommonJS-модуль (.cjs), потому что dependency-cruiser подгружает его через require() даже в ESM-проектах.
Почему один конфиг, не разрозненные .deprc.json в каждой папке:
- Единая точка правил — легко найти, что enforce-ируется.
depcruiseприменяет всеforbidden-правила за один проход, результат предсказуем.- В CI одна команда, один exit-code.
Что проверять
R-HEX-TEST-1: три обязательных правила — чистота core/, независимость адаптеров, изоляция app/.
// .dependency-cruiser.cjs
module.exports = {
forbidden: [
{
name: 'core-pure',
severity: 'error',
comment: 'R-HEX-CORE-X1/X2: core/ не импортирует ничего инфраструктурного',
from: { path: '^src/core' },
to: {
path: '^(src/(adapters|app)|node_modules/(@nestjs|typeorm|class-validator|axios|kafkajs))',
},
},
{
name: 'adapters-independent',
severity: 'error',
comment: 'R-HEX-AIN-X4: in-adapter не знает про out-adapter',
from: { path: '^src/adapters/in' },
to: { path: '^src/adapters/out' },
},
{
name: 'nobody-depends-on-app',
severity: 'error',
comment: 'R-HEX-BOOT-X2: app/ — только composition root, от него не зависит никто',
from: { path: '^src/(core|adapters)' },
to: { path: '^src/app' },
},
],
options: {
tsConfig: { fileName: 'tsconfig.json' },
enhancedResolveOptions: { exportsFields: ['exports'] },
},
};
Что каждое правило ловит:
| Правило | Что проверяет | Код |
|---|---|---|
core-pure | core/ не импортирует @nestjs/*, typeorm, class-validator, axios, kafkajs | R-HEX-CORE-X1, R-HEX-CORE-X2 |
adapters-independent | adapters/in/* не импортирует adapters/out/* | R-HEX-AIN-X4 |
nobody-depends-on-app | core/ и adapters/* не импортируют app/ | R-HEX-BOOT-X2 |
Дополнительные правила, которые добавляют по мере роста:
{
name: 'adapters-out-independent',
severity: 'error',
comment: 'R-HEX-AOUT-X4: out-adapter не знает другой out-adapter',
from: { path: '^src/adapters/out/(?<system>[^/]+)/' },
to: { path: '^src/adapters/out/(?!\\k<system>)' },
},
{
name: 'core-no-cross-bc',
severity: 'warn',
comment: 'bounded context не импортирует другой BC напрямую — через port',
from: { path: '^src/core/(?<bc>[^/]+)/' },
to: { path: '^src/core/(?!\\k<bc>|shared)' },
},
adapters-out-independent бэк-ссылочным регексом \k<system> исключает импорты внутри одной системы, запрещает между разными. core-no-cross-bc — предупреждение: bounded context не должен напрямую импортировать агрегаты соседнего BC, только через port-интерфейс.
Портфель правил для Order / Product / Customer
Конкретный пример для трёх bounded contexts:
// .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|undici|kafkajs|ioredis)',
].join('|'),
},
},
{
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' },
},
{
name: 'order-not-imports-customer-aggregate',
severity: 'error',
comment: 'Order BC взаимодействует с Customer только через CustomerPort',
from: { path: '^src/core/order' },
to: { path: '^src/core/customer/aggregate' },
},
{
name: 'order-not-imports-product-aggregate',
severity: 'error',
comment: 'Order BC проверяет Product через ProductPort, не напрямую',
from: { path: '^src/core/order' },
to: { path: '^src/core/product/aggregate' },
},
],
options: {
tsConfig: { fileName: 'tsconfig.json' },
},
};
Запрет прямого импорта агрегата Customer из core/order/ гарантирует, что взаимодействие идёт только через CustomerPort — интерфейс в core/order/port/out/customer-port.ts. Это соответствует R-HEX-PORT-2: port-методы оперируют domain-типами, определёнными в том же BC.
Required CI check
R-HEX-TEST-2: depcruise — required check; PR не мерджится при падении.
# .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"'
В branch protection (GitHub): architecture-check — required status check. Без него — правила декларативные, а не enforcement.
Почему именно required, не просто informational:
- Информационный check обходят. Если он не блокирует мерж, нарушение откладывают на «потом». «Потом» копится.
- Сигнализирует приоритет. «У нас required arch-check» — это сигнал команде, что границы не опциональны.
- Делает ошибку дешёвой. Поймали в CI на PR — дёшево. Поймали в проде — дорого.
Альтернатива: 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 | eslint-plugin-boundaries |
|---|---|---|
| Скорость настройки | быстро — один .cjs файл | нужен ESLint-config |
| Интеграция с CI | отдельная команда | входит в eslint . |
| Детализация ошибок | граф с конкретным путём импорта | строка с нарушением |
| Регексы по путям | гибко, include/exclude | через pattern элементов |
В новом проекте — dependency-cruiser, он проще в начале. Если ESLint уже настроен — eslint-plugin-boundaries, чтобы не дублировать toolchain.
Локальная проверка
Разработчик запускает до push:
npx depcruise --validate .dependency-cruiser.cjs src
Вывод при нарушении:
error core-pure: src/core/order/usecases/create-order.handler.ts →
node_modules/@nestjs/common/index.js
(R-HEX-CORE-X1: core/ не импортирует ничего инфраструктурного)
1 error, 0 warnings
Граф зависимостей для визуализации (опционально):
npx depcruise --validate .dependency-cruiser.cjs \
--output-type dot src | dot -T svg -o arch.svg
SVG показывает все рёбра импортов — удобно при онбординге нового разработчика или при разборе, откуда взялось нарушение.
Типичные нарушения и как они выглядят
Четыре самых частых нарушения в NestJS-проектах, которые ловит depcruise:
1. @Injectable() в core/
// core/order/usecases/create-order.handler.ts — ПЛОХО
import { Injectable } from '@nestjs/common'; // R-HEX-CORE-X1
@Injectable()
export class CreateOrderHandler { ... }
Исправление: убрать @Injectable(), wiring перенести в useFactory-провайдер в app/order.module.ts.
2. Контроллер инжектит TypeORM-репозиторий
// adapters/in/http/order.controller.ts — ПЛОХО
import { TypeOrmOrderRepository } from 'src/adapters/out/persistence/...'; // R-HEX-AIN-X4
Исправление: только Dispatcher из core/shared/, репозиторий — через handler.
3. typeorm в core/
// core/order/aggregate/order.ts — ПЛОХО
import { Entity, Column } from 'typeorm'; // R-HEX-CORE-X2, R-HEX-CORE-X4
Исправление: доменный класс — plain TypeScript class без TypeORM-декораторов. @Entity()/@Column() — только на ORM-entity в adapters/out/persistence/.
4. SberPaymentAdapter инжектит NotificationsAdapter
// adapters/out/sber/sber-payment.adapter.ts — ПЛОХО
import { NotificationsAdapter } from 'src/adapters/out/notifications/...'; // R-HEX-AOUT-X4
Исправление: координация двух out-adapter'ов — в handler'е в core/; handler инжектит PaymentPort и NotificationsPort через Symbol-токены.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
| Только code-review для enforcement границ | R-HEX-TEST-X1 | depcruise или eslint-plugin-boundaries как required CI check |
| Разрозненные конфиги по папкам вместо единого | R-HEX-TEST-3 | Один .dependency-cruiser.cjs в корне, скан src/ |
depcruise запускается вручную, не в CI | R-HEX-TEST-2 | required check в branch protection; без него конфиг — просто документация |
core/ импортирует @nestjs/common для @Injectable() | R-HEX-CORE-X1 | Plain-классы в core/; @Injectable() только в adapters/*; wiring — useFactory в app/ |
adapters/in/http/ импортирует adapters/out/persistence/ | R-HEX-AIN-X4 | Контроллер → Dispatcher → Handler → port-токен → out-adapter |
Куда дальше
- Core слой — что именно проверяет правило
core-pureи почему@Injectable()запрещён вcore/. - Структура модулей — дерево папок и полный шаблон
.dependency-cruiser.cjs. - Ports — Symbol-токены и интерфейсы, которые служат точкой зависимостей для
core/. - Adapters in — почему
adapters/in/*не может знать проadapters/out/*. - Adapters out — per-system изоляция и почему
adapters/out/*независимы друг от друга. - Bootstrap / Composition root —
app/как единственная точка сборки, от которой не зависит никто. - Когда переходить на Hexagonal — архитектурные тесты оправданы только при Уровне 3.