Chapter 6: Communication Between Aggregates - Domain Events
Of course. After creating Aggregates and Repositories, a natural question arises: "How can one Aggregate communicate with or cause an effect in another Aggregate without breaking the rules we've established?" The answer is Domain Events.
The Scenario 📝
- System: An e-commerce application.
- New Business Rule: "When an
Orderis successfully paid for, the system must check the customer's total spending. If it exceeds a certain threshold, the customer is upgraded to VIP status." - Problem: Paying for an
Orderand upgrading aCustomerare two business operations belonging to two completely different Aggregates. How do they interact?
The Problematic Design: Logic in the Application Service
The most intuitive approach is to cram the logic into the Application Service.
csharp
public class OrderApplicationService
{
private readonly IOrderRepository _orderRepo;
private readonly ICustomerRepository _customerRepo;
public async Task MarkOrderAsPaid(int orderId)
{
// Start transaction
var order = await _orderRepo.GetByIdAsync(orderId);
order.MarkAsPaid(); // Change the state of the Order Aggregate
// MISTAKE: Handling Customer logic inside the Order service
var customer = await _customerRepo.GetByIdAsync(order.CustomerId);
customer.UpdateLifetimeValue(order.TotalPrice);
if (customer.IsEligibleForVipStatus())
{
customer.UpgradeToVip();
}
// Trying to save two Aggregates in the same transaction
await _orderRepo.SaveChangesAsync(order);
await _customerRepo.SaveChangesAsync(customer);
// End transaction
}
}- Problem Analysis:
- Violates "One Transaction, One Aggregate" Rule: DDD encourages each transaction to modify only a single Aggregate to ensure consistency. The code above is changing both
OrderandCustomer. - Tightly Coupled: The
Orderprocessing logic is now hard-coded with a dependency on theCustomerlogic. If new rules are added later (e.g., send an email, add reward points), this service will become increasingly bloated. - Violates Single Responsibility Principle: The
Orderservice should not be responsible for handlingCustomerlogic.
- Violates "One Transaction, One Aggregate" Rule: DDD encourages each transaction to modify only a single Aggregate to ensure consistency. The code above is changing both
The Solution: Use Domain Events for Communication ✅
The Logic:
- When an important action occurs, the Aggregate Root does not call another service. Instead, it creates and records a Domain Event. This event is simply an object that describes "what happened."
- After the Aggregate's transaction is successfully saved, the system "dispatches" these events.
- Other modules in the system, called Event Handlers, "listen" for these events and execute their own logic in separate transactions.
Optimized Code:
Define the Event: (This is a simple Value Object)
csharppublic record OrderPaidEvent(int OrderId, int CustomerId, Money TotalPrice);Raise the Event from the Aggregate:
csharppublic class Order : AggregateRoot // Assume a base class for managing events { public void MarkAsPaid() { // ... business logic ... this.Status = "Paid"; // Add the event to a list to be dispatched later this.AddDomainEvent(new OrderPaidEvent(this.Id, this.CustomerId, this.TotalPrice)); } }Create a Separate Event Handler:
csharp// This handler only listens for the OrderPaidEvent public class UpgradeCustomerToVipHandler : IEventHandler<OrderPaidEvent> { private readonly ICustomerRepository _customerRepo; public async Task Handle(OrderPaidEvent domainEvent) { // The Customer handling logic is completely contained here var customer = await _customerRepo.GetByIdAsync(domainEvent.CustomerId); customer.UpdateLifetimeValue(domainEvent.TotalPrice); if (customer.IsEligibleForVipStatus()) { customer.UpgradeToVip(); } await _customerRepo.SaveChangesAsync(customer); } }Update the Application Service: The
Orderservice is now very simple and focused on its own task.csharppublic async Task MarkOrderAsPaid(int orderId) { var order = await _orderRepo.GetByIdAsync(orderId); order.MarkAsPaid(); await _orderRepo.SaveChangesAsync(order); // <-- After saving, the event will be dispatched }(The "dispatching" of the event is typically handled by a central Dispatcher mechanism, for example, using the MediatR library)
Analysis of the Result ✨
- Loosely Coupled: The
OrderAggregate knows nothing about theCustomer's VIP system. It just announces, "I have been paid." Any module interested in this event (VIP upgrade, email sending, inventory notification...) can listen and react. - Adheres to Aggregate Boundaries: The transaction for paying an
Orderis simple and only involves theOrder. The transaction for upgrading theCustomeris a separate, independent transaction triggered afterward. - Focused and Maintainable: The VIP upgrade logic is neatly contained within the
UpgradeCustomerToVipHandler, right where it belongs.
Conclusion:
- Domain Events are the primary communication mechanism between Aggregates.
- They allow you to build loosely coupled systems where different parts can react to changes without being directly dependent on each other.
- This is an essential pattern for maintaining clean Aggregate boundaries and building complex, maintainable applications.