# Aggregate Design (Part 1)

While the theory is something on its own, when it comes to practice, what a lot of teams have ended up with are classes that do not model behavior, but rather classes that either do too much, or model data they are not concerned with.

In this series, I hope to help clarify some of the biggest anti-patterns and what i learnt when working with large scale codebases.

<div data-node-type="callout">
<div data-node-type="callout-emoji">ℹ</div>
<div data-node-type="callout-text"><strong>Note: </strong>like with everything in programming, the answer is usually “it depends”. This article can be seen as opinionated and a lot of the information here is knowledge that I came across in real-world projects but nonetheless anecdotal and based on personal experience and how I learnt DDD.</div>
</div>

## What is an aggregate?

the Oxford dictionary defines an aggregate as:

> "A whole, formed by combining separate elements."

In DDD, aggregates are defined as a **cluster of associated domain objects** (VOs, entities) that is treated as a single unit - a **consistency boundary** that external services can reference, and it enforces its own business rules and invariants.

## It’s not about relationships

Don’t think of aggregates as relational models:

> "An aggregate is like a table in my database, and the relationships between aggregates are like foreign keys."

That’s not correct, because: aggregates are not optimized for *persistence*, but rather for *business rules* and *consistency boundaries*.

When you model aggregates like rdbms tables, you end up:

1. Thinking in joins and foreign key constraints instead of business invariants
    
2. Over-coupling aggregates to update multiple aggregates in a single transaction
    
3. Building a domain model that mirrors your database schema, not your **ubiquitous language**.
    

(More on those anti patterns in another article)

In DDD, persistence is secondary. the aggregate should be about transactional integrity and domain meaning, not storage layout.

Its about the “writes“ - not in the database sense but in the CQRS sense. Aggregates, and in general, your entire domain is concerned with the write (commands if you use CQRS) side of things. When you start to realize this, the way you think about your domain starts to change.

> "Should my aggregate have a total orders count?"

Before: "yes, because my client wants to see it"

After: "it depends. Is there a business rule that depends on order count? If not, then a read model should have that and not my domain model."

## Chase the behavior

When most people start modeling, they think like this:

“What are the things in my domain, and how do they relate to each other?”

So they (conceptually) draw something like:

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1760641324299/ede9639c-2b35-4bc6-8a9c-f878034c27d0.png align="center")

But that’s a relationship map about how objects connect, not *what they do*.

Instead of asking “What is this object related to?”, you should be asking “What behaviors or business rules must this concept enforce by itself?”

You design your aggregate around:

* What operations must be atomic (transactional)
    
* What invariants must always hold true together after each transaction
    
* What domain language and commands apply to it
    

Instead of defining aggregates by relationships, you define them by responsibilities. A very good guide for this is going to be your ubiquitous language (which is the first model of your domain).

## Cross-aggregate communication

You might be wondering, if we're not modelling relationally, then how can two aggregates within the same bounded context talk to each other? And generally speaking, they shouldn’t.

An aggregate is meant to enforce its **own** consistency boundary. Each aggregate protects its invariants and should be able to maintain those invariants *without depending* on the internal state of another aggregate. If two aggregates really need to coordinate to remain valid transactionally, that could be a sign that they actually belong together as a single aggregate.

In general, when an aggregate needs to reference another aggregate, it does so by **identity** and not by holding a direct reference to the other object. This avoids pulling entire object graphs into memory and ensures that each aggregate remains truly independent by making persistence operations simple & explicit, and enforcing clear transactional boundaries.

When aggregates *do* need to coordinate, they can communicate through domain events or domain services, depending on whether or not this coordination requires transactional consistency (then opt for services), or if eventual consistency is acceptable (then opt for events).

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1760638510318/6fd09ea1-a63e-4a11-8cd0-cbef89664295.png align="center")

This prevents a single transaction from touching too many things, leading to locking contention, or making it hard to reason about consistency.

## Cross-context communication

Many people find cross-context communication to be tricky, but conceptually it’s simpler than it seems because you *technically* shouldn’t be able to reference another module directly at all. Each bounded context owns its own model, language, and rules. That means there are *no direct code-level references* between contexts and all communication happens through explicit contracts or integration patterns, not through shared objects or class references.

This is where a lot of the strategic DDD comes into play with things like upstream/downstream relationships, open-host services, and context-mapping for how contexts interact while remaining autonomous.

