← назад к разделу

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-purecore/ не импортирует ничего инфраструктурного: ни @nestjs/*, ни typeorm, ни axios, ни другие внешние зависимости. Доменный слой должен быть чистым TypeScript без фреймворка.
  • adapters-independent — входящие адаптеры (adapters/in/*) не знают про исходящие (adapters/out/*). Контроллер HTTP не должен напрямую брать репозиторий из базы данных.
  • nobody-depends-on-appapp/ это точка сборки, а не библиотека. Никто на неё не опирается.

Дополнительные правила по мере роста

Когда в проекте появляется несколько 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 — архитектурные тесты оправданы только при достаточной сложности проекта.