API Design: REST, DTOs, and Swagger in NestJS
For .NET engineers who know: ASP.NET Core controllers, model binding, data annotations, Swashbuckle, and
[ApiController]You’ll learn: How NestJS maps every ASP.NET Core API pattern — DTOs, validation, Swagger, versioning, pagination — to its own decorator-based system, and where the two diverge in ways that will catch you off guard Time: 15-20 min read
The .NET Way (What You Already Know)
In ASP.NET Core you define a controller, decorate it with attributes, and the framework handles model binding, validation, and OpenAPI documentation. A typical endpoint looks like this:
[ApiController]
[Route("api/v1/[controller]")]
[Produces("application/json")]
public class ProductsController : ControllerBase
{
private readonly IProductService _productService;
public ProductsController(IProductService productService)
{
_productService = productService;
}
/// <summary>Get a paginated list of products.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResult<ProductDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> GetProducts([FromQuery] ProductQueryDto query)
{
var result = await _productService.GetProductsAsync(query);
return Ok(result);
}
[HttpPost]
[ProducesResponseType(typeof(ProductDto), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> CreateProduct([FromBody] CreateProductDto dto)
{
var product = await _productService.CreateAsync(dto);
return CreatedAtAction(nameof(GetProduct), new { id = product.Id }, product);
}
}
Validation lives on the DTO via data annotations, and model state is checked automatically by [ApiController]:
public class CreateProductDto
{
[Required]
[MaxLength(200)]
public string Name { get; set; }
[Required]
[Range(0.01, double.MaxValue, ErrorMessage = "Price must be positive")]
public decimal Price { get; set; }
[MaxLength(1000)]
public string? Description { get; set; }
}
Swashbuckle generates OpenAPI from the XML doc comments, [ProducesResponseType] attributes, and DTO property types — all without a separate spec file.
The NestJS Way
NestJS is architecturally similar: controllers handle routing, DTOs carry data, providers hold business logic, and a decorator on the DTO class drives both validation and OpenAPI documentation. The tooling is @nestjs/swagger (wraps Swagger UI) and class-validator / class-transformer for runtime validation.
Project Setup
npm install @nestjs/swagger swagger-ui-express
npm install class-validator class-transformer
Enable validation globally in main.ts:
// main.ts
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Global validation pipe — equivalent to [ApiController] auto-ModelState checking
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // Strip properties not in DTO (like [BindNever])
forbidNonWhitelisted: true, // Throw on unknown properties
transform: true, // Auto-transform payload types (string -> number etc.)
transformOptions: {
enableImplicitConversion: true,
},
}),
);
// OpenAPI / Swagger setup
const config = new DocumentBuilder()
.setTitle('Products API')
.setDescription('Product catalog service')
.setVersion('1.0')
.addBearerAuth() // JWT auth header in Swagger UI
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document); // Serves at /api/docs
await app.listen(3000);
}
bootstrap();
Defining a DTO with Validation and Swagger Decorators
In NestJS, a single class carries three responsibilities: it is the DTO shape, the validation spec, and the OpenAPI schema. The same class does what [Required], [MaxLength], [ProducesResponseType], and the XML doc comment did separately in .NET:
// dto/create-product.dto.ts
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsString,
IsNotEmpty,
MaxLength,
IsNumber,
IsPositive,
IsOptional,
Min,
IsEnum,
} from 'class-validator';
export enum ProductCategory {
Electronics = 'electronics',
Clothing = 'clothing',
Books = 'books',
}
export class CreateProductDto {
@ApiProperty({
description: 'Product display name',
example: 'Wireless Keyboard',
maxLength: 200,
})
@IsString()
@IsNotEmpty()
@MaxLength(200)
name: string;
@ApiProperty({
description: 'Price in USD cents (integer to avoid float precision issues)',
example: 4999,
minimum: 1,
})
@IsNumber()
@IsPositive()
@Min(1)
priceInCents: number;
@ApiPropertyOptional({
description: 'Detailed product description',
maxLength: 1000,
})
@IsOptional()
@IsString()
@MaxLength(1000)
description?: string;
@ApiProperty({ enum: ProductCategory })
@IsEnum(ProductCategory)
category: ProductCategory;
}
// dto/update-product.dto.ts
import { PartialType } from '@nestjs/swagger';
import { CreateProductDto } from './create-product.dto';
// PartialType makes all properties optional AND preserves swagger + validation decorators.
// Equivalent to a partial update DTO in C# — but with zero duplication.
export class UpdateProductDto extends PartialType(CreateProductDto) {}
PartialType from @nestjs/swagger (not the one from @nestjs/mapped-types) generates OpenAPI correctly for partial updates. This is the NestJS equivalent of writing a separate PatchProductDto with all optional properties — but done in one line.
The Controller
// products.controller.ts
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
HttpCode,
HttpStatus,
NotFoundException,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiParam,
ApiBearerAuth,
} from '@nestjs/swagger';
import { ProductsService } from './products.service';
import { CreateProductDto } from './dto/create-product.dto';
import { UpdateProductDto } from './dto/update-product.dto';
import { ProductQueryDto } from './dto/product-query.dto';
import { ProductDto } from './dto/product.dto';
import { PagedResult } from '../common/paged-result';
@ApiTags('Products') // Groups endpoints in Swagger UI — like [ApiExplorerSettings]
@ApiBearerAuth() // JWT required — like [Authorize]
@Controller('products') // Route prefix — like [Route("products")]
export class ProductsController {
constructor(private readonly productsService: ProductsService) {}
@Get()
@ApiOperation({ summary: 'List products with pagination' })
@ApiResponse({ status: 200, description: 'Paginated list', type: PagedResult<ProductDto> })
@ApiResponse({ status: 400, description: 'Invalid query parameters' })
async getProducts(
@Query() query: ProductQueryDto,
): Promise<PagedResult<ProductDto>> {
return this.productsService.findAll(query);
}
@Get(':id')
@ApiParam({ name: 'id', description: 'Product UUID' })
@ApiResponse({ status: 200, type: ProductDto })
@ApiResponse({ status: 404, description: 'Product not found' })
async getProduct(@Param('id') id: string): Promise<ProductDto> {
const product = await this.productsService.findOne(id);
if (!product) {
throw new NotFoundException(`Product ${id} not found`);
}
return product;
}
@Post()
@HttpCode(HttpStatus.CREATED) // 201 — like return CreatedAtAction(...)
@ApiOperation({ summary: 'Create a product' })
@ApiResponse({ status: 201, type: ProductDto })
@ApiResponse({ status: 422, description: 'Validation failed' })
async createProduct(@Body() dto: CreateProductDto): Promise<ProductDto> {
return this.productsService.create(dto);
}
@Put(':id')
@ApiResponse({ status: 200, type: ProductDto })
async updateProduct(
@Param('id') id: string,
@Body() dto: UpdateProductDto,
): Promise<ProductDto> {
return this.productsService.update(id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT) // 204
async deleteProduct(@Param('id') id: string): Promise<void> {
await this.productsService.remove(id);
}
}
Query Parameters with Validation
Query parameters are a common friction point. NestJS handles them via a query DTO combined with @Query():
// dto/product-query.dto.ts
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsInt, Min, Max, IsString, IsEnum } from 'class-validator';
import { Type } from 'class-transformer';
import { ProductCategory } from './create-product.dto';
export class ProductQueryDto {
@ApiPropertyOptional({ default: 1, minimum: 1 })
@IsOptional()
@IsInt()
@Min(1)
@Type(() => Number) // Query params arrive as strings — this coerces them
page: number = 1;
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 })
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
@Type(() => Number)
pageSize: number = 20;
@ApiPropertyOptional()
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional({ enum: ProductCategory })
@IsOptional()
@IsEnum(ProductCategory)
category?: ProductCategory;
}
Standard Response Envelope and Pagination
Define a reusable paginated response type once, and reference it everywhere:
// common/paged-result.ts
import { ApiProperty } from '@nestjs/swagger';
export class PagedResult<T> {
@ApiProperty({ isArray: true })
data: T[];
@ApiProperty()
totalCount: number;
@ApiProperty()
page: number;
@ApiProperty()
pageSize: number;
@ApiProperty()
totalPages: number;
constructor(data: T[], totalCount: number, page: number, pageSize: number) {
this.data = data;
this.totalCount = totalCount;
this.page = page;
this.pageSize = pageSize;
this.totalPages = Math.ceil(totalCount / pageSize);
}
}
// common/api-response.ts — Standard envelope for single-resource responses
export class ApiResponseEnvelope<T> {
@ApiProperty()
success: boolean = true;
data: T;
@ApiProperty({ required: false })
message?: string;
}
Standard Error Format
NestJS has built-in exception filters. The default error format differs from ASP.NET Core’s ProblemDetails. You can customize it to match RFC 7807 (ProblemDetails) if your clients expect that format:
// filters/http-exception.filter.ts
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { Request, Response } from 'express';
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status = exception.getStatus();
const exceptionResponse = exception.getResponse();
// Emit RFC 7807 ProblemDetails format — mirrors ASP.NET Core default
const problemDetails = {
type: `https://httpstatuses.com/${status}`,
title: HttpStatus[status] ?? 'Error',
status,
detail:
typeof exceptionResponse === 'string'
? exceptionResponse
: (exceptionResponse as any).message,
instance: request.url,
traceId: request.headers['x-request-id'] ?? crypto.randomUUID(),
};
response.status(status).json(problemDetails);
}
}
Register it globally in main.ts:
app.useGlobalFilters(new HttpExceptionFilter());
API Versioning
NestJS supports URI versioning, header versioning, and media-type versioning via @nestjs/versioning:
// main.ts — enable URI versioning
import { VersioningType } from '@nestjs/common';
app.enableVersioning({
type: VersioningType.URI, // Routes become /v1/products, /v2/products
defaultVersion: '1',
});
// products-v2.controller.ts
import { Controller, Version } from '@nestjs/common';
@Controller('products')
@Version('2') // This controller handles /v2/products
export class ProductsV2Controller {
// V2-specific endpoints
}
Generating the OpenAPI Spec
Export the spec to a file for client code generation:
// scripts/generate-openapi.ts
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { writeFileSync } from 'fs';
import { AppModule } from '../src/app.module';
async function generate() {
const app = await NestFactory.create(AppModule, { logger: false });
const config = new DocumentBuilder()
.setTitle('Products API')
.setVersion('1.0')
.build();
const document = SwaggerModule.createDocument(app, config);
writeFileSync('./openapi.json', JSON.stringify(document, null, 2));
await app.close();
console.log('OpenAPI spec written to openapi.json');
}
generate();
Generate TypeScript client code from the spec using openapi-typescript:
npx openapi-typescript ./openapi.json -o ./src/generated/api-types.ts
Zod as an Alternative to class-validator
class-validator is the default NestJS validation approach, but Zod is increasingly preferred because it produces types directly from schemas (no duplication, no decorator boilerplate). Use nestjs-zod to integrate:
// dto/create-product.zod.ts
import { z } from 'zod';
import { createZodDto } from 'nestjs-zod';
const CreateProductSchema = z.object({
name: z.string().min(1).max(200),
priceInCents: z.number().int().positive(),
description: z.string().max(1000).optional(),
category: z.enum(['electronics', 'clothing', 'books']),
});
// This generates a class that ValidationPipe understands AND Swagger can inspect
export class CreateProductDto extends createZodDto(CreateProductSchema) {}
// Infer the plain type if needed
export type CreateProduct = z.infer<typeof CreateProductSchema>;
The trade-off: nestjs-zod requires replacing ValidationPipe with ZodValidationPipe and adding a custom Swagger plugin. For greenfield projects, this is worth it. For teams already invested in class-validator, the decorator approach is fine.
Key Differences
| Concept | ASP.NET Core | NestJS |
|---|---|---|
| Route attribute | [Route("api/[controller]")] | @Controller('products') |
| HTTP method | [HttpGet], [HttpPost] | @Get(), @Post() |
| Route parameter | [FromRoute] / parameter name | @Param('id') |
| Query string | [FromQuery] | @Query() or @Query('name') |
| Request body | [FromBody] | @Body() |
| Validation | Data annotations on DTO | class-validator decorators on DTO |
| Auto-validation | [ApiController] | ValidationPipe global pipe |
| Status code | return StatusCode(201, ...) | @HttpCode(HttpStatus.CREATED) |
| Swagger setup | Swashbuckle NuGet + AddSwaggerGen() | @nestjs/swagger + DocumentBuilder |
| Swagger group | [ApiExplorerSettings(GroupName="")] | @ApiTags('Group') |
| Swagger description | XML doc comment /// <summary> | @ApiOperation({ summary: '' }) |
| Response type | [ProducesResponseType(typeof(T), 200)] | @ApiResponse({ status: 200, type: T }) |
| Error format | ProblemDetails (RFC 7807) by default | Custom — use an ExceptionFilter |
| Partial update DTO | Write PatchDto with all optional props | PartialType(CreateDto) — zero duplication |
| Versioning | AddApiVersioning() + [ApiVersion("1")] | enableVersioning() + @Version('1') |
Gotchas for .NET Engineers
1. Validation decorators do nothing without the global ValidationPipe
In ASP.NET Core, [ApiController] activates model validation automatically. In NestJS, the class-validator decorators on your DTO are just metadata — they do nothing unless you register ValidationPipe. If you forget to add it in main.ts, invalid requests pass straight through to your service with no error and no warning.
// Without this, @IsString(), @IsNotEmpty() etc. are ignored entirely
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
Always add this to main.ts before anything else. The whitelist: true option is the equivalent of [BindNever] — it strips unknown properties from the incoming body rather than silently passing them through.
2. transform: true is required for query parameter type coercion — but it has side effects
Query parameters arrive as strings. Without transform: true in ValidationPipe, a @IsInt() check on page will always fail because "1" is a string, not an integer. Adding transform: true fixes this but also means NestJS will attempt to convert class instances, which can produce surprising behaviour if you have constructors with logic.
// Also requires @Type(() => Number) on the property for reliable coercion
@ApiPropertyOptional({ default: 1 })
@IsOptional()
@IsInt()
@Min(1)
@Type(() => Number) // Without this, transform: true alone is unreliable
page: number = 1;
The @Type(() => Number) decorator from class-transformer is what actually drives the conversion. The transform: true flag in ValidationPipe enables class-transformer to run at all.
3. PartialType must come from @nestjs/swagger, not @nestjs/mapped-types
NestJS ships PartialType in two packages. The one in @nestjs/mapped-types works for validation but does not emit OpenAPI metadata. The one in @nestjs/swagger does both. Import from the wrong package and your partial update DTOs will be invisible in the Swagger UI.
// Wrong — loses Swagger decorators
import { PartialType } from '@nestjs/mapped-types';
// Correct — preserves Swagger AND validation decorators
import { PartialType } from '@nestjs/swagger';
4. The default error response format is not RFC 7807
ASP.NET Core returns ProblemDetails by default when [ApiController] is active. NestJS returns a simpler JSON structure:
{
"statusCode": 400,
"message": ["name must not be empty"],
"error": "Bad Request"
}
If your API clients or monitoring tools expect RFC 7807, you need to write a custom ExceptionFilter (as shown above). This is a one-time setup, but it is easy to forget and will break clients that parse type and title fields from problem details.
5. @ApiResponse({ type: PagedResult<ProductDto> }) loses generic type information
TypeScript generics are erased at runtime. When NestJS introspects PagedResult<ProductDto> for OpenAPI generation, it sees PagedResult — the <ProductDto> part is gone. To make Swagger show the correct data array type, you need to either use getSchemaPath with refs, or create concrete subclasses:
// Option A: concrete subclass (verbose but reliable)
export class ProductPagedResult extends PagedResult<ProductDto> {
@ApiProperty({ type: [ProductDto] })
declare data: ProductDto[];
}
// Option B: inline schema reference (more flexible)
@ApiResponse({
schema: {
allOf: [
{ $ref: getSchemaPath(PagedResult) },
{
properties: {
data: { type: 'array', items: { $ref: getSchemaPath(ProductDto) } },
},
},
],
},
})
6. class-validator does not validate nested objects by default
If your DTO contains a nested object, you must add @ValidateNested() and @Type(() => NestedClass) explicitly. Without these, the nested object passes validation even if its properties are invalid.
import { ValidateNested, IsArray } from 'class-validator';
import { Type } from 'class-transformer';
export class OrderDto {
@IsArray()
@ValidateNested({ each: true }) // Validate each element in the array
@Type(() => LineItemDto) // class-transformer must know the concrete type
lineItems: LineItemDto[];
}
Hands-On Exercise
You have an ASP.NET Core orders API. Translate it to NestJS.
Requirements:
-
Create a
CreateOrderDtowith:customerId(UUID string, required)lineItems(array ofLineItemDto, minimum 1 item, nested validation)shippingAddress(nestedAddressDto, required)notes(string, optional, max 500 chars)
-
Create
LineItemDtowithproductId(UUID),quantity(integer, 1-999),unitPriceInCents(positive integer). -
Create
AddressDtowithstreet,city,country(all required strings),postalCode(optional string). -
Create
UpdateOrderDtousingPartialTypefrom@nestjs/swagger. -
Create
OrderQueryDtofor query parameters:page,pageSize,status(enum:pending | confirmed | shipped | delivered | cancelled),customerId. -
Create
OrdersControllerwith GET, GET :id, POST, PUT :id, DELETE :id endpoints. Include appropriate@ApiResponsedecorators on each method. -
Register
ValidationPipewithwhitelist: true,transform: true, andforbidNonWhitelisted: true. -
Write a custom
ExceptionFilterthat convertsNotFoundExceptionto an RFC 7807 response withstatus: 404.
Verification: Hit the /api/docs route in your browser. Every DTO property should appear with its type, description, and example. Send a POST body with a missing customerId — you should receive a 422 with a message array listing the violated constraints.
Quick Reference
| Task | ASP.NET Core | NestJS |
|---|---|---|
| Mark controller | [ApiController] | (implicit — ValidationPipe does this) |
| Route prefix | [Route("api/[controller]")] | @Controller('products') |
| GET endpoint | [HttpGet("{id}")] | @Get(':id') |
| POST endpoint | [HttpPost] | @Post() |
| Set status code | return StatusCode(201, obj) | @HttpCode(HttpStatus.CREATED) + return value |
| Read body | [FromBody] CreateDto dto | @Body() dto: CreateDto |
| Read query param | [FromQuery] string search | @Query('search') search: string |
| Read route param | [FromRoute] string id | @Param('id') id: string |
| Required string | [Required] | @IsString() @IsNotEmpty() |
| Max length | [MaxLength(200)] | @MaxLength(200) |
| Numeric range | [Range(1, 100)] | @Min(1) @Max(100) |
| Optional property | public string? Notes | @IsOptional() + notes?: string |
| Enum validation | [EnumDataType(typeof(Status))] | @IsEnum(Status) |
| Nested validation | Automatic | @ValidateNested() @Type(() => Nested) |
| Partial update DTO | Write separate class | PartialType(CreateDto) from @nestjs/swagger |
| Query string coercion | Automatic | @Type(() => Number) + transform: true |
| Swagger group | XML comment + config | @ApiTags('Group') |
| Swagger summary | /// <summary>text</summary> | @ApiOperation({ summary: 'text' }) |
| Swagger property | Automatic from type | @ApiProperty({ description: '', example: '' }) |
| Optional swagger prop | Automatic from ? | @ApiPropertyOptional() |
| Response type | [ProducesResponseType(typeof(T), 200)] | @ApiResponse({ status: 200, type: T }) |
| 404 response | return NotFound() | throw new NotFoundException('msg') |
| 422 validation error | Automatic with [ApiController] | Automatic with ValidationPipe |
| Serve Swagger UI | app.UseSwaggerUI() | SwaggerModule.setup('api/docs', app, doc) |
| Export spec | CLI tool | writeFileSync from SwaggerModule.createDocument |
| API versioning | AddApiVersioning() | enableVersioning({ type: VersioningType.URI }) |
Decorator import sources:
// Routing and HTTP
import { Controller, Get, Post, Put, Delete, Body, Param, Query, HttpCode, HttpStatus } from '@nestjs/common';
// Swagger / OpenAPI
import { ApiTags, ApiOperation, ApiResponse, ApiProperty, ApiPropertyOptional, ApiBearerAuth, PartialType } from '@nestjs/swagger';
// Validation
import { IsString, IsNotEmpty, IsInt, IsOptional, IsEnum, IsArray, ValidateNested, Min, Max, MaxLength } from 'class-validator';
import { Type } from 'class-transformer';
Further Reading
- NestJS Controllers — the official controller reference, covering all decorators and parameter bindings
- NestJS Validation —
ValidationPipe,class-validator, andclass-transformerintegration - NestJS OpenAPI (Swagger) — the complete
@nestjs/swaggerguide including mapped types, decorators, and CLI plugin - class-validator documentation — full list of validation decorators
- nestjs-zod — Zod-based validation and Swagger generation for NestJS
- openapi-typescript — generate TypeScript types from your OpenAPI spec for client-side consumption