Demystified Aggregates in DDD & .NET: From Theory to Practice
Introduction
Domain-Driven Design (DDD) is one of the key foundations of modern software architecture and has taken a strong place in the .NET world. At the center of DDD are Aggregates, which protect the consistency of business rules. While they are one of DDD’s biggest strengths, they’re also one of the most commonly misunderstood ideas. Trying to follow “pure” DDD rules to the letter often clashes with the complexity and performance needs of real-world projects, leaving developers in tough situations. The goal of this article is to take a fresh, practical look at Aggregates and show how they can be applied in a way that works in real life.
Chapter 1: Laying the Groundwork: What Is a Classic Aggregate?
Before jumping into pragmatic shortcuts, let’s make sure we’re all on the same page. To do that, we’ll start with the classic “by the book” definition of an Aggregate and the rules that make it tick.
What Exactly Is an Aggregate?
At its simplest, an Aggregate is a group of related objects (Entities and Value Objects) that are treated as one unit of change. And this group has a leader: the Aggregate Root.
Aggregate Root → Think of it as the gatekeeper. All outside commands (like “add a product to the order”) must go through the root. You can’t just poke around and change stuff inside.
Entity → Objects within the Aggregate that have their own identity (ID). Example: an
OrderLine
inside anOrder
.Value Object → Objects without an identity. They’re defined entirely by their values, like an
Address
orMoney
.
The Aggregate’s main purpose isn’t just grouping things together—it’s about protecting business rules (invariants). For example: “an order’s total amount can never be negative.” The Aggregate Root makes sure rules like this are never broken.
The Role of Aggregates: Transaction Boundaries
The most important job of an Aggregate is defining the transactional consistency boundary. In other words:
👉 Any change you make inside an Aggregate either fully succeeds or fully fails. There’s no half-done state.
From a database perspective, when you call SaveChanges()
or Commit()
, everything within one Aggregate gets saved in a single transaction. If you add a product and update the total price, those two actions are atomic—they succeed together. Thanks to Aggregates, you’ll never end up in weird states like “product was added but total wasn’t updated.”
The Golden Rules of Aggregates
Classic DDD lays out three golden rules for working with Aggregates:
Talk Only to the Root
You can’t directly update something like anOrderLine
. You must go through the root:Order.AddOrderLine(...)
orOrder.RemoveOrderLine(...)
. That way, the root always enforces the rules.Reference Other Aggregates by ID Only
AnOrder
shouldn’t hold aCustomer
object directly. Instead, it should just storeCustomerId
. This keeps Aggregates independent and avoids loading massive object graphs.Change Only One Aggregate per Transaction
Need to create an order and update loyalty points? Classic DDD says: do it in two steps. First, save theOrder
. Then publish a domain event to update theCustomer
. This enables scalability but introduces eventual consistency.
A Classic Example: The Order Aggregate in .NET
Here’s a simple example showing an Order
Aggregate that enforces a business rule:
// Aggregate Root: The entry point and rule enforcer
public class Order
{
public Guid Id { get; private set; }
public Guid CustomerId { get; private set; }
private readonly List<OrderLine> _orderLines = new();
public IReadOnlyCollection<OrderLine> OrderLines => _orderLines.AsReadOnly();
public decimal TotalPrice { get; private set; }
public Order(Guid id, Guid customerId)
{
Id = id;
CustomerId = customerId;
}
public void AddOrderLine(Guid productId, int quantity, decimal price)
{
// Rule 1: Max 10 order lines
if (_orderLines.Count >= 10)
throw new InvalidOperationException("An order can contain at most 10 products.");
// Rule 2: No duplicate products
var existingLine = _orderLines.FirstOrDefault(ol => ol.ProductId == productId);
if (existingLine != null)
throw new InvalidOperationException("This product is already in the order.");
var orderLine = new OrderLine(productId, quantity, price);
_orderLines.Add(orderLine);
RecalculateTotalPrice();
}
private void RecalculateTotalPrice()
{
TotalPrice = _orderLines.Sum(ol => ol.TotalPrice);
}
}
public class OrderLine
{
public Guid Id { get; private set; }
public Guid ProductId { get; private set; }
public int Quantity { get; private set; }
public decimal UnitPrice { get; private set; }
public decimal TotalPrice => Quantity * UnitPrice;
public OrderLine(Guid productId, int quantity, decimal unitPrice)
{
Id = Guid.NewGuid();
ProductId = productId;
Quantity = quantity;
UnitPrice = unitPrice;
}
}
Here, the Order
enforces the rule “an order can have at most 10 items” inside its AddOrderLine
method. Nobody outside the class can bypass this, because _orderLines
is private.
👉 That’s the real strength of a classic Aggregate: business rules are always protected at the boundary.
Chapter 2: Theory in Books vs. Reality in Code — Why Classic Aggregates Struggle
In Chapter 1, we painted the “ideal” world of DDD. Aggregates were like fortresses guarding our business rules…
But what happens when we try to build that fortress in a real project with tools like Entity Framework Core? That’s when the gap between theory and practice starts to show up.
1. That .Include()
Chain — Do We Really Need It? The Performance Trap
DDD books tell us: “To validate a business rule, you must load the entire aggregate into memory.”
Sounds reasonable if consistency is the goal.
But let’s picture a scenario: we have an Order
aggregate with 500 order lines inside it. And all we want to do is change its status to Confirmed
.
// Just to update a single field...
var order = await _context.Orders
.Include(o => o.OrderLines) // <-- 500 rows pulled in!
.SingleOrDefaultAsync(o => o.Id == orderId);
order.Confirm(); // Just sets order.Status = "Confirmed";
await _context.SaveChangesAsync();
This query pulls all 500 order lines into memory just so we can flip a single Status
field. Even in small projects, this is a silent performance killer. As the system grows, it will drag your app down.
2. The Abandoned Fortress — Sliding into Anemic Domain Models
Now, what’s a developer’s natural reaction to this? Something like:
“Pulling this much data is expensive. Maybe I should strip down the aggregate into a plain POCO with properties only, and move the logic into an OrderService
class.”
This is how we slip straight into the Anemic Domain Model trap. Our classes lose their behavior, becoming nothing more than data bags.
The whole DDD principle of “keep behavior close to data” evaporates. Business logic leaks out of the aggregate and spreads across services. We think we’re doing DDD, but in reality, we’ve fallen back into classic transaction-script style coding.
3. One Model Doesn’t Fit All — The Clash of Command and Query
Aggregates are designed for commands — write operations where business rules must be enforced.
But what about queries? Imagine a dashboard where we just want to list the last 10 orders. All we need is OrderId
, CustomerName
, and TotalAmount
.
Loading 10 fully-hydrated Order
aggregates (with all their order lines) just for that list? That’s like using a cannon to hunt a sparrow. Wasteful, slow, and clumsy.
Aggregates simply aren’t built for reporting or read-heavy scenarios.
And there you have it — the three usual suspects that make developers doubt DDD in real life:
Performance headaches
The risk of falling into an Anemic Model
Aggregates being too heavy for read operations
So, should we give up on DDD? Absolutely not!
The key is to stop following the rules blindly and instead focus on their real intent. In the next chapter, we’ll explore the pragmatic approach — Demystified Aggregates — and how they can actually help us solve these problems.
Chapter 3: Enter the Solution — What Exactly Is a "Demystified Aggregate"?
The issues we listed in the last chapter don’t mean DDD is bad. They just show that blindly applying textbook rules without considering the realities of your project creates friction.
A Demystified Aggregate isn’t a library or a framework. It’s a way of thinking. Its philosophy is simple: focus on the Aggregate’s real job, and make sure it does that job as efficiently as possible.
1. Philosophy: Focus on Purpose, Not Rules
What’s the Aggregate’s most sacred duty?
To protect business rules (invariants) during a data change (command).
Here’s the key: an Aggregate’s job isn’t to always hold all data in memory. Its job is to ensure consistency while performing an operation.
Think of it like a security guard at a bank vault. Their job is to make sure transfers are done correctly. They don’t need to memorize the serial number of every single banknote. They just need the critical info for the current operation: the balance and the transfer amount.
The Demystified Aggregate says the same thing: when running a method, you only load the data that method actually needs, not the entire Aggregate.
2. The Core Idea: What “State” Does a Behavior Actually Need?
To apply this idea in code, ask yourself:
“What data does the Confirm()
method on my Order
Aggregate actually need?”
Maybe just the order’s current
Status
. ("Pending"
can become"Confirmed"
,"Cancelled"
throws an error.)What about
AddItem(product, quantity)
?It needs the
Status
(can’t add items to a cancelled order).And maybe the existing
OrderLines
(to increase quantity if the item already exists).
See the pattern? Each behavior needs different data. So why load everything every single time?
3. How Do We Do This in .NET & EF Core? Practical Solutions
Putting this philosophy into code is easier than you might think.
The Approach: Purpose-Built Repository Methods
Instead of a generic GetByIdAsync()
, create methods tailored to the operation at hand. Let’s revisit our classic Order Confirmation scenario in a “Before & After” style.
BEFORE (Classic & Inefficient Approach)
// Repository Layer
public async Task<Order> GetByIdAsync(Guid id)
{
// LOAD EVERYTHING!
return await _context.Orders
.Include(o => o.OrderLines)
.SingleOrDefaultAsync(o => o.Id == id);
}
// Application Service Layer
public async Task ConfirmOrderAsync(Guid orderId)
{
var order = await _orderRepository.GetByIdAsync(orderId);
order.Confirm(); // This method might not even care about OrderLines!
await _unitOfWork.SaveChangesAsync();
}
AFTER (Demystified & Focused Approach)
// Repository Layer
public async Task<Order> GetForConfirmationAsync(Guid id)
{
// LOAD ONLY WHAT WE NEED! (No OrderLines needed)
return await _context.Orders
.SingleOrDefaultAsync(o => o.Id == id);
}
// Application Service Layer
public async Task ConfirmOrderAsync(Guid orderId)
{
// Intent is crystal clear in the code!
var order = await _orderRepository.GetForConfirmationAsync(orderId);
// Aggregate still protects the business rule.
// Confirm() checks status, etc.
order.Confirm();
await _unitOfWork.SaveChangesAsync();
}
What Do We Gain?
Awesome Performance: We avoid unnecessary JOINs and data transfer.
Clear Intent: Anyone reading
GetForConfirmationAsync
immediately knows this operation only cares about the order itself, not its items. Code documents itself.No Compromise: Our Aggregate still enforces the business rules via
Confirm()
. DDD’s spirit remains intact.
For read/query operations, the answer is even simpler: skip Aggregates altogether! Use optimized queries that return DTOs via Select
projections, or even raw SQL with Dapper.
That’s the essence of a Demystified Aggregate: using the right tool for the right job.
In the next chapter, we’ll wrap everything up and tie all the concepts together.
Conclusion: Pragmatism Beats Dogmatism in DDD
We’ve reached the finish line. We started with the “pure” textbook definition of Aggregates in the ideal world of Domain-Driven Design. Then we hit the real-world walls of performance and complexity. Finally, we learned how to break through those walls.
The biggest lesson from the Demystified Aggregates approach is simple:
DDD isn’t a rigid rulebook — it’s a way of thinking.
Our goal isn’t to implement the “most pure DDD ever written in a book.” It’s to make our domain logic clean, solid, understandable, and performant. In this journey, patterns and rules should serve us, not the other way around.
Key Takeaways
Focus on the Core Purpose:
The primary reason an Aggregate exists is to enforce business rules (invariants) and ensure consistency while handling a command. Every design decision should revolve around this purpose.Load Only What You Need:
You don’t have to load the entire Aggregate to execute a behavior. Use purpose-built repository methods (GetForX()
) to fetch just the data needed for the operation. This can drastically improve both performance and readability.Separate Writing from Reading:
Use rich, protected Aggregates for commands (write operations). For queries (read operations), don’t burden your Aggregates. Instead, rely on projections, DTOs, or optimized queries. This is one of the simplest, most practical ways to embrace CQRS principles.
Don’t be afraid to shape your Aggregates based on your project and the realities of your tools (like Entity Framework Core). The power of DDD lies in its flexibility and pragmatism.
Comments
No one has commented yet, be the first to comment!