What is the difference between Domain Models and Data Transfer Objects (DTOs)?
Quick Answer
Domain Models encapsulate business state and behavior and live in the domain layer, enforcing invariants. DTOs are simple, behavior-less data containers used to transfer data across boundaries (API requests/responses, between layers), shaped for the consumer rather than the domain. Keeping them separate prevents leaking internal model details, decouples API contracts from the domain, and avoids over/under-posting — usually mapped with a mapper or manually.
Detailed Answer
Domain Models and Data Transfer Objects (DTOs) serve different purposes in a software system. Understanding their differences is crucial for maintaining clean architecture and proper separation of concerns.
Key Differences:
| Aspect | Domain Models | DTOs |
|---|---|---|
| Purpose | Represent business concepts and rules | Transfer data between layers |
| Business Logic | Contains business logic and rules | No business logic |
| Validation | Domain validation and invariants | Data format validation |
| Lifecycle | Long-lived, persistent | Short-lived, transient |
| Coupling | Tightly coupled to business domain | Loosely coupled, generic |
| Immutability | Can be mutable (entities) or immutable (value objects) | Usually immutable |
Domain Models:
Domain models represent business concepts and contain business logic. They are the heart of the domain-driven design.
// Domain Model - Order Entity
public class Order : AggregateRoot<OrderId>
{
public OrderId Id { get; private set; }
public CustomerId CustomerId { get; private set; }
public OrderStatus Status { get; private set; }
public Money Total { get; private set; }
public DateTime CreatedAt { get; private set; }
private readonly List<OrderItem> _items = new();
public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();
public Order(OrderId id, CustomerId customerId)
{
Id = id;
CustomerId = customerId;
Status = OrderStatus.Draft;
Total = new Money(0, "USD");
CreatedAt = DateTime.UtcNow;
}
// Business logic
public void AddItem(ProductId productId, Quantity quantity, Money unitPrice)
{
if (Status != OrderStatus.Draft)
throw new InvalidOperationException("Cannot add items to a confirmed order");
if (quantity.Value <= 0)
throw new ArgumentException("Quantity must be positive");
var existingItem = _items.FirstOrDefault(i => i.ProductId == productId);
if (existingItem != null)
{
existingItem.IncreaseQuantity(quantity);
}
else
{
_items.Add(new OrderItem(productId, quantity, unitPrice));
}
RecalculateTotal();
}
public void Confirm()
{
if (Status != OrderStatus.Draft)
throw new InvalidOperationException("Order is not in draft status");
if (_items.Count == 0)
throw new InvalidOperationException("Cannot confirm an empty order");
// Business rule: Minimum order amount
if (Total.Amount < 10)
throw new InvalidOperationException("Minimum order amount is $10");
Status = OrderStatus.Confirmed;
AddDomainEvent(new OrderConfirmedEvent(Id, CustomerId, Total));
}
private void RecalculateTotal()
{
Total = _items.Aggregate(new Money(0, "USD"), (sum, item) => sum + item.Total);
}
}
// Domain Model - Value Object
public class Money : ValueObject
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
if (amount < 0) throw new ArgumentException("Amount cannot be negative");
if (string.IsNullOrWhiteSpace(currency))
throw new ArgumentException("Currency is required");
Amount = amount;
Currency = currency.ToUpperInvariant();
}
protected override IEnumerable<object> GetEqualityComponents()
{
yield return Amount;
yield return Currency;
}
public static Money operator +(Money left, Money right)
{
if (left.Currency != right.Currency)
throw new InvalidOperationException("Cannot add different currencies");
return new Money(left.Amount + right.Amount, left.Currency);
}
}
DTOs (Data Transfer Objects):
DTOs are simple objects used to transfer data between different layers of the application.
// DTO for creating an order
public class CreateOrderDto
{
public int CustomerId { get; set; }
public List<OrderItemDto> Items { get; set; }
}
public class OrderItemDto
{
public int ProductId { get; set; }
public int Quantity { get; set; }
public decimal UnitPrice { get; set; }
}
// DTO for order response
public class OrderDto
{
public int Id { get; set; }
public int CustomerId { get; set; }
public string Status { get; set; }
public decimal Total { get; set; }
public string Currency { get; set; }
public DateTime CreatedAt { get; set; }
public List<OrderItemDto> Items { get; set; }
}
// DTO for order summary
public class OrderSummaryDto
{
public int Id { get; set; }
public int CustomerId { get; set; }
public string Status { get; set; }
public decimal Total { get; set; }
public DateTime CreatedAt { get; set; }
}
Mapping Between Domain Models and DTOs:
public class OrderMapper
{
public static OrderDto ToDto(Order order)
{
return new OrderDto
{
Id = order.Id.Value,
CustomerId = order.CustomerId.Value,
Status = order.Status.ToString(),
Total = order.Total.Amount,
Currency = order.Total.Currency,
CreatedAt = order.CreatedAt,
Items = order.Items.Select(ToItemDto).ToList()
};
}
public static OrderItemDto ToItemDto(OrderItem item)
{
return new OrderItemDto
{
ProductId = item.ProductId.Value,
Quantity = item.Quantity.Value,
UnitPrice = item.UnitPrice.Amount
};
}
public static Order ToDomain(CreateOrderDto dto)
{
var order = new Order(new OrderId(dto.CustomerId), new CustomerId(dto.CustomerId));
foreach (var itemDto in dto.Items)
{
order.AddItem(
new ProductId(itemDto.ProductId),
new Quantity(itemDto.Quantity),
new Money(itemDto.UnitPrice, "USD")
);
}
return order;
}
}
Usage in Application Layer:
public class OrderApplicationService
{
private readonly IOrderRepository _orderRepository;
private readonly OrderMapper _mapper;
public async Task<OrderDto> CreateOrderAsync(CreateOrderDto createOrderDto)
{
// Convert DTO to domain model
var order = OrderMapper.ToDomain(createOrderDto);
// Business logic is handled by the domain model
order.Confirm();
// Save domain model
await _orderRepository.SaveAsync(order);
// Convert back to DTO for response
return OrderMapper.ToDto(order);
}
public async Task<OrderDto> GetOrderAsync(int orderId)
{
var order = await _orderRepository.GetByIdAsync(new OrderId(orderId));
if (order == null)
throw new OrderNotFoundException(orderId);
return OrderMapper.ToDto(order);
}
public async Task<List<OrderSummaryDto>> GetOrderSummariesAsync(int customerId)
{
var orders = await _orderRepository.GetByCustomerIdAsync(new CustomerId(customerId));
return orders.Select(order => new OrderSummaryDto
{
Id = order.Id.Value,
CustomerId = order.CustomerId.Value,
Status = order.Status.ToString(),
Total = order.Total.Amount,
CreatedAt = order.CreatedAt
}).ToList();
}
}
API Controller Usage:
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
private readonly OrderApplicationService _orderService;
[HttpPost]
public async Task<ActionResult<OrderDto>> CreateOrder([FromBody] CreateOrderDto createOrderDto)
{
try
{
var order = await _orderService.CreateOrderAsync(createOrderDto);
return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order);
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
[HttpGet("{id}")]
public async Task<ActionResult<OrderDto>> GetOrder(int id)
{
try
{
var order = await _orderService.GetOrderAsync(id);
return Ok(order);
}
catch (OrderNotFoundException)
{
return NotFound();
}
}
[HttpGet("customer/{customerId}")]
public async Task<ActionResult<List<OrderSummaryDto>>> GetCustomerOrders(int customerId)
{
var orders = await _orderService.GetOrderSummariesAsync(customerId);
return Ok(orders);
}
}
Validation Differences:
// Domain Model Validation (Business Rules)
public class Order : AggregateRoot<OrderId>
{
public void Confirm()
{
// Business rule validation
if (Status != OrderStatus.Draft)
throw new InvalidOperationException("Order is not in draft status");
if (_items.Count == 0)
throw new InvalidOperationException("Cannot confirm an empty order");
if (Total.Amount < 10)
throw new InvalidOperationException("Minimum order amount is $10");
Status = OrderStatus.Confirmed;
}
}
// DTO Validation (Data Format)
public class CreateOrderDto
{
[Required]
[Range(1, int.MaxValue, ErrorMessage = "Customer ID must be positive")]
public int CustomerId { get; set; }
[Required]
[MinLength(1, ErrorMessage = "Order must have at least one item")]
public List<OrderItemDto> Items { get; set; }
}
public class OrderItemDto
{
[Required]
[Range(1, int.MaxValue, ErrorMessage = "Product ID must be positive")]
public int ProductId { get; set; }
[Required]
[Range(1, int.MaxValue, ErrorMessage = "Quantity must be positive")]
public int Quantity { get; set; }
[Required]
[Range(0.01, double.MaxValue, ErrorMessage = "Unit price must be positive")]
public decimal UnitPrice { get; set; }
}
When to Use Each:
Use Domain Models when:
- Representing business concepts
- Implementing business logic and rules
- Maintaining business invariants
- Modeling the core domain
Use DTOs when:
- Transferring data between layers
- Exposing data through APIs
- Serializing/deserializing data
- Reducing coupling between layers
Common Mistakes:
// ❌ BAD: DTO with business logic
public class OrderDto
{
public int Id { get; set; }
public string Status { get; set; }
public decimal Total { get; set; }
public void Confirm() // Business logic in DTO
{
if (Status != "Draft")
throw new InvalidOperationException("Cannot confirm non-draft order");
Status = "Confirmed";
}
}
// ❌ BAD: Domain model used as DTO
public class Order : AggregateRoot<OrderId>
{
public int Id { get; set; } // Should be OrderId
public int CustomerId { get; set; } // Should be CustomerId
public string Status { get; set; } // Should be OrderStatus enum
public decimal Total { get; set; } // Should be Money value object
}
// ✅ GOOD: Proper separation
public class Order : AggregateRoot<OrderId>
{
public OrderId Id { get; private set; }
public CustomerId CustomerId { get; private set; }
public OrderStatus Status { get; private set; }
public Money Total { get; private set; }
public void Confirm() { /* Business logic */ }
}
public class OrderDto
{
public int Id { get; set; }
public int CustomerId { get; set; }
public string Status { get; set; }
public decimal Total { get; set; }
// No business logic
}
Key Takeaways:
- Domain Models contain business logic and represent business concepts
- DTOs are simple data containers for transferring data between layers
- Domain Models are long-lived and persistent
- DTOs are short-lived and transient
- Use mapping to convert between domain models and DTOs
- Keep them separate to maintain clean architecture
- Domain Models enforce business rules, DTOs handle data format validation