System Design

Mastering DDD Tactical Patterns: Aggregates, Value Objects, Repositories

Dive deep into Domain-Driven Design's tactical patterns: Aggregates, Value Objects, and Repositories. Learn to build robust, maintainable domain models with practical examples.

Khader Vali September 23, 2026 16 min read

Mastering DDD Tactical Patterns: Aggregates, Value Objects, and Repositories

Welcome, fellow engineers! In the complex world of software development, where business logic often twists and turns like a Gordian knot, we frequently find ourselves grappling with systems that are hard to understand, harder to change, and prone to bugs. This is precisely the challenge Domain-Driven Design (DDD) aims to address. DDD isn’t just a set of tools or frameworks; it’s a software development approach that places the core business domain at the center of the software, emphasizing a deep understanding of the business and a collaborative approach with domain experts.

While DDD encompasses broad strategic patterns like Bounded Contexts and Ubiquitous Language, today we’re going to dive deep into the trenches of implementation – the tactical patterns. Specifically, we’ll dissect three fundamental building blocks: Aggregates, Value Objects, and Repositories. These patterns are the bedrock for constructing robust, maintainable, and highly expressive domain models that truly reflect the business reality.

As a senior engineer, I’ve seen firsthand how adopting these patterns transforms chaotic codebases into elegant, resilient systems. Let’s explore how they work, why they’re essential, and how to wield them effectively in your projects.

The Core Problem: Managing Complexity in the Domain

Before we jump into the solutions, let’s briefly reiterate the problem these patterns solve. Modern business applications are inherently complex. They involve intricate rules, numerous data relationships, and a constant stream of evolving requirements. Without a structured approach, this complexity quickly leads to:

  • Anemic Domain Models: Where business logic resides in service layers, leaving entities as mere data holders, leading to dispersed, hard-to-trace behavior.
  • Inconsistent Data: Where business rules are violated due to scattered logic or uncontrolled direct access to data, leading to a system state that doesn’t reflect reality.
  • Tight Coupling: Where changes in one part of the system have unforeseen ripple effects across others, making maintenance a nightmare.
  • Difficult Testing: Complex, interconnected components are challenging to isolate and test effectively.

The tactical patterns of DDD provide a powerful antidote to these ailments, enabling us to build a domain model that is rich, consistent, and insulated from the complexities of infrastructure.

<

Mastering DDD Tactical Patterns: Aggregates, Value Objects, Repositories
Generated Image

>

Value Objects: The Unseen Heroes of Immutability

Let’s start with Value Objects, often overlooked but incredibly powerful. In DDD, every object in your domain model is either an Entity or a Value Object. The distinction is crucial.

What is a Value Object?

A Value Object is an object that describes a characteristic or attribute of a thing. It has no conceptual identity beyond its attributes. It’s defined by what it *is*, not by *who* it is. Think of a monetary amount, a date range, or an address. If you have two instances of “10 USD” with the same currency and amount, they are considered identical. If you replace one “10 USD” with another, nothing fundamentally changes from a domain perspective; it’s still “10 USD.”

Key characteristics of Value Objects:

  1. Immutable: Once created, its internal state cannot be changed. If you need a modified version, you create a new Value Object instance.

  2. Equality Based on Attributes: Two Value Objects are considered equal if all their attributes are equal, not based on memory reference or a unique ID.

  3. No Side Effects: Operations on Value Objects should return new Value Objects, never modify the original in place.

  4. Self-Validating: A Value Object should enforce its own invariants upon construction, ensuring it’s always in a valid state.

  5. No Conceptual Identity: Unlike Entities, Value Objects don’t have a unique ID that distinguishes them from others of the same type and value.

Why Use Value Objects?

  • Enhanced Domain Expressiveness: Instead of passing around raw strings or integers (e.g., a string streetName, string city, string zipCode), you pass a single Address Value Object. This makes your code more readable and communicative about its intent.

  • Reduced Bugs: Immutability is a powerful defense against accidental state changes and concurrency issues. Since Value Objects are always valid upon creation, you reduce the chances of invalid states propagating through your system.

  • Increased Safety: Encapsulating domain logic (like currency conversion in a Money object or address validation in an Address object) within the Value Object itself means that logic is applied consistently wherever the object is used.

  • Easier Testing: Immutable objects with no side effects are inherently easier to test because their behavior is predictable.

