Domain Events
A Domain Event is an important pattern in Domain-Driven Design for capturing business facts. It records important business events that have occurred in the domain, enabling loosely coupled communication between aggregates.
What is a Domain Event
Section titled “What is a Domain Event”A domain event represents something that has already happened in the domain, with the following characteristics:
- Business meaning: reflects a real business fact
- Past tense: event names use the past tense (e.g.
OrderPlaced, notPlaceOrder) - Immutability: once an event is created, it cannot be modified
- Asynchronous processing: event handlers respond to events asynchronously
Defining a Domain Event
Section titled “Defining a Domain Event”A Basic Event
Section titled “A Basic Event”using MiCake.DDD.Domain;
// Order submitted eventpublic class OrderSubmittedEvent : IDomainEvent{ public int OrderId { get; } public int CustomerId { get; } public decimal TotalAmount { get; } public DateTime SubmittedAt { get; }
public OrderSubmittedEvent(int orderId, int customerId, decimal totalAmount) { OrderId = orderId; CustomerId = customerId; TotalAmount = totalAmount; SubmittedAt = DateTime.UtcNow; }}
// User registered eventpublic class UserRegisteredEvent : IDomainEvent{ public int UserId { get; } public string Email { get; } public DateTime RegisteredAt { get; }
public UserRegisteredEvent(int userId, string email) { UserId = userId; Email = email; RegisteredAt = DateTime.UtcNow; }}Record Events (Recommended)
Section titled “Record Events (Recommended)”Using C# records allows you to define events more concisely:
// Define events using recordspublic record ProductCreatedEvent(int ProductId, string Name, decimal Price) : IDomainEvent;
public record PriceChangedEvent(int ProductId, decimal OldPrice, decimal NewPrice) : IDomainEvent;
public record OrderCancelledEvent(int OrderId, string Reason) : IDomainEvent;Raising Domain Events
Section titled “Raising Domain Events”Raising Events in an Aggregate Root
Section titled “Raising Events in an Aggregate Root”public class Order : AggregateRoot<int>{ private List<OrderItem> _items = new();
public int CustomerId { get; private set; } public OrderStatus Status { get; private set; }
public void Submit() { if (Status != OrderStatus.Draft) throw new DomainException("Only draft orders can be submitted");
if (!_items.Any()) throw new DomainException("Cannot submit empty order");
// Change the status Status = OrderStatus.Submitted;
// Raise a domain event RaiseDomainEvent(new OrderSubmittedEvent(Id, CustomerId, TotalAmount)); }
public void Cancel(string reason) { if (Status == OrderStatus.Shipped) throw new DomainException("Cannot cancel shipped order");
Status = OrderStatus.Cancelled; RaiseDomainEvent(new OrderCancelledEvent(Id, reason)); }
public void AddItem(int productId, int quantity, decimal price) { var item = new OrderItem(productId, quantity, price); _items.Add(item);
// Adding an item can also raise an event RaiseDomainEvent(new OrderItemAddedEvent(Id, productId, quantity)); }}Handling Domain Events
Section titled “Handling Domain Events”Creating an Event Handler
Section titled “Creating an Event Handler”using MiCake.DDD.Domain;using System.Threading;using System.Threading.Tasks;
// Order submitted event handlerpublic class OrderSubmittedEventHandler : IDomainEventHandler<OrderSubmittedEvent>{ private readonly IEmailService _emailService; private readonly ILogger<OrderSubmittedEventHandler> _logger;
public OrderSubmittedEventHandler( IEmailService emailService, ILogger<OrderSubmittedEventHandler> logger) { _emailService = emailService; _logger = logger; }
public async Task HandleAsync(OrderSubmittedEvent domainEvent, CancellationToken cancellationToken = default) { _logger.LogInformation($"Order {domainEvent.OrderId} submitted by customer {domainEvent.CustomerId}");
// Send the order confirmation email await _emailService.SendOrderConfirmationAsync( domainEvent.CustomerId, domainEvent.OrderId, domainEvent.TotalAmount );
// Other business logic... }}
// User registered event handlerpublic class UserRegisteredEventHandler : IDomainEventHandler<UserRegisteredEvent>{ private readonly IEmailService _emailService; private readonly IRepository<UserProfile, int> _profileRepository;
public async Task HandleAsync(UserRegisteredEvent domainEvent, CancellationToken cancellationToken = default) { // 1. Send a welcome email await _emailService.SendWelcomeEmailAsync(domainEvent.Email);
// 2. Create the user profile var profile = UserProfile.Create(domainEvent.UserId); await _profileRepository.AddAsync(profile, cancellationToken); // The changes are committed by the ambient UoW - no manual save is needed
// 3. Write a log Console.WriteLine($"User {domainEvent.UserId} registered at {domainEvent.RegisteredAt}"); }}One Event, Multiple Handlers
Section titled “One Event, Multiple Handlers”A single event can have multiple handlers:
// Handler 1: send an emailpublic class OrderSubmittedEmailHandler : IDomainEventHandler<OrderSubmittedEvent>{ public async Task HandleAsync(OrderSubmittedEvent domainEvent, CancellationToken cancellationToken) { // Send an email }}
// Handler 2: update inventorypublic class OrderSubmittedInventoryHandler : IDomainEventHandler<OrderSubmittedEvent>{ public async Task HandleAsync(OrderSubmittedEvent domainEvent, CancellationToken cancellationToken) { // Decrease inventory }}
// Handler 3: write a logpublic class OrderSubmittedLoggingHandler : IDomainEventHandler<OrderSubmittedEvent>{ public async Task HandleAsync(OrderSubmittedEvent domainEvent, CancellationToken cancellationToken) { // Write a log }}
// These three handlers are executed in sequenceAutomatic Event Dispatch
Section titled “Automatic Event Dispatch”MiCake automatically dispatches domain events when the unit of work commits (IUnitOfWork.CommitAsync()):
public class OrderService{ private readonly IRepository<Order, int> _orderRepository; private readonly IUnitOfWork _unitOfWork;
public async Task SubmitOrder(int orderId) { // 1. Load the aggregate root var order = await _orderRepository.FindAsync(orderId);
// 2. Call the business method (raises the event, but does not dispatch it yet) order.Submit(); // Internally: RaiseDomainEvent(new OrderSubmittedEvent(...))
// 3. Update the aggregate root await _orderRepository.UpdateAsync(order);
// 4. Commit the unit of work - all events are dispatched automatically at this point await _unitOfWork.CommitAsync(); // The CommitAsync internal flow: // a. Collect all pending events on the aggregate root // b. Persist the data to the database (flush) // c. Dispatch events to the corresponding handlers in order // d. Clear the dispatched events }}The Event Dispatch Flow
Section titled “The Event Dispatch Flow”1. Business method call order.Submit() ↓2. Raise the domain event RaiseDomainEvent(new OrderSubmittedEvent(...)) ↓3. The event is temporarily stored on the aggregate root _domainEvents.Add(event) ↓4. Commit the unit of work await unitOfWork.CommitAsync() ↓5. Collect all events events = aggregateRoot.DomainEvents ↓6. Persist the data dbContext.SaveChanges() ↓7. Dispatch the events foreach (event in events) foreach (handler in GetHandlers(event)) await handler.HandleAsync(event) ↓8. Clear the events aggregateRoot.ClearDomainEvents()Use Cases
Section titled “Use Cases”1. Cross-Aggregate Communication
Section titled “1. Cross-Aggregate Communication”// The order aggregatepublic class Order : AggregateRoot<int>{ public void Submit() { Status = OrderStatus.Submitted;
// Raise the event to notify other aggregates RaiseDomainEvent(new OrderSubmittedEvent(Id, Items)); }}
// The inventory aggregate responds in the event handlerpublic class OrderSubmittedInventoryHandler : IDomainEventHandler<OrderSubmittedEvent>{ private readonly IRepository<Product, int> _productRepository;
public async Task HandleAsync(OrderSubmittedEvent domainEvent, CancellationToken cancellationToken) { // Decrease inventory foreach (var item in domainEvent.Items) { var product = await _productRepository.FindAsync(item.ProductId); product.DecreaseStock(item.Quantity); await _productRepository.UpdateAsync(product); }
// The changes are committed by the ambient UoW }}2. Business Process Coordination
Section titled “2. Business Process Coordination”// The user registration flowpublic class User : AggregateRoot<int>{ public void Register(string email, string password) { // Registration logic Email = email; SetPassword(password); Status = UserStatus.Pending;
// Raise the registration event RaiseDomainEvent(new UserRegisteredEvent(Id, email)); }}
// Multiple handlers coordinate to complete the registration flowpublic class SendVerificationEmailHandler : IDomainEventHandler<UserRegisteredEvent>{ public async Task HandleAsync(UserRegisteredEvent domainEvent, CancellationToken cancellationToken) { // Send a verification email }}
public class CreateUserProfileHandler : IDomainEventHandler<UserRegisteredEvent>{ public async Task HandleAsync(UserRegisteredEvent domainEvent, CancellationToken cancellationToken) { // Create the user profile }}
public class InitializeUserSettingsHandler : IDomainEventHandler<UserRegisteredEvent>{ public async Task HandleAsync(UserRegisteredEvent domainEvent, CancellationToken cancellationToken) { // Initialize user settings }}3. Auditing and Logging
Section titled “3. Auditing and Logging”public class OrderStatusChangedEvent : IDomainEvent{ public int OrderId { get; } public OrderStatus OldStatus { get; } public OrderStatus NewStatus { get; } public DateTime ChangedAt { get; }}
public class OrderAuditEventHandler : IDomainEventHandler<OrderStatusChangedEvent>{ private readonly IAuditLogRepository _auditRepository;
public async Task HandleAsync(OrderStatusChangedEvent domainEvent, CancellationToken cancellationToken) { var auditLog = new AuditLog { EntityType = nameof(Order), EntityId = domainEvent.OrderId, Action = "StatusChanged", OldValue = domainEvent.OldStatus.ToString(), NewValue = domainEvent.NewStatus.ToString(), Timestamp = domainEvent.ChangedAt };
await _auditRepository.AddAsync(auditLog); // The changes are committed by the ambient UoW - no manual save is needed }}4. Sending Notifications
Section titled “4. Sending Notifications”public class OrderShippedEvent : IDomainEvent{ public int OrderId { get; } public int CustomerId { get; } public string TrackingNumber { get; }}
public class OrderShippedNotificationHandler : IDomainEventHandler<OrderShippedEvent>{ private readonly INotificationService _notificationService;
public async Task HandleAsync(OrderShippedEvent domainEvent, CancellationToken cancellationToken) { // Send an email notification await _notificationService.SendEmailAsync( domainEvent.CustomerId, "Order Shipped", $"Your order has been shipped. Tracking number: {domainEvent.TrackingNumber}" );
// Send an SMS notification await _notificationService.SendSmsAsync( domainEvent.CustomerId, $"Order shipped. Track: {domainEvent.TrackingNumber}" );
// Send a push notification await _notificationService.SendPushNotificationAsync( domainEvent.CustomerId, "Order Shipped", "Your order is on the way!" ); }}Best Practices
Section titled “Best Practices”1. Name Events in the Past Tense
Section titled “1. Name Events in the Past Tense”// ✅ Correct - use the past tensepublic class OrderPlacedEvent : IDomainEvent { }public class PaymentCompletedEvent : IDomainEvent { }public class UserRegisteredEvent : IDomainEvent { }
// ❌ Wrong - use the present tense or imperative formpublic class PlaceOrderEvent : IDomainEvent { }public class CompletePaymentEvent : IDomainEvent { }public class RegisterUserEvent : IDomainEvent { }2. Events Should Be Immutable
Section titled “2. Events Should Be Immutable”// ✅ Correct - all properties are read-onlypublic class OrderCreatedEvent : IDomainEvent{ public int OrderId { get; } // Read-only public DateTime CreatedAt { get; }
public OrderCreatedEvent(int orderId) { OrderId = orderId; CreatedAt = DateTime.UtcNow; }}
// ❌ Wrong - properties can be modifiedpublic class OrderCreatedEvent : IDomainEvent{ public int OrderId { get; set; } // Mutable public DateTime CreatedAt { get; set; }}3. Keep Event Handlers Idempotent
Section titled “3. Keep Event Handlers Idempotent”public class OrderCreatedEmailHandler : IDomainEventHandler<OrderCreatedEvent>{ private readonly IEmailService _emailService; private readonly IEmailLogRepository _emailLogRepository;
public async Task HandleAsync(OrderCreatedEvent domainEvent, CancellationToken cancellationToken) { // Check whether it has already been sent (idempotency) var alreadySent = await _emailLogRepository.ExistsAsync( l => l.OrderId == domainEvent.OrderId && l.Type == "OrderCreated" );
if (alreadySent) return; // Already sent, skip
// Send the email await _emailService.SendOrderConfirmationAsync(domainEvent.OrderId);
// Record the log await _emailLogRepository.AddAsync(new EmailLog { OrderId = domainEvent.OrderId, Type = "OrderCreated", SentAt = DateTime.UtcNow }); // The changes are committed by the ambient UoW - no manual save is needed }}4. Events Should Contain Sufficient Information
Section titled “4. Events Should Contain Sufficient Information”// ✅ Good practice - include the necessary informationpublic class OrderSubmittedEvent : IDomainEvent{ public int OrderId { get; } public int CustomerId { get; } public decimal TotalAmount { get; } public List<OrderItemDto> Items { get; } // Include detailed information public DateTime SubmittedAt { get; }
// Event handlers do not need to query the order details again}
// ❌ Bad practice - insufficient informationpublic class OrderSubmittedEvent : IDomainEvent{ public int OrderId { get; } // Only the ID
// Event handlers need to query the database for details}5. Avoid Long-Running Operations in Event Handlers
Section titled “5. Avoid Long-Running Operations in Event Handlers”// ❌ Avoid - synchronously execute time-consuming operationspublic class OrderPlacedHandler : IDomainEventHandler<OrderPlacedEvent>{ public async Task HandleAsync(OrderPlacedEvent domainEvent, CancellationToken cancellationToken) { // This blocks the transaction await SendEmailAsync(); // May be slow await CallExternalApiAsync(); // May fail await GeneratePdfAsync(); // Very time-consuming }}
// ✅ Recommended - publish to a message queue for asynchronous processingpublic class OrderPlacedHandler : IDomainEventHandler<OrderPlacedEvent>{ private readonly IMessageQueue _messageQueue;
public async Task HandleAsync(OrderPlacedEvent domainEvent, CancellationToken cancellationToken) { // Publish to the queue quickly await _messageQueue.PublishAsync(new SendOrderEmailCommand(domainEvent.OrderId)); await _messageQueue.PublishAsync(new GenerateInvoiceCommand(domainEvent.OrderId)); }}Summary
Section titled “Summary”The MiCake domain event mechanism:
- Implement the
IDomainEventinterface to define events - Raise events in aggregate roots via
RaiseDomainEvent - Implement
IDomainEventHandler<TEvent>to handle events - Dispatch events automatically when the unit of work commits (
CommitAsync/FlushAsync) - Used to implement loosely coupled communication between aggregates
- Supports one event with multiple handlers
Next steps:
- Learn about Domain Services to understand service design
- Read about Unit of Work to understand transaction management
- Check out Aggregate Roots to review aggregate design
