System Design

DDD Tactical Patterns: Aggregates, VOs, Repositories

Explore Domain-Driven Design tactical patterns: Aggregates, Value Objects, and Repositories. Learn to build robust, consistent, and maintainable systems with practical examples.

Khader Vali June 23, 2026 12 min read

Mastering Domain-Driven Design Tactical Patterns: Aggregates, Value Objects, and Repositories

Welcome, fellow engineers! In the complex world of software development, building systems that accurately reflect business needs, remain maintainable over time, and scale efficiently is a perpetual challenge. We’ve all seen projects where the codebase becomes a tangled mess, resisting change and breeding bugs. This is precisely the kind of problem Domain-Driven Design (DDD) was conceived to solve.

At its core, DDD is an approach to software development that emphasizes understanding and modeling the core business domain. It’s about aligning the software structure with the business processes and language. While strategic DDD helps us define the big picture—identifying Bounded Contexts and Ubiquitous Language—tactical DDD provides the building blocks, the patterns we use to construct the domain model itself. These tactical patterns are our primary tools for crafting robust, expressive, and maintainable domain logic.

In this comprehensive article, we’re going to dive deep into three fundamental tactical patterns that form the backbone of many DDD-inspired architectures: Value Objects, Aggregates, and Repositories. We’ll explore their definitions, characteristics, benefits, and practical applications through code examples and real-world scenarios. My goal is to equip you with a solid understanding of how these patterns work together to manage complexity and enforce consistency in your domain models.

By the end of this journey, you should feel confident in identifying when and how to apply these patterns, transforming your domain logic from a fragile structure into a resilient and expressive powerhouse.

Understanding the Building Blocks: Entities vs. Value Objects

Before we dissect Aggregates, we need to firmly grasp the distinction between two fundamental types of domain objects: Entities and Value Objects. This distinction is crucial for building a correct and effective domain model.

Entities: Objects with Identity and Lifecycle

In DDD, an Entity is an object that is defined by its identity, rather than its attributes. Even if all its attributes change, it’s still the “same” entity. Entities have a distinct lifecycle and can be mutated over time. Think of a Customer, an Order, or a Product. Each of these has a unique identifier (e.g., CustomerId, OrderId, ProductId) that distinguishes it from all other objects, even if their attributes temporarily match.

Key characteristics of Entities:

  • Unique Identity: Each entity has a unique identifier that persists throughout its lifecycle.
  • Mutability: An entity’s attributes can change over time.
  • Lifecycle: Entities are created, exist, and are eventually removed from the system.
  • Behavior: Entities encapsulate behavior related to their identity and state.

Here’s a simple example of an Entity:


public class Product
{
    public Guid Id { get; private set; }
    public string Name { get; private set; }
    public string Description { get; private set; }
    public decimal Price { get; private set; }
    public int StockQuantity { get; private set; }

    // Private constructor for ORM or factory method
    private Product() { } 

    public Product(Guid id, string name, string description, decimal price, int stockQuantity)
    {
        if (id == Guid.Empty) throw new ArgumentException("Product ID cannot be empty.", nameof(id));
        if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Product name cannot be empty.", nameof(name));
        if (price <= 0) throw new ArgumentOutOfRangeException(nameof(price), "Product price must be positive.");
        if (stockQuantity < 0) throw new ArgumentOutOfRangeException(nameof(stockQuantity), "Stock quantity cannot be negative.");

        Id = id;
        Name = name;
        Description = description;
        Price = price;
        StockQuantity = stockQuantity;
    }

    public void UpdatePrice(decimal newPrice)
    {
        if (newPrice <= 0) throw new ArgumentOutOfRangeException(nameof(newPrice), "New price must be positive.");
        Price = newPrice;
    }

    public void AdjustStock(int quantityChange)
    {
        // Example: Prevent stock from going negative
        if (StockQuantity + quantityChange < 0)
        {
            throw new InvalidOperationException("Cannot reduce stock below zero.");
        }
        StockQuantity += quantityChange;
    }

    // Equality based on Id
    public override bool Equals(object obj)
    {
        return obj is Product other && Id == other.Id;
    }

    public override int GetHashCode()
    {
        return Id.GetHashCode();
    }
}

In this Product entity, the Id is paramount. Two products with the same name, description, and price are still different products if their Ids differ. The UpdatePrice and AdjustStock methods demonstrate its mutability and encapsulated behavior.