Value Object Code Example

Let’s consider a common scenario: representing money. Instead of a decimal amount and a string currency, we can create 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 ArgumentException("Amount cannot be negative.", nameof(amount));
        if (string.IsNullOrWhiteSpace(currency))
            throw new ArgumentException("Currency cannot be empty.", nameof(currency));
        // A more robust implementation would validate against a list of known currencies
        if (currency.Length != 3 || !currency.All(char.IsUpper))
             throw new ArgumentException("Currency must be a 3-letter uppercase code.", nameof(currency));

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

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

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

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

    // Equality implementation is crucial for Value Objects
    public override bool Equals(object obj)
    {
        return Equals(obj as Money);
    }

    public bool Equals(Money other)
    {
        if (other is 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:N2} {Currency}";
    }

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

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

In this example, Money is immutable. Any arithmetic operation returns a *new* Money object. Its equality is based on its Amount and Currency. This encapsulates validation and business rules directly where they belong.

Another common example is Address:

public class Address : IEquatable<Address>
{
    public string Street { get; }
    public string City { get; }
    public string StateProvince { get; }
    public string PostalCode { get; }
    public string Country { get; }

    public Address(string street, string city, string stateProvince, string postalCode, string country)
    {
        // Basic validation: A real-world scenario would have more robust validation
        if (string.IsNullOrWhiteSpace(street)) throw new ArgumentException("Street is required.", nameof(street));
        if (string.IsNullOrWhiteSpace(city)) throw new ArgumentException("City is required.", nameof(city));
        // ... more validation ...

        Street = street;
        City = city;
        StateProvince = stateProvince;
        PostalCode = postalCode;
        Country = country;
    }

    // Example of a domain method on a Value Object
    public Address WithNewStreet(string newStreet)
    {
        return new Address(newStreet, City, StateProvince, PostalCode, Country);
    }

    // Equality implementation for Address
    public override bool Equals(object obj)
    {
        return Equals(obj as Address);
    }

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

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

By using Address as a Value Object, we ensure that an address is always valid and consistent. If a customer moves, we don’t mutate their existing address; we associate a *new* Address object with their profile.

Entities vs. Value Objects: A Quick Recap

The core difference lies in identity. An Entity *has* an identity that persists over time and across different states. For example, a Customer entity remains the same customer even if their name or address changes. Its identity (e.g., CustomerId) is paramount. Entities are mutable, though their mutations should be controlled by their own methods to maintain invariants.

A Value Object, conversely, *is* its attributes. It has no conceptual identity. If all its attributes are the same, two Value Objects are considered equal. Value Objects are immutable. This distinction profoundly impacts how you model your domain and manage state changes.

Aggregates: Consistency Boundaries and Transactional Integrity

Once you have your Entities and Value Objects, how do you manage their relationships and ensure consistency across a cluster of related objects? Enter Aggregates.

What is an Aggregate?

An Aggregate is a cluster of associated objects (Entities and Value Objects) that are treated as a single unit for data changes. Each Aggregate has a single entry point, known as the Aggregate Root. The Aggregate Root is an Entity that is responsible for maintaining the consistency of the entire Aggregate.

Think of an Aggregate as a transactional boundary. When you load an Aggregate, you load all objects within its boundary. When you save an Aggregate, you save all changes to its internal objects as a single, atomic operation. This ensures that all invariants (business rules that must always be true) within that boundary are maintained.

Key rules for Aggregates:

  1. Only the Aggregate Root can be referenced from outside the Aggregate. Other Entities within the Aggregate (called “internal” or “child” Entities) cannot be directly accessed or modified by external objects. This protects the Aggregate’s invariants.

