Skip to main content

NestJS DTO (Data Transfer Object) Standards

DTOs define the shape of data sent to and received from API endpoints. They provide type safety, validation, and Swagger documentation.

Core Principles

Separate request and response DTOs Request DTOs handle input validation. Response DTOs shape output and generate Swagger docs.

Always use class instances Controllers must return new ResponseDTO(), not plain objects. Swagger documentation depends on this.

Extend base classes Use BaseReadManyQueryDTO and BaseReadManyResponseDTO for pagination/sorting consistency.

Validation and documentation together Combine class-validator decorators with @ApiProperty decorators on every field.

File Structure

module-name/
├── dto/
│ ├── request.dto.ts # Query and body DTOs
│ └── response.dto.ts # Response DTOs and result classes

Request DTOs

Query Parameters (GET requests)

For filtering, pagination, and sorting in list endpoints. Query parameters are passed directly to services which apply them to TypeORM query builders.

Foreign Key and Finite Field Filtering Pattern

For foreign key fields (UUIDs) and finite fields (enums, statuses, domains), use the in{Field}s and nin{Field}s pattern to allow filtering by multiple values:

  • in{Field}s: Include records where the field matches ANY value in the array (SQL IN)
  • nin{Field}s: Exclude records where the field matches ANY value in the array (SQL NOT IN)

Field naming conventions:

  • Foreign keys: inUserIds, ninUserIds, inUserIntegrationIds, ninUserIntegrationIds
  • Finite values: inIntegrationDomains, ninIntegrationDomains, inStatuses, ninStatuses

Example with foreign keys and finite fields:

import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsOptional, IsArray, IsUUID } from 'class-validator';
import { BaseReadManyQueryDTO } from '../../../common/dto/requests';

export class UserIntegrationContentReadManyQueryDTO extends BaseReadManyQueryDTO {
constructor(value: any) {
super(value);
Object.assign(this, value);
}

@ApiPropertyOptional({
description: 'User IDs to include in the filtered results',
type: [String],
example: ['123e4567-e89b-12d3-a456-426614174000'],
})
@IsOptional()
@IsArray()
@IsUUID('4', { each: true })
public inUserIds?: string[];

@ApiPropertyOptional({
description: 'User IDs to exclude from the filtered results',
type: [String],
example: ['123e4567-e89b-12d3-a456-426614174000'],
})
@IsOptional()
@IsArray()
@IsUUID('4', { each: true })
public ninUserIds?: string[];

@ApiPropertyOptional({
description: 'Integration domains to include in the filtered results',
type: [String],
example: ['gmail', 'slack'],
})
@IsOptional()
@IsArray()
@IsString({ each: true })
public inIntegrationDomains?: string[];

@ApiPropertyOptional({
description: 'Integration domains to exclude from the filtered results',
type: [String],
example: ['ftp'],
})
@IsOptional()
@IsArray()
@IsString({ each: true })
public ninIntegrationDomains?: string[];
}

Example with simple string filters:

export class UserReadManyQueryDTO extends BaseReadManyQueryDTO {
constructor(value: any) {
super(value);
Object.assign(this, value);
}

@ApiPropertyOptional({
description: 'Filter to include certain email addresses',
example: 'john@example.com',
})
@IsOptional()
@IsEmail()
public inEmails?: string;

@ApiPropertyOptional({
description: 'Filter to exclude certain email addresses',
example: 'john@example.com',
})
@IsOptional()
@IsEmail()
public ninEmails?: string;

@ApiPropertyOptional({
description: 'Filter to include certain names (partial match)',
example: 'John',
})
@IsOptional()
@IsString()
public inNames?: string;

@ApiPropertyOptional({
description: 'Filter to exclude certain names (partial match)',
example: 'John',
})
@IsOptional()
@IsString()
public ninNames?: string;
}

Body Parameters (POST/PUT/PATCH requests)

For create and update operations.

import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsEmail, MinLength, MaxLength, IsOptional } from 'class-validator';

