You can emit domain events from CQRS aggregates to trigger side effects, but the trick is doing it after the aggregate has successfully persisted its state.
Let’s say you have an Order aggregate and you want to send a confirmation email when an order is placed.
public class Order
{
private List<IDomainEvent> _domainEvents = new List<IDomainEvent>();
public Guid Id { get; private set; }
public OrderStatus Status { get; private set; }
public void PlaceOrder(Guid orderId, IEnumerable<OrderItem> items)
{
if (Status != OrderStatus.Pending)
throw new InvalidOperationException("Order cannot be placed more than once.");
Id = orderId;
// ... logic to add items, calculate total ...
Status = OrderStatus.Placed;
_domainEvents.Add(new OrderPlacedEvent(Id, items.Select(i => i.ProductId).ToList()));
}
// Method to retrieve and clear events
public IEnumerable<IDomainEvent> GetAndClearDomainEvents()
{
var events = _domainEvents.ToList();
_domainEvents.Clear();
return events;
}
}
public enum OrderStatus { Pending, Placed, Shipped, Cancelled }
public record OrderPlacedEvent(Guid OrderId, List<Guid> ProductIds) : IDomainEvent;
public interface IDomainEvent {}
The OrderPlacedEvent is added to an internal list (_domainEvents) within the Order aggregate. This list is then cleared after the events are published, ensuring that the same event isn’t published multiple times if the aggregate is reloaded.
When you save the Order aggregate, your persistence mechanism (e.g., an event store or a relational database) should first save the aggregate’s state (like Id and Status). Only after this state has been successfully persisted do you retrieve and publish the domain events.
Imagine your OrderService handles the command and persistence:
public class OrderService
{
private readonly IRepository<Order> _orderRepository;
private readonly IDomainEventPublisher _eventPublisher;
public OrderService(IRepository<Order> orderRepository, IDomainEventPublisher eventPublisher)
{
_orderRepository = orderRepository;
_eventPublisher = eventPublisher;
}
public async Task PlaceOrder(Guid orderId, IEnumerable<OrderItem> items)
{
var order = new Order(); // Or load from repository if it exists
order.PlaceOrder(orderId, items);
// 1. Persist the aggregate state FIRST
await _orderRepository.SaveAsync(order);
// 2. THEN publish domain events
var domainEvents = order.GetAndClearDomainEvents();
foreach (var domainEvent in domainEvents)
{
await _eventPublisher.PublishAsync(domainEvent);
}
}
}
public interface IRepository<T> { Task SaveAsync(T aggregate); }
public interface IDomainEventPublisher { Task PublishAsync(IDomainEvent domainEvent); }
The OrderService calls _orderRepository.SaveAsync(order) which commits the aggregate’s state to your chosen data store. If that succeeds, it then calls order.GetAndClearDomainEvents() to get the list of events that occurred during the command execution and iterates through them, publishing each one via _eventPublisher.
A common pattern is to use a message bus (like Azure Service Bus, RabbitMQ, or Kafka) for publishing these events. A separate process or worker then subscribes to these events and performs the side effect, such as sending an email.
public class EmailNotificationService
{
public async Task Handle(OrderPlacedEvent notification)
{
Console.WriteLine($"Sending confirmation email for order {notification.OrderId}...");
// ... logic to send email using a mail service ...
await Task.Delay(100); // Simulate email sending
Console.WriteLine($"Email sent for order {notification.OrderId}.");
}
}
The EmailNotificationService would be registered with your event bus subscriber, listening specifically for OrderPlacedEvent messages.
The most surprising thing about this pattern is how it decouples the core business logic from external concerns. The Order aggregate itself has no knowledge of email confirmations or shipping notifications; it simply declares what happened. This makes the aggregate simpler, more testable, and easier to evolve.
When you have multiple event handlers for the same event, they’ll all be triggered. If one handler fails, it shouldn’t necessarily stop others from processing, but your infrastructure needs to handle retries and idempotency gracefully.
This approach ensures that side effects are only triggered if the primary action (saving the aggregate state) was successful, preventing scenarios where an email is sent for an order that ultimately failed to save.
The next challenge is often managing distributed transactions or ensuring eventual consistency when side effects involve other services or databases.