Опирается на правила: R-HEX-TEST-1R-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-purecore/ не импортирует @nestjs/*, typeorm, class-validator, axios, kafkajsR-HEX-CORE-X1, R-HEX-CORE-X2
adapters-independentadapters/in/* не импортирует adapters/out/*R-HEX-AIN-X4
nobody-depends-on-appcore/ и 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-cruisereslint-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-X1depcruise или eslint-plugin-boundaries как required CI check
Разрозненные конфиги по папкам вместо единогоR-HEX-TEST-3Один .dependency-cruiser.cjs в корне, скан src/
depcruise запускается вручную, не в CIR-HEX-TEST-2required check в branch protection; без него конфиг — просто документация
core/ импортирует @nestjs/common для @Injectable()R-HEX-CORE-X1Plain-классы в 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.