  2. Objects within the Aggregate can hold references to other Aggregates’ Roots, but only by their unique identity (ID). This prevents accidental modification of external Aggregates and keeps Aggregates loosely coupled.

  3. Deletion of the Aggregate Root implies deletion of all objects within the Aggregate. The Aggregate lives and dies as a single unit.

  4. Changes to an Aggregate occur within a single transaction. This is fundamental to maintaining consistency. You load the root, make changes through its methods, and then save the root, ensuring all internal objects are updated atomically.

  5. Aggregates should be as small as possible. Large Aggregates lead to contention, performance issues, and make it harder to reason about consistency. Aim for Aggregates that encapsulate a few closely related objects and a small set of invariants.

Why Use Aggregates?

  • Strong Consistency: Aggregates guarantee that a set of related objects always remains in a valid state according to business rules. All changes are atomic.

  • Simplified Transaction Management: Since an Aggregate is the unit of consistency, your database transactions align directly with Aggregate boundaries, making transaction management much simpler.

  • Clear Domain Boundaries: Aggregates explicitly define what objects belong together and which rules apply to their collective state. This improves understanding and communication.

  • Reduced Coupling: By only allowing external references to Aggregate Roots by ID, Aggregates promote loose coupling between different parts of your domain model.

Aggregate Code Example: An Order

Let’s consider an e-commerce Order as an Aggregate. An Order typically consists of an Order (the root Entity), several OrderLine items (which can be Value Objects or child Entities depending on complexity), and references to a Customer and Product (by ID).

Architecture Description of the Order Aggregate

[Order Aggregate]
  - Order (Aggregate Root, Entity)
    - OrderId (Value Object for identity or simple GUID/int)
    - OrderDate (Value Object)
    - Status (Value Object/Enum)
    - ShippingAddress (Value Object)
    - CustomerId (Reference by ID to Customer Aggregate)
    - List<OrderLine> (Collection of Value Objects)
      - OrderLine (Value Object)
        - ProductId (Reference by ID to Product Aggregate)
        - ProductName (string, cached for historical purposes)
        - Quantity (Value Object/int)
        - UnitPrice (Money Value Object)
        - LineTotal (Money Value Object, derived)
    - TotalAmount (Money Value Object, derived)

[Key Invariants for Order Aggregate]
- An order cannot be placed with zero items.
- An order's status can only transition in a valid sequence (e.g., Pending -> Placed -> Shipped -> Delivered).
- The total amount of the order must always be the sum of its line item totals.
- Product quantities must be positive.
public enum OrderStatus
{
    Pending,
    Placed,
    Shipped,
    Delivered,
    Cancelled
}

// OrderLine as a Value Object
public class OrderLine : IEquatable<OrderLine>
{
    public Guid ProductId { get; }
    public string ProductName { get; } // Cached product name at time of order
    public int Quantity { get; }
    public Money UnitPrice { get; } // Money is a Value Object

    public Money LineTotal => UnitPrice.Multiply(Quantity);

    public OrderLine(Guid productId, string productName, int quantity, Money unitPrice)
    {
        if (productId == Guid.Empty) throw new ArgumentException("Product ID cannot be empty.", nameof(productId));
        if (string.IsNullOrWhiteSpace(productName)) throw new ArgumentException("Product name is required.", nameof(productName));
        if (quantity <= 0) throw new ArgumentException("Quantity must be positive.", nameof(quantity));
        if (unitPrice == null) throw new ArgumentNullException(nameof(unitPrice));
        if (unitPrice.Amount < 0) throw new ArgumentException("Unit price cannot be negative.", nameof(unitPrice));

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

    // Equality for Value Object
    public override bool Equals(object obj) => Equals(obj as OrderLine);
    public bool Equals(OrderLine other)
    {
        if (other is null) return false;
        if (ReferenceEquals(this, other)) return true;
        return ProductId == other.ProductId &&
               ProductName == other.ProductName &&
               Quantity == other.Quantity &&
               UnitPrice == other.UnitPrice;
    }
    public override int GetHashCode() => HashCode.Combine(ProductId, ProductName, Quantity, UnitPrice);
}

// Order as the Aggregate Root
public class Order
{
    // Private setters to enforce encapsulation and ensure invariants are maintained through methods
    public Guid Id { get; private set; }
    public Guid CustomerId { get; private set; }
    public DateTime OrderDate { get; private set; }
    public OrderStatus Status { get; private set; }
    public Address ShippingAddress { get; private set; } // Address is a Value Object