Value Objects: Describing a Characteristic

In contrast to Entities, a Value Object is an immutable object that represents a descriptive aspect of the domain. It is identified by the values of its attributes, not by a unique identity. If two Value Objects have the same attribute values, they are considered equal and interchangeable. Think of a Money amount (e.g., $10.00), an Address (Street, City, Zip), or a DateRange. These objects don't have an ID of their own; their "identity" is derived entirely from their constituent values.

Key characteristics of Value Objects:

  • Immutability: Once created, its attributes cannot be changed. Any "modification" results in a new Value Object instance.
  • Equality by Value: Two Value Objects are equal if all their attributes are equal.
  • No Side Effects: Operations on Value Objects return new Value Objects, never modifying the original.
  • Self-Validating: They often enforce their own invariants upon construction.
  • No Identity: They do not have a unique identifier.

Why are Value Objects so powerful?

  1. Reduced Bugs: Immutability eliminates an entire class of concurrency issues and unexpected side effects.
  2. Increased Expressiveness: Instead of passing around raw strings or decimals, you pass meaningful domain concepts like Money or Address.
  3. Better Domain Modeling: They naturally group related data and behavior.
  4. Easier Testing: Their simple, immutable nature makes them trivial to test.

Let's look at a Money Value Object:


public class Money : IEquatable<Money>
{
    public decimal Amount { get; }
    public string Currency { get; }

    public Money(decimal amount, string currency)
    {
        if (amount < 0)
            throw new ArgumentOutOfRangeException(nameof(amount), "Money amount cannot be negative.");
        if (string.IsNullOrWhiteSpace(currency))
            throw new ArgumentException("Currency cannot be empty.", nameof(currency));
        // A real system would validate currency codes (e.g., "USD", "EUR")
        if (currency.Length != 3)
            throw new ArgumentException("Currency must be a 3-letter ISO code.", nameof(currency));

        Amount = amount;
        Currency = currency.ToUpperInvariant(); // Standardize currency format
    }

    public Money Add(Money other)
    {
        if (Currency != other.Currency)
            throw new InvalidOperationException("Cannot add money with different currencies.");
        return new Money(Amount + other.Amount, Currency);
    }

    public Money Subtract(Money other)
    {
        if (Currency != other.Currency)
            throw new InvalidOperationException("Cannot subtract money with different currencies.");
        return new Money(Amount - other.Amount, Currency);
    }

    public Money Multiply(decimal factor)
    {
        if (factor < 0)
            throw new ArgumentOutOfRangeException(nameof(factor), "Multiplication factor cannot be negative.");
        return new Money(Amount * factor, Currency);
    }

    // Override Equals and GetHashCode for value equality
    public override bool Equals(object obj)
    {
        return Equals(obj as Money);
    }

    public bool Equals(Money other)
    {
        if (other == null) return false;
        if (ReferenceEquals(this, other)) return true;
        return Amount == other.Amount && Currency == other.Currency;
    }

    public override int GetHashCode()
    {
        return HashCode.Combine(Amount, Currency);
    }

    public override string ToString()
    {
        return $"{Amount:F2} {Currency}";
    }

    // Optional: Operator overloading for convenience
    public static bool operator ==(Money left, Money right)
    {
        if (ReferenceEquals(left, null)) return ReferenceEquals(right, null);
        return left.Equals(right);
    }

    public static bool operator !=(Money left, Money right)
    {
        return !(left == right);
    }

    public static Money operator +(Money left, Money right) => left.Add(right);
    public static Money operator -(Money left, Money right) => left.Subtract(right);
    public static Money operator *(Money left, decimal right) => left.Multiply(right);
}

Notice how Money enforces immutability and value equality. When you add two Money objects, a new Money object is returned. This prevents unexpected side effects and makes the code much safer and easier to reason about.

Here’s another common Value Object: Address.


public class Address : IEquatable<Address>
{
    public string Street { get; }
    public string City { get; }
    public string State { get; } // Or Province/Region
    public string ZipCode { get; } // Or PostalCode
    public string Country { get; }

    public Address(string street, string city, string state, string zipCode, string country)
    {
        if (string.IsNullOrWhiteSpace(street)) throw new ArgumentException("Street cannot be empty.", nameof(street));
        if (string.IsNullOrWhiteSpace(city)) throw new ArgumentException("City cannot be empty.", nameof(city));
        if (string.IsNullOrWhiteSpace(zipCode)) throw new ArgumentException("Zip Code cannot be empty.", nameof(zipCode));
        if (string.IsNullOrWhiteSpace(country)) throw new ArgumentException("Country cannot be empty.", nameof(country));

        Street = street;
        City = city;
        State = state;
        ZipCode = zipCode;
        Country = country;
    }

