| name | presentation |
| description | Use when creating presentation layer artifacts for a NestJS bounded context — REST controllers, request DTOs with class-validator, Swagger decorators, custom validators, error filters, or API documentation. Covers the HTTP boundary with proper validation and error mapping. |
| argument-hint | Entity or artifact (e.g., "controller Orders", "dto CreateOrder", "error-filter") |
Presentation Layer
The presentation layer is the HTTP boundary. It owns input validation, Swagger docs, and error mapping. It must not contain business logic.
Directory Structure
infrastructure/
└── controllers/
├── <context>.controller.ts
├── __tests__/
│ └── <context>.controller.spec.ts
└── dtos/
├── create-<context>.request.dto.ts
├── update-<context>.request.dto.ts
├── list-<context>.request.dto.ts
└── __tests__/
└── create-<context>.request.dto.spec.ts
Or as a top-level presentation/ folder when separated from infrastructure/:
presentation/
├── controllers/
│ └── <context>.controller.ts
├── dtos/
│ └── create-<context>.http-dto.ts
├── presenters/
│ └── create-<context>.presenter.ts # maps use case output to HTTP response
└── validators/
└── <field>.validator.ts # @ValidatorConstraint classes
1. Controller Pattern
Use CommandBus / QueryBus for CQRS contexts. Use @Inject(TOKEN) for plain use case contexts.
import {
Controller, Post, Get, Patch, Delete, Body, Param, Query,
HttpCode, HttpStatus, UseGuards,
} from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import {
ApiTags, ApiBearerAuth, ApiOperation, ApiResponse, ApiBody,
} from '@nestjs/swagger';
import { AuthGuard } from '@/auth/guards/auth.guard';
import { CurrentUser, User } from '@/auth/decorators/current-user.decorator';
import { CurrentOrganization, OrgContext } from '@/auth/decorators/current-organization.decorator';
import { Create<Context>RequestDto } from './dtos/create-<context>.request.dto';
import { Create<Context>Command } from '../../application/commands/create-<context>.command';
import { Get<Context>Query } from '../../application/queries/get-<context>.query';
import { List<Context>Query } from '../../application/queries/list-<context>.query';
@ApiTags('<Contexts>')
@Controller('<contexts>')
@UseGuards(AuthGuard)
@ApiBearerAuth()
export class <Context>Controller {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
@Post()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Create a new <context>' })
@ApiResponse({ status: 201, description: '<Context> created' })
@ApiResponse({ status: 400, description: 'Validation error' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
async create(
@Body() dto: Create<Context>RequestDto,
@CurrentOrganization() org: OrgContext,
): Promise<{ id: string }> {
return this.commandBus.execute(
new Create<Context>Command(org.id, dto.name),
);
}
@Get(':id')
@ApiOperation({ summary: 'Get <context> by id' })
@ApiResponse({ status: 200, description: '<Context> found' })
@ApiResponse({ status: 404, description: '<Context> not found' })
async findOne(
@Param('id') id: string,
@CurrentOrganization() org: OrgContext,
) {
return this.queryBus.execute(new Get<Context>Query(id, org.id));
}
@Get()
@ApiOperation({ summary: 'List <contexts> with pagination' })
async findAll(
@Query() query: List<Context>RequestDto,
@CurrentOrganization() org: OrgContext,
) {
return this.queryBus.execute(
new List<Context>Query({ organizationId: org.id, ...query }),
);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete a <context>' })
@ApiResponse({ status: 204, description: 'Deleted' })
async remove(
@Param('id') id: string,
@CurrentOrganization() org: OrgContext,
): Promise<void> {
await this.commandBus.execute(new Delete<Context>Command(id, org.id));
}
}
See full template: references/controller-patterns.md
2. Request DTOs
class-validator and class-transformer live ONLY in request DTOs. Never in domain VOs or application services.
import { IsString, IsNotEmpty, IsOptional, IsEmail } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class Create<Context>RequestDto {
@ApiProperty({ description: 'Name', example: 'My <Context>' })
@IsString()
@IsNotEmpty()
name!: string;
@ApiPropertyOptional({ description: 'Description', example: 'Optional description' })
@IsOptional()
@IsString()
description?: string;
}
Global ValidationPipe config (in main.ts):
app.useGlobalPipes(
new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }),
);
See full template: references/request-dto-patterns.md
3. Custom Validators
For cross-field or complex format rules that class-validator built-ins cannot express.
import {
ValidatorConstraint, ValidatorConstraintInterface,
ValidationArguments, ValidationOptions, registerDecorator,
} from 'class-validator';
@ValidatorConstraint({ name: '<validatorName>', async: false })
export class <Field>Validator implements ValidatorConstraintInterface {
validate(value: unknown, args: ValidationArguments): boolean {
return typeof value === 'string' && value.length > 0;
}
defaultMessage(): string {
return '<Field> is invalid';
}
}
export function Is<Field>(options?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName,
options,
constraints: [],
validator: <Field>Validator,
});
};
}
See full template: references/request-dto-patterns.md (custom validator section)
4. Validation Layers
Each layer has a single validation responsibility. Do not duplicate across layers.
Request DTO (presentation) — format, presence, types (class-validator)
↓
Application DTO (application) — contract between layers (TypeScript interfaces)
↓
Domain VO (domain) — business invariants (manual validate())
↓
Queue Schema (integration) — inter-service contracts (Zod)
See full diagram: references/validation-layers.md
5. Error Filter
Maps domain errors to HTTP status codes. Registered globally.
import {
ExceptionFilter, Catch, ArgumentsHost, HttpStatus, Logger,
} from '@nestjs/common';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { NotFoundError } from '@/shared/domain/errors/not-found-error';
import { BusinessRuleViolationError } from '@/shared/domain/errors/business-rule-violation.error';
import { ConflictError } from '@/shared/domain/errors/conflict.error';
const ERROR_STATUS_MAP: Array<[new (...args: never[]) => Error, HttpStatus]> = [
[NotFoundError, HttpStatus.NOT_FOUND],
[BusinessRuleViolationError, HttpStatus.UNPROCESSABLE_ENTITY],
[ConflictError, HttpStatus.CONFLICT],
];
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const reply = ctx.getResponse<FastifyReply>();
const request = ctx.getRequest<FastifyRequest>();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
for (const [ErrorClass, httpStatus] of ERROR_STATUS_MAP) {
if (exception instanceof ErrorClass) {
status = httpStatus;
break;
}
}
const message =
exception instanceof Error ? exception.message : 'Internal server error';
if (status === HttpStatus.INTERNAL_SERVER_ERROR) {
this.logger.error(exception);
}
void reply.status(status).send({
statusCode: status,
message,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}
See full template: references/error-filter-patterns.md
6. Swagger Documentation
Always add @ApiOperation, @ApiResponse, and @ApiProperty. Include request body examples for complex DTOs.
@ApiBody({
type: Create<Context>RequestDto,
examples: {
basic: {
summary: 'Basic example',
value: { name: 'My <Context>' },
},
},
})
Rules
class-validator decorators ONLY in request DTOs — never in domain VOs or application services.
- Global
ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }).
- Controller methods contain no business logic — delegate immediately to
CommandBus/QueryBus or use case.
- Always include
organizationId from the current auth context — never trust it from the request body.
HttpExceptionFilter maps domain errors to status codes — domain layer never imports NestJS.
- All endpoints documented with
@ApiOperation and @ApiResponse.
Checklist