Data Transfer Object (DTO) Design
DTO Best Practices
Read aloud is unavailable in this browser.
The Art of DTO Design
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:
- Consumers constantly confused about field meanings
- Breaking changes that require major version bumps
- Inconsistent naming that makes the API feel unprofessional
- Security vulnerabilities from over-exposure
- Performance problems from bloated payloads
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.
Naming Conventions
Names are the primary interface to your DTOs. Consistent, clear naming reduces cognitive load and makes your API feel professional and well-designed.
- •Use clear suffixes —
CreateOrderRequest,OrderResponse,OrderSummaryDTO. The suffix immediately signals purpose. - •Distinguish request from response — Never share DTOs between input and output.
OrderDTOfor both causes confusion. - •Include operation context —
CreateUserRequest,UpdateUserRequest,PatchUserRequestare all different. - •Version when necessary —
OrderResponseV1,OrderResponseV2for breaking changes (though prefer backwards compatibility). - •Avoid abbreviations —
CustomerDTOnotCustDTO. 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.
Nullability and Optional Fields
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}- •Be explicit — Don't rely on implicit nullability. Type every optional field with
| null. - •Collections are never null — Return empty arrays
[], notnull. Consumers can simply loop without null checks. - •Distinguish undefined from null — In PATCH operations: absent means 'don't change', null means 'clear the value'.
- •Document defaults — When optional fields have defaults, document them clearly in the API spec.
- •Don't overuse optionality — If a field is almost always present, make it required and handle the rare cases explicitly.
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.
Immutability in DTOs
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.
Versioning DTOs for API Evolution
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' } ]};DTO Documentation
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;}- •Purpose — What is this DTO used for? Which endpoints return/accept it?
- •Field meanings — Don't assume field names are self-explanatory. Describe what each field represents.
- •Formats — Date formats (ISO 8601), ID formats (UUID), string patterns (phone, email).
- •Units — Currency (cents vs dollars), weight (grams vs kg), time (seconds vs minutes).
- •Constraints — Min/max values, required lengths, allowed patterns.
- •Null semantics — What does null mean for each nullable field?
- •Examples — Concrete example values help more than abstract descriptions.
- •Related DTOs — Cross-reference summary vs detail DTOs, request vs response.
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.
Security Considerations
DTOs are your last line of defense against data exposure. Proper DTO design prevents security vulnerabilities:
- •Whitelist, don't blacklist — DTOs should explicitly include fields, not exclude them. New fields are secure by default.
- •Role-specific DTOs — Create separate DTOs for different authorization levels (public vs admin).
- •No sensitive data in client-bound DTOs — Passwords, tokens, internal IDs, PII unless necessary.
- •Validate input DTOs — Never trust client data. Validate type, format, range, and length.
- •Sanitize output — Escape or remove potentially dangerous content (HTML, scripts).
- •Audit what you expose — Regularly review DTOs to ensure no accidental exposure.
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.
Common DTO Antipatterns
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.
KEEP LEARNING
There’s more to understand.
You’re reading the introduction. Create a free account to read the complete lesson, save notes and keep track of your progress.