    // For value equality
    public override bool Equals(object obj)
    {
        return Equals(obj as Address);
    }

    public bool Equals(Address other)
    {
        if (other == null) return false;
        if (ReferenceEquals(this, other)) return true;
        return Street == other.Street &&
               City == other.City &&
               State == other.State &&
               ZipCode == other.ZipCode &&
               Country == other.Country;
    }

    public override int GetHashCode()
    {
        return HashCode.Combine(Street, City, State, ZipCode, Country);
    }

    public override string ToString()
    {
        return $"{Street}, {City}, {State} {ZipCode}, {Country}";
    }
}

Value Objects are powerful tools for making your domain model more expressive, robust, and less prone to errors. They are a cornerstone of effective DDD.

DDD Tactical Patterns: Aggregates, VOs, Repositories
Generated Image

Aggregates: Enforcing Consistency Boundaries

While Entities and Value Objects give us the basic building blocks, real-world domains involve complex relationships and invariants—business rules that must always be true. Managing these invariants across a graph of interconnected objects can quickly become overwhelming, leading to inconsistent states and bugs. This is where Aggregates come into play.

What is an Aggregate?

An Aggregate is a cluster of domain objects (Entities and Value Objects) that are treated as a single unit for data changes. It has a single public entry point, called the Aggregate Root, which is an Entity responsible for ensuring the consistency of the entire Aggregate. All operations that modify any object within the Aggregate must go through the Aggregate Root.

Think of an Aggregate as a transactional boundary. All changes within an Aggregate must occur within a single transaction to maintain its invariants. This dramatically simplifies consistency management.

Key Characteristics of Aggregates:

  1. Aggregate Root: The central Entity within the Aggregate. It's the only object that external clients should hold direct references to. The Root has a global identity.
  2. Consistency Boundary: The Aggregate defines a boundary within which all objects must be consistent with each other. Invariants are enforced by the Aggregate Root.
  3. Global vs. Local Identity: Only the Aggregate Root has a global identity (e.g., OrderId). Entities within the Aggregate (non-root entities) have local identities that are only unique within that Aggregate (e.g., OrderItemId is unique within an Order).
  4. Referencing Other Aggregates by ID: Objects outside an Aggregate should only hold references to the Aggregate Root by its global identity (ID). They should never hold direct object references to internal Entities or Value Objects of another Aggregate. This prevents direct manipulation of internal state and ensures that all changes go through the Aggregate Root, preserving invariants.
  5. Transactional Integrity: All modifications within an Aggregate are typically committed in a single transaction. This simplifies persistence and ensures atomicity.

Why Use Aggregates?

  • Stronger Consistency: By centralizing operations through the Aggregate Root, we guarantee that all internal invariants are maintained.
  • Reduced Complexity: Aggregates hide internal structure, presenting a simpler facade to the outside world. This reduces the number of objects clients need to interact with directly.
  • Simplified Transaction Management: Since an Aggregate is a transactional boundary, database transactions become simpler: one transaction per Aggregate change.
  • Improved Performance and Concurrency: Smaller, well-defined Aggregates reduce contention and the scope of locking in concurrent systems.
  • Better Encapsulation: The Aggregate Root acts as a guardian, preventing unauthorized or inconsistent modifications to its internal parts.

Designing Aggregates: Practical Considerations

  • Keep Aggregates Small: This is a crucial guideline. Large Aggregates lead to heavy transaction locks, poor performance, and reduced concurrency. A good heuristic is that an Aggregate should not encompass more than what can reasonably be loaded into memory and modified in a single user interaction or business operation.
  • One Aggregate Root Per Aggregate: No exceptions.
  • Reference Other Aggregates by ID: Always! This is fundamental for maintaining clear boundaries and preventing hidden dependencies. If an Order needs to know about a Customer, it stores the CustomerId, not a direct reference to the Customer object. The Customer entity might live in a separate Aggregate.
  • Enforce Invariants in the Root: Any business rule that spans across multiple objects within the Aggregate must be enforced by the Aggregate Root.

Example: The Order Aggregate

