Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

ConceptASP.NET CoreNestJS
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()
ValidationData annotations on DTOclass-validator decorators on DTO
Auto-validation[ApiController]ValidationPipe global pipe
Status codereturn StatusCode(201, ...)@HttpCode(HttpStatus.CREATED)
Swagger setupSwashbuckle NuGet + AddSwaggerGen()@nestjs/swagger + DocumentBuilder
Swagger group[ApiExplorerSettings(GroupName="")]@ApiTags('Group')
Swagger descriptionXML doc comment /// <summary>@ApiOperation({ summary: '' })
Response type[ProducesResponseType(typeof(T), 200)]@ApiResponse({ status: 200, type: T })
Error formatProblemDetails (RFC 7807) by defaultCustom — use an ExceptionFilter
Partial update DTOWrite PatchDto with all optional propsPartialType(CreateDto) — zero duplication
VersioningAddApiVersioning() + [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:

  1. Create a CreateOrderDto with:

    • customerId (UUID string, required)
    • lineItems (array of LineItemDto, minimum 1 item, nested validation)
    • shippingAddress (nested AddressDto, required)
    • notes (string, optional, max 500 chars)
  2. Create LineItemDto with productId (UUID), quantity (integer, 1-999), unitPriceInCents (positive integer).

  3. Create AddressDto with street, city, country (all required strings), postalCode (optional string).

  4. Create UpdateOrderDto using PartialType from @nestjs/swagger.

  5. Create OrderQueryDto for query parameters: page, pageSize, status (enum: pending | confirmed | shipped | delivered | cancelled), customerId.

  6. Create OrdersController with GET, GET :id, POST, PUT :id, DELETE :id endpoints. Include appropriate @ApiResponse decorators on each method.

  7. Register ValidationPipe with whitelist: true, transform: true, and forbidNonWhitelisted: true.

  8. Write a custom ExceptionFilter that converts NotFoundException to an RFC 7807 response with status: 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

TaskASP.NET CoreNestJS
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 codereturn 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 propertypublic string? Notes@IsOptional() + notes?: string
Enum validation[EnumDataType(typeof(Status))]@IsEnum(Status)
Nested validationAutomatic@ValidateNested() @Type(() => Nested)
Partial update DTOWrite separate classPartialType(CreateDto) from @nestjs/swagger
Query string coercionAutomatic@Type(() => Number) + transform: true
Swagger groupXML comment + config@ApiTags('Group')
Swagger summary/// <summary>text</summary>@ApiOperation({ summary: 'text' })
Swagger propertyAutomatic from type@ApiProperty({ description: '', example: '' })
Optional swagger propAutomatic from ?@ApiPropertyOptional()
Response type[ProducesResponseType(typeof(T), 200)]@ApiResponse({ status: 200, type: T })
404 responsereturn NotFound()throw new NotFoundException('msg')
422 validation errorAutomatic with [ApiController]Automatic with ValidationPipe
Serve Swagger UIapp.UseSwaggerUI()SwaggerModule.setup('api/docs', app, doc)
Export specCLI toolwriteFileSync from SwaggerModule.createDocument
API versioningAddApiVersioning()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