Domain-Driven Design: Aggregates in Practice

Ankit Sharma
11 min readOct 18, 2023

--

Introduction

Domain-Driven Design (DDD) is a powerful methodology for building complex software systems that closely represent the real-world domain they serve. One of the fundamental concepts in DDD is the Aggregates, which plays a central role in organizing and managing the domain model. In this article, we will delve into the world of Aggregate Roots, their significance, and the best practices associated with their design and use, using C# code examples. To illustrate these principles, we will explore a Task Management System as an example, providing insights into when to create separate Aggregate Roots, managing many-to-many relationships, and enforcing validation rules within Aggregates.

What is an Aggregate Root?

An Aggregate in DDD is a cluster of related objects that are treated as a single unit. Each Aggregate has a designated entry point, known as the Aggregate Root. The Aggregate Root controls access to the objects within the Aggregate and enforces invariants and business rules within the domain. This concept provides a way to encapsulate related domain objects, ensuring data consistency and integrity.

In DDD, determining the boundaries of Aggregates and their Aggregate Roots is a crucial design decision, and it all depends on the business use case. However, the following scenarios can help guide the decision to create separate Aggregates or use an existing aggregate:

In our Task Management System, tasks can be naturally considered as an Aggregate. To represent this, we need to decide what entity within the Aggregate will act as the Aggregate Root. Typically, the Aggregate Root should be the object that has the most meaning and is most likely to be accessed from outside the Aggregate. In the context of task management, a good choice for the Aggregate Root could be the Task entity itself.

When to include entities inside existing Aggregate?

Transactional Boundary

An Aggregate Root defines a transactional boundary within which changes must occur atomically. In other words, any modification to the state of an Aggregate, including the Aggregate Root itself and its associated entities, must either succeed entirely or fail. This ensures that the data within the Aggregate remains in a consistent state.

Enforcing Invariants

Invariants are the rules that must be maintained within the Aggregate to ensure data integrity. These invariants represent business rules or conditions that should always be true. For example, if you’re modeling an e-commerce system, an invariant might be that the total price of all the order items must not increase by max amount allowed on the order limit. Therefore, Order and Order Item both are usually part of the same aggregate

💡As a rule of thumb, every time you want to operate on an Aggregate Root, we must retrieve the full object.

When to create a separate Aggregate for an entity?

Different Lifecycles

When entities within the same Aggregate have different lifecycles, it might be a sign that they should belong to a separate Aggregate. In the Task Management System, a Tag is an entity that could be attached to one or more Tasks. Let’s try to first keep both Task and Tag inside the same aggregate and observe what happens by looking at the code example below.

public class Task : AggregateRoot
{
public int Id { get; private set; }
public string Title { get; private set; }
public TaskStatus Status { get; private set; }
// Other task attributes...
private List<Tag> _tags = new List<Tag>();

public Task(int id, string title)
{
Id = id;
Title = title;
Status = TaskStatus.Active; // Initialize as active
}
public void AddTag(Tag tag)
{
// Ensure that the assignment meets business rules.
if (!_tags.Contains(tag))
{
_tags.Add(tag);
}
}
}

public class Tag
{
public int Id { get; private set; }
public string Name { get; private set; }
// Other tag attributes...
public Tag(int id, string name)
{
Id = id;
Name = name;
}
public void UpdateName(string newName)
{
// Ensure that the name can be updated based on business rules.
Name = newName;
}
}

By keeping Task and Tag entities in the same aggregate, we are treating them as a single unit. While you may keep these entities in different database tables, you cannot create a tag unless a task is created. The other way of thinking is that when a task is deleted all the other tags will also be deleted. This is typically not the use case in a Task Management System and hence we need to redesign this by keeping their life cycles in mind. If an entity’s existence is closely tied to another Aggregate Root, it should be part of that Aggregate.

Here’s the C# code to represent Tasks and Tags as separate Aggregates:

public class Task : AggregateRoot
{
public int Id { get; private set; }
public string Title { get; private set; }
public TaskStatus Status { get; private set; }
// Other task attributes...

private List<int> _tagIds = new List<int>();
public Task(int id, string title, TaskStatus status)
{
Id = id;
Title = title;
Status = status;
}
public void AssignTag(int tagId)
{
// Ensure that the assignment meets business rules.
if (!_tagIds.Contains(tagId))
{
_tagIds.Add(tagId);
}
}
public void RemoveTag(int tagId)
{
// Ensure that the unassignment meets business rules.
_tagIds.Remove(tagId);
}
}

public class Tag : AggregateRoot
{
public int Id { get; private set; }
public string Name { get; private set; }
// Other tag attributes...
public Tag(int id, string name)
{
Id = id;
Name = name;
}
}

The difference here is that instead of keeping the entire object in the Task entity, we are now keeping just the identities of the assigned tags. With this code, we can manage Tasks and Tags separately and freely assign or remove tags from any Task without impacting the actual Tag object.

💡 As a rule of thumb, Aggregate Roots should reference each other via identity.