Let's consider an e-commerce system. An Order is a perfect candidate for an Aggregate. It typically consists of:

  • The Order entity itself (the Aggregate Root).
  • A collection of OrderItem entities (internal entities).
  • An Address Value Object for shipping.
  • A CustomerId to link to the customer (referencing another Aggregate by ID).
  • A Money Value Object for the total amount.

Here's how we might model the Order Aggregate:


// OrderItem is an internal Entity within the Order Aggregate
public class OrderItem : IEquatable<OrderItem>
{
    public Guid Id { get; private set; } // Local identity within the Order aggregate
    public Guid ProductId { get; private set; } // Reference to Product Aggregate Root by ID
    public string ProductName { get; private set; } // Denormalized for reporting/snapshot
    public Money Price { get; private set; } // Value Object
    public int Quantity { get; private set; }

    // Private constructor for ORM
    private OrderItem() { }

    internal OrderItem(Guid id, Guid productId, string productName, Money price, int quantity)
    {
        if (id == Guid.Empty) throw new ArgumentException("Order item ID cannot be empty.", nameof(id));
        if (productId == Guid.Empty) throw new ArgumentException("Product ID cannot be empty.", nameof(productId));
        if (string.IsNullOrWhiteSpace(productName)) throw new ArgumentException("Product name cannot be empty.", nameof(productName));
        if (price == null) throw new ArgumentNullException(nameof(price));
        if (quantity <= 0) throw new ArgumentOutOfRangeException(nameof(quantity), "Quantity must be positive.");

        Id = id;
        ProductId = productId;
        ProductName = productName;
        Price = price;
        Quantity = quantity;
    }

    // Behavior specific to OrderItem
    internal void UpdateQuantity(int newQuantity)
    {
        if (newQuantity <= 0) throw new ArgumentOutOfRangeException(nameof(newQuantity), "Quantity must be positive.");
        Quantity = newQuantity;
    }

    internal Money GetLineTotal() => Price.Multiply(Quantity);

    public override bool Equals(object obj) => Equals(obj as OrderItem);
    public bool Equals(OrderItem other)
    {
        if (other == null) return false;
        if (ReferenceEquals(this, other)) return true;
        return Id == other.Id; // Equality by local identity
    }
    public override int GetHashCode() => Id.GetHashCode();
}

// Order is the Aggregate Root
public class Order
{
    public Guid Id { get; private set; } // Global identity
    public Guid CustomerId { get; private set; } // Reference to Customer Aggregate by ID
    public DateTime OrderDate { get; private set; }
    public OrderStatus Status { get; private set; }
    public Address ShippingAddress { get; private set; } // Value Object

    private readonly List<OrderItem> _items;
    public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();

    public Money TotalAmount
    {
        get
        {
            if (!_items.Any()) return new Money(0, "USD"); // Or the system's default currency
            return _items.Aggregate(new Money(0, _items.First().Price.Currency), 
                                     (currentTotal, item) => currentTotal.Add(item.GetLineTotal()));
        }
    }

    // OrderStatus enum for clarity
    public enum OrderStatus
    {
        Pending,
        Processing,
        Shipped,
        Delivered,
        Cancelled
    }

    // Private constructor for ORM
    private Order()
    {
        _items = new List<OrderItem>();
    }

    public Order(Guid id, Guid customerId, Address shippingAddress)
    {
        if (id == Guid.Empty) throw new ArgumentException("Order ID cannot be empty.", nameof(id));
        if (customerId == Guid.Empty) throw new ArgumentException("Customer ID cannot be empty.", nameof(customerId));
        if (shippingAddress == null) throw new ArgumentNullException(nameof(shippingAddress));

        Id = id;
        CustomerId = customerId;
        OrderDate = DateTime.UtcNow;
        Status = OrderStatus.Pending;
        ShippingAddress = shippingAddress;
        _items = new List<OrderItem>();
    }

    // Behavior that modifies the aggregate and enforces invariants
    public void AddItem(Guid productId, string productName, Money price, int quantity)
    {
        if (Status != OrderStatus.Pending)
        {
            throw new InvalidOperationException("Cannot add items to an order that is not pending.");
        }
        if (_items.Any(item => item.ProductId == productId))
        {
            // If item already exists, update quantity instead of adding new
            var existingItem = _items.First(item => item.ProductId == productId);
            existingItem.UpdateQuantity(existingItem.Quantity + quantity);
        }
        else
        {
            _items.Add(new OrderItem(Guid.NewGuid(), productId, productName, price, quantity));
        }
    }

