--- title: Use DTOs and Serialization for API Responses impact: MEDIUM impactDescription: Response DTOs prevent accidental data exposure and ensure consistency tags: api, dto, serialization, class-transformer --- ## Use DTOs and Serialization for API Responses Never return entity objects directly from controllers. Use response DTOs with class-transformer's `@Exclude()` and `@Expose()` decorators to control exactly what data is sent to clients. This prevents accidental exposure of sensitive fields and provides a stable API contract. **Incorrect (returning entities directly or manual spreading):** ```typescript // Return entities directly @Controller('users') export class UsersController { @Get(':id') async findOne(@Param('id') id: string): Promise { return this.usersService.findById(id); // Returns: { id, email, passwordHash, ssn, internalNotes, ... } // Exposes sensitive data! } } // Manual object spreading (error-prone) @Get(':id') async findOne(@Param('id') id: string) { const user = await this.usersService.findById(id); return { id: user.id, email: user.email, name: user.name, // Easy to forget to exclude sensitive fields // Hard to maintain across endpoints }; } ``` **Correct (use class-transformer with @Exclude and response DTOs):** ```typescript // Enable class-transformer globally async function bootstrap() { const app = await NestFactory.create(AppModule); app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector))); await app.listen(3000); } // Entity with serialization control @Entity() export class User { @PrimaryGeneratedColumn('uuid') id: string; @Column() email: string; @Column() name: string; @Column() @Exclude() // Never include in responses passwordHash: string; @Column({ nullable: true }) @Exclude() ssn: string; @Column({ default: false }) @Exclude({ toPlainOnly: true }) // Exclude from response, allow in requests isAdmin: boolean; @CreateDateColumn() createdAt: Date; @Column() @Exclude() internalNotes: string; } // Now returning entity is safe @Controller('users') export class UsersController { @Get(':id') async findOne(@Param('id') id: string): Promise { return this.usersService.findById(id); // Returns: { id, email, name, createdAt } // Sensitive fields excluded automatically } } // For different response shapes, use explicit DTOs export class UserResponseDto { @Expose() id: string; @Expose() email: string; @Expose() name: string; @Expose() @Transform(({ obj }) => obj.posts?.length || 0) postCount: number; constructor(partial: Partial) { Object.assign(this, partial); } } export class UserDetailResponseDto extends UserResponseDto { @Expose() createdAt: Date; @Expose() @Type(() => PostResponseDto) posts: PostResponseDto[]; } // Controller with explicit DTOs @Controller('users') export class UsersController { @Get() @SerializeOptions({ type: UserResponseDto }) async findAll(): Promise { const users = await this.usersService.findAll(); return users.map(u => plainToInstance(UserResponseDto, u)); } @Get(':id') async findOne(@Param('id') id: string): Promise { const user = await this.usersService.findByIdWithPosts(id); return plainToInstance(UserDetailResponseDto, user, { excludeExtraneousValues: true, }); } } // Groups for conditional serialization export class UserDto { @Expose() id: string; @Expose() name: string; @Expose({ groups: ['admin'] }) email: string; @Expose({ groups: ['admin'] }) createdAt: Date; @Expose({ groups: ['admin', 'owner'] }) settings: UserSettings; } @Controller('users') export class UsersController { @Get() @SerializeOptions({ groups: ['public'] }) async findAllPublic(): Promise { // Returns: { id, name } } @Get('admin') @UseGuards(AdminGuard) @SerializeOptions({ groups: ['admin'] }) async findAllAdmin(): Promise { // Returns: { id, name, email, createdAt } } @Get('me') @SerializeOptions({ groups: ['owner'] }) async getProfile(@CurrentUser() user: User): Promise { // Returns: { id, name, settings } } } ``` Reference: [NestJS Serialization](https://docs.nestjs.com/techniques/serialization)