Loading content...
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:
getShippingCost() appears in the JSONThis 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.
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.
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 _internalNotes are 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 _customer field 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.
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, and internalNotes are nowhere to be seen.
Pre-Computed Values: Instead of a calculateTotal() method, we have totalCents already 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 Date objects. Money is cents as integers, not Money value objects with potential precision issues.
Derived Convenience Fields: isEditable and isCancellable are 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.
Many teams, under time pressure, choose to expose domain objects directly. This decision creates a cascade of problems that grow over time:
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.
One of the most important distinctions between domain objects and DTOs is their different lifecycles and reasons for change:
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.
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:
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'.
Here are concrete guidelines for distinguishing domain objects from DTOs in your codebase:
*Request, *Response, *DTO, *Payload, *View. Domain objects use pure domain names: Order, Customer, Payment.api/dto, web/contracts, or presentation packages. Domain objects live in domain, core, or model packages.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.
The distinction between domain objects and DTOs is not pedantic—it's a critical architectural concern. Let's consolidate:
What's next:
Now that we understand the distinction between domain objects and DTOs, we need to address a practical challenge: how do we efficiently convert between them? The next page explores DTO mapping strategies—from manual mapping to automated solutions—with their trade-offs and best practices.
You now understand the fundamental differences between DTOs and domain objects, the risks of conflating them, and how to maintain clear boundaries. Next, we'll dive into the practical mechanics of mapping between these two object types.