Skip to content
ExponaBLOG
NESTJS · DAY 008 / 100

DTOs that protect your API: patterns, pitfalls and fixes

Your DTO is a security boundary, not just a type.

Expona Admin8 min read

A DTO is not just a shape definition—it's your security boundary. Whether you're building an e-commerce platform, a CRM, or a SaaS product, the moment your API accepts a request, you're trusting that DTO to validate the contract. Get this wrong and you expose your system to mass assignment, injection attacks, and business logic corruption.

Over my 5.5 years building production platforms, I've seen teams treat DTOs as afterthoughts, decorating fields half-heartedly and binding directly to database entities. By day 8 of the NestJS series, you need to understand that DTOs that protect your API are not optional overhead—they are the line between a secure, predictable system and one that silently accepts malformed, malicious, or unintended data.

Decorate every field with class-validator rules

In NestJS, the class-validator library gives you decorators that enforce the schema at runtime. Every field in your DTO should have at least one decorator that documents what you accept and rejects everything else. This is not about type safety; TypeScript already does that at compile time. This is about runtime enforcement—the moment the request body hits your controller.

Consider a real-world example: you're building a lead capture system for a SaaS platform. The frontend sends a name, email, and optional phone number. Here's what a properly decorated DTO looks like:

typescript
import {
  IsString,
  IsEmail,
  IsOptional,
  IsPhoneNumber,
  Length,
} from 'class-validator';

export class CreateLeadDto {
  @IsString()
  @Length(2, 80)
  name: string;

  @IsEmail()
  email: string;

  @IsOptional()
  @IsPhoneNumber()
  phone?: string;
}

Each decorator serves a purpose. @IsString() ensures the field is a string, not a number or object sneaked in by a clever payload. @Length(2, 80) prevents empty strings and protects against unbounded input that could cause performance issues or database bloat. @IsEmail() validates email format—a cheap way to reject garbage data before hitting your database. @IsOptional() marks phone as truly optional, and @IsPhoneNumber() enforces a valid format if provided.

To activate validation in NestJS, you need the ValidationPipe in your main.ts:

typescript
import { ValidationPipe } from '@nestjs/common';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe());
  await app.listen(3000);
}
bootstrap();

Now, if someone sends {"name": "", "email": "not-an-email"}, the request is rejected before your code runs. The ValidationPipe returns a 400 with detailed errors, telling the client exactly what went wrong. This is not rude; this is clear communication. In a production ride-hailing or travel platform, malformed data hitting your business logic can cascade into inconsistent state, double charges, or cancelled bookings that weren't really cancelled.

Nested objects require @ValidateNested() and @Type()

When your DTO contains nested objects, you must explicitly tell class-validator to validate them. If you skip this, nested fields bypass validation entirely, and you've just created a blind spot in your security boundary.

Imagine a booking creation endpoint in a travel platform. The request includes an address object with street, city, zip code, and country. Without proper nesting validation, someone could send {"address": null} or {"address": {"street": "", "city": 123}} and your DTO would accept it.

typescript
import {
  IsString,
  IsOptional,
  ValidateNested,
  Length,
  IsPostalCode,
} from 'class-validator';
import { Type } from 'class-transformer';

export class AddressDto {
  @IsString()
  @Length(5, 100)
  street: string;

  @IsString()
  @Length(2, 50)
  city: string;

  @IsPostalCode('US')
  zipCode: string;

  @IsString()
  @Length(2, 2)
  country: string;
}

export class CreateBookingDto {
  @IsString()
  destinationName: string;

  @ValidateNested()
  @Type(() => AddressDto)
  address: AddressDto;

  @IsOptional()
  @ValidateNested()
  @Type(() => AddressDto)
  billingAddress?: AddressDto;
}

@ValidateNested() tells class-validator to recursively validate the nested object. @Type(() => AddressDto) (from class-transformer) ensures the plain JSON object is transformed into an actual AddressDto instance before validation runs. Without @Type, the nested object remains a plain object and validation rules don't apply correctly.

This pattern becomes essential in complex domains. In a CRM system, a contact creation DTO might nest company information, addresses, and phone numbers. Each nested DTO has its own contract. Enforce it at every level.

Mapped types eliminate duplication and keep DTOs in sync

As your API grows, you'll need multiple DTOs for the same entity: CreateXDto, UpdateXDto, PatchXDto. Manually duplicating decorators is error-prone. If you add a new field to CreateXDto and forget to add it to UpdateXDto, your update endpoint suddenly silently ignores that field. Mapped types solve this.

NestJS provides PartialType, PickType, and OmitType to build new DTOs from existing ones. Consider a user management flow in a SaaS platform:

typescript
import {
  IsString,
  IsEmail,
  Length,
  IsOptional,
} from 'class-validator';
import {
  PartialType,
  OmitType,
  PickType,
} from '@nestjs/mapped-types';

