System Design

DDD Tactical Patterns: Aggregates, Value Objects, Repositories

Dive deep into Domain-Driven Design tactical patterns: Aggregates, Value Objects, and Repositories. Learn how to build robust, maintainable, and consistent domain models with practical C# examples and real-world scenarios.

Khader Vali August 23, 2026 18 min read

As senior software engineers, we’re constantly striving to build systems that are not just functional, but also robust, maintainable, and adaptable to change. In the complex landscape of modern software development, understanding and effectively applying principles like Domain-Driven Design (DDD) is paramount. DDD, at its core, is an approach to software development that emphasizes a deep understanding of the business domain, aligning software design with the domain model.

While strategic DDD helps us define bounded contexts and ubiquitous language, it’s the tactical patterns that guide us in crafting the actual code within those contexts. These patterns are the building blocks that translate our domain understanding into concrete, executable software. Among the most fundamental and powerful tactical patterns are Aggregates, Value Objects, and Repositories. Mastering these three can significantly elevate the quality and clarity of your domain models, leading to systems that are easier to reason about, test, and evolve.

In this comprehensive guide, we’ll embark on a journey to demystify these core DDD tactical patterns. We’ll explore their definitions, purposes, design considerations, and illustrate their practical application with C# code examples and real-world scenarios. By the end, you’ll have a solid grasp of how to leverage Aggregates, Value Objects, and Repositories to build resilient, domain-rich applications.

Understanding Domain-Driven Design Tactical Patterns

Before we dive into the specifics, let’s briefly contextualize tactical patterns within DDD. Eric Evans’ seminal work, “Domain-Driven Design: Tackling Complexity in the Heart of Software,” introduced a wealth of patterns, broadly categorized into strategic and tactical.

  • Strategic DDD Patterns focus on high-level organizational aspects of the domain. This includes defining Bounded Contexts, establishing a Ubiquitous Language, and mapping context relationships. These patterns help us understand the boundaries and interactions between different parts of a large system.
  • Tactical DDD Patterns, on the other hand, are concerned with the detailed design of the domain model within a single Bounded Context. They provide guidelines for structuring the code, defining the behavior and data of domain objects, and managing their lifecycle and persistence. Aggregates, Value Objects, and Repositories are prime examples of these.

These tactical patterns are not mere architectural constructs; they are tools for expression. They allow us to model the complexities of our domain with precision, ensuring that our software truly reflects the business rules and concepts it’s meant to embody. Let’s start with Aggregates, the guardians of consistency.

Aggregates: The Consistency Boundary

In any non-trivial application, objects often have relationships with each other. When changes occur, these relationships can lead to complex state management and potential inconsistencies. This is where Aggregates come in. An Aggregate is a cluster of associated domain objects that are treated as a single unit for the purpose of data changes. It’s a consistency boundary, ensuring that all invariants (business rules) within the cluster are maintained after any operation.

What is an Aggregate?

Imagine a complex object graph. An Aggregate draws a boundary around a subset of these objects, declaring them a single transactional unit. Any changes to objects within this boundary must happen together, as one atomic operation, to preserve the aggregate’s internal consistency. This concept is crucial for maintaining data integrity and simplifying transactional management.

The key idea is that external objects should only hold references to the Aggregate Root, not to its internal members. This encapsulation prevents direct manipulation of internal objects, forcing all interactions through the Aggregate Root, which can then enforce the necessary business rules.

The Aggregate Root

Every Aggregate has one specific entity called the Aggregate Root. This is the only object in the Aggregate that external objects are allowed to reference. The Aggregate Root is responsible for maintaining the consistency of the entire Aggregate. All operations that modify the state of any object within the Aggregate must go through the Aggregate Root. It acts as a gateway, ensuring that the Aggregate’s invariants are always upheld.

Think of it like the conductor of an orchestra. The conductor (Aggregate Root) directs all the musicians (internal entities and value objects) to play in harmony, ensuring the overall consistency and integrity of the performance (the Aggregate’s state).

Designing Effective Aggregates

Designing good Aggregates requires careful thought. Here are some key principles:

  1. Small Aggregates: Strive for smaller aggregates. Large aggregates tend to be contention points in concurrent systems and make it harder to reason about consistency. Only include what’s absolutely necessary to enforce a single set of invariants.
  2. Refer by ID: Objects outside an Aggregate should only hold references to the Aggregate Root by its unique identifier (ID), not by direct object references. This promotes loose coupling and allows for better scalability in distributed systems.
  3. Transactional Consistency: An Aggregate defines a transactional boundary. All operations within an Aggregate must complete successfully or be rolled back as a single unit.
  4. Encapsulation: The Aggregate Root is the only entry point to the Aggregate. Internal entities and value objects within the Aggregate should not be directly accessible from outside. Their lifecycle and state changes are managed by the Aggregate Root.
  5. Eventual Consistency for Inter-Aggregate Communication: When one Aggregate needs to interact with another, it should typically do so by referencing the other Aggregate’s ID, and potentially by raising domain events. Direct object references between different Aggregate Roots are generally discouraged as they can lead to distributed transactions and complex consistency challenges.

