Опирается на правила: R-HEX-AIN-1R-HEX-AIN-4 и R-HEX-AIN-X1R-HEX-AIN-X4 из Hexagonal Style Guide → раздел 5. Adapters in.

Важно знать

  • На каждый тип входа — отдельная папка: adapters/in/http/, adapters/in/http-admin/, adapters/in/kafka/, adapters/in/cli/.
  • Контроллер маппит request-DTO (class-validator) → UseCase, вызывает Dispatcher, возвращает response-DTO. Никакой бизнес-логики.
  • Маппер order-request.mapper.ts — отдельный файл в adapters/in/http/, переводит REST-DTO ↔ UseCase command и domain ↔ response-DTO.
  • In-adapter знает NestJS + class-validator. Не знает про adapters/out/* — ни persistence/, ни внешние системы.
  • @Injectable() и NestJS-декораторы запрещены в core/. Контроллеры живут только в adapters/in/*.
  • Контроллер зовёт Dispatcher, не репозиторий напрямую — иначе транзакция и авторизация обходятся.
  • Контроллер возвращает response-DTO, не domain-агрегат. Внутренняя структура не утекает наружу.
  • Границы enforce-ются dependency-cruiser в CI (adapters-independent-правило) — не кодревью.

In-adapter — это точка, через которую внешний мир (HTTP-клиент, Kafka-сообщение, CLI-команда) входит в сервис. Он принимает запрос, маппит его в UseCase command, отдаёт Dispatcher, получает результат, маппит обратно в response-DTO. Никакой бизнес-логики — только трансформация и маршрутизация. Раскрытие правил R-HEX-AIN-* ниже.

Per-purpose папки

R-HEX-AIN-1: каждый тип входа — своя папка в adapters/in/.

src/
  adapters/
    in/
      http/            # публичный REST для конечного пользователя
      http-admin/      # REST для админ-панели, отдельный Guard/JwtStrategy
      kafka/           # Kafka consumers как entry-point
      cli/             # CLI / batch (опционально)

В Java это разные gradle-модули; в Node нет compile-time изоляции модулей — вместо неё контракт dependency-cruiser (adapters-independent-правило). Admin-контроллеры в отдельной папке потому, что у них свой @UseGuards(AdminJwtGuard) и своя политика аутентификации — смешивать с user-контроллерами значит потерять изоляцию безопасности.

Что это даёт:

  • Per-purpose security. http/ принимает JWT с user-audience; http-admin/ — admin-audience, отдельный AuthModule. Каждый со своими Guards.
  • Независимые маппинги. Публичный API и admin-API могут иметь разные DTO-контракты. Раздельные папки делают это явным.
  • Import-контроль. dependency-cruiser не даст adapters/in/http/ импортировать adapters/in/http-admin/ или adapters/out/* — нарушение видно в CI немедленно.

Контроллер, Dispatcher, маппер

R-HEX-AIN-2 / R-HEX-AIN-3: контроллер маппит DTO → command, диспатчит, маппит обратно. Маппер — отдельный файл.

// adapters/in/http/order.controller.ts
import { Body, Controller, Post, HttpCode, HttpStatus } from '@nestjs/common';
import { Dispatcher } from 'src/core/shared/dispatcher';
import { OrderRequestMapper } from './order-request.mapper';
import { CreateOrderDto } from './dto/create-order.dto';
import { OrderResponseDto } from './dto/order-response.dto';

@Controller('orders')
export class OrderController {
  constructor(
    private readonly dispatcher: Dispatcher,
    private readonly mapper: OrderRequestMapper,
  ) {}

  @Post()
  @HttpCode(HttpStatus.CREATED)
  async createOrder(@Body() dto: CreateOrderDto): Promise<OrderResponseDto> {
    const cmd = this.mapper.toCreateOrderCommand(dto);
    const order = await this.dispatcher.dispatch(cmd);
    return this.mapper.toResponseDto(order);
  }
}
// adapters/in/http/dto/create-order.dto.ts
import { IsUUID, IsArray, ArrayNotEmpty, IsNumber, Min } from 'class-validator';

export class CreateOrderDto {
  @IsUUID()
  customerId: string;

  @IsArray()
  @ArrayNotEmpty()
  items: OrderItemDto[];

  @IsNumber()
  @Min(1)
  totalAmount: number;
}
// adapters/in/http/order-request.mapper.ts
import { Injectable } from '@nestjs/common';
import { CreateOrderCommand } from 'src/core/order/usecases/create-order.command';
import { CustomerId } from 'src/core/order/value-object/customer-id';
import { Money } from 'src/core/shared/value-object/money';
import { Order } from 'src/core/order/aggregate/order';
import { CreateOrderDto } from './dto/create-order.dto';
import { OrderResponseDto } from './dto/order-response.dto';

@Injectable()
export class OrderRequestMapper {
  toCreateOrderCommand(dto: CreateOrderDto): CreateOrderCommand {
    return new CreateOrderCommand(
      new CustomerId(dto.customerId),
      dto.items.map(i => ({ productId: i.productId, qty: i.qty })),
      Money.ofRub(dto.totalAmount),
    );
  }

  toResponseDto(order: Order): OrderResponseDto {
    return {
      id: order.id.value,
      status: order.status,
      totalAmount: order.totalAmount.amount,
      customerId: order.customerId.value,
    };
  }
}

Что важно:

  • @Injectable() на OrderRequestMapper разрешён — маппер живёт в adapters/in/, а не в core/. Запрет @Injectable() действует только на классы core/ (R-HEX-CORE-3).
  • CreateOrderDto с class-validator-декораторами — деталь in-adapter. В core/ этого нет.
  • toResponseDto принимает domain-агрегат Order, возвращает plain-DTO. Domain не утекает наружу.

Kafka consumer как in-adapter

// adapters/in/kafka/product-events.consumer.ts
import { Controller } from '@nestjs/common';
import { EventPattern, Payload } from '@nestjs/microservices';
import { Dispatcher } from 'src/core/shared/dispatcher';
import { ProductEventMapper } from './product-event.mapper';

@Controller()
export class ProductEventsConsumer {
  constructor(
    private readonly dispatcher: Dispatcher,
    private readonly mapper: ProductEventMapper,
  ) {}

  @EventPattern('product.price-updated')
  async onPriceUpdated(@Payload() event: unknown): Promise<void> {
    const cmd = this.mapper.toPriceUpdatedCommand(event);
    await this.dispatcher.dispatch(cmd);
  }
}

Kafka consumer структурно эквивалентен HTTP-контроллеру: принимает сообщение, маппит в command, диспатчит. Бизнес-логики нет — она в handler'е в core/.

Что in-adapter знает и не знает

R-HEX-AIN-4: in-adapter знает NestJS + class-validator, не знает про другие адаптеры.

Знает:

  • @nestjs/common@Controller, @Get, @Post, @Body, @Param, @UseGuards, @HttpCode.
  • @nestjs/microservices@EventPattern, @MessagePattern, @Payload для Kafka-consumer.
  • class-validator / class-transformer — декораторы на request-DTO.
  • Dispatcher из core/shared/ — единственная точка входа в core.

Не знает:

  • adapters/out/persistence/ — нет импорта TypeORM-репозитория.
  • adapters/out/sber/, adapters/out/notifications/ — нет вызовов внешних систем.
  • adapters/in/http-admin/ — user-контроллер не импортирует admin-контроллер.

Контракт enforce-ится .dependency-cruiser.cjs:

{ name: 'adapters-independent', severity: 'error',
  from: { path: '^src/adapters/in' },
  to:   { path: '^src/adapters/out' } },

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

АнтипаттернПравилоЧто взамен
Бизнес-логика в контроллере (if (dto.totalAmount > 100_000))R-HEX-AIN-X1Логика в domain-агрегате (Order.create()) или в CommandHandler
Контроллер инжектит OrderRepository напрямуюR-HEX-AIN-X2Только через DispatcherCreateOrderHandler → порт → адаптер
Контроллер возвращает domain-агрегат Order как HTTP-ответR-HEX-AIN-X3Маппер в OrderResponseDto; domain не утекает за границу адаптера
adapters/in/http/ импортирует adapters/out/persistence/R-HEX-AIN-X4Адаптеры зависят от core/, не друг от друга; координация — в handler'е
@Injectable() на классах core/R-HEX-CORE-X1Plain-классы в core/; wiring через useFactory-провайдер в app/

Пример запрещённого контроллера:

// ПЛОХО
@Controller('orders')
export class OrderController {
  constructor(private readonly orderRepo: TypeOrmOrderRepository) {}

  @Post()
  async createOrder(@Body() dto: CreateOrderDto) {
    if (dto.totalAmount > 100_000) {                    // R-HEX-AIN-X1
      throw new BadRequestException('Сумма слишком большая');
    }
    const order = Order.create(dto.customerId, dto.items, dto.totalAmount);
    await this.orderRepo.save(order);                   // R-HEX-AIN-X2
    return order;                                       // R-HEX-AIN-X3
  }
}

Что не так:

  • Логика разъезжается. Правило «сумма < 100К» нужно в Kafka-consumer'е, CLI-команде, scheduled-задаче. В контроллере — копируется руками.
  • Транзакция теряется. TransactionRunner живёт на handler'е. Прямой repo.save() в контроллере — каждая операция в отдельной транзакции.
  • Domain утекает наружу. order — это агрегат с методами, TypeScript сериализует его «как есть». Jackson-аналог в NestJS (class-transformer) сломает rich-методы или выдаст неожиданный JSON.

Правильно:

// ХОРОШО
@Post()
@HttpCode(HttpStatus.CREATED)
async createOrder(@Body() dto: CreateOrderDto): Promise<OrderResponseDto> {
  const cmd = this.mapper.toCreateOrderCommand(dto);
  const order = await this.dispatcher.dispatch(cmd);
  return this.mapper.toResponseDto(order);
}

Куда дальше

  • Adapters out — симметричная сторона: реализация port-интерфейсов из core.
  • Ports — Symbol-токены, интерфейсы, port-исключения.
  • Core слой — что живёт в core и почему без NestJS-декораторов.
  • Bootstrap / Composition root — wiring портов через useFactory, AppModule.
  • Структура модулей — дерево папок и .dependency-cruiser.cjs.
  • Архитектурные тесты — depcruise в CI как required check.
  • Когда переходить на Hexagonal — когда in-adapter-раскладка оправдана.