export class CreateUserDto {
  @IsString()
  @Length(2, 50)
  firstName: string;

  @IsString()
  @Length(2, 50)
  lastName: string;

  @IsEmail()
  email: string;

  @IsString()
  @Length(8, 128)
  password: string;

  @IsOptional()
  @IsString()
  avatar?: string;
}

// Update allows partial changes to certain fields
export class UpdateUserDto extends PartialType(
  OmitType(CreateUserDto, ['password', 'email']),
) {}

// Patch also allows partial changes (same approach)
export class PatchUserDto extends PartialType(CreateUserDto) {}

// Admin password reset—only the password field
export class ResetPasswordDto extends PickType(CreateUserDto, [
  'password',
]) {}

PartialType makes all fields optional. OmitType removes sensitive fields like password and email from the update DTO—users shouldn't be able to change their email through a general update endpoint. PickType selects only specific fields, useful for targeted operations like password resets.

The benefit: if you add a new required field to CreateUserDto, all mapped types automatically include it (with the same constraints). Your DTOs stay in sync without manual edits. In a production e-commerce platform, this consistency prevents subtle bugs where one endpoint accepts a discount code but another doesn't, leading to customer confusion and support overhead.

Never bind directly to database entities

This is the most critical rule: never use your Prisma model or database entity as a DTO. Many teams bind requests directly to the database model and pass the result to the service layer. This is catastrophically dangerous.

Here's why. Your User entity in Prisma has fields like id, role, tenantId, createdAt, and isAdmin. If you accept requests directly into this shape, a clever client can send:

typescript
{
  "firstName": "Alice",
  "email": "alice@example.com",
  "role": "admin",
  "isAdmin": true,
  "tenantId": 42
}

If your controller uses the User entity as a DTO with no validation, the client just promoted themselves to admin across a different tenant. This is a mass assignment vulnerability. I've seen this happen in real production systems—one team's misconfigured endpoint became a backdoor for privilege escalation.

The correct approach: separate your DTOs from your entities entirely.

typescript
// user.service.ts
export class UserService {
  async create(dto: CreateUserDto) {
    const user = await this.prisma.user.create({
      data: {
        firstName: dto.firstName,
        lastName: dto.lastName,
        email: dto.email,
        password: await hashPassword(dto.password),
        // Controlled defaults—never from DTO
        role: 'user',
        isAdmin: false,
        tenantId: this.getCurrentTenantId(),
      },
    });
    return user;
  }

  async update(userId: string, dto: UpdateUserDto) {
    // Only update what's in the DTO
    const updates: Prisma.UserUpdateInput = {};
    if (dto.firstName !== undefined) updates.firstName = dto.firstName;
    if (dto.lastName !== undefined) updates.lastName = dto.lastName;
    if (dto.avatar !== undefined) updates.avatar = dto.avatar;

    return this.prisma.user.update({
      where: { id: userId },
      data: updates,
    });
  }
}

Notice: role, isAdmin, and tenantId are never populated from the DTO. They're controlled by your business logic. The service layer is the gatekeeper that decides what the database actually receives, not the request payload.

Common mistakes

  • Decorating without understanding. Copying validators from Stack Overflow without reading what they do. @IsEmail() catches most garbage, but @IsPhoneNumber() is strict—know your tool or use a permissive custom validator.
  • Forgetting ValidationPipe scope. Adding ValidationPipe only to specific controllers instead of globally. Inconsistency means some endpoints validate, others don't. Use global validation in main.ts and override selectively only when necessary.
  • Ignoring nested validation. Decorating the parent DTO but leaving nested objects undecorated. A common pattern: request body has a company object that's never validated because you forgot @ValidateNested().
  • Over-validating. Being so strict that legitimate requests fail. @IsPhoneNumber() without a region can reject valid numbers. Balance security with usability using @IsOptional() and permissive patterns where appropriate.
  • Reusing entities as DTOs. The shortcut that feels fast until someone exploits a field you didn't think they'd change. Always separate DTOs from entities.

Key takeaways

  • Decorate every DTO field with class-validator rules. This documents your contract and enforces it at runtime before your business logic runs.
  • Use @ValidateNested() and @Type() for nested objects. Validation doesn't recurse automatically—you must explicitly enable it.
  • Leverage mapped types (PartialType, OmitType, PickType) to build related DTOs without duplication. Consistency across endpoints prevents silent bugs.
  • Never bind requests directly to database entities. DTOs are your security boundary. Your service layer controls what the database receives, not the request payload.
  • Enable validation globally via ValidationPipe in main.ts. Inconsistent validation across endpoints is a security risk.
  • Think of DTOs as a contract with clear terms. Every decorator is a term. Every field is a promise about what you accept. Break that promise and your system becomes unpredictable.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles