Mastering DDD: A Developer’s Guide to Implementing Aggregates

Welcome back to our series on Domain-Driven Design (DDD)! In our previous article, we skimmed the surface of some fundamental DDD concepts like Aggregates, Entities, and Value Objects. If you found that intriguing, buckle up because we’re about to take a deep dive into these core components, specifically through the lens of a developer.

While DDD can often appear complex and daunting, breaking it down into its individual parts like we’re doing in this series can make it much more approachable. Today, we’ll be focusing on Aggregates. There will be another article about Entities, and Value Objects but you should get enough information from the Getting Started with Domain-Driven Design for Developers to be able to fallow this article.

Aggregates plus Entities, and Value Objects are three building blocks that serve as the bedrock of well-designed DDD-based applications.

The objective of this post is to move beyond mere definitions, and delve into the practical aspects of implementing Aggregates in your software projects. To make things more concrete, we’ll be using C# code examples from the eShopOnContainers Ordering Service—a real-world, open-source project that employs DDD principles.

By the end of this post, you’ll gain a solid understanding of how to design and implement Aggregates in your own domain-driven projects. So, let’s get started!

Aggregate

An Aggregate is a pattern in Domain-Driven Design (DDD), a cluster of domain objects that can be treated as a single unit. An Aggregate is essentially a collection of Entities and Value Objects that logically belong together and are always consistent with respect to invariants. At its heart, an Aggregate serves to enforce business rules and invariants, ensuring that all changes to the data are consistent with the business model.

Aggregate Root

The Aggregate Root is the primary entry point to the Aggregate; it’s the gatekeeper that ensures all interactions and changes within the Aggregate are consistent. In our Order example, the Order Entity itself could be the Aggregate Root.

In coding practices, the Aggregate Root represents a specialized variation of an Entity class. To put it differently, an Aggregate Root assumes a distinctive role as an entity, serving as the central gateway to access other classes within its specific Aggregate. In the context of eShopOnContainers, specifically within the Ordering service, you’ll find two Aggregates inside the AggregatesModel folder: BuyerAggregate and OrderAggregate.

By convention, the name of the Aggregate Root typically matches the name of the Aggregate itself. In the codebase we’re examining—specifically eShopOnContainers—the Aggregate Root is marked by implementing the IAggregateRoot interface. This interface is empty and serves primarily as a marker to distinguish the Aggregate Root from other entities within the same Aggregate. Take a look at the Order class for example:

public class Order: Entity, IAggregateRoot
{
// Code 
}

Let’s delve into the code a bit: Since Order serves as our Aggregate Root, the rest of the application should interact with the Entities and Value Objects within this Aggregate exclusively through the public methods of the Order class. Here’s a code snippet taken directly from the original codebase to illustrate this point:

public class Order: Entity, IAggregateRoot
{
//Some code

    private readonly List<OrderItem> _orderItems;
    public IReadOnlyCollection<OrderItem> OrderItems => _orderItems;

//More Code
    public void AddOrderItem(int productId, string productName, decimal unitPrice, decimal discount, string pictureUrl, int units = 1)
    {
        var existingOrderForProduct = _orderItems.Where(o => o.ProductId == productId)
            .SingleOrDefault();

        if (existingOrderForProduct != null)
        {
            //if previous line exist modify it with higher discount  and units..

            if (discount > existingOrderForProduct.GetCurrentDiscount())
            {
                existingOrderForProduct.SetNewDiscount(discount);
            }

            existingOrderForProduct.AddUnits(units);
        }
        else
        {
            //add validated new order item

            var orderItem = new OrderItem(productId, productName, unitPrice, discount, pictureUrl, units);
            _orderItems.Add(orderItem);
        }
    }
   //Even more Code
}

In line 11, AddOrderItem(/*args*/) is a public method. Although there’s a class called OrderItem, the rest of the application isn’t intended to interact with it directly. The sole avenue for creating a new Order Item is through the Order class. This centralized logic ensures that the creation of an “Order Item” is managed in one specific place, preventing the logic from becoming scattered throughout your application.

I’d also like to draw your attention to a pattern in lines 5-6. While you can publicly access the collection of OrderItems, you cannot add any Order Items directly to this collection (see line 6). The exclusive means of adding more Order Items is via the Order class (as shown in lines 5 and 30).

Here is a sample how application interact with this aggregate, see the how code for CreateOrderCommandHandler

namespace Microsoft.eShopOnContainers.Services.Ordering.API.Application.Commands;

using Microsoft.eShopOnContainers.Services.Ordering.Domain.AggregatesModel.OrderAggregate;

public class CreateOrderCommandHandler
    : IRequestHandler<CreateOrderCommand, bool>
{
 // some code 
 public async Task<bool> Handle(CreateOrderCommand message, CancellationToken cancellationToken)
    {
     // some code
        var order = new Order(message.UserId, message.UserName, address, message.CardTypeId, message.CardNumber, message.CardSecurityNumber, message.CardHolderName, message.CardExpiration);

        foreach (var item in message.OrderItems)
        {
            order.AddOrderItem(item.ProductId, item.ProductName, item.UnitPrice, item.Discount, item.PictureUrl, item.Units);
        }
     // some code 
  }
  // some code 
}

As you see in a well-designed Aggregate, not only do the Entities and Value Objects have a clear relationship with each other, but they also work together to enforce invariants and business rules.