A common misconception is to design Aggregates based on hierarchy and relationships between entities. However, the primary focus should be on behaviors and the invariants you need to enforce within the Aggregate. The data that drives these invariants is what matters most, and it should be based on the behaviors being exposed. Building an object model hierarchy without considering the behaviors can lead to unnecessary complexity.

Concurrent Access & Performance due to Large Collections within Aggregates

Another rule while creating aggregates is that we must populate the whole aggregate before performing any write operations. In a Task Management System, tasks may have comments. Unlike Task and Tags, Comments may not have their own life cycle because it is tightly coupled with a Task. In other words, a comment is created only when a Task is present. All comments associated with a task must be deleted when a task is deleted. Looking at this business requirement, we might think, this is certainly a single unit, which indeed it is but as per the rule, if we were to fetch all the comments along with a task, then we might observe concurrency and performance issues. and Hence, it would be a good practice to keep them in a separate aggregate.

However, If there’s an invariant / business rule that needs to be protected by returning all of the comments under an aggregate boundary, return them all. In contrast, if there’s no underlying invariant to protect, you can optimize by not returning the entire collection for command operations, as it could lead to performance issues.

public class Task : AggregateRoot
{
public int Id { get; private set; }
public string Title { get; private set; }
public TaskStatus Status { get; private set; }
// Other task attributes...

private List<int> _tagIds = new List<int>();
public Task(int id, string title, TaskStatus status)
{
Id = id;
Title = title;
Status = status;
}
public void AssignTag(int tagId)
{
// Ensure that the assignment meets business rules.
if (!_tagIds.Contains(tagId))
{
_tagIds.Add(tagId);
}
}
public void RemoveTag(int tagId)
{
// Ensure that the unassignment meets business rules.
_tagIds.Remove(tagId);
}
}

public class Comment : AggregateRoot
{
public int Id { get; private set; }
public string Text { get; private set; }
public int taskId { get; private set; }
// Other tag attributes...
public Tag(int taskId, string name)
{
taskId = taskId;
Name = name;
}
}

Since we have separated these items into two different aggregates for performance reasons rather than based on their lifecycles, we now need to address two issues.

How do we delete all comments when a task is deleted?

To ensure consistency between the Task and Comments, we should embrace eventual consistency. This entails triggering a domain event upon task deletion. By capturing the event, we can subsequently delete any comments associated with the deleted task.

What if a business rule says that a user can create only 10 comments?

In such a case, we can keep just an integer property inside a Task aggregate representing the number of comments. If the count increases to more than 10, we can raise an exception.

💡 One key point to remember is that you only need to enforce invariants when making state changes. If data within an entity is not related to any invariants or does not need to be consistent within an Aggregate, it doesn’t serve a purpose within that Aggregate.

Don’t Use Domain Model for Querying

A common mistake is using the domain model, including aggregates, for querying purposes. The domain model is primarily designed for handling commands, and you should avoid complex queries that may hinder system performance. For queries, it’s better to execute them directly against repositories or explore techniques like building Read Models.

Relationship Between DDD and CQRS

In the context of DDD, it’s essential to distinguish between Command Query Responsibility Segregation (CQRS) and Aggregates. CQRS is not about using different data stores but about having separate paths for reading and writing. Aggregates fit into this picture by providing a structured way to handle commands (changes to the data) within your domain model.

When it comes to querying (reading data), you can execute queries directly against the repositories or explore the construction of Read Models. This aligns with the principle of keeping your domain model lean and not using it for querying purposes.

public class TaskService
{
private readonly TaskListRepository taskListRepository;
public void AddTask(TaskList taskList, Task task)
{
// Command side - modifies data.
taskList.AddTask(task);
taskListRepository.Save(taskList);
}
public Task GetTask(Guid taskId)
{
// Query side - retrieves data.
return taskListRepository.GetTask(taskId);
}
}

Managing many-to-many relationships

There are two types of many-to-many relationships. The first type is like the example of Tasks and Tags. In this case, we just assign a tag to a task without any additional information about the relationship. Additionally, a tag is assigned to a task, rather than the task being assigned to a tag. From a database perspective, it is still considered a many-to-many relationship, but the relationship is always established from one direction.

The second type is called a “junction table” or “association table” and involves a many-to-many relationship with relationship-level metadata. In this type of relationship, two entities are connected through an intermediary entity that stores additional information about the relationship itself. This relationship may also be established in either direction.

Imagine there is a business requirement that states that a Task can be assigned to a User or (a User can also be assigned to a task) with additional relationship level information like Task Assignment Date. Additionally, there is a business rule stating that a given user should have more than 2 relationships with the same Assignment Date.

In this situation, one possible solution is to create a separate aggregate root, as demonstrated below. However, a challenge arises when determining where to store the business rule that enforces the relationship constraint outlined in the requirements. Since we need the complete list of relationships to perform validation, one option is to utilize Domain Services. These services can retrieve all the relationships for the user, validate the constraint, and subsequently add the relationship.

public class TaskUserRelationship : AggregateRoot
{
public int RelationshipId { get; set; }
public int TaskId { get; set; }
public int UserId { get; set; }
public DateTime AssignmentDate { get; set; }
}

