In an ordinary NestJS controller you can write anything: call a repository directly, drop amount-validation logic in there, return an internal domain object to the outside world. The code works, but it is awkward to test, the logic sprawls across controllers, and changing a rule ("the amount must not exceed 100,000") means hunting through several places.
Hexagonal architecture introduces a clear boundary: the inbound adapter (adapter in) is the single point through which an HTTP request, a Kafka message, or a CLI command enters the service. The adapter translates the "external language" (DTOs, events) into the "language of the core" (commands, queries), and that is where its job ends.
What an inbound adapter does
Three steps, no more:
- Accept the request (HTTP body, Kafka payload, CLI arguments).
- Map it into a command for the core.
- Pass the command to the
Dispatcher, get the result, map it back into a response.
There is no business logic. Rule checks, calculations, state changes — all of that lives in the core, in the CommandHandler. The controller knows nothing about it.
A separate folder for each type of input
When everything sits in a single controllers/ folder, it is easy to accidentally mix user-facing HTTP with the administrative HTTP API — each with different authorization rules. Or to add a Kafka-consumer import into an HTTP controller.
The hexagonal approach: each type of input gets its own folder in adapters/in/:
src/
adapters/
in/
http/ # public REST for users
http-admin/ # REST for the administrative API
kafka/ # Kafka consumers
cli/ # CLI commands (if needed)
What this gives you:
- Isolated security.
http/validates a JWT with the user audience,http-admin/has its own guard with the administrative audience. You can no longer mix them by accident. - Different contracts. The public and administrative APIs may have different DTOs. Separate folders make this explicit.
- Import control. In CI you can set up dependency-cruiser, and it will flag any import from
adapters/in/http/intoadapters/out/, or the other way around, as an error.
Controller, mapper, Dispatcher
Consider an HTTP controller for creating an order.
// 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);
}
}
The three lines in the method are no accident. The controller literally does three steps: mapped, passed, mapped back.
The DTO describes the shape of the incoming request and validates the fields with 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;
}
The mapper is a separate file that translates DTOs into core commands and back:
// 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,
};
}
}
The mapper is kept in a separate file rather than inline in the controller because the transformations grow. As the number of fields increases, the controller stays three lines long, and all the complexity is isolated in the mapper, which can be tested separately.
A Kafka consumer as an inbound adapter
A Kafka consumer is built exactly like an HTTP controller: it accepts a message, maps it into a command, dispatches it. The only difference is that instead of @Post() there is @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);
}
}
The business logic is not repeated in the HTTP controller or the Kafka consumer — it lives in one place, in the CommandHandler in the core.
What the adapter knows and does not know
The inbound adapter knows about:
@nestjs/common—@Controller,@Get,@Post,@Body,@Param,@UseGuards.@nestjs/microservices—@EventPattern,@Payloadfor Kafka.class-validator/class-transformer— decorators on the request DTO.Dispatcherfromcore/shared/— the single entry point into the core.
The adapter does not know about:
- Anything in
adapters/out/— neither TypeORM repositories nor external-system clients. - Other inbound adapters —
http/does not importhttp-admin/.
Common mistakes and how to fix them
Business logic in the controller. It looks harmless:
// Bad
@Post()
async createOrder(@Body() dto: CreateOrderDto) {
if (dto.totalAmount > 100_000) {
throw new BadRequestException('Amount too large');
}
// ...
}
The problem: the same rule has to be duplicated in the Kafka consumer and the CLI command. Within a month they will diverge. The rule "the amount must not exceed 100K" should live in the domain aggregate (Order.create()) or in the CommandHandler — once, for all entry points.
The controller calls the repository directly.
// Bad
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); // the transaction is lost
}
The TransactionRunner works at the CommandHandler level. Calling the repository directly in the controller leaves each operation without a transaction and bypasses centralized authorization.
The controller returns the domain aggregate.
// Bad
return order; // Order is an aggregate with methods
TypeScript serializes order "as is". The aggregate's internal fields and helper methods all end up in the HTTP response. The correct approach is to always return a response DTO through the mapper.
The correct controller:
// Good
@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);
}
In short
- The inbound adapter is the entry point of the outside world into the service: HTTP, Kafka, CLI.
- Three steps: accept the request → map it into a command → dispatch → map the response.
- Each type of input gets its own folder (
adapters/in/http/,adapters/in/kafka/). - The mapper is a separate file, not embedded in the controller.
- The adapter knows nothing about
adapters/out/or about other inbound adapters. - Business logic in the controller is always duplication: the rule has to be repeated for every entry point.
- Calling the repository directly from the controller bypasses the transaction and authorization.
- The domain aggregate is not returned to the outside — only the response DTO.
What to read next
- Adapters out — the symmetric side: implementing the port interfaces from the core.
- Ports — Symbol tokens, interfaces, port exceptions.
- Core layer — what lives in the core and why without NestJS decorators.
- Bootstrap / Composition root — wiring the ports through
useFactoryandAppModule.