By adhering to these principles, we can create Aggregates that are clear, manageable, and robust.

Real-World Scenario: E-commerce Order

Let’s consider an e-commerce platform. A classic example of an Aggregate is an Order. An Order consists of the order itself (the Aggregate Root), a collection of OrderLineItems, and potentially a ShippingAddress, BillingAddress, and PaymentInformation. All these components together form a consistent unit.

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

When you create an order, add items, change quantities, or update the shipping address, all these operations must ensure the overall consistency of the Order. For example, the total amount of the order must always reflect the sum of its line items, and the order status transitions (e.g., from ‘Pending’ to ‘Paid’) must follow specific business rules.

Here’s a simplified C# representation of an Order Aggregate:


using System;
using System.Collections.Generic;
using System.Linq;

// Value Object for Money (will be defined later)
public record Money(decimal Amount, string Currency)
{
    public static Money Zero(string currency) => new Money(0, currency);

    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);
    }
}

// Value Object for Product ID (example)
public record ProductId(Guid Value);

// Value Object for Order Line Item
public class OrderLineItem
{
    public ProductId ProductId { get; private set; }
    public string ProductName { get; private set; }
    public int Quantity { get; private set; }
    public Money UnitPrice { get; private set; }
    public Money TotalPrice => UnitPrice.Multiply(Quantity); // Assuming Money has a Multiply method

    // Constructor for new line item
    public OrderLineItem(ProductId productId, string productName, int quantity, Money unitPrice)
    {
        if (quantity <= 0) throw new ArgumentOutOfRangeException(nameof(quantity), "Quantity must be positive.");
        ProductId = productId ?? throw new ArgumentNullException(nameof(productId));
        ProductName = productName ?? throw new ArgumentNullException(nameof(productName));
        UnitPrice = unitPrice ?? throw new ArgumentNullException(nameof(unitPrice));
        Quantity = quantity;
    }

    // Method to update quantity
    public void UpdateQuantity(int newQuantity)
    {
        if (newQuantity <= 0) throw new ArgumentOutOfRangeException(nameof(newQuantity), "Quantity must be positive.");
        Quantity = newQuantity;
    }
}

// Aggregate Root: Order
public class Order
{
    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 Money TotalAmount { get; private set; }

    private readonly List<OrderLineItem> _lineItems = new List<OrderLineItem>();
    public IReadOnlyCollection<OrderLineItem> LineItems => _lineItems.AsReadOnly();

    // Private constructor for internal creation/rehydration by repository
    private Order() { }

    // Factory method for creating a new Order
    public static Order CreateNew(Guid customerId, string currency)
    {
        if (customerId == Guid.Empty) throw new ArgumentException("Customer ID cannot be empty.", nameof(customerId));
        return new Order
        {
            Id = Guid.NewGuid(),
            CustomerId = customerId,
            OrderDate = DateTime.UtcNow,
            Status = OrderStatus.Pending,
            TotalAmount = Money.Zero(currency)
        };
    }

    // Domain operation: Add a product to the order
    public void AddProduct(ProductId productId, string productName, int quantity, Money unitPrice)
    {
        if (Status != OrderStatus.Pending)
            throw new InvalidOperationException("Cannot add products to an order that is not pending.");

        var existingItem = _lineItems.FirstOrDefault(item => item.ProductId == productId);
        if (existingItem != null)
        {
            existingItem.UpdateQuantity(existingItem.Quantity + quantity);
        }
        else
        {
            _lineItems.Add(new OrderLineItem(productId, productName, quantity, unitPrice));
        }
        RecalculateTotalAmount();
    }

    // Domain operation: Remove a product from the order
    public void RemoveProduct(ProductId productId)
    {
        if (Status != OrderStatus.Pending)
            throw new InvalidOperationException("Cannot remove products from an order that is not pending.");

        var itemToRemove = _lineItems.FirstOrDefault(item => item.ProductId == productId);
        if (itemToRemove != null)
        {
            _lineItems.Remove(itemToRemove);
            RecalculateTotalAmount();
        }
    }