    private readonly List<OrderLine> _orderLines;
    public IReadOnlyCollection<OrderLine> OrderLines => _orderLines.AsReadOnly();

    public Money TotalAmount
    {
        get
        {
            if (!_orderLines.Any()) return new Money(0, "USD"); // Or whatever default currency
            return _orderLines
                .Select(line => line.LineTotal)
                .Aggregate((current, next) => current.Add(next));
        }
    }

    // Constructor for creating a new Order (ensuring initial state is valid)
    public Order(Guid customerId, Address shippingAddress, IEnumerable<OrderLine> initialOrderLines)
    {
        if (customerId == Guid.Empty) throw new ArgumentException("Customer ID cannot be empty.", nameof(customerId));
        if (shippingAddress == null) throw new ArgumentNullException(nameof(shippingAddress));
        if (initialOrderLines == null || !initialOrderLines.Any())
            throw new ArgumentException("An order must have at least one line item.", nameof(initialOrderLines));

        Id = Guid.NewGuid();
        CustomerId = customerId;
        OrderDate = DateTime.UtcNow;
        Status = OrderStatus.Pending;
        _orderLines = new List<OrderLine>();
        ShippingAddress = shippingAddress;

        foreach (var line in initialOrderLines)
        {
            _orderLines.Add(line);
        }
    }

    // Private constructor for reconstitution by Repository (e.g., ORM)
    // Avoids re-validating state that is assumed to be valid from persistence
    private Order(Guid id, Guid customerId, DateTime orderDate, OrderStatus status, Address shippingAddress, List<OrderLine> orderLines)
    {
        Id = id;
        CustomerId = customerId;
        OrderDate = orderDate;
        Status = status;
        ShippingAddress = shippingAddress;
        _orderLines = orderLines ?? new List<OrderLine>();
    }

    // Domain methods to change the state of the Order
    // These methods encapsulate business rules and maintain invariants

    public void AddOrderLine(OrderLine newLine)
    {
        if (Status != OrderStatus.Pending)
            throw new InvalidOperationException("Cannot add items to an order that is not pending.");
        if (newLine == null) throw new ArgumentNullException(nameof(newLine));

        // Check for existing product and update quantity if applicable
        var existingLine = _orderLines.FirstOrDefault(l => l.ProductId == newLine.ProductId);
        if (existingLine != null)
        {
            // For simplicity, we'll assume replacing the line item
            // A more complex scenario might require creating a new OrderLine
            // with updated quantity or a dedicated method for updating quantity.
            // For now, let's just add it as a new line.
            // This is a design choice and depends on the specific domain rule.
            _orderLines.Add(newLine);
        }
        else
        {
            _orderLines.Add(newLine);
        }
    }

    public void RemoveOrderLine(Guid productId)
    {
        if (Status != OrderStatus.Pending)
            throw new InvalidOperationException("Cannot remove items from an order that is not pending.");

        var lineToRemove = _orderLines.FirstOrDefault(line => line.ProductId == productId);
        if (lineToRemove == null)
            throw new InvalidOperationException($"Product with ID {productId} not found in order.");

        _orderLines.Remove(lineToRemove);

        if (!_orderLines.Any())
        {
            // Optionally, cancel the order if it becomes empty
            CancelOrder("Order became empty.");
        }
    }

    public void PlaceOrder()
    {
        if (Status != OrderStatus.Pending)
            throw new InvalidOperationException("Only pending orders can be placed.");
        if (!_orderLines.Any())
            throw new InvalidOperationException("Cannot place an empty order.");

        Status = OrderStatus.Placed;
        // Optionally, raise a Domain Event here: new OrderPlacedEvent(Id)
    }