(I assume) we already know that the ubiquitous language is a good indicator for separating the boundaries of a system - i.e. once the language begins to change, chances are you’re operating in a different context. To make this easier we'll use the following example of a car rental management system with 3 bounded contexts:

* Rental Contracting
    
* Fleet Management
    
* Customer Relations
    

In RentalContracting, a **"contract"** is made for a **"driver"**. But what a driver really is, a **"customer"** from the CustomerRelations bounded context (see how the language differs?).

Different words, same underlying real-world concept, but different **models and responsibilities**.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1760636158596/a03b2303-b5be-48e0-8280-95fdd3f2e790.png align="center")

From the **RentalContracting** point of view:

* It doesn’t manage the full lifecycle of a customer (onboarding, behavior metrics, contact details, ....)
    
* It just needs enough information to decide whether a given person is *eligible to drive* e.g., valid license, age, traffic history, etc.
    

So instead of referencing the Customer entity directly (which belongs to another bounded context), the RentalContracting defines its own local model called `Driver` which is a contextual abstraction that maps to a `Customer` in **CustomerRelations**, but it exists independently in the Rental Context’s model. When it needs to create a contract, it doesn’t pull in a `Customer` object in, it either:

* Queries a public API or open-host service exposed by the Customer Relations, or
    
* receives an integration event (e.g. CustomerVerified) that provides the data it needs to populate its local Driver model.
    

The result is that the rental context can enforce its own invariants without breaking modularity or crossing model boundaries.

So in short, Even though “Driver” and “Customer” refer to the same person in the real-world, they live in different *conceptual universes*. The relationship between them isn’t a direct reference but rather it’s a **mapping** between two models that speak different dialects of the same language.

For my visual learners, here’s a simplified example:

```csharp
// Domain.RentalContracting
public record Driver(string Name);

public record Car(string Plates);
```

```csharp
// Domain.RentalContracting
public class Rental : AggregateRoot
{
    public Driver Driver { get; private set; }
    public Car Car { get; private set; }
    public RentalPeriod { get; private set; }
    public DateTimeOffset? EndDate { get; private set; }
    public RentalStatus Status { get; private set; }

    public static Rental Create(Driver driver, Car car, RentalPeriod period)
    {
        var rental = new Rental(driver, car)
        {
            Period = period,
            Status = RentalStatus.Booked
        };

        RaiseEvent(new RentalCreatedEvent(rental.Id, rental.Driver, rental.Car));
        return rental;
    }

    public void Start()
    {
        if(Status != RentalStatus.Booked)
        {
            throw new DomainException("Rental has already started");
        }

        Status = RentalStatus.Started;
        RaiseEvent(new RentalStartedEvent(id));
    }

    public void Stop()
    {
        if(Status != RentalStatus.Started)
        {
            throw new DomainException("Cannot stop a rental that has not started");
        }

        Status = RentalStatus.Stopped;
        EndDate = DateTimeOffset.UtcNow;
        RaiseEvent(new RentalStoppedEvent(id));
    }
}
```

```csharp
// Application.RentalContracting
public interface IDriverService
{
    Task<Driver> GetDriver(Guid id);
}

public interface ICarService
{
    Task<Car> GetCar(Guid id);
}

// This is where the Orchestration happens
// We use our ACL's to fetch the Driver/Car
// In order to create a new rental.
public class CreateRentalCommandHandler : IRequestHandler<CreateRentalCommand>
{
    private readonly IDriverService _driverService;
    private readonly ICarService _carService;

    public CreateRentalCommandHandler(IDriverService driverService, ICarService carService)
    {
        _driverService = driverService;
        _carService = carService;
    }

    public async Task Handle(CreateRentalCommand request, CancellationToken cancellationToken = default)
    {
        var driver = await _driverService.GetDriver(request.DriverId);
        var car = await _carService.GetCar(request.CarId);

        var rental = Rental.Create(driver, car, new RentalPeriod(request.StartDate, request.EndDate));
    }
}
```

```csharp
// Infrastructure Layer
// this is assuming you're in the same codebase.
// If not, these could be API requests for example,
// to retrieve the Car/Driver.
public class DriverService : IDriverService
{
    private readonly DbContext _dbContext;

    public async Task<Driver> GetDriver(Guid id)
    {
        var customer = await _dbContext.Customers.FirstOrDefaultAsync(c => c.Id == id);
        return new Driver(customer.Name);
    }
}

public class CarService : ICarService
{
    private readonly DbContext _dbContext;

    public async Task<Car> GetCar(Guid id)
    {
        var vehicle = await _dbContext.Vehicles.FirstOrDefaultAsync(c => c.Id == id);
        return new Car(vehicle.Plates);
    }
}
```

