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

В обычном NestJS-контроллере можно написать всё что угодно: вызвать репозиторий напрямую, положить туда логику валидации суммы, вернуть наружу внутренний объект домена. Код работает, но тестировать его неудобно, логика расползается по контроллерам, а изменение правила («сумма не больше 100 000») нужно искать в нескольких местах.

Гексагональная архитектура вводит чёткую границу: входящий адаптер (adapter in) — это единственная точка, через которую HTTP-запрос, Kafka-сообщение или CLI-команда попадают в сервис. Адаптер переводит «внешний язык» (DTO, события) в «язык ядра» (команды, запросы), и на этом его работа заканчивается.

Что делает входящий адаптер

Три шага, не больше:

  1. Принять запрос (HTTP-тело, Kafka-payload, аргументы CLI).
  2. Смаппить его в команду для ядра.
  3. Передать команду в Dispatcher, получить результат, смаппить обратно в ответ.

Бизнес-логики нет. Проверка правил, расчёты, изменение состояния — всё это в ядре, в CommandHandler. Контроллер об этом ничего не знает.

Отдельная папка на каждый тип входа

Когда всё в одной папке controllers/, легко случайно смешать HTTP для пользователей и HTTP для административного API — с разными правилами авторизации. Или добавить импорт Kafka-потребителя в HTTP-контроллер.

Гексагональный подход: каждый тип входа — своя папка в adapters/in/:

src/
  adapters/
    in/
      http/            # публичный REST для пользователей
      http-admin/      # REST для административного API
      kafka/           # Kafka consumers
      cli/             # CLI-команды (если нужны)

Что это даёт:

  • Изолированная безопасность. http/ проверяет JWT с аудиторией пользователя, http-admin/ — свой guard с административной аудиторией. Смешать их уже не получится случайно.
  • Разные контракты. Публичный API и административный могут иметь разные DTO. Раздельные папки делают это явным.
  • Контроль импортов. В CI можно настроить dependency-cruiser и он будет ошибкой отмечать любой импорт из adapters/in/http/ в adapters/out/ или наоборот.

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

Рассмотрим HTTP-контроллер для создания заказа.

// 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);
  }
}

Три строки в методе — это не случайно. Контроллер буквально делает три шага: смаппил, передал, смаппил обратно.

DTO описывает форму входящего запроса и проверяет поля с помощью class-validator:

// 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;
}

Маппер — отдельный файл, который переводит DTO в команды ядра и обратно:

// 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,
    };
  }
}

Маппер кладут в отдельный файл, а не прямо в контроллер, потому что преобразования разрастаются. Когда полей становится больше, контроллер остаётся в три строки, а вся сложность изолирована в маппере, который можно тестировать отдельно.

Kafka consumer как входящий адаптер

Kafka consumer устроен ровно так же, как HTTP-контроллер: принимает сообщение, маппит в команду, диспатчит. Только вместо @Post()@EventPattern():

// 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);
  }
}

Бизнес-логика не повторяется ни в HTTP-контроллере, ни в Kafka-потребителе — она живёт в одном месте, в CommandHandler в ядре.

Что адаптер знает и не знает

Входящий адаптер знает:

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

Адаптер не знает:

  • Ничего из adapters/out/ — ни TypeORM-репозиториев, ни клиентов внешних систем.
  • Других входящих адаптеров — http/ не импортирует http-admin/.

Частые ошибки и как их исправить

Бизнес-логика в контроллере. Выглядит невинно:

// Плохо
@Post()
async createOrder(@Body() dto: CreateOrderDto) {
  if (dto.totalAmount > 100_000) {
    throw new BadRequestException('Сумма слишком большая');
  }
  // ...
}

Проблема: это же правило нужно продублировать в Kafka-потребителе и CLI-команде. Через месяц они разойдутся. Правило «сумма не больше 100К» должно жить в доменном агрегате (Order.create()) или в CommandHandler — один раз, для всех точек входа.

Контроллер вызывает репозиторий напрямую.

// Плохо
constructor(private readonly orderRepo: TypeOrmOrderRepository) {}

@Post()
async createOrder(@Body() dto: CreateOrderDto) {
  const order = Order.create(dto.customerId, dto.items, dto.totalAmount);
  await this.orderRepo.save(order); // транзакция теряется
}

TransactionRunner работает на уровне CommandHandler. Прямой вызов репозитория в контроллере оставляет каждую операцию без транзакции и обходит централизованную авторизацию.

Контроллер возвращает доменный агрегат.

// Плохо
return order; // Order — агрегат с методами

TypeScript сериализует order «как есть». Внутренние поля агрегата, вспомогательные методы — всё уйдёт в HTTP-ответ. Правильно — всегда возвращать response-DTO через маппер.

Правильный контроллер:

// Хорошо
@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);
}

Коротко

  • Входящий адаптер — точка входа внешнего мира в сервис: HTTP, Kafka, CLI.
  • Три шага: принять запрос → смаппить в команду → диспатчить → смаппить ответ.
  • Каждый тип входа — отдельная папка (adapters/in/http/, adapters/in/kafka/).
  • Маппер — отдельный файл, не встроенный в контроллер.
  • Адаптер не знает ни о adapters/out/, ни о других входящих адаптерах.
  • Бизнес-логика в контроллере — это всегда дублирование: правило нужно повторить для каждой точки входа.
  • Вызов репозитория напрямую из контроллера обходит транзакцию и авторизацию.
  • Доменный агрегат наружу не возвращают — только response-DTO.

Что почитать дальше

  • Adapters out — симметричная сторона: реализация port-интерфейсов из ядра.
  • Ports — Symbol-токены, интерфейсы, port-исключения.
  • Core слой — что живёт в ядре и почему без NestJS-декораторов.
  • Bootstrap / Composition root — подключение портов через useFactory и AppModule.