Data Transfer Object (DTO) Design
DTO vs Domain Objects
Read aloud is unavailable in this browser.
Two Objects, Two Purposes
Here's a scenario that plays out in countless codebases: A developer needs to return order data from an API. They look at the existing Order entity in the domain layer and think, "Why create another class? This already has everything I need." So they annotate the domain entity with JSON serialization directives and return it directly.
For a week, everything works. Then the problems begin:
- A security audit reveals the API is exposing internal audit fields
- A frontend developer is confused why
getShippingCost()appears in the JSON - A schema migration breaks the API because it changed the entity structure
- Different clients need different subsets of the order data
This is the pain of confusing DTOs with domain objects.
Understanding the fundamental difference between these two object types—and maintaining crisp boundaries between them—is one of the most important skills in API design.
By the end of this page, you will understand the fundamental differences between DTOs and domain objects, why domain objects should never cross API boundaries, the specific risks of conflation, and how to think about the relationship between these object types.
Fundamental Nature Differences
Domain objects and DTOs serve entirely different purposes. Their differences are not superficial—they reflect fundamentally different design goals:
Domain Objects exist to model business concepts with their full complexity. They encapsulate both data and behavior, enforce business rules, and represent the truth of your domain.
DTOs exist to transfer data across boundaries. They carry data in a format optimized for transport, with no behavior and no business logic.
| Characteristic | Domain Object | Data Transfer Object |
|---|---|---|
| Primary Purpose | Model business concepts | Carry data across boundaries |
| Behavior | Rich behavior, business methods | No behavior, data only |
| Validation | Enforces invariants always | May have schema validation, no business rules |
| Identity | Often has identity (Entity) | No meaningful identity |
| Lifecycle | Managed by domain services | Created for transfer, then discarded |
| Dependencies | May reference other domain objects | Self-contained, no external dependencies |
| Mutability | Often mutable with controlled state changes | Preferably immutable |
| Serialization | Not designed for serialization | Optimized for serialization |
| Evolution | Changes when business rules change | Changes when API contract changes |
| Location | Core domain layer | API boundary layer |
The key philosophical difference:
Domain objects answer the question: "What is the truth about this business concept?"
DTOs answer the question: "What data does this consumer need to see?"
These are fundamentally different questions with different optimal answers.
Think of a domain object as the actual reality of a concept in your system—with all its complexity, rules, and behavior. A DTO is a photograph of that reality—a simplified, flat representation suitable for sharing with others. The photograph isn't the person; the DTO isn't the domain object.
Anatomy of a Domain Object
Let's examine what a well-designed domain object looks like and why it's unsuitable for direct API exposure:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899
// A rich domain entity with behavior and invariantsclass Order { private readonly _id: OrderId; private _status: OrderStatus; private _items: OrderItem[]; private _customerId: CustomerId; private _shippingAddress: Address; private _paymentDetails: PaymentDetails; // Internal tracking - should NEVER be exposed private _fraudScore: number; private _riskFlags: RiskFlag[]; private _internalNotes: string[]; private _auditTrail: AuditEntry[]; // Lazy-loaded relationships private _customer: Customer | null = null; constructor(id: OrderId, customerId: CustomerId, items: OrderItem[]) { // Invariant: orders must have at least one item if (items.length === 0) { throw new EmptyOrderError(); } this._id = id; this._customerId = customerId; this._items = items; this._status = OrderStatus.PENDING; } // === BUSINESS BEHAVIOR === // Method enforces business rules addItem(item: OrderItem): void { if (this._status !== OrderStatus.PENDING) { throw new OrderModificationError( "Cannot add items to non-pending order" ); } const existingItem = this._items.find(i => i.productId === item.productId); if (existingItem) { existingItem.increaseQuantity(item.quantity); } else { this._items.push(item); } this.recordAudit("ITEM_ADDED", item); } // Complex business logic calculateTotal(): Money { const subtotal = this._items.reduce( (sum, item) => sum.add(item.lineTotal()), Money.zero(this.currency) ); const discount = this.calculateDiscount(subtotal); const tax = this.calculateTax(subtotal.subtract(discount)); const shipping = this.calculateShipping(); return subtotal.subtract(discount).add(tax).add(shipping); } // Private helpers private calculateDiscount(subtotal: Money): Money { // Complex discount logic... } private calculateTax(amount: Money): Money { // Tax calculation based on jurisdiction... } private calculateShipping(): Money { // Shipping calculation based on address and items... } // State transitions with invariants submit(): void { if (this._status !== OrderStatus.PENDING) { throw new InvalidStateTransitionError(); } // Validate all business rules before submission this.validateForSubmission(); this._status = OrderStatus.SUBMITTED; this.raise(new OrderSubmittedEvent(this)); } // Domain events private domainEvents: DomainEvent[] = []; private raise(event: DomainEvent): void { this.domainEvents.push(event); } // ... many more methods}Why this object is dangerous to expose directly:
-
Sensitive Internal Data: Fields like
_fraudScore,_riskFlags, and_internalNotesare for internal use only. Exposing them is a security violation. -
Method Exposure: Serializing this object might expose method names, which reveals implementation details.
-
Lazy-Loading Issues: The
_customerfield might be a lazy-loaded proxy. Serializing could either trigger unexpected database queries or fail with serialization errors. -
Circular References: Domain objects often have bidirectional relationships (Order ↔ Customer ↔ Orders). These create infinite loops during serialization.
-
Invariant Violations: Domain objects protect their invariants through encapsulation. Deserializing JSON directly into a domain object could bypass constructor validation, creating invalid state.
When you serialize a domain object, you're taking a carefully designed, behavior-rich model and flattening it into data. In the process, you lose all invariant protection and expose implementation details. When you deserialize back, you might create objects that violate their own invariants because deserialization typically bypasses constructors.
Anatomy of a DTO
In contrast, here's what a DTO for the same Order concept looks like—designed specifically for API consumption:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071
// A DTO designed for API responseinterface OrderResponseDTO { // Identifiers as simple strings readonly orderId: string; readonly orderNumber: string; // Status as serializable enum value readonly status: 'pending' | 'submitted' | 'processing' | 'shipped' | 'delivered' | 'cancelled'; // Timestamps as ISO 8601 strings readonly createdAt: string; readonly updatedAt: string; readonly submittedAt: string | null; readonly shippedAt: string | null; readonly deliveredAt: string | null; // Customer summary (not full customer) readonly customer: { readonly customerId: string; readonly name: string; readonly email: string; }; // Pre-computed monetary values in cents (avoiding float issues) readonly subtotalCents: number; readonly discountCents: number; readonly taxCents: number; readonly shippingCents: number; readonly totalCents: number; readonly currency: string; // Items as flat DTOs readonly items: OrderItemDTO[]; // Addresses as simple structures readonly shippingAddress: AddressDTO; readonly billingAddress: AddressDTO; // Useful metadata for client display readonly isEditable: boolean; // Derived: can items be modified? readonly isCancellable: boolean; // Derived: can order be cancelled? readonly estimatedDeliveryDate: string | null; // Hypermedia for discoverability readonly links: { readonly self: string; readonly customer: string; readonly cancel: string | null; readonly trackShipment: string | null; };} interface OrderItemDTO { readonly lineItemId: string; readonly productId: string; readonly productName: string; readonly productImageUrl: string; readonly sku: string; readonly quantity: number; readonly unitPriceCents: number; readonly lineTotalCents: number;} interface AddressDTO { readonly line1: string; readonly line2: string | null; readonly city: string; readonly state: string; readonly postalCode: string; readonly country: string;}Observe the differences:
-
No Sensitive Data: The
fraudScore,riskFlags, andinternalNotesare nowhere to be seen. -
Pre-Computed Values: Instead of a
calculateTotal()method, we havetotalCentsalready computed. The client doesn't need to know how the total was calculated. -
Flat Structure: No lazy-loading. No proxies. Just simple, nested data structures that serialize cleanly.
-
Serialization-Friendly Types: Dates are ISO 8601 strings, not
Dateobjects. Money is cents as integers, notMoneyvalue objects with potential precision issues. -
Derived Convenience Fields:
isEditableandisCancellableare derived from internal state but presented as simple booleans. The client doesn't need to know the rules. -
Explicit Nullability: Every optional field is explicitly typed as
| null, making the contract crystal clear.
The DTO represents the complexity of the Order domain object (with its validation, business rules, state machine, and calculations) as simple, already-resolved data. This is the power of DTOs: they translate rich domain behavior into static data snapshots.
The Risks of Using Domain Objects as DTOs
Many teams, under time pressure, choose to expose domain objects directly. This decision creates a cascade of problems that grow over time:
- •Security Vulnerabilities — Every field in your domain object is potentially exposed unless explicitly excluded. New fields added to the domain object are automatically exposed unless developers remember to add exclusion annotations. This is a recipe for data leaks.
- •Tight Coupling — External consumers become dependent on your internal structure. Renaming a field, changing a type, or restructuring your domain now requires coordinated changes with every API consumer.
- •Evolution Paralysis — Fear of breaking consumers prevents domain model improvement. Teams accumulate technical debt because they can't safely refactor.
- •Serialization Bugs — ORM proxies, lazy-loading, circular references, and framework-specific objects (like Hibernate proxies or JPA converters) cause serialization failures that are hard to debug.
- •Over-Fetching & Under-Fetching — Domain objects contain all fields. Some consumers receive too much data; others don't receive data they need (because adding to the domain to satisfy the API is backwards).
- •Invalid Deserialization — When clients POST data directly to domain objects, they can construct objects that violate invariants because deserialization bypasses constructors and validation.
12345678910111213141516171819202122232425262728293031323334353637383940414243
// === THE DANGEROUS ANTI-PATTERN === // Domain entity marked up for serialization (DON'T DO THIS)@Entity()class User { @PrimaryGeneratedColumn() id: number; @Column() email: string; @Column() @JsonIgnore() // Easy to forget to add this! passwordHash: string; @Column() @JsonIgnore() // What if someone removes this "cleanup"? passwordSalt: string; @Column({ nullable: true }) @JsonIgnore() // Added later, forgot the annotation! socialSecurityNumber: string; // EXPOSED! @Column() createdAt: Date; // New developer adds this field... @Column() internalCreditRating: number; // EXPOSED by default! @OneToMany(() => Order, order => order.user) orders: Order[]; // Circular reference, serialization nightmare} // Controller returning domain object directly@Get('/users/:id')async getUser(@Param('id') id: number) { // Every field, every relationship, potentially exposed return this.userRepository.findOne({ where: { id }, relations: ['orders'] // And all orders, and their items... });}This exact pattern has caused multiple high-profile data breaches. When domain objects are serialized directly, new sensitive fields added by developers who are unaware of API implications are exposed automatically. Blacklist-based approaches (@JsonIgnore) fail because the default is exposure, not protection.
Different Lifecycles and Evolution Patterns
One of the most important distinctions between domain objects and DTOs is their different lifecycles and reasons for change:
- •Business rules change
- •New domain behaviors are needed
- •Invariants are discovered or refined
- •Performance optimizations are needed
- •Integration with new systems requires restructuring
- •Domain understanding deepens (ubiquitous language evolves)
- •API consumers request new data
- •New API versions are released
- •Consumer-facing format changes
- •New consumers need different views
- •Performance requires payload optimization
- •Compliance requires data filtering
These are independent forces. A domain object might need to split into two entities due to discovered bounded context boundaries—but the API contract might stay exactly the same. Conversely, a new mobile client might need a compact DTO format while the domain model stays unchanged.
When you couple these by using domain objects as DTOs, every change in either world affects the other. You lose the ability to evolve independently.
12345678910111213141516171819202122232425262728293031323334
SCENARIO: Breaking up a monolithic Order entity Domain Side (Internal Restructuring):┌─────────────────────────────────────────────────────────────────┐│ BEFORE ││ ┌──────────────────────────────────────────────────────────┐ ││ │ Order (mega-entity) │ ││ │ - id, status, items[], customer, shipping, payment, │ ││ │ - fulfillment, returns, refunds, history, notes... │ ││ └──────────────────────────────────────────────────────────┘ ││ ││ AFTER ││ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ││ │ Order │ │ Fulfillment │ │ Payment │ ││ │ - id │ │ - orderId │ │ - orderId │ ││ │ - items │ │ - carrier │ │ - method │ ││ │ - status │ │ - tracking │ │ - status │ ││ └─────────────┘ └─────────────┘ └─────────────┘ │└─────────────────────────────────────────────────────────────────┘ API Side (External Contract - UNCHANGED):┌─────────────────────────────────────────────────────────────────┐│ GET /orders/123 ││ Returns: OrderResponseDTO ││ { ││ "orderId": "123", ││ "status": "shipped", ││ "items": [...], ││ "fulfillment": { "carrier": "UPS", "tracking": "..." }, ││ "payment": { "method": "card", "status": "completed" } ││ } ││ ││ The DTO looks the same! Mapper now aggregates from 3 entities │└─────────────────────────────────────────────────────────────────┘With proper DTO separation, the domain refactoring above only requires updating the mapper. All API consumers continue working unchanged. This decoupling is essential for maintaining large systems over time.
Where the Boundary Lives
Understanding where to draw the line between domain objects and DTOs is critical. The boundary typically exists at these points:
123456789101112131415161718192021222324252627282930313233
┌─────────────────────────────────────────────────────────────────┐│ EXTERNAL CLIENTS ││ (Mobile Apps, Web Clients, Partner Systems) │└───────────────────────────────┬─────────────────────────────────┘ │ │ ◄── BOUNDARY 1: API Controller │ DTOs enter/exit here ▼┌─────────────────────────────────────────────────────────────────┐│ APPLICATION LAYER ││ Controllers, API Handlers, Presentation Services ││ ┌───────────────────────────────────────────────────────────┐ ││ │ RequestDTO ──► Mapper ──► Domain Objects │ ││ │ ResponseDTO ◄── Mapper ◄── Domain Objects │ ││ └───────────────────────────────────────────────────────────┘ │└───────────────────────────────┬─────────────────────────────────┘ │ │ Domain Objects only ▼┌─────────────────────────────────────────────────────────────────┐│ DOMAIN LAYER ││ Entities, Value Objects, Domain Services, Repositories ││ (Pure business logic, no DTO awareness) │└───────────────────────────────┬─────────────────────────────────┘ │ │ ◄── BOUNDARY 2: Infrastructure │ Persistence models may differ ▼┌─────────────────────────────────────────────────────────────────┐│ INFRASTRUCTURE LAYER ││ Database, External APIs, Message Queues ││ (May have own DTO-like structures for external communication) │└─────────────────────────────────────────────────────────────────┘Key Principle: Domain objects never cross boundaries.
The domain layer should be completely unaware of:
- How data is presented to API consumers (DTOs)
- How data is persisted in the database (persistence models)
- How data is sent to external systems (integration DTOs)
This isolation keeps your domain model pure and focused on business logic.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
// === PROPER BOUNDARY ENFORCEMENT === // Application layer - handles DTO conversionclass OrderController { constructor( private orderService: OrderService, // Domain service private orderMapper: OrderMapper // DTO mapper ) {} @Post('/orders') async createOrder( @Body() requestDTO: CreateOrderRequestDTO // DTO comes in ): Promise<OrderResponseDTO> { // DTO goes out // Convert DTO to domain objects const command = this.orderMapper.toCreateCommand(requestDTO); // Domain operation returns domain object const order = await this.orderService.createOrder(command); // Convert domain object to DTO before response return this.orderMapper.toResponseDTO(order); } @Get('/orders/:id') async getOrder( @Param('id') id: string ): Promise<OrderResponseDTO> { // Domain operation const order = await this.orderService.findOrder(OrderId.from(id)); if (!order) { throw new NotFoundException(); } // Convert to DTO at the boundary return this.orderMapper.toResponseDTO(order); }} // Domain layer - NO knowledge of DTOsclass OrderService { // Notice: no DTOs in signatures, only domain objects createOrder(command: CreateOrderCommand): Order { // Pure domain logic } findOrder(id: OrderId): Order | null { // Pure domain logic }}The mapper lives at the boundary layer (often called the Application or Presentation layer). It's the translator between the external world's language (DTOs) and the internal world's language (domain objects). In Clean Architecture terms, this is an 'interface adapter'.
Practical Differentiation Guidelines
Here are concrete guidelines for distinguishing domain objects from DTOs in your codebase:
- •Naming Convention — DTOs should have clear suffixes:
*Request,*Response,*DTO,*Payload,*View. Domain objects use pure domain names:Order,Customer,Payment. - •Package/Directory Structure — DTOs live in
api/dto,web/contracts, orpresentationpackages. Domain objects live indomain,core, ormodelpackages. - •Type Definition — DTOs are typically interfaces or simple classes. Domain objects are classes with methods.
- •Field Types — DTOs use primitives and simple strings. Domain objects use value objects (
Money,Email,Address).
1234567891011121314151617181920212223242526272829303132333435363738
// Recommended project structure showing clear separation src/├── api/ // API boundary layer│ ├── controllers/│ │ └── OrderController.ts│ ├── dto/ // DTOs live here│ │ ├── request/│ │ │ ├── CreateOrderRequest.ts│ │ │ └── UpdateOrderRequest.ts│ │ ├── response/│ │ │ ├── OrderResponse.ts│ │ │ ├── OrderSummaryResponse.ts│ │ │ └── OrderDetailResponse.ts│ │ └── common/│ │ ├── AddressDTO.ts│ │ └── MoneyDTO.ts│ └── mappers/│ └── OrderMapper.ts│├── domain/ // Domain layer - pure business logic│ ├── order/│ │ ├── Order.ts // Domain entity (has behavior)│ │ ├── OrderItem.ts│ │ ├── OrderStatus.ts│ │ └── OrderService.ts // Domain service│ ├── customer/│ │ └── Customer.ts│ └── common/│ ├── Money.ts // Value object│ ├── Address.ts // Value object│ └── Email.ts // Value object│└── infrastructure/ // Infrastructure layer ├── persistence/ │ └── OrderRepository.ts └── external/ └── PaymentGateway.tsQuestions to Ask When Designing:
| Question | If Yes → Domain Object | If Yes → DTO |
|---|---|---|
| Does it need methods with business logic? | ✓ | |
| Will it cross a API/network boundary? | ✓ | |
| Does it enforce invariants? | ✓ | |
| Is it designed for serialization? | ✓ | |
| Does it have identity that persists? | ✓ | |
| Is it a snapshot of data at a point in time? | ✓ |
If you're considering exposing any object through an API, create a DTO for it. The initial overhead of a mapper is tiny compared to the flexibility it provides. It's always easier to simplify (remove a DTO that turned out to be unnecessary) than to untangle coupled systems later.
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.