Опирается на правила: R-HEX-MOD-1R-HEX-MOD-5 и R-HEX-MOD-X1R-HEX-MOD-X3 из Hexagonal Style Guide → раздел 2. Структура модулей.

Важно знать

  • В Node нет compile-time изоляции gradle-модулей — её заменяет dependency-cruiser (или eslint-plugin-boundaries) с контрактом в .dependency-cruiser.cjs, запущенным в CI как required check.
  • src/core/ не импортирует @nestjs/*, typeorm, class-validator, axios — никаких инфраструктурных зависимостей. Нарушение ловит правило core-pure в dependency-cruiser.
  • Per-system out: adapters/out/persistence/, adapters/out/sber/, adapters/out/notifications/ — отдельная папка на каждую внешнюю систему.
  • Per-purpose in: adapters/in/http/, adapters/in/http-admin/, adapters/in/kafka/ — admin отдельно от user: свой Guard, свой JwtStrategy.
  • app/ — composition root: main.ts, AppModule, конфиг, Dockerfile. Никто не зависит от app/ — это закрывающий узел.
  • Стрелка зависимостей строго: app → adapters → core. core не зависит от адаптеров. Адаптеры не зависят друг от друга.
  • NestJS-декораторы (@Injectable, @Inject) запрещены в core/ — plain-классы, wiring через useFactory-провайдеры в app/ или feature-модулях.

В Java Hexagonal держится на gradle-модулях: компилятор не даст импортировать Spring из core/. В Node compile-time изоляции модулей нет — npm workspaces возможны, но для одного сервиса это избыточно. Вместо этого контракт границ описывается в .dependency-cruiser.cjs и проверяется в CI. Без этого файла Hexagonal в Node — конвенция на доверии: кто-то добавит import { DataSource } from 'typeorm' в core/order/aggregate/order.ts и тест пройдёт, IDE промолчит, ревью пропустит. Раскрытие правил R-HEX-MOD-* ниже.

Раскладка src/

R-HEX-MOD-1: стандартное дерево папок NestJS-сервиса на Hexagonal.

src/
  core/
    order/
      aggregate/          # Order, OrderItem
      value-object/       # OrderId, Money, CustomerId
      event/              # OrderCreatedEvent, OrderConfirmedEvent
      port/
        out/              # OrderRepository, PaymentPort — интерфейсы + Symbol-токены
      usecases/           # CreateOrderCommand, CreateOrderHandler, GetOrderQuery
      service/            # OrderDomainService (опционально, для cross-aggregate логики)
    product/
      aggregate/
      port/out/
      usecases/
    shared/
      dispatcher.ts       # Dispatcher — единственная точка входа в core
      value-object/       # Money, Clock, TransactionRunner
  adapters/
    in/
      http/               # публичный REST (user-facing)
      http-admin/         # REST для админ-панели, отдельный Guard
      kafka/              # Kafka consumers как entry-point
      cli/                # CLI / batch (опционально)
    out/
      persistence/        # TypeORM — реализация OrderRepository
      sber/               # axios-клиент Sber — реализация PaymentPort
      notifications/      # SMS / email провайдер
      kafka/              # KafkaJS — реализация EventPublisher
  app/
    main.ts               # NestFactory.create + enableShutdownHooks
    app.module.ts         # AppModule — собирает feature-модули, биндит порты
    config/               # ConfigModule, типизированный конфиг
    Dockerfile

Минимальный набор для Уровня 3: core/<bc>/, adapters/out/persistence/, adapters/in/http/, app/. Дальше — добавляем папки по мере роста: появился Sber — adapters/out/sber/; появился Kafka-consumer — adapters/in/kafka/.

dependency-cruiser — контракт границ

R-HEX-MOD-2 / R-HEX-MOD-X1: вместо gradle-модулей — .dependency-cruiser.cjs с тремя запретами.

// .dependency-cruiser.cjs
module.exports = {
  forbidden: [
    {
      name: 'core-pure',
      severity: 'error',
      comment: 'core не импортирует инфраструктуру — R-HEX-MOD-2 / R-HEX-CORE-X1',
      from: { path: '^src/core' },
      to: {
        path: '^(src/(adapters|app)|node_modules/(@nestjs|typeorm|class-validator|axios|kafkajs))',
      },
    },
    {
      name: 'adapters-independent',
      severity: 'error',
      comment: 'in-адаптеры не импортируют out-адаптеры — R-HEX-AIN-X4',
      from: { path: '^src/adapters/in' },
      to: { path: '^src/adapters/out' },
    },
    {
      name: 'nobody-depends-on-app',
      severity: 'error',
      comment: 'app — закрывающий узел, никто не импортирует его — R-HEX-MOD-5',
      from: { path: '^src/(core|adapters)' },
      to: { path: '^src/app' },
    },
  ],
};

Запуск в CI (в package.json):

{
  "scripts": {
    "arch:check": "depcruise --validate .dependency-cruiser.cjs src"
  }
}

PR-пайплайн запускает npm run arch:check как required check — без зелёного статуса мердж заблокирован. Это аналог ArchUnit в Java: не кодревью, а автомат. Одно нарушение падает явным сообщением вида:

error core-pure: src/core/order/aggregate/order.ts
  → node_modules/@nestjs/common/index.js

Нет возможности «не заметить».

core/ — ноль инфраструктурных зависимостей

R-HEX-MOD-2 / R-HEX-CORE-3: core/ зависит только от TS/stdlib и доменных утилит.

// tsconfig.json — path aliases, чтобы core/ не видел ничего лишнего
{
  "compilerOptions": {
    "paths": {
      "core/*": ["src/core/*"]
    }
  }
}

Что не появляется в package.json зависимостях core/-классов:

  • @nestjs/common, @nestjs/core, @nestjs/microservices — инфраструктура DI.
  • typeorm — persistence-деталь; маппинг в adapters/out/persistence/*.mapper.ts.
  • class-validator, class-transformer — деталь in-adapter; в core/ нет декораторов на DTO.
  • axios, undici, kafkajs — транспорт; в core/ — только порт-интерфейсы.

Что разрешено:

  • uuid — генерация идентификаторов.
  • big.js — денежная арифметика без погрешности.
  • Собственные доменные утилиты без инфраструктурных зависимостей.

Пример чистого агрегата:

// core/order/aggregate/order.ts
import { randomUUID } from 'node:crypto';
import { OrderId } from '../value-object/order-id';
import { CustomerId } from '../value-object/customer-id';
import { Money } from '../../shared/value-object/money';
import { OrderStatus } from '../value-object/order-status';
import { OrderCreatedEvent } from '../event/order-created.event';

export class Order {
  private constructor(
    readonly id: OrderId,
    readonly customerId: CustomerId,
    private _totalAmount: Money,
    private _status: OrderStatus,
    private readonly _events: unknown[],
  ) {}

  static create(customerId: CustomerId, items: OrderItem[], clock: Clock): Order {
    const id = new OrderId(randomUUID());
    const total = items.reduce((acc, i) => acc.add(i.price), Money.zero());
    const order = new Order(id, customerId, total, OrderStatus.PENDING, []);
    order._events.push(new OrderCreatedEvent(id, customerId, total, clock.now()));
    return order;
  }

  confirm(): void {
    if (this._status !== OrderStatus.PENDING) {
      throw new OrderAlreadyConfirmedError(this.id);
    }
    this._status = OrderStatus.CONFIRMED;
  }

  get totalAmount(): Money { return this._totalAmount; }
  get status(): OrderStatus { return this._status; }
  pullEvents(): unknown[] { return this._events.splice(0); }
}

Ни одного импорта из @nestjs/* или typeorm. Зависимость dependency-cruiser (core-pure) зелёная.

Plain-классы в core, wiring в app/

R-HEX-CORE-3: NestJS-декораторы @Injectable() / @Inject() на классах core/ запрещены.

В Java Spring автоматически подбирает beans из classpath через component scan — поэтому @Service в core/ допустим при определённых условиях. В NestJS авто-пикающего стартера нет: если @Injectable() нет, Nest просто не зарегистрирует класс. Но это и не нужно: handler'ы и domain-сервисы в core/ — plain TypeScript-классы, инстанцируются вручную через useFactory-провайдеры в app/ или feature-модулях.

// app/order.module.ts — wiring plain-handler'а
import { Module } from '@nestjs/common';
import { CreateOrderHandler } from 'src/core/order/usecases/create-order.handler';
import {
  ORDER_REPOSITORY,
  TX_RUNNER,
  CLOCK,
} from 'src/core/order/port/out/tokens';
import { TypeOrmOrderRepository } from 'src/adapters/out/persistence/typeorm-order.repository';
import { PgTransactionRunner } from 'src/adapters/out/persistence/pg-transaction-runner';
import { SystemClock } from 'src/adapters/out/clock/system-clock';

@Module({
  providers: [
    TypeOrmOrderRepository,
    PgTransactionRunner,
    SystemClock,
    { provide: ORDER_REPOSITORY, useClass: TypeOrmOrderRepository },
    { provide: TX_RUNNER, useClass: PgTransactionRunner },
    { provide: CLOCK, useClass: SystemClock },
    {
      provide: CreateOrderHandler,
      useFactory: (repo, tx, clock) => new CreateOrderHandler(repo, tx, clock),
      inject: [ORDER_REPOSITORY, TX_RUNNER, CLOCK],
    },
  ],
  exports: [CreateOrderHandler],
})
export class OrderModule {}

CreateOrderHandler — plain-класс без декораторов:

// core/order/usecases/create-order.handler.ts
import { OrderRepository } from '../port/out/order-repository';
import { TransactionRunner } from '../../shared/value-object/transaction-runner';
import { Clock } from '../../shared/value-object/clock';
import { CreateOrderCommand } from './create-order.command';
import { Order } from '../aggregate/order';

export class CreateOrderHandler {
  constructor(
    private readonly orders: OrderRepository,
    private readonly tx: TransactionRunner,
    private readonly clock: Clock,
  ) {}

  async handle(cmd: CreateOrderCommand): Promise<Order> {
    const order = Order.create(cmd.customerId, cmd.items, this.clock);
    return this.tx.run(() => this.orders.save(order));
  }
}

Нет @Injectable(). Нет @Inject(). Нет зависимости на @nestjs/common. dependency-cruiser доволен.

Per-system out-адаптеры

R-HEX-MOD-3: на каждую внешнюю систему — отдельная папка в adapters/out/.

adapters/out/
  persistence/            # TypeORM — PG, реализует OrderRepository, ProductRepository
  sber/                   # axios-клиент Sber, реализует PaymentPort
  notifications/          # SMS-провайдер, реализует NotificationPort
  kafka/                  # kafkajs, реализует DomainEventPublisher
  s3/                     # S3-клиент, реализует FileStoragePort

Зачем разделять:

  • Изоляция зависимостей. core/ не знает Sber-SDK. persistence/ не знает kafkajs. Если меняем SMS-провайдера — правка только в adapters/out/notifications/, остальные папки не трогаются.
  • Resilience per-system. Таймаут, retry, circuit breaker для Sber и для SMS — разные. Один общий axios-инстанс для всего исходящего — антипаттерн, правило R-RES-ISO-1 из Resilience Style Guide.
  • Тестируемость. В интеграционных тестах подменяем конкретный out-адаптер заглушкой через порт-токен. Другие адаптеры не затрагиваются.

Пример биндинга Sber-адаптера на порт:

// adapters/out/sber/sber.module.ts
import { Module } from '@nestjs/common';
import { PAYMENT_PORT } from 'src/core/payment/port/out/payment-port';
import { SberPaymentAdapter } from './sber-payment.adapter';
import { SberHttpClient } from './sber-http.client';
import { SberPaymentMapper } from './sber-payment.mapper';

@Module({
  providers: [
    SberHttpClient,
    SberPaymentMapper,
    { provide: PAYMENT_PORT, useClass: SberPaymentAdapter },
  ],
  exports: [PAYMENT_PORT],
})
export class SberModule {}

Per-purpose in-адаптеры

R-HEX-MOD-4: каждый тип входа — отдельная папка в adapters/in/.

adapters/in/
  http/            # публичный REST, JWT с user-audience
  http-admin/      # REST для админ-панели, JWT с admin-audience, mTLS
  kafka/           # Kafka consumers как entry-point (не простой sync-read)
  cli/             # CLI / batch (если есть)

Зачем разделять:

  • Разный security-профиль. http/ принимает JWT с aud: user-service; http-admin/aud: admin-service и другой JwtStrategy. Смешать в одной папке — значит потерять изоляцию: один @UseGuards() обслуживает оба контракта.
  • Независимые DTO-контракты. Публичный API и admin-API могут расходиться по shape. Раздельные папки делают это явным и безопасным.
  • Import-контроль. dependency-cruiser (adapters-independent-правило) не даст http/ импортировать http-admin/ или любой out/-адаптер.

Пример admin-контроллера с отдельным Guard:

// adapters/in/http-admin/product.admin.controller.ts
import { Controller, Get, UseGuards } from '@nestjs/common';
import { AdminJwtGuard } from './guards/admin-jwt.guard';
import { Dispatcher } from 'src/core/shared/dispatcher';
import { GetProductsAdminQuery } from 'src/core/product/usecases/get-products-admin.query';
import { ProductAdminResponseDto } from './dto/product-admin-response.dto';
import { ProductAdminMapper } from './product-admin.mapper';

@Controller('admin/products')
@UseGuards(AdminJwtGuard)
export class ProductAdminController {
  constructor(
    private readonly dispatcher: Dispatcher,
    private readonly mapper: ProductAdminMapper,
  ) {}

  @Get()
  async list(): Promise<ProductAdminResponseDto[]> {
    const products = await this.dispatcher.dispatch(new GetProductsAdminQuery());
    return products.map(p => this.mapper.toAdminDto(p));
  }
}

AdminJwtGuard — в adapters/in/http-admin/guards/. Контроллер из adapters/in/http/ не может его импортировать — dependency-cruiser блокирует.

app/ — composition root

R-HEX-MOD-5: app/ собирает всё. Никто не зависит от app/.

app/
  main.ts          # NestFactory.create, enableShutdownHooks, ValidationPipe
  app.module.ts    # AppModule: импортирует feature-модули и адаптеры
  config/
    configuration.ts   # типизированный конфиг (ConfigModule.forRoot)
  Dockerfile

main.ts минимален — только запуск:

// app/main.ts
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }));
  app.enableShutdownHooks();
  await app.listen(process.env.PORT ?? 3000);
}

bootstrap();

AppModule собирает все feature-модули и адаптеры:

// app/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { OrderModule } from './order.module';
import { ProductModule } from './product.module';
import { CustomerModule } from './customer.module';
import { SberModule } from 'src/adapters/out/sber/sber.module';
import { OrderHttpModule } from 'src/adapters/in/http/order-http.module';
import { AdminHttpModule } from 'src/adapters/in/http-admin/admin-http.module';
import configuration from './config/configuration';

@Module({
  imports: [
    ConfigModule.forRoot({ load: [configuration], isGlobal: true }),
    TypeOrmModule.forRootAsync({ ... }),
    OrderModule,
    ProductModule,
    CustomerModule,
    SberModule,
    OrderHttpModule,
    AdminHttpModule,
  ],
})
export class AppModule {}

app/ — это единственный узел, который знает про всех. Он зависит от адаптеров и от core/. Адаптеры не зависят от app/. Это закрывающий узел dependency-cruiser (nobody-depends-on-app-правило).

Стрелка зависимостей

Главное правило, из которого следует всё остальное:

app → adapters → core
  • app/ знает про всех.
  • Каждый adapter/ знает только core/ (через port-интерфейсы и токены).
  • core/ не знает никого.
  • Адаптеры не знают друг друга. Координация — use case в core/, handler инжектит оба порта.

В числах: если Customer-handler нужен и OrderRepository, и NotificationPort — оба порта инжектируются в CustomerCommandHandler в core/. persistence/ и notifications/ не знают друг о друге.

// core/customer/usecases/register-customer.handler.ts
export class RegisterCustomerHandler {
  constructor(
    private readonly customers: CustomerRepository,
    private readonly notifications: NotificationPort,
    private readonly tx: TransactionRunner,
  ) {}

  async handle(cmd: RegisterCustomerCommand): Promise<Customer> {
    const customer = Customer.register(cmd.phone, cmd.name);
    await this.tx.run(async () => {
      await this.customers.save(customer);
      await this.notifications.sendWelcome(customer.phone, customer.name);
    });
    return customer;
  }
}

CustomerRepository и NotificationPort — интерфейсы в core/. Реализации (TypeOrmCustomerRepository в persistence/, SmsNotificationAdapter в notifications/) не знают друг о друге.

Что запрещено

АнтипаттернПравилоЧто взамен
Полагаться на дисциплину вместо dependency-cruiserR-HEX-MOD-X1.dependency-cruiser.cjs + depcruise в CI как required check
import { DataSource } from 'typeorm' в core/order/aggregate/order.tsR-HEX-MOD-X2 / R-HEX-CORE-X2TypeORM только в adapters/out/persistence/; маппинг в *.mapper.ts
User- и admin-контроллеры в одной папке adapters/in/http/R-HEX-MOD-X3adapters/in/http/ и adapters/in/http-admin/ с отдельными Guards
@Injectable() на CreateOrderHandler в core/R-HEX-CORE-X1Plain-класс + useFactory-провайдер в app/order.module.ts
adapters/in/http/ импортирует adapters/out/persistence/R-HEX-AIN-X4In-адаптер знает только core/; координация через Dispatcher → Handler
Бизнес-логика в app/app.module.tsR-HEX-BOOT-X1app/ только собирает модули и биндит порты; логика в core/

Куда дальше

  • Adapters in — NestJS-контроллеры, маппинг DTO → command, Dispatcher.
  • Adapters out — реализация port-интерфейсов, TypeORM и axios-адаптеры.
  • Архитектурные тесты — depcruise --validate в CI: полный конфиг и разбор ошибок.
  • Bootstrap / Composition root — AppModule, useFactory-биндинги, конфиг.
  • Core слой — агрегаты, port-интерфейсы и plain-handlers без NestJS-декораторов.
  • Ports — Symbol-токены, интерфейс vs класс, port-исключения в core/.
  • Когда переходить на Hexagonal — когда раскладка с dependency-cruiser оправдана.