    public void RemoveItem(Guid itemId)
    {
        if (Status != OrderStatus.Pending)
        {
            throw new InvalidOperationException("Cannot remove items from an order that is not pending.");
        }
        var itemToRemove = _items.FirstOrDefault(item => item.Id == itemId);
        if (itemToRemove == null)
        {
            throw new ArgumentException($"Order item with ID {itemId} not found.", nameof(itemId));
        }
        _items.Remove(itemToRemove);
    }

    public void UpdateItemQuantity(Guid itemId, int newQuantity)
    {
        if (Status != OrderStatus.Pending)
        {
            throw new InvalidOperationException("Cannot update items in an order that is not pending.");
        }
        var itemToUpdate = _items.FirstOrDefault(item => item.Id == itemId);
        if (itemToUpdate == null)
        {
            throw new ArgumentException($"Order item with ID {itemId} not found.", nameof(itemId));
        }
        itemToUpdate.UpdateQuantity(newQuantity);
        if (itemToUpdate.Quantity == 0) // If quantity becomes 0, remove the item
        {
            _items.Remove(itemToUpdate);
        }
    }

    public void PlaceOrder()
    {
        if (Status != OrderStatus.Pending)
        {
            throw new InvalidOperationException("Order cannot be placed if not in pending status.");
        }
        if (!_items.Any())
        {
            throw new InvalidOperationException("Cannot place an empty order.");
        }
        Status = OrderStatus.Processing;
        // Further business logic, e.g., send notifications, integrate with payment gateway
    }

    public void ShipOrder()
    {
        if (Status != OrderStatus.Processing)
        {
            throw new InvalidOperationException("Order can only be shipped if in processing status.");
        }
        Status = OrderStatus.Shipped;
    }

    public void DeliverOrder()
    {
        if (Status != OrderStatus.Shipped)
        {
            throw new InvalidOperationException("Order can only be delivered if in shipped status.");
        }
        Status = OrderStatus.Delivered;
    }

    public void CancelOrder()
    {
        if (Status == OrderStatus.Shipped || Status == OrderStatus.Delivered)
        {
            throw new InvalidOperationException("Cannot cancel an order that has been shipped or delivered.");
        }
        Status = OrderStatus.Cancelled;
        // Further business logic, e.g., refund payment
    }

    public override bool Equals(object obj)
    {
        return obj is Order other && Id == other.Id;
    }

    public override int GetHashCode()
    {
        return Id.GetHashCode();
    }
}

Diagram of the Order Aggregate (in words):


+-------------------+
|   Order (ROOT)    |
|   - Id (Global)   |
|   - CustomerId    |-----> (References Customer Aggregate by ID)
|   - OrderDate     |
|   - Status        |
|   - ShippingAddress |
|       (Value Object) |
+-------------------+
        |
        |  Contains (1-to-Many)
        V
+-------------------+
|    OrderItem      |
|    - Id (Local)   |
|    - ProductId    |-----> (References Product Aggregate by ID)
|    - ProductName  |
|    - Price (VO)   |
|    - Quantity     |
+-------------------+

Notice how all interactions with the Order's internal state (like adding or removing items) go through the Order Aggregate Root. The Order root also enforces invariants, such as preventing items from being added to an order that is no longer pending. The CustomerId and ProductId are stored as IDs, not direct object references, respecting the Aggregate boundary rule. This design ensures that the Order Aggregate remains consistent and valid throughout its lifecycle.

DDD Tactical Patterns: Aggregates, VOs, Repositories
Generated Image

Repositories: Abstracting Persistence

Now that we have our richly modeled Aggregates, how do we get them into and out of storage? Directly interacting with databases (SQL, NoSQL, etc.) within our domain model would violate the Separation of Concerns and tie our domain to infrastructure details. This is where the Repository pattern shines.

Written by

Khader Vali

Senior Software Engineer specializing in cloud architecture, real-time systems, and enterprise-scale applications.

Share this article

Related Articles

Fault-Tolerant Systems: Circuit Breakers, Retries & Bulkheads

Jun 27, 2026 · 17 min read

Build Feature Flag Systems for Continuous Delivery

Jun 22, 2026 · 15 min read

Database Sharding Strategies for High Scale Apps

Jun 19, 2026 · 18 min read