    // Domain operation: Update product quantity
    public void UpdateProductQuantity(ProductId productId, int newQuantity)
    {
        if (Status != OrderStatus.Pending)
            throw new InvalidOperationException("Cannot update quantity for an order that is not pending.");

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

        itemToUpdate.UpdateQuantity(newQuantity);
        RecalculateTotalAmount();
    }

    // Internal helper to ensure consistency
    private void RecalculateTotalAmount()
    {
        TotalAmount = Money.Zero(TotalAmount.Currency);
        foreach (var item in _lineItems)
        {
            TotalAmount = TotalAmount.Add(item.TotalPrice);
        }
    }

    // Domain operation: Mark order as paid
    public void MarkAsPaid()
    {
        if (Status != OrderStatus.Pending)
            throw new InvalidOperationException($"Order cannot be paid in current status: {Status}.");
        Status = OrderStatus.Paid;
        // Optionally, raise a Domain Event: OrderPaidEvent
    }

    // Domain operation: Cancel order
    public void CancelOrder()
    {
        if (Status != OrderStatus.Pending)
            throw new InvalidOperationException($"Order cannot be cancelled in current status: {Status}.");
        Status = OrderStatus.Cancelled;
        // Optionally, raise a Domain Event: OrderCancelledEvent
    }
}

public enum OrderStatus
{
    Pending,
    Paid,
    Shipped,
    Delivered,
    Cancelled
}

In this example:

  • Order is the Aggregate Root. Its methods like `AddProduct`, `RemoveProduct`, `UpdateProductQuantity`, `MarkAsPaid`, and `CancelOrder` encapsulate the business logic and ensure invariants are maintained.
  • OrderLineItem is an entity within the Aggregate, managed by the Order. It doesn’t have its own public `Id` setter, and its lifecycle is entirely dependent on the `Order`.
  • Money and ProductId are Value Objects, immutable and compared by value.
  • The RecalculateTotalAmount() method is a private helper that ensures the TotalAmount always reflects the sum of line items – a crucial invariant.
  • The constructor is private, enforcing the use of a factory method (`CreateNew`) to ensure proper initialization.

This structure ensures that an Order is always in a valid state, making the system more robust and easier to understand.

Value Objects: Precision and Immutability

Often, in our domain models, we encounter concepts that have no conceptual identity of their own; they are defined purely by their attributes. These are perfect candidates for Value Objects, a powerful DDD tactical pattern that promotes immutability, reduces errors, and improves clarity.

What is a Value Object?

A Value Object is an object that measures, quantifies, or describes a thing in the domain. It has no conceptual identity distinct from its attributes. It is defined by its properties, not by a unique identifier. Two Value Objects are considered equal if all their attributes are equal.

Contrast this with an Entity, which does have a unique identity that persists over time, regardless of changes to its attributes. For example, a CustomerId is an Entity ID, but a Money amount is a Value Object.

Characteristics of Value Objects

  • No Identity: Value Objects do not have a unique identifier (like a GUID or an auto-incrementing integer). Their identity is derived solely from the values of their attributes.
  • Immutability: Once created, the state of a Value Object should not change. If you need to “change” a Value Object, you create a new one with the desired attributes. This makes them inherently thread-safe and easier to reason about.
  • Compared by Value: Two Value Objects are considered equal if all their attributes are equal, not if they are the same instance in memory.
  • Replaces Primitive Obsession: Instead of using primitive types (like string for an email or decimal for money), Value Objects allow us to encapsulate meaning and behavior related to those concepts.

Why Use Value Objects?

  1. Expressiveness: Value Objects make your domain model more expressive. Instead of `decimal price`, you have `Money price`. Instead of `string street, string city, string state, string zip`, you have `Address`. This makes the code easier to read and understand.
  2. Ensuring Validity: You can embed validation logic directly into the Value Object’s constructor, guaranteeing that once created, the object is always in a valid state. For example, an `EmailAddress` Value Object can ensure the string is a valid email format.
  3. Reduced Duplication: Behavior related to the value can be encapsulated within the Value Object. For example, `Money` can have methods for `Add`, `Subtract`, `Multiply`.
  4. Immutability Benefits: Immutability simplifies concurrency, makes objects safe to pass around, and reduces the chance of unexpected side effects.
  5. Reduced Primitive Obsession: This is a common code smell where primitive data types (strings, integers, decimals) are used to represent complex domain concepts, leading to scattered validation logic and poor encapsulation. Value Objects elegantly solve this.

Designing Value Objects

