What are the main building blocks of DDD (Entities, Value Objects, Aggregates, Domain Services)?
Quick Answer
DDD's tactical building blocks model the domain: Entities have a distinct identity that persists over time; Value Objects are immutable and defined by their attributes (no identity); Aggregates are clusters of entities/value objects with a single root that enforces invariants and is the consistency boundary; and Domain Services hold domain logic that doesn't fit a single entity. Together they create a rich, behavior-focused model.
Detailed Answer
Domain-Driven Design provides several tactical patterns as building blocks to model complex business domains effectively. These patterns help create a rich domain model that accurately represents business concepts.
1. Entities
Entities are objects with a distinct identity that persists over time. Their identity is more important than their attributes.
public abstract class Entity<TId> where TId : ValueObject
{
public TId Id { get; protected set; }
protected Entity(TId id)
{
Id = id ?? throw new ArgumentNullException(nameof(id));
}
public override bool Equals(object obj)
{
if (obj is not Entity<TId> other) return false;
if (ReferenceEquals(this, other)) return true;
return Id.Equals(other.Id);
}
public override int GetHashCode() => Id.GetHashCode();
}
public class Customer : Entity<CustomerId>
{
public CustomerName Name { get; private set; }
public Email Email { get; private set; }
public CustomerStatus Status { get; private set; }
private readonly List<OrderId> _orderIds = new();
public IReadOnlyList<OrderId> OrderIds => _orderIds.AsReadOnly();
public Customer(CustomerId id, CustomerName name, Email email) : base(id)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
Email = email ?? throw new ArgumentNullException(nameof(email));
Status = CustomerStatus.Active;
}
public void UpdateEmail(Email newEmail)
{
if (Status == CustomerStatus.Closed)
throw new InvalidOperationException("Cannot update email for closed customer");
Email = newEmail;
}
public void Close()
{
if (Status == CustomerStatus.Closed)
throw new InvalidOperationException("Customer is already closed");
Status = CustomerStatus.Closed;
}
public void AddOrder(OrderId orderId)
{
if (!_orderIds.Contains(orderId))
_orderIds.Add(orderId);
}
}
2. Value Objects
Value objects are defined by their attributes rather than identity. They are immutable and compared by value.
public abstract class ValueObject
{
protected abstract IEnumerable<object> GetEqualityComponents();
public override bool Equals(object obj)
{
if (obj == null || obj.GetType() != GetType()) return false;
var other = (ValueObject)obj;
return GetEqualityComponents().SequenceEqual(other.GetEqualityComponents());
}
public override int GetHashCode()
{
return GetEqualityComponents()
.Select(x => x?.GetHashCode() ?? 0)
.Aggregate((x, y) => x ^ y);
}
}
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);
}
public static Money operator -(Money left, Money right)
{
if (left.Currency != right.Currency)
throw new InvalidOperationException("Cannot subtract different currencies");
return new Money(left.Amount - right.Amount, left.Currency);
}
public static Money operator *(Money money, decimal multiplier)
{
return new Money(money.Amount * multiplier, money.Currency);
}
}
public class Address : ValueObject
{
public string Street { get; }
public string City { get; }
public string State { get; }
public string ZipCode { get; }
public string Country { get; }
public Address(string street, string city, string state, string zipCode, string country)
{
Street = street ?? throw new ArgumentNullException(nameof(street));
City = city ?? throw new ArgumentNullException(nameof(city));
State = state ?? throw new ArgumentNullException(nameof(state));
ZipCode = zipCode ?? throw new ArgumentNullException(nameof(zipCode));
Country = country ?? throw new ArgumentNullException(nameof(country));
}
protected override IEnumerable<object> GetEqualityComponents()
{
yield return Street;
yield return City;
yield return State;
yield return ZipCode;
yield return Country;
}
}
3. Aggregates
Aggregates are clusters of related objects treated as a unit for data changes. They have one aggregate root that controls access.
public abstract class AggregateRoot<TId> : Entity<TId> where TId : ValueObject
{
private readonly List<DomainEvent> _domainEvents = new();
public IReadOnlyList<DomainEvent> DomainEvents => _domainEvents.AsReadOnly();
protected AggregateRoot(TId id) : base(id) { }
protected void AddDomainEvent(DomainEvent domainEvent)
{
_domainEvents.Add(domainEvent);
}
public void ClearDomainEvents()
{
_domainEvents.Clear();
}
}
public class Order : AggregateRoot<OrderId>
{
public CustomerId CustomerId { get; private set; }
public OrderStatus Status { get; private set; }
public DateTime CreatedAt { get; private set; }
public Money Total { get; private set; }
private readonly List<OrderItem> _items = new();
public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();
public Order(OrderId id, CustomerId customerId) : base(id)
{
CustomerId = customerId ?? throw new ArgumentNullException(nameof(customerId));
Status = OrderStatus.Draft;
CreatedAt = DateTime.UtcNow;
Total = new Money(0, "USD");
}
public void AddItem(ProductId productId, Quantity quantity, Money unitPrice)
{
if (Status != OrderStatus.Draft)
throw new InvalidOperationException("Cannot add items to a confirmed order");
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 RemoveItem(ProductId productId)
{
if (Status != OrderStatus.Draft)
throw new InvalidOperationException("Cannot remove items from a confirmed order");
var item = _items.FirstOrDefault(i => i.ProductId == productId);
if (item != null)
{
_items.Remove(item);
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");
Status = OrderStatus.Confirmed;
AddDomainEvent(new OrderConfirmedEvent(Id, CustomerId, Total));
}
public void Cancel()
{
if (Status == OrderStatus.Shipped)
throw new InvalidOperationException("Cannot cancel a shipped order");
Status = OrderStatus.Cancelled;
AddDomainEvent(new OrderCancelledEvent(Id, CustomerId));
}
private void RecalculateTotal()
{
Total = _items.Aggregate(new Money(0, "USD"), (sum, item) => sum + item.Total);
}
}
public class OrderItem : Entity<OrderItemId>
{
public ProductId ProductId { get; private set; }
public Quantity Quantity { get; private set; }
public Money UnitPrice { get; private set; }
public Money Total => UnitPrice * Quantity.Value;
public OrderItem(ProductId productId, Quantity quantity, Money unitPrice)
: base(new OrderItemId(Guid.NewGuid()))
{
ProductId = productId ?? throw new ArgumentNullException(nameof(productId));
Quantity = quantity ?? throw new ArgumentNullException(nameof(quantity));
UnitPrice = unitPrice ?? throw new ArgumentNullException(nameof(unitPrice));
}
public void IncreaseQuantity(Quantity additionalQuantity)
{
Quantity = new Quantity(Quantity.Value + additionalQuantity.Value);
}
public void UpdateQuantity(Quantity newQuantity)
{
Quantity = newQuantity ?? throw new ArgumentNullException(nameof(newQuantity));
}
}
4. Domain Services
Domain services contain business logic that doesn't naturally belong to entities or value objects.
public interface IDomainService
{
}
public class OrderPricingService : IDomainService
{
private readonly ICustomerRepository _customerRepository;
private readonly IProductRepository _productRepository;
public OrderPricingService(ICustomerRepository customerRepository, IProductRepository productRepository)
{
_customerRepository = customerRepository;
_productRepository = productRepository;
}
public Money CalculateOrderTotal(Order order)
{
var baseTotal = order.Items.Sum(item => item.Total);
var customer = _customerRepository.GetById(order.CustomerId);
// Apply customer-specific discount
var discountPercentage = GetCustomerDiscountPercentage(customer);
var discountAmount = baseTotal * (discountPercentage / 100);
// Apply bulk order discount
var bulkDiscount = CalculateBulkDiscount(order.Items.Count());
var bulkDiscountAmount = baseTotal * (bulkDiscount / 100);
var totalDiscount = discountAmount + bulkDiscountAmount;
return baseTotal - totalDiscount;
}
private decimal GetCustomerDiscountPercentage(Customer customer)
{
return customer.Status switch
{
CustomerStatus.VIP => 15m,
CustomerStatus.Premium => 10m,
CustomerStatus.Regular => 5m,
_ => 0m
};
}
private decimal CalculateBulkDiscount(int itemCount)
{
return itemCount switch
{
>= 20 => 10m,
>= 10 => 5m,
_ => 0m
};
}
}
public class OrderValidationService : IDomainService
{
public ValidationResult ValidateOrder(Order order, Customer customer)
{
var errors = new List<string>();
// Check customer status
if (customer.Status == CustomerStatus.Suspended)
errors.Add("Customer account is suspended");
// Check order limits
if (order.Total.Amount > customer.CreditLimit)
errors.Add("Order total exceeds customer credit limit");
// Check product availability
foreach (var item in order.Items)
{
if (!IsProductAvailable(item.ProductId, item.Quantity))
errors.Add($"Product {item.ProductId} is not available in requested quantity");
}
return new ValidationResult(errors);
}
private bool IsProductAvailable(ProductId productId, Quantity quantity)
{
// Implementation would check inventory
return true; // Simplified for example
}
}
public class ValidationResult
{
public bool IsValid => !Errors.Any();
public List<string> Errors { get; }
public ValidationResult(List<string> errors)
{
Errors = errors ?? new List<string>();
}
}
Key Characteristics:
| Building Block | Identity | Mutability | Lifecycle | Responsibility |
|---|---|---|---|---|
| Entity | Has identity | Mutable | Long-lived | Business logic with identity |
| Value Object | No identity | Immutable | Short-lived | Encapsulate values |
| Aggregate | Has identity | Mutable | Long-lived | Consistency boundary |
| Domain Service | No identity | Stateless | Per operation | Cross-cutting business logic |
Best Practices:
- Entities: Focus on identity and business rules
- Value Objects: Make them immutable and comparable
- Aggregates: Keep them small and focused
- Domain Services: Use sparingly, prefer methods on entities
- Consistency: Maintain invariants within aggregates
- Encapsulation: Hide internal state and expose behavior
These building blocks work together to create a rich, expressive domain model that accurately represents business concepts and rules.