Loading learning content...
Creating a DTO is simple—it's just a data container. But creating a well-designed DTO that serves consumers effectively, evolves gracefully, and maintains consistency across a large API surface? That's an art that separates amateur APIs from professional ones.
Poorly designed DTOs lead to:
This page distills the best practices that experienced API designers have learned—often the hard way—about DTO design.
By the end of this page, you will have a comprehensive toolkit of DTO best practices covering naming conventions, nullability, versioning, documentation, security, and common antipatterns to avoid. These practices apply regardless of your technology stack.
Names are the primary interface to your DTOs. Consistent, clear naming reduces cognitive load and makes your API feel professional and well-designed.
CreateOrderRequest, OrderResponse, OrderSummaryDTO. The suffix immediately signals purpose.OrderDTO for both causes confusion.CreateUserRequest, UpdateUserRequest, PatchUserRequest are all different.OrderResponseV1, OrderResponseV2 for breaking changes (though prefer backwards compatibility).CustomerDTO not CustDTO. Clarity trumps brevity in API contracts.123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
// === DTO TYPE NAMING === // Request DTOs - describe the actioninterface CreateOrderRequest { ... }interface UpdateOrderRequest { ... }interface CancelOrderRequest { ... }interface SearchOrdersRequest { ... } // Response DTOs - describe the viewinterface OrderResponse { ... } // Full detailinterface OrderSummaryResponse { ... } // Abbreviated for listsinterface OrderCreatedResponse { ... } // Creation confirmation // List responses with paginationinterface OrderListResponse { items: OrderSummaryResponse[]; pagination: PaginationInfo;} // Composition DTOsinterface OrderWithCustomerResponse { order: OrderResponse; customer: CustomerSummaryResponse;} // === FIELD NAMING === interface OrderResponse { // ✓ Self-describing, no ambiguity orderId: string; // Not: id (id of what?) orderNumber: string; // Human-readable order reference customerEmailAddress: string; // Not: email (customer's or business's?) // ✓ Consistent unit suffix for numeric values orderTotalCents: number; // Clear we're using cents shippingWeightGrams: number; deliveryTimeMinutes: number; // ✓ Consistent date format indication createdAt: string; // ISO 8601 by convention estimatedDeliveryDate: string; // Not: delivery (is it a date or object?) lastUpdatedAt: string; // ✓ Boolean prefixes isActive: boolean; // Not: active (could be a status) isEditable: boolean; hasShipped: boolean; canBeCancelled: boolean; // ✓ Enum values as readable strings status: 'pending' | 'processing' | 'shipped'; // Not: 1, 2, 3 paymentMethod: 'credit_card' | 'paypal' | 'bank_transfer';}| ❌ Avoid | ✓ Prefer | Reason |
|---|---|---|
id | orderId | Unambiguous in nested contexts |
email | customerEmailAddress | Clear whose email and what type |
total | orderTotalCents | Units are explicit |
date | createdAt, shippedAt | Describes what the date represents |
active | isActive | Boolean intent is clear |
status: 1 | status: 'pending' | Self-documenting enum values |
addr | shippingAddress | No abbreviations |
data | orderDetails | Specific, not generic |
Whatever naming convention you choose, apply it consistently across your entire API. An API with consistently applied 'imperfect' conventions is easier to use than one with mixed 'perfect' and 'imperfect' naming. Document your conventions and enforce them in code reviews.
How you handle optional and missing data in DTOs is crucial for consumer experience. Inconsistent null/undefined handling leads to defensive coding and bugs.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071
// === EXPLICIT NULLABILITY === interface OrderResponse { // Required fields - always present orderId: string; status: string; createdAt: string; // Optional fields - explicitly typed as nullable shippedAt: string | null; // null until shipped deliveredAt: string | null; // null until delivered cancelledAt: string | null; // null unless cancelled // Optional references - null when not applicable tracking: TrackingInfo | null; refund: RefundInfo | null; // Collections - always present, may be empty items: OrderItemResponse[]; // Never null, can be [] discounts: DiscountResponse[]; // Never null, can be []} // === REQUEST DTOs: optional vs required === interface CreateOrderRequest { // Required fields - must be provided customerId: string; items: CreateOrderItemRequest[]; shippingAddressId: string; // Optional fields - use '?' in TypeScript // Use explicit markers in other languages billingAddressId?: string; // Defaults to shipping if omitted couponCode?: string; // Optional discount code giftMessage?: string; // Optional gift message // Optional with known default expeditedShipping?: boolean; // Defaults to false signatureRequired?: boolean; // Defaults to true for expensive orders} // Never use null for 'not provided' - use field absence instead// null means "explicitly set to nothing"interface UpdateOrderRequest { // To clear a field, explicitly set null giftMessage?: string | null; // undefined = don't change, null = clear it} // === THE NULL VS UNDEFINED PROBLEM === // In JSON:// - undefined fields are omitted entirely// - null fields are present with null value // This distinction matters for PATCH operations:{ "giftMessage": null // Explicitly clear the gift message}// vs{ // giftMessage not present - leave existing value unchanged } // TypeScript pattern for PATCH DTOsinterface PatchOrderRequest { // Each field is optional, but when present can be null giftMessage?: string | null; shippingAddressId?: string; // Can't be nulled - must have shipping}| null.[], not null. Consumers can simply loop without null checks.Tony Hoare called null references his 'billion dollar mistake'. In DTO design, inconsistent or implicit nullability propagates this mistake to every API consumer. Explicit nullability with typed contracts (like TypeScript interfaces or OpenAPI's nullable property) prevents null-related bugs.
DTOs should be immutable once created. Since they represent a snapshot of data at a point in time, there's no reason to modify them after construction.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
// === ENFORCING IMMUTABILITY === // TypeScript: Use 'readonly' on all propertiesinterface OrderResponse { readonly orderId: string; readonly status: string; readonly createdAt: string; readonly items: readonly OrderItemResponse[]; // Read-only array too} // Or use Readonly<T> typetype ImmutableOrderResponse = Readonly<OrderResponse>; // Deep readonly for nested objectstype DeepReadonly<T> = { readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];}; type FullyImmutableOrder = DeepReadonly<OrderResponse>; // === JAVA/KOTLIN: Records or data classes === // Java Record (Java 14+)public record OrderResponse( String orderId, String status, String createdAt, List<OrderItemResponse> items) { // Immutable by default - no setters generated // Defensive copy for collection public OrderResponse { items = List.copyOf(items); }} // Kotlin Data Classdata class OrderResponse( val orderId: String, val status: String, val createdAt: String, val items: List<OrderItemResponse> // val = immutable reference) // === C#: Records or init-only properties === public record OrderResponse( string OrderId, string Status, string CreatedAt, IReadOnlyList<OrderItemResponse> Items); // Or with init-only setterspublic class OrderResponse{ public string OrderId { get; init; } public string Status { get; init; } public IReadOnlyList<OrderItemResponse> Items { get; init; }} // === WHY IMMUTABILITY MATTERS FOR DTOs === // Without immutability - dangerousfunction processOrder(order: OrderResponse) { order.status = 'modified'; // Oops! Mutated the shared DTO return order;} const order = await api.getOrder('123');processOrder(order); // order is now mutatedrender(order); // Rendering incorrect data // With immutability - safefunction processOrder(order: OrderResponse): OrderResponse { // order.status = 'modified'; // Compile error! // Must create new instance with changes return { ...order, status: 'modified' };}When DTOs are immutable, you can safely cache and share them without worrying about one consumer modifying data that another is reading. This is especially valuable in concurrent/async environments and when implementing client-side caching.
APIs evolve over time. New fields are added, old fields become irrelevant, and sometimes breaking changes are unavoidable. Proper DTO versioning strategies enable this evolution without breaking consumers.
Strategy 1: Additive Changes (Preferred)
The best approach is to design DTOs that can grow without breaking existing consumers:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
// === ADDITIVE CHANGES (Non-Breaking) === // Version 1: Original DTOinterface OrderResponseV1 { orderId: string; status: string; totalCents: number; createdAt: string;} // Version 1.1: Added new optional fields (NON-BREAKING)interface OrderResponse { // No version suffix needed orderId: string; status: string; totalCents: number; createdAt: string; // New fields are optional, so old clients ignore them estimatedDeliveryDate?: string; // Added in v1.1 carbonOffsetCents?: number; // Added in v1.2 // New nested object, optional sustainability?: { // Added in v1.3 carbonFootprintKg: number; offsetStatus: string; };} // Old clients: { orderId, status, totalCents, createdAt }// They simply ignore new fields they don't recognize // New clients: Full object with new features// Compatible with older server responses that lack new fields // === RULES FOR ADDITIVE COMPATIBILITY ===// ✓ Add new optional fields// ✓ Add new enum values (if client handles unknown values)// ✓ Add new nested optional objects// ✓ Add new endpoints// // ✗ Remove fields// ✗ Rename fields // ✗ Change field types// ✗ Change required to optional// ✗ Change field semanticsStrategy 2: Explicit Versioned DTOs (For Breaking Changes)
When breaking changes are necessary, maintain parallel DTO versions:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566
// === EXPLICIT VERSIONED DTOs === // api/v1/orders/dto.tsnamespace V1 { export interface OrderResponse { orderId: string; status: string; total: number; // Legacy: dollars as float (bad idea) orderDate: string; // Legacy: different name }} // api/v2/orders/dto.ts namespace V2 { export interface OrderResponse { orderId: string; status: OrderStatus; // Changed to enum totalCents: number; // BREAKING: now cents as integer createdAt: string; // BREAKING: renamed from orderDate updatedAt: string; // New required field } export type OrderStatus = 'pending' | 'processing' | 'shipped' | 'delivered';} // === MAPPER HANDLES BOTH VERSIONS === class OrderApiMapper { toV1Response(order: Order): V1.OrderResponse { return { orderId: order.id.value, status: order.status.toString(), total: order.total.amountInCents / 100, // Convert to dollars for v1 orderDate: order.createdAt.toISOString(), }; } toV2Response(order: Order): V2.OrderResponse { return { orderId: order.id.value, status: this.toV2Status(order.status), totalCents: order.total.amountInCents, createdAt: order.createdAt.toISOString(), updatedAt: order.updatedAt.toISOString(), }; }} // === CONTROLLER ROUTES BOTH VERSIONS === @Controller('api')class OrderController { @Get('v1/orders/:id') async getOrderV1(@Param('id') id: string): Promise<V1.OrderResponse> { const order = await this.orderService.findById(id); return this.mapper.toV1Response(order); } @Get('v2/orders/:id') async getOrderV2(@Param('id') id: string): Promise<V2.OrderResponse> { const order = await this.orderService.findById(id); return this.mapper.toV2Response(order); }}Maintain old versions only as long as necessary. Communicate a deprecation timeline (e.g., 'v1 will be removed 12 months after v2 release'), provide migration guides, and monitor v1 usage to know when it's safe to remove.
Strategy 3: Wire Format Versioning
Include version information in the response itself:
123456789101112131415161718192021222324252627282930
// Include schema version in responseinterface ApiResponse<T> { schemaVersion: string; // '2.1.0' data: T; deprecations?: DeprecationWarning[];} interface DeprecationWarning { field: string; message: string; removalDate: string; replacement?: string;} // Example responseconst response: ApiResponse<OrderResponse> = { schemaVersion: '2.1.0', data: { orderId: '123', // ... }, deprecations: [ { field: 'orderDate', message: 'Use createdAt instead', removalDate: '2025-01-01', replacement: 'createdAt' } ]};Well-documented DTOs reduce support burden, prevent misuse, and make your API easier to integrate with. Documentation should live as close to the code as possible.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
/** * Response DTO for order details. * * This DTO provides comprehensive order information suitable for * order detail pages and receipts. * * @see OrderSummaryResponse for list views * @see CreateOrderRequest for order creation * @since API v2.0 */interface OrderResponse { /** * Unique identifier for the order. * Format: UUID v4 * @example "550e8400-e29b-41d4-a716-446655440000" */ readonly orderId: string; /** * Human-readable order number for customer communication. * Format: ORD-YYYY-NNNNNN * @example "ORD-2024-001234" */ readonly orderNumber: string; /** * Current order status. * * Values: * - 'pending': Order placed, awaiting payment confirmation * - 'processing': Payment confirmed, preparing for shipment * - 'shipped': Order dispatched, in transit * - 'delivered': Successfully delivered to customer * - 'cancelled': Order cancelled (see cancellationReason) * * @see statusChangedAt for when the status last changed */ readonly status: 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled'; /** * Order total in cents (smallest currency unit). * Includes subtotal, tax, shipping, and discounts. * * For display, divide by 100 and format with currency symbol. * @example 12599 (represents $125.99 USD) * @min 0 */ readonly totalCents: number; /** * ISO 8601 timestamp when the order was created. * Always in UTC timezone. * @example "2024-01-15T10:30:00.000Z" */ readonly createdAt: string; /** * ISO 8601 timestamp when order shipped, or null if not yet shipped. * Present only when status is 'shipped' or 'delivered'. * * @example "2024-01-16T09:15:00.000Z" */ readonly shippedAt: string | null; /** * Whether the order can be modified. * * Returns true only when: * - Status is 'pending' * - Order was created within the last 24 hours * - No items have begun fulfillment * * Use the PATCH /orders/{id} endpoint to modify. */ readonly isEditable: boolean; /** * List of items in the order. * * Always contains at least one item (empty orders cannot exist). * Items are sorted by line item ID (creation order). * * @minItems 1 */ readonly items: OrderItemResponse[]; /** * Hypermedia links for related actions and resources. * * Links with null values indicate the action is not available * for this order's current state. */ readonly links: { /** Link to this order (self-reference) */ readonly self: string; /** Link to cancel order, or null if not cancellable */ readonly cancel: string | null; /** Link to shipment tracking page, or null if not shipped */ readonly trackShipment: string | null; };} // === OpenAPI/Swagger annotations (for auto-generated docs) === import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; class OrderResponseDto { @ApiProperty({ description: 'Unique order identifier (UUID v4)', example: '550e8400-e29b-41d4-a716-446655440000', format: 'uuid', }) orderId: string; @ApiProperty({ description: 'Order total in cents', example: 12599, minimum: 0, }) totalCents: number; @ApiPropertyOptional({ description: 'Shipment timestamp, null if not shipped', example: '2024-01-16T09:15:00.000Z', nullable: true, }) shippedAt: string | null;}Use tools like OpenAPI/Swagger, TypeDoc, or JSDoc to generate documentation from annotated code. This keeps documentation synchronized with implementation and reduces the chance of stale docs.
DTOs are your last line of defense against data exposure. Proper DTO design prevents security vulnerabilities:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091
// === ROLE-SPECIFIC DTOs === // Public DTO - minimal, safe datainterface UserPublicProfileDTO { readonly userId: string; readonly displayName: string; readonly avatarUrl: string; readonly memberSince: string; // NO: email, phone, address, etc.} // Self-profile DTO - user can see their own datainterface UserSelfProfileDTO { readonly userId: string; readonly displayName: string; readonly email: string; readonly phone: string | null; readonly emailVerified: boolean; readonly settings: UserSettingsDTO; // NO: internal flags, admin notes, etc.} // Admin DTO - operational data for supportinterface UserAdminViewDTO { readonly userId: string; readonly displayName: string; readonly email: string; readonly phone: string | null; readonly accountStatus: 'active' | 'suspended' | 'banned'; readonly suspensionReason: string | null; readonly lastLoginAt: string | null; readonly failedLoginAttempts: number; readonly createdByReferral: string | null; readonly orderCount: number; readonly totalSpentCents: number; // Still NO: password hashes, SSN, etc.} // Controller returns appropriate DTO based on requester@Get('/users/:id')async getUser( @Param('id') userId: string, @CurrentUser() requester: User): Promise<UserPublicProfileDTO | UserSelfProfileDTO | UserAdminViewDTO> { const user = await this.userService.findById(userId); // Own profile - see personal data if (requester.id === userId) { return this.mapper.toSelfProfileDTO(user); } // Admin - see operational data if (requester.hasRole('admin')) { return this.mapper.toAdminViewDTO(user); } // Everyone else - public data only return this.mapper.toPublicProfileDTO(user);} // === INPUT VALIDATION === class CreateUserRequestDTO { @IsString() @Length(2, 50) @Matches(/^[a-zA-Z0-9_-]+$/, { message: 'Username can only contain letters, numbers, underscores, and hyphens' }) username: string; @IsEmail() @MaxLength(255) @Transform(({ value }) => value.toLowerCase().trim()) // Normalize email: string; @IsString() @MinLength(12, { message: 'Password must be at least 12 characters' }) @MaxLength(128) @Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, { message: 'Password must contain uppercase, lowercase, and number' }) password: string; // Will be hashed - never stored as-is @IsOptional() @IsString() @MaxLength(200) @Transform(({ value }) => sanitizeHtml(value)) // Remove dangerous content bio?: string;}In 2019, Facebook inadvertently exposed millions of user phone numbers through an API that returned full User objects instead of a minimal DTO. The internal phone number field was never meant to be public but wasn't explicitly excluded. Whitelist-based DTOs would have prevented this.
Learning what not to do is as important as learning best practices. These antipatterns appear frequently in real codebases:
Antipattern 1: The God DTO
One DTO that tries to serve all purposes:
1234567891011121314151617181920212223242526272829
// ❌ THE GOD DTO - Does everything, confuses everyoneinterface OrderDTO { // Used for creation customerId?: string; items?: CreateItemDTO[]; // Used for responses orderId?: string; orderNumber?: string; status?: string; createdAt?: string; // Used for updates newStatus?: string; updateReason?: string; // Used for admin views fraudScore?: number; internalNotes?: string; // Everything optional because different uses need different fields // Result: No one knows what to actually provide or expect} // ✓ SOLUTION: Purpose-specific DTOsinterface CreateOrderRequest { ... }interface UpdateOrderStatusRequest { ... }interface OrderResponse { ... }interface OrderAdminView { ... }Antipattern 2: Primitive Obsession in Response DTOs
While DTOs use primitives for serialization, don't lose structure:
123456789101112131415161718192021222324
// ❌ TOO FLAT - Lost meaningful structureinterface OrderDTO { orderId: string; customerName: string; customerEmail: string; customerPhone: string; shippingStreet: string; shippingCity: string; shippingState: string; shippingZip: string; shippingCountry: string; billingStreet: string; billingCity: string; // ... 30 more flat fields} // ✓ STRUCTURED BUT SERIALIZABLEinterface OrderDTO { orderId: string; customer: CustomerSummaryDTO; shippingAddress: AddressDTO; billingAddress: AddressDTO; // Logical grouping preserves meaning}Antipattern 3: Leaky Abstraction
Exposing implementation details through DTOs:
1234567891011121314151617
// ❌ LEAKY ABSTRACTIONinterface OrderDTO { _id: ObjectId; // MongoDB implementation detail __v: number; // Mongoose version key _createdBy: string; // Internal audit field hibernateProxy: boolean; // ORM mechanism exposed items: HibernatePersistentSet; // ORM collection type orderStatusId: number; // Exposes database FK} // ✓ CLEAN ABSTRACTIONinterface OrderDTO { orderId: string; // Clean string ID createdAt: string; // Standard timestamp items: OrderItemDTO[]; // Plain array status: 'pending' | 'shipped'; // Meaningful enum}Antipattern 4: Inconsistent Conventions
123456789101112131415161718192021222324252627
// ❌ INCONSISTENT - Same API, different conventionsinterface OrderDTO { order_id: string; // snake_case orderNumber: string; // camelCase CreatedAt: string; // PascalCase TOTAL: number; // SCREAMING_CASE is_active: boolean; // snake_case boolean hasShipped: boolean; // camelCase boolean total_amount: number; // Dollars? shipping_cents: number; // Cents? date: string; // Which date? timestamp: number; // Unix timestamp?} // ✓ CONSISTENT - Pick conventions and stick to theminterface OrderDTO { orderId: string; // camelCase everywhere orderNumber: string; createdAt: string; // ISO 8601 string totalCents: number; // Always cents, suffixed shippingCents: number; isActive: boolean; // is/has prefix for booleans hasShipped: boolean;}These antipatterns often appear together. A project with a God DTO usually also has inconsistent naming and leaky abstractions. Fixing one antipattern often makes others visible. Invest in DTO design guidelines and enforce them from the start.
DTO design is where API craftsmanship shows. These practices, applied consistently, result in APIs that are a pleasure to consume:
| Category | Practice |
|---|---|
| Naming | Clear suffixes (Request/Response), no abbreviations |
| Nullability | Explicit types, empty arrays not null |
| Immutability | Readonly properties, defensive copies |
| Versioning | Additive changes; explicit versioning when needed |
| Documentation | Field descriptions, examples, constraints |
| Security | Whitelist fields, role-specific DTOs, input validation |
| Structure | Purpose-specific, not God DTOs |
Module Complete:
You've now completed the DTO Design module. You understand what DTOs are, how they differ from domain objects, strategies for mapping between them, and the best practices that lead to well-designed APIs.
The principles learned here apply beyond DTOs—to any API contract design, message payloads, or data exchange format. The core insight remains: deliberately designed boundaries that decouple internal implementation from external contracts enable systems to evolve gracefully.
Congratulations! You've mastered DTO Design. You can now create Data Transfer Objects that are clear, secure, evolvable, and a pleasure for API consumers to work with. Apply these practices to build APIs that stand the test of time.