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 (SQLIN)nin{Field}s: Exclude records where the field matches ANY value in the array (SQLNOT 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
publicmodifier on properties - ❌ Not extending base classes (
BaseReadManyQueryDTO,BaseReadManyResponseDTO) - ❌ Using underscore for result class names (
UserResult, notUser_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,
});
}