export class UserCreateOneBodyDTO {
constructor(value: any) {
Object.assign(this, value);
}

@ApiProperty({
description: 'User name',
example: 'John Doe',
})
@IsString()
@MinLength(1)
@MaxLength(100)
public name: string;

@ApiProperty({
description: 'User email address',
example: 'john@example.com',
})
@IsEmail()
public email: string;

@ApiPropertyOptional({
description: 'Optional bio',
example: 'Software engineer',
})
@IsOptional()
@IsString()
@MaxLength(500)
public bio?: string;
}

Response DTOs

Result Class

Defines the shape of a single domain object. Matches service domain interfaces.

import { ApiProperty } from '@nestjs/swagger';

export class UserResult {
@ApiProperty({
type: String,
example: 'uuid-123',
description: 'Unique identifier',
})
public id: string;

@ApiProperty({
type: String,
example: 'John Doe',
description: 'User name',
})
public name: string;

@ApiProperty({
type: String,
example: 'john@example.com',
description: 'Email address',
})
public email: string;

@ApiProperty({
type: Date,
example: '2025-10-23T10:30:45.123Z',
description: 'Creation timestamp',
})
public createdAt: Date;

@ApiProperty({
type: Date,
example: '2025-10-23T10:30:45.123Z',
description: 'Last update timestamp',
})
public updatedAt: Date;
}

List Response (ReadMany)

For collection endpoints that return multiple results.

import { ApiProperty } from '@nestjs/swagger';
import { BaseReadManyResponseDTO } from '../../../dto/responses';

export class UserReadManyResponseDTO extends BaseReadManyResponseDTO {
constructor(value: any) {
super(value);
this.operation = 'user.readMany'; // Dot notation
Object.assign(this, value);
}

@ApiProperty({
type: UserReadManyQueryDTO,
description: 'Query parameters used',
})
public query: UserReadManyQueryDTO;

@ApiProperty({
type: Number,
description: 'Total matching results',
example: 42,
})
public total: number;

@ApiProperty({
type: [UserResult], // Array notation
description: 'Array of users',
})
public results: UserResult[];
}

Single Response (ReadOne)

For endpoints that return a single result.

import { ApiProperty } from '@nestjs/swagger';
import { BaseReadOneResponseDTO } from '../../../dto/responses';

export class UserReadOneResponseDTO extends BaseReadOneResponseDTO {
constructor(value: any) {
super(value);
this.operation = 'user.readOne';
Object.assign(this, value);
}

@ApiProperty({
type: UserResult,
description: 'The requested user',
})
public result: UserResult;
}

Common Validation Decorators

// String validation
@IsString()
@MinLength(1)
@MaxLength(100)
@IsEmail()
@IsUrl()

// Number validation
@IsNumber()
@Min(0)
@Max(100)
@IsInt()

// Boolean validation
@IsBoolean()

// Enum validation
@IsEnum(['active', 'inactive'])

// Optional fields
@IsOptional() // Must be first decorator

// Array validation
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(10)

// Nested objects
@ValidateNested()
@Type(() => NestedDTO)

Anti-Patterns

❌ Don't return plain objects

Controllers must return instantiated DTO classes.

// Bad - plain object
@Get()
async readMany(@Query() query: QueryDTO) {
const users = await this.service.readMany(query);
return { users }; // Breaks Swagger!
}

// Good - instantiated DTO
@Get()
async readMany(@Query() query: QueryDTO) {
const { total, results } = await this.service.readMany(query);
return new UserReadManyResponseDTO({ query, total, results });
}

❌ Don't skip constructors

Every DTO needs a constructor that calls Object.assign().

// Bad - no constructor
export class UserBodyDTO {
@IsString()
public name: string;
}

// Good - has constructor
export class UserBodyDTO {
constructor(value: any) {
Object.assign(this, value);
}

@IsString()
public name: string;
}

❌ Don't forget validation decorators

Every field needs validation, even if just @IsOptional().

// Bad - no validation
@ApiPropertyOptional()
public name?: string;

// Good - has validation
@ApiPropertyOptional()
@IsOptional()
@IsString()
public name?: string;

❌ Don't use @ApiProperty on optional fields

Use @ApiPropertyOptional for optional fields.

// Bad - required field decorator on optional field
@ApiProperty()
@IsOptional()
public bio?: string;