---

## Persistence Entities

The natural next question to ask is how can we persist our domain model which is not optimized for storage, and the answer is that your infrastructure has a separate model.

One of the main confusions I had when I first started out with ddd was that my `AppDbContext` would look something like:

```csharp
public DbSet<Rental> Rentals { get; } // Rental Aggregate Root from RentalContracting context
public DbSet<Customer> Customers { get; } // Customer Aggregate Root from CustomerRelations context
public DbSet<Vehicles> Vehicles { get; } // Vehicles Aggregate Root from FleetManagement context
```

The problem is that if we model our domain correctly, it would either be difficult or outright impossible to have the database represent our domain model correctly.

The solution? Its to separate your domain model from your persistence entities (which like we had already said, is how you should be thinking):

```csharp
// Infrastructure layer
[Table("Rentals")]
public class RentalEntity
{
    [Key]
    public Guid Id { get; set; }
    
    [Required]
    public Guid DriverId { get; set; }

    [ForeignKey(nameof(PrimaryDriverId))]
    public DriverEntity Driver { get; set; } = null!;

    // ...
}

public class AppDbContext : DbContext
{
    public DbSet<RentalEntity> Rentals { get; }
}
```

Persistence is an infrastructure-layer concern, and so naturally that’s where the persistence entities belong. They can specify db-specific annotations like `[Key]` etc. and optimize the persistence model for storage (Navigation properties, etc.). If you’re using EfCore, you could also opt for the `IEntityTypeConfiguration` approach, if you’re not a fan of data annotations.

The point is that you have 3 models:

* Domain Model: lives within your domain layer, represents your business logic and consistence boundaries
    
* Persistence Entities: a representation of your domain layer that will be persisted and is defined in the infrastructure layer
    
* DTOs: Simple objects that live in your application layer and transfer/translate between your domain and client
    

This is where mapping libraries (e.g. AutoMapper) come into play. They exist to make the sort of mapping between the different layers much easier to work with. You could also manually perform the mappings.

But this raises an important question: What about queries? If our persistence entities exist within our infrastructure layer, how is the application layer supposed to perform rich queries, if any queries at all?

This problem isn’t too different from the age-old question of whether or not the application layer should reference EfCore or your DbContext in order to perform any sort of meaningful queries. If you lean more towards referencing the DbContext in your application layer, then by default you have access to the persistence entities.

Personally, I think that queries are more of an infrastructure layer concern due to the nature of the optimizations that can be performed and the fact that persistence entities exist there. The application layer still defines the “contracts”, here’s what it could look like:

```csharp
// Application Layer
public record ListRentalsQuery(int Page, int PageSize) : IRequest<PaginatedResponse<ListRentalsQueryResponse>>;
public record ListRentalsQueryResponse(Guid Id, DateTime StartDate, DateTimeEndDate); 

// Infrastructure Layer
public class ListRentalsQueryHandler : IRequestHandler<ListRentalsQuery, PaginatedResponse<ListRentalsQueryResponse>>
{
    private readonly AppDbContext _dbContext;

    public ListRentalsQueryHandler(AppDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public async Task Handle(ListRentalsQuery request, CancellationToken cancellationToken = default)
    {
        var rentals = await _dbContext.Rentals
            .AsNoTracking()
            .Select(r => new ListRentalsQueryResponse(r.Id, r.StartDate, r.EndDate))
            .Skip(request.Page * request.PageSize)
            .Take(request.PageSize)
            .ToListAsync();

        return new PaginatedResponse<ListRentalsQueryResponse>
        {
            Data = rentals,
            Page = request.Page,
            PageSize = request.PageSize
        }
    }
} 
```

---

This brings me to the end of this article on how I personally go about thinking when designing domain models and the effects it has across the entire system. To summarise:

* You don’t model aggregates based on what you think the relationships are
    
* You look at the behavior and business rules
    
* Aggregates are not meant to be optimized for persistence
    
* Persistence entities can and should be modelled separately
    

In the next article, we’ll take a look at some anti-patterns and then we’ll take a look at how we can promote our traditional CRUD-based aggregates into event-sourced aggregates. Thanks 👋.