public class TaskUserRelationshipService
{
public void CreateRelationship(Task task, User user, DateTime assignmentDate)
{
var userRelations = this.relationshipRepository.GetRelationsByUser(user.Id);
if(!userRelations.CheckExist(assignmentDate))
{
var newRelation = TaskUserRelationship(taskId, userId, assignmentDate);
this.relationshipRepository.Save(newRelation);
}
}
}

Cons of using Domain Services

While Domain Services play a crucial role in encapsulating business logic, relying on them exclusively can lead to anemic Domain Models. An anemic Domain Model lacks behavior and is primarily a data structure.

To maintain a rich Domain Model, it’s important to carefully evaluate which behavior belongs to entities and when to delegate to Domain Services. It’s acceptable to have more classes in your Domain Model when it results in a more expressive and maintainable design.

Alternatively, we can form an aggregate in this form

public class TaskUserRelationship : AggregateRoot
{
public int TaskId { get; set; }
public List<TaskAssignment> Existing { get; set; }

public void AddRelation(User user, DateTime assignmentDate)
{
if(!userRelations.CheckExist(assignmentDate))
{
var newRelation = TaskUserRelationship(taskId, userId, assignmentDate);
this.relationshipRepository.Save(newRelation);
}
}
}

public class TaskAssignment : AggregateRoot
{
public int RelationshipId { get; set; }
public int UserId { get; set; }
public DateTime AssignmentDate { get; set; }
}

Both approaches have their own advantages and disadvantages. It ultimately comes down to invariants and making trade-offs.

Validations in Aggregate

💡 Make sure your aggregate is always valid and never enters an invalid state. Avoid designing aggregates that accept everything and require developers to call the IsValid method for validation.

Validation within Aggregates is crucial. Invariants need to be enforced consistently to maintain the integrity of the Aggregate. Validations can be implemented in various ways, such as verifying data and raising exceptions if validation fails. If there are not too many business rules, then try to keep the aggregate lean by simply throwing exceptions.

Due Date Validity

Suppose there’s a rule that specifies that a task’s due date must be in the future. The Task entity, as the Aggregate Root, should be responsible for validating and enforcing this rule.

public class Task : AggregateRoot
{
public string Title { get; private set; }
public DateTime DueDate { get; private set; }
public void SetDueDate(DateTime dueDate)
{
if (dueDate <= DateTime.Now)
{
throw new ArgumentException("Due date must be in the future.");
}
DueDate = dueDate;
}
}

However, if you have numerous intricate validation rules and desire a more sophisticated method of managing them, one option is to utilize the Specification Pattern along with throwing exceptions (which will be discussed in the upcoming article). However, just to provide a comprehensive overview, let’s briefly outline how it would appear. One big advantage of the specification pattern is the Single Responsibility Principle and Re-usability.

Task Title Length

public class Task
{
public int Id { get; private set; }
public string Title { get; private set; }
public Task(int id, string title)
{
Id = id;
var specification = TaskTitleLengthSpecification(5, 100);
if(!specification.IsSatisfiedBy(title))
throw new ValidationException("Invalid Title");
Title = title;
}
}
// Specification interface
public interface ISpecification<T>
{
bool IsSatisfiedBy(T entity);
}
// TaskTitleLengthSpecification checks if the task's title meets length criteria.
public class TaskTitleLengthSpecification : ISpecification<Task>
{
private readonly int minTitleLength;
private readonly int maxTitleLength;
public TaskTitleLengthSpecification(int minTitleLength, int maxTitleLength)
{
this.minTitleLength = minTitleLength;
this.maxTitleLength = maxTitleLength;
}
public bool IsSatisfiedBy(Task task)
{
int titleLength = task.Title.Length;
return title length >= minTitleLength && titleLength <= maxTitleLength;
}
}

Conclusion

In conclusion, understanding Aggregate Roots and their role in enforcing invariants is fundamental in Domain-Driven Design. Aggregates define transactional boundaries, ensure data consistency, and provide a clear structure for managing changes within your domain model. Careful consideration of the entities and their transactions, as well as a balance between invariants and performance optimization, is crucial when designing and implementing Aggregate Roots in your domain model. By adhering to these principles, you can build robust and maintainable software systems that accurately reflect your domain’s complexity and requirements.

References

https://khalilstemmler.com/articles/typescript-domain-driven-design/one-to-many-performance/

https://udidahan.com/2009/01/24/ddd-many-to-many-object-relational-mapping/

https://stackoverflow.com/questions/16378688/ddd-one-to-many-relationship-between-user-aggregate-root-and-almost-all-entitie

https://medium.com/ssense-tech/ddd-beyond-the-basics-mastering-aggregate-design-26591e218c8c

https://codeopinion.com/aggregate-ddd-isnt-hierarchy-relationships/

https://learn.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/domain-model-layer-validations

https://stackoverflow.com/questions/24420636/ddd-invariants-business-rules-and-validation?rq=3

https://christiantietze.de/posts/2015/03/3-ways-model-relationship-domain/

https://christiantietze.de/posts/2015/03/3-ways-model-relationship-domain/

--

--