    public void ShipOrder()
    {
        if (Status != OrderStatus.Placed)
            throw new InvalidOperationException("Only placed orders can be shipped.");

        Status = OrderStatus.Shipped;
        // Optionally, raise a Domain Event here: new OrderShippedEvent(Id)
    }

    public void DeliverOrder()
    {
        if (Status != OrderStatus.Shipped)
            throw new InvalidOperationException("Only shipped orders can be delivered.");

        Status = OrderStatus.Delivered;
        // Optionally, raise a Domain Event here: new OrderDeliveredEvent(Id)
    }

    public void CancelOrder(string reason)
    {
        if (Status == OrderStatus.Delivered || Status == OrderStatus.Cancelled)
            throw new InvalidOperationException("Cannot cancel a delivered or already cancelled order.");

        Status = OrderStatus.Cancelled;
        // Optionally, raise a Domain Event here: new OrderCancelledEvent(Id, reason)
    }

    public void ChangeShippingAddress(Address newAddress)
    {
        if (newAddress == null) throw new ArgumentNullException(nameof(newAddress));
        // Only allow address change if order is pending or placed, not shipped
        if (Status != OrderStatus.Pending && Status != OrderStatus.Placed)
            throw new InvalidOperationException("Cannot change shipping address for an order that has been shipped or delivered.");

        ShippingAddress = newAddress;
    }
}

In this Order Aggregate, the Order class is the root. It controls access to its internal OrderLine Value Objects. All state changes go through the Order‘s public methods (e.g., PlaceOrder(), AddOrderLine()), which enforce the Aggregate’s invariants. Notice how CustomerId and ProductId are stored as IDs, not direct object references. This adheres to the rule of referencing other Aggregates only by ID.

Pitfalls to Avoid with Aggregates

  • Too Large Aggregates: The “God Object” anti-pattern. If your Aggregate covers too many Entities and invariants, it becomes a bottleneck for concurrency and performance. It also becomes hard to manage its consistency rules.

  • Not Respecting Boundaries: Directly modifying internal Entities of an Aggregate from outside its root. This bypasses the root’s consistency enforcement and can lead to invalid states.

  • Lack of Clear Root: If it’s unclear which Entity is the root, or if multiple Entities claim to be roots of the same cluster, your consistency boundary is ill-defined.

  • Transactional Scope Too Broad: Trying to make changes across multiple Aggregates in a single ACID transaction. Each Aggregate should be its own transactional unit. If you need to coordinate changes across multiple Aggregates, consider eventual consistency, domain events, and sagas.

Repositories: The Gateway to Persistence

We’ve designed our rich domain model with Entities, Value Objects, and Aggregates. But how do we get these objects into and out of persistence (e.g., a database)? This is where Repositories come into play.

What is a Repository?

A Repository is a mechanism for encapsulating the storage, retrieval, and search behavior of Aggregates. It acts as a collection-like interface for Aggregates, allowing clients (typically application services) to interact with them as if they were in an in-memory collection, without worrying about the underlying database technology or ORM details.

Key responsibilities and characteristics of Repositories:

  1. Persistence Abstraction: Decouples the domain layer from the infrastructure layer. Your domain model doesn’t know or care how it’s being persisted.

  2. Aggregate-Centric: Repositories *always* work with Aggregate Roots. You never create a Repository for a Value Object or an internal Entity of an Aggregate.

  3. Collection-Like Interface: Provides methods like GetById(id), Add(aggregate), Remove(aggregate), and search methods (e.g., FindByCustomerId(id)).

  4. Guarantees Consistency: When a Repository retrieves an Aggregate, it must return a fully consistent and hydrated Aggregate

Written by

Khader Vali

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

Share this article

Related Articles

Distributed Caching: Redis, Memcached & CDN Patterns

Oct 08, 2026 · 17 min read

Distributed Caching: Redis, Memcached, & CDN Patterns

Jul 08, 2026 · 16 min read

Idempotent APIs: Reliability in Distributed Systems

Aug 02, 2026 · 17 min read