DDD in C#: Entities, Value Objects, and Aggregates

This article provides a practical guide to implementing the core tactical patterns of Domain-Driven Design (DDD)—Value Objects, Entities, and Aggregate Roots—using modern C#. It covers the architectural role of each concept, the design constraints required to preserve domain integrity, and concrete C# code examples leveraging modern language features like records, strongly-typed identifiers, and encapsulated collections.


Implementing Value Objects

A Value Object is an immutable conceptual whole defined entirely by its attributes rather than a unique identifier. Two value objects with identical properties are considered equal.

In modern C#, the simplest and most effective way to implement a Value Object is by using positional record types. Records automatically provide structural equality, immutability, and concise syntax.

public record Money
{
    public decimal Amount { get; }
    public string Currency { get; }

    public Money(decimal amount, string currency)
    {
        if (amount < 0)
            throw new ArgumentException("Amount cannot be negative.", nameof(amount));

        if (string.IsNullOrWhiteSpace(currency) || currency.Length != 3)
            throw new ArgumentException("Currency must be a valid 3-letter ISO code.", nameof(currency));

        Amount = amount;
        Currency = currency.ToUpperInvariant();
    }

    public Money Add(Money other)
    {
        if (Currency != other.Currency)
            throw exciting InvalidOperationException("Cannot add amounts with different currencies.");

        return new Money(Amount + other.Amount, Currency);
    }
}

Key rules for Value Objects:

  • Immutability: State cannot change after instantiation. Operations return a new instance instead of modifying the existing one.
  • Self-Validation: The constructor must enforce all invariant rules.
  • Value Equality: Equality is determined by comparing property values, not memory references.

Implementing Entities

An Entity represents an individual domain concept defined by a distinct identity that persists through state changes over time. Two entities with identical attributes are distinct if their identifiers differ.

To implement entities cleanly, define a generic abstract base class that encapsulates identifier equality logic:

public abstract class Entity<TId> where TId : notnull
{
    public TId Id { get; protected init; }

    protected Entity(TId id)
    {
        Id = id;
    }

    public override bool Equals(object? obj)
    {
        if (obj is not Entity<TId> other) return false;
        if (ReferenceEquals(this, other)) return true;
        return EqualityComparer<TId>.Default.Equals(Id, other.Id);
    }

    public override int GetHashCode() => EqualityComparer<TId>.Default.GetHashCode(Id);

    public static bool operator ==(Entity<TId>? left, Entity<TId>? right) => Equals(left, right);
    public static bool operator !=(Entity<TId>? left, Entity<TId>? right) => !Equals(left, right);
}

Using strongly-typed IDs prevents primitive obsession and assignment bugs:

public readonly record struct OrderItemId(Guid Value);

public class OrderItem : Entity<OrderItemId>
{
    public string ProductName { get; private set; }
    public Money UnitPrice { get; private set; }
    public int Quantity { get; private set; }

    internal OrderItem(OrderItemId id, string productName, Money unitPrice, int quantity) 
        : base(id)
    {
        if (quantity <= 0)
            throw new ArgumentOutOfRangeException(nameof(quantity), "Quantity must be greater than zero.");

        ProductName = productName;
        UnitPrice = unitPrice;
        Quantity = quantity;
    }

    internal void UpdateQuantity(int newQuantity)
    {
        if (newQuantity <= 0)
            throw new ArgumentOutOfRangeException(nameof(newQuantity), "Quantity must be greater than zero.");

        Quantity = newQuantity;
    }
}

Key rules for Entities:

  • Identity-Based Equality: Comparison is based solely on the Id property.
  • Encapsulated Mutation: Properties have private or protected setters. Mutating state must occur via explicit domain methods that enforce invariants.

Implementing Aggregates and Aggregate Roots

An Aggregate is a cluster of associated Entities and Value Objects treated as a single transactional boundary. The Aggregate Root is the primary Entity through which all external interactions must pass. External objects are prohibited from directly modifying the internal members of an Aggregate.

An aggregate root typically emits domain events to notify other parts of the system when significant state changes occur.

public readonly record struct OrderId(Guid Value);

public interface IAggregateRoot { }

public class Order : Entity<OrderId>, IAggregateRoot
{
    private readonly List<OrderItem> _items = new();

    public CustomerId CustomerId { get; private set; }
    public OrderStatus Status { get; private set; }
    public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();

    public Order(OrderId id, CustomerId customerId) : base(id)
    {
        CustomerId = customerId;
        Status = OrderStatus.Draft;
    }

    public void AddItem(string productName, Money unitPrice, int quantity)
    {
        if (Status != OrderStatus.Draft)
            throw new InvalidOperationException("Cannot add items to a finalized order.");

        var existingItem = _items.FirstOrDefault(i => i.ProductName == productName);

        if (existingItem != null)
        {
            existingItem.UpdateQuantity(existingItem.Quantity + quantity);
        }
        else
        {
            _items.Add(new OrderItem(new OrderItemId(Guid.NewGuid()), productName, unitPrice, quantity));
        }
    }

    public void Submit()
    {
        if (!_items.Any())
            throw new InvalidOperationException("Cannot submit an empty order.");

        Status = OrderStatus.Submitted;
    }
}

public enum OrderStatus
{
    Draft,
    Submitted,
    Cancelled
}

Key rules for Aggregates:

  • Single Entry Point: External consumers can only load, reference, and invoke methods on the Aggregate Root.
  • Encapsulated Collections: Expose internal collections via read-only abstractions (such as IReadOnlyCollection<T>) to prevent direct list manipulation.
  • Transactional Consistency: Any business invariant spanning multiple entities inside the aggregate must be satisfied within a single transaction managed by the root.
  • Reference by Identity: Aggregates should reference other Aggregates by their unique identifiers, not by direct object reference.