When designing Value Objects in C#, consider the following:

  • Private Setters/Read-Only Properties: Ensure properties are set only during construction.
  • Constructor Validation: Validate input parameters in the constructor to guarantee a valid state upon creation.
  • Override Equals() and GetHashCode(): Implement value-based equality. C# 9+ `record` types provide this out-of-the-box, making them excellent candidates for Value Objects.
  • Methods for Operations: Add methods that perform operations on the Value Object, always returning a new instance (due to immutability) rather than modifying the current one.

Real-World Scenario: Financial Transactions

In financial systems, handling money accurately is paramount. Using a `decimal` or `double` directly can lead to precision issues and makes it difficult to enforce currency rules. A Money Value Object is a perfect fit here.

Similarly, an Address in a shipping system, a DateRange for booking, or a FullName for a user are all excellent candidates for Value Objects.

Let’s refine our Money Value Object and introduce an Address Value Object:


using System;
using System.Collections.Generic;
using System.Linq;

// Base class for Value Objects to simplify Equals and GetHashCode implementation
public abstract class ValueObject
{
    protected static bool EqualOperator(ValueObject left, ValueObject right)
    {
        if (ReferenceEquals(left, null) ^ ReferenceEquals(right, null))
        {
            return false;
        }
        return ReferenceEquals(left, right) || left.Equals(right);
    }

    protected static bool NotEqualOperator(ValueObject left, ValueObject right)
    {
        return !(EqualOperator(left, right));
    }

    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 != null ? x.GetHashCode() : 0)
            .Aggregate((x, y) => x ^ y);
    }
}

// Money Value Object
public class Money : ValueObject
{
    public decimal Amount { get; }
    public string Currency { get; }

    public Money(decimal amount, string currency)
    {
        if (amount < 0) throw new ArgumentOutOfRangeException(nameof(amount), "Amount cannot be negative.");
        if (string.IsNullOrWhiteSpace(currency)) throw new ArgumentException("Currency cannot be empty.", nameof(currency));
        // Add more robust currency validation if needed (e.g., ISO 4217 codes)
        Amount = amount;
        Currency = currency.ToUpperInvariant(); // Standardize currency codes
    }

    public static Money Zero(string currency) => new Money(0, currency);

    // Operations return new Money objects (immutability)
    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.");
        if (Amount < other.Amount)
            throw new InvalidOperationException("Cannot subtract more money than available.");
        return new Money(Amount - other.Amount, Currency);
    }

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

    protected override IEnumerable<object> GetEqualityComponents()
    {
        yield return Amount;
        yield return Currency;
    }

    public static bool operator ==(Money left, Money right) => EqualOperator(left, right);
    public static bool operator !=(Money left, Money right) => NotEqualOperator(left, right);
}

// Address Value Object
public class Address : ValueObject
{
    public string Street { get; }
    public string City { get; }
    public string State { get; } // or Region/Province
    public string PostalCode { get; }
    public string Country { get; }

    public Address(string street, string city, string state, string postalCode, 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(state)) throw new ArgumentException("State cannot be empty.", nameof(state));
        if (string.IsNullOrWhiteSpace(postalCode)) throw new ArgumentException("Postal Code cannot be empty.", nameof(postalCode));
        if (string.IsNullOrWhiteSpace(country)) throw new ArgumentException("Country cannot be empty.", nameof(country));

        Street = street;
        City = city;
        State = state;
        PostalCode = postalCode;
        Country = country;
    }

    protected override IEnumerable<object> GetEqualityComponents()
    {
        yield return Street;
        yield return City;
        yield return State;
        yield return PostalCode;
        yield return Country;
    }

    public static bool operator ==(Address left, Address right) => EqualOperator(left, right);
    public static bool operator !=(Address left, Address right) => NotEqualOperator(left, right);
}

In this example:

  • Both Money and Address inherit from a base ValueObject class to provide a reusable implementation of value-based equality. This pattern is common in C# DDD implementations.
  • Their constructors perform validation, ensuring that an invalid Money or Address cannot be created.
  • Methods like `Add`, `Subtract`, and `Multiply` on Money return new instances, adhering to the immutability principle.

By using Value Objects, we prevent common pitfalls like passing around raw decimals or strings that lack context, and we enforce domain rules directly at the object creation level, leading to a much more robust and expressive domain model.

Repositories: Abstracting Persistence

In any application, our domain objects need to be stored and retrieved from a persistent storage mechanism (like a database). Directly embedding database access logic within our domain entities would violate the Single Responsibility Principle and pollute the domain model with infrastructure concerns. This is where the Repository pattern shines.

What is a Repository?

