Skip to content

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 Order is 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 Order and upgrading a Customer are 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:
    1. 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 Order and Customer.
    2. Tightly Coupled: The Order processing logic is now hard-coded with a dependency on the Customer logic. If new rules are added later (e.g., send an email, add reward points), this service will become increasingly bloated.
    3. Violates Single Responsibility Principle: The Order service should not be responsible for handling Customer logic.

The Solution: Use Domain Events for Communication ✅

  • The Logic:

    1. 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."
    2. After the Aggregate's transaction is successfully saved, the system "dispatches" these events.
    3. Other modules in the system, called Event Handlers, "listen" for these events and execute their own logic in separate transactions.
  • Optimized Code:

    1. Define the Event: (This is a simple Value Object)

      csharp
      public record OrderPaidEvent(int OrderId, int CustomerId, Money TotalPrice);
    2. Raise the Event from the Aggregate:

      csharp
      public 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));
          }
      }
    3. 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);
          }
      }
    4. Update the Application Service: The Order service is now very simple and focused on its own task.

      csharp
      public 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 ✨

  1. Loosely Coupled: The Order Aggregate knows nothing about the Customer'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.
  2. Adheres to Aggregate Boundaries: The transaction for paying an Order is simple and only involves the Order. The transaction for upgrading the Customer is a separate, independent transaction triggered afterward.
  3. 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.