// Good - optional field decorator
@ApiPropertyOptional()
@IsOptional()
public bio?: string;

❌ Don't forget operation names

Response DTOs need this.operation set in constructor.

// Bad - no operation
constructor(value: any) {
super(value);
Object.assign(this, value);
}

// Good - operation set
constructor(value: any) {
super(value);
this.operation = 'user.readMany';
Object.assign(this, value);
}

❌ Don't skip examples and descriptions

Every @ApiProperty needs description and example for Swagger docs.

// Bad - no docs
@ApiProperty({ type: String })
public name: string;

// Good - complete docs
@ApiProperty({
type: String,
description: 'User name',
example: 'John Doe',
})
public name: string;

Other common mistakes:

  • ❌ Using @ApiProperty() instead of @ApiPropertyOptional() for optional fields
  • ❌ Forgetting public modifier on properties
  • ❌ Not extending base classes (BaseReadManyQueryDTO, BaseReadManyResponseDTO)
  • ❌ Using underscore for result class names (UserResult, not User_Result)

Service Layer Implementation

Implementing inIds/ninIds Filters

When implementing inIds/ninIds filters in services, update both the input interface and the query builder logic:

Service Input Interface:

export interface UserIntegrationContentReadManyInput extends BaseReadManyInput {
inUserIds?: string[];
ninUserIds?: string[];
inUserIntegrationIds?: string[];
ninUserIntegrationIds?: string[];
inIntegrationDomains?: string[];
ninIntegrationDomains?: string[];
inExternalIds?: string[];
ninExternalIds?: string[];
}

Query Builder Implementation:

async readMany(values: UserIntegrationContentReadManyInput) {
const queryBuilder = this.contentRepository.createQueryBuilder('content');

// Apply base filters (inIds, ninIds, date ranges, etc.)
applyBaseReadManyFilters(queryBuilder, values);

// Foreign key filters - use IN/NOT IN for arrays
if (values.inUserIds?.length) {
queryBuilder.andWhere('content.userId IN (:...inUserIds)', {
inUserIds: values.inUserIds,
});
}

if (values.ninUserIds?.length) {
queryBuilder.andWhere('content.userId NOT IN (:...ninUserIds)', {
ninUserIds: values.ninUserIds,
});
}

// Finite field filters - use IN/NOT IN for arrays
if (values.inIntegrationDomains?.length) {
queryBuilder.andWhere('content.integrationDomain IN (:...inIntegrationDomains)', {
inIntegrationDomains: values.inIntegrationDomains,
});
}

if (values.ninIntegrationDomains?.length) {
queryBuilder.andWhere('content.integrationDomain NOT IN (:...ninIntegrationDomains)', {
ninIntegrationDomains: values.ninIntegrationDomains,
});
}

if (values.inExternalIds) {
queryBuilder.andWhere('content.externalId IN (:...inExternalIds)', {
inExternalIds: values.inExternalIds,
});
}

if (values.ninExternalIds) {
queryBuilder.andWhere('content.externalId IN (:...ninExternalIds)', {
ninExternalIds: values.ninExternalIds,
});
}

// ... rest of implementation
}

Controller Implementation:

@Get()
async readMany(@Query() query: UserIntegrationContentReadManyQueryDTO) {
const { total, results } = await this.service.readMany({
inUserIds: query.inUserIds,
ninUserIds: query.ninUserIds,
inUserIntegrationIds: query.inUserIntegrationIds,
ninUserIntegrationIds: query.ninUserIntegrationIds,
inIntegrationDomains: query.inIntegrationDomains,
ninIntegrationDomains: query.ninIntegrationDomains,
externalId: query.externalId,
inIds: query.inIds,
ninIds: query.ninIds,
skip: query.skip,
limit: query.limit,
sortKey: query.sortKey,
sortValue: query.sortValue,
createdAtFrom: query.createdAtFrom,
createdAtTo: query.createdAtTo,
updatedAtFrom: query.updatedAtFrom,
updatedAtTo: query.updatedAtTo,
});

return new UserIntegrationContentReadManyResponseDTO({
query,
total,
results,
});
}