A Repository mediates between the domain layer and data mapping layers, acting like an in-memory collection of domain objects. It provides a clean, domain-centric interface for storing, retrieving, and querying Aggregates. The Repository pattern abstracts away the complexities of the underlying data storage mechanism, allowing the domain model to remain focused purely on business logic.

Clients (typically application services) use Repositories to interact with persistent data without needing to know the specifics of how that data is stored or retrieved. They simply ask the Repository for an Aggregate by its ID, or save an Aggregate, treating the Repository as if it were an in-memory collection.

Repositories and Aggregates

The relationship between Repositories and Aggregates is fundamental in DDD:

  • Repositories always deal with Aggregate Roots. You should never create a Repository for an entity that is not an Aggregate Root. This reinforces the Aggregate’s role as a consistency boundary. When you retrieve an Aggregate Root, the Repository is responsible for reconstituting the entire Aggregate (the root entity and all its internal entities and value objects) from storage.
  • Repositories ensure Aggregate consistency. When saving an Aggregate, the Repository should save the entire Aggregate as a single transactional unit, ensuring that all changes within its boundary are persisted together.

Designing Repositories

Effective Repository design adheres to several principles:

  1. One Repository Per Aggregate Root: Generally, you should have one Repository interface for each Aggregate Root in your domain. This keeps Repositories focused and aligned with your consistency boundaries.
  2. Collection-Like Interface: Repositories should offer methods that resemble those of a collection: `GetById(id)`, `Add(aggregate)`, `Remove(aggregate)`, and potentially methods for querying specific aggregates based on domain criteria (e.g., `FindOrdersByCustomerId(customerId)`).
  3. Focus on Domain Language: The methods of a Repository should speak the Ubiquitous Language of the domain. Avoid generic methods like `GetAll()` if “all” doesn’t make sense for that Aggregate in the domain context.
  4. Abstraction (Interface): Always define Repository interfaces in the Domain Layer and implement them in the Infrastructure Layer. This adheres to the Dependency Inversion Principle, keeping your domain model clean and independent of persistence concerns.
  5. No Generic Repositories: While tempting, generic repositories (e.g., `IRepository`) often lead to an anemic domain model or expose too much flexibility, potentially breaking Aggregate encapsulation. Each Aggregate Root has unique persistence needs and queries, best handled by a dedicated interface.
  6. No “Save” Method on Aggregate: The Aggregate itself should not be responsible for saving its state. Its responsibility is domain logic. The Repository handles the persistence.

Real-World Scenario: Product Catalog Management

Continuing with our e-commerce example, consider managing products. A Product could be an Aggregate Root. We would need a ProductRepository to manage the persistence of Product Aggregates.

Here’s an example of an IOrderRepository interface and a simple in-memory implementation:


using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

// Assuming the Order aggregate definition from above is available

// Domain Layer: IOrderRepository interface
public interface IOrderRepository
{
Task<Order> GetByIdAsync(Guid orderId);
Task AddAsync(Order order);
Task UpdateAsync(Order order); // Often, just 'Add' handles both new and existing via ORM
Task DeleteAsync(Order order); // Or DeleteByIdAsync(Guid orderId)

// Example of domain-specific query
Task<IEnumerable<Order>> GetOrdersByCustomerIdAsync(Guid customerId);
Task<IEnumerable<Order>> GetPendingOrdersAsync();
}

// Infrastructure Layer: InMemoryOrderRepository implementation (for demonstration)
public class InMemoryOrderRepository : IOrderRepository
{
private readonly List<Order> _orders = new List<Order>();

public Task<Order> GetByIdAsync(Guid orderId)
{
return Task.FromResult(_orders.FirstOrDefault(o => o.Id == orderId));
}

public Task AddAsync(Order order)
{
// In a real scenario, check for existing order to prevent duplicates or handle updates.
// For simplicity, we just add.
if (_orders.Any(o => o.Id == order.Id))
{
throw new InvalidOperationException($"Order with ID {order.Id} already exists.");
}
_orders.Add(order);
return Task.CompletedTask;
}

public Task UpdateAsync(Order order)
{
var existingOrder = _orders.FirstOrDefault(o => o.Id == order.Id);
if (existingOrder == null)
{
throw new InvalidOperationException($"Order with ID {order.Id} not found for update.");
}
// In a real ORM scenario, the ORM would track changes.
// Here,

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

Jun 17, 2026 · 19 min read

Full Stack TypeScript: tRPC & Prisma End-to-End

May 26, 2026 · 10 min read

Distributed Caching Strategies: Redis, Memcached, and CDN Patterns

Jul 17, 2026 · 17 min read