Опирается на правила:
R-HEX-AIN-1…R-HEX-AIN-4иR-HEX-AIN-X1…R-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 | Только через Dispatcher → CreateOrderHandler → порт → адаптер |
Контроллер возвращает 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-X1 | Plain-классы в 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-раскладка оправдана.