What are the main building blocks of DDD (Entities, Value Objects, Aggregates, Domain Services)?

8 minadvancedDDDentitiesvalue-objectsaggregates

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 BlockIdentityMutabilityLifecycleResponsibility
EntityHas identityMutableLong-livedBusiness logic with identity
Value ObjectNo identityImmutableShort-livedEncapsulate values
AggregateHas identityMutableLong-livedConsistency boundary
Domain ServiceNo identityStatelessPer operationCross-cutting business logic

Best Practices:

  1. Entities: Focus on identity and business rules
  2. Value Objects: Make them immutable and comparable
  3. Aggregates: Keep them small and focused
  4. Domain Services: Use sparingly, prefer methods on entities
  5. Consistency: Maintain invariants within aggregates
  6. 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.