Designing Aggregates

When structuring your domain model, one of the most critical decisions you’ll make is identifying your Aggregates and Aggregate Roots. These choices significantly influence the maintainability, consistency, and transactional integrity of your application. To illustrate how to make such decisions, let’s consider the Order and Buyer Aggregates within the Ordering service of the eShopOnContainers example.

Criteria for Identifying an Aggregate Root

  1. Life Cycle Dependency: If one entity doesn’t make sense without the other, they should belong to the same Aggregate.
  2. Consistency Boundary: The Aggregate should be the largest unit of consistency. All operations within the Aggregate should be transactionally consistent.
  3. Invariant Maintenance: The Aggregate Root is responsible for ensuring that all invariants of the Aggregate are satisfied.
  4. Domain Rules: The business rules and invariants should dictate how the Aggregate is structured.
  5. Transaction Scope: Usually, transactions should not cross Aggregate boundaries.

Order as an Aggregate Root

In the Ordering service, an Order entity naturally acts as an Aggregate Root. This makes sense when you consider:

  • Life Cycle: Order Items have no meaning without an Order; they live and die with it.
  • Consistency: When you add an Order Item, the Order’s total price should be recalculated, ensuring transactional consistency.
  • Invariant Maintenance: The Order class ensures that you cannot add a duplicate OrderItem or go below a certain order value.

The Order Aggregate Root is the only object with which the application layer interacts for anything related to an order, maintaining the consistency of the whole Aggregate.

Buyer as an Aggregate Root

Similarly, a Buyer could be an Aggregate Root, considering:

  • Life Cycle: Things like PaymentMethod are tightly coupled with Buyer.
  • Consistency: When a new PaymentMethod is added, it might become the default payment method for the buyer, requiring a consistency check.
  • Invariant Maintenance: The Buyer ensures that there are not multiple default PaymentMethods.

Here, again, the Buyer class serves as a gatekeeper, enforcing all rules and invariants related to a buyer.

Bounded Contexts and Aggregates

As previously discussed, a Bounded Context is a logical and physical boundary around a coherent and consistent domain or subdomain. It encapsulates the entities, value objects, aggregates, services, and repositories that belong to that domain or subdomain. This division serves to delineate different parts of the system, making it easier for teams to focus on specific functionalities without worrying about the rest.

How Do They Interact?

Here’s how Bounded Contexts and Aggregates complement each other:

  1. Contextual Boundaries: Each Bounded Context may have one or more Aggregates, defining the range of functionalities and responsibilities within that context.
  2. Consistency: Aggregates ensure transactional consistency within their boundaries, which is crucial when they operate within a specific Bounded Context.
  3. Isolation: Bounded Contexts provide a higher level of isolation, often corresponding to microservices in an architecture. Aggregates within these Bounded Contexts are isolated too, which means changes to one Aggregate do not directly affect another Aggregate in a different Bounded Context.
  4. Ubiquitous Language: Both concepts contribute to establishing a ubiquitous language within their boundaries. The terms and definitions used within a Bounded Context are explicit and specific, and this language extends down to the Aggregates and the models they contain. There is a good read about Ubiquitous Language: Implementing a Common Vocabulary in Your Code (external link)
  5. Team Alignment: Bounded Contexts often align with organizational structures, and the same can be said for Aggregates to a lesser extent. Teams can own a specific Bounded Context and the Aggregates within it, reducing conflicts and increasing productivity.
  6. Domain Events: In more complex scenarios, changes in one Aggregate might trigger domain events that could have side effects in another Aggregate, potentially even in another Bounded Context. However, these interactions are usually explicitly designed and carefully managed to maintain consistency.

An Example: Order and Buyer in Ordering Context

Let’s consider our earlier example of the Ordering service in eShopOnContainers. Here, Order and Buyer serve as Aggregates, and the whole Ordering system can be considered a Bounded Context.

  • The Order Aggregate ensures that all operations related to an order are consistent.
  • The Buyer Aggregate takes care of operations related to the customer’s profile and payment methods.
  • Both Aggregates operate within the Ordering Bounded Context, contributing to the ubiquitous language and isolated functionalities of this context.

Common Pitfalls and Best Practices

  1. Too Large Aggregates: It’s crucial to find the right size for your Aggregates. If your Aggregate includes too many Entities, it may become difficult to maintain and could lead to performance issues.
  2. Consistency Over Immediate Consistency: Your Aggregate should ensure eventual consistency rather than immediate consistency. In microservices architectures, especially, immediate consistency can be challenging to achieve without sacrificing system availability.
  3. Avoid Lazy Loading: Lazy loading can lead to unpredictable behavior and should be avoided within Aggregates.
  4. Design Small, Expand as Needed: Start with the smallest possible Aggregate and expand it only when the business logic clearly requires a larger boundary for maintaining consistency.

Conclusion

By now, you should have a clearer picture of what Aggregates are and how they function as a part of the Domain-Driven Design methodology. They act as the “safety net,” ensuring that your application’s domain logic remains consistent and maintainable. With this understanding, you are better equipped to explore more complex aspects of DDD in your role as a developer, especially within microservices architectures like eShopOnContainers.

Up next, we’ll be deep diving into Entities and Value Objects, the other major building blocks in DDD. These concepts are crucial to master, as they form the very fabric of your Aggregates. Stay tuned!


Posted

in

by

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *