Domain Events are records of something significant that happened in the domain, expressed in the past tense.
Core Characteristics
Immutable Facts
- Represent things that already happened
- Cannot be changed after creation
- Historical record of domain changes
Past Tense Naming
- OrderSubmitted, PaymentProcessed, UserRegistered
- Not “SubmitOrder” or “ProcessPayment”
- Describes what happened, not what to do
Domain Concepts
- Meaningful to domain experts
- Part of ubiquitous language
- Business-focused, not technical
Purpose and Benefits
Decouple Aggregates
- Avoid direct dependencies between aggregates
- Coordinate changes across boundaries
- Enable eventual consistency
Audit Trail
- Complete history of domain changes
- Support for debugging and analysis
- Regulatory compliance
Enable Event Sourcing
- Store events instead of current state
- Rebuild state by replaying events
- Time travel and analytics
Trigger Side Effects
- Send notifications
- Update read models
- Integrate with external systems
Example Domain Events
public class OrderSubmitted : DomainEvent
{
public OrderId OrderId { get; }
public CustomerId CustomerId { get; }
public Money TotalAmount { get; }
public DateTime SubmittedAt { get; }
public OrderSubmitted(OrderId orderId, CustomerId customerId, Money totalAmount)
{
OrderId = orderId;
CustomerId = customerId;
TotalAmount = totalAmount;
SubmittedAt = DateTime.UtcNow;
}
}
public class PaymentReceived : DomainEvent
{
public PaymentId PaymentId { get; }
public OrderId OrderId { get; }
public Money Amount { get; }
public PaymentMethod Method { get; }
public DateTime ReceivedAt { get; }
}
public class UserRegistered : DomainEvent
{
public UserId UserId { get; }
public Email Email { get; }
public DateTime RegisteredAt { get; }
}Raising Domain Events
Within Aggregate
The aggregate records events as it changes state; they’re dispatched later
(see the collecting pattern below), after the transaction commits. The
AddDomainEvent helper comes from the AggregateRoot base.
public class Order : AggregateRoot<OrderId>
{
public void Submit()
{
if (!_lines.Any())
throw new InvalidOperationException("Cannot submit empty order");
Status = OrderStatus.Submitted;
// Record the fact — dispatched after a successful save
AddDomainEvent(new OrderSubmitted(
Id,
CustomerId,
CalculateTotal()
));
}
}Collecting Events Pattern
// Extends Entity<TId> so a root gets identity *and* event collection.
public abstract class AggregateRoot<TId> : Entity<TId>
{
private readonly List<DomainEvent> _domainEvents = new();
public IReadOnlyList<DomainEvent> DomainEvents => _domainEvents;
protected void AddDomainEvent(DomainEvent domainEvent)
{
_domainEvents.Add(domainEvent);
}
public void ClearDomainEvents()
{
_domainEvents.Clear();
}
}
public class Order : AggregateRoot<OrderId>
{
public void Submit()
{
Status = OrderStatus.Submitted;
AddDomainEvent(new OrderSubmitted(Id, CustomerId, CalculateTotal()));
}
}
// In repository
public class OrderRepository
{
public void Save(Order order)
{
_dbContext.SaveChanges();
// Dispatch events after successful save
foreach (var @event in order.DomainEvents)
{
_eventDispatcher.Dispatch(@event);
}
order.ClearDomainEvents();
}
}Handling Domain Events
Event Handlers
public class OrderSubmittedHandler : IHandleDomainEvent<OrderSubmitted>
{
private readonly IInventoryService _inventoryService;
private readonly IEmailService _emailService;
public void Handle(OrderSubmitted @event)
{
// Reserve inventory
_inventoryService.ReserveItems(@event.OrderId);
// Send confirmation email
_emailService.SendOrderConfirmation(
@event.CustomerId,
@event.OrderId
);
}
}Multiple Handlers
// Handler 1: Update inventory
public class ReserveInventoryHandler : IHandleDomainEvent<OrderSubmitted>
{
public void Handle(OrderSubmitted @event)
{
var inventory = _repository.GetByOrderId(@event.OrderId);
inventory.ReserveItems(@event.Items);
_repository.Save(inventory);
}
}
// Handler 2: Send notification
public class SendOrderConfirmationHandler : IHandleDomainEvent<OrderSubmitted>
{
public void Handle(OrderSubmitted @event)
{
_emailService.SendConfirmation(@event.CustomerId, @event.OrderId);
}
}
// Handler 3: Update analytics
public class RecordOrderMetricsHandler : IHandleDomainEvent<OrderSubmitted>
{
public void Handle(OrderSubmitted @event)
{
_analytics.RecordSale(@event.TotalAmount, @event.SubmittedAt);
}
}Event Dispatcher Pattern
public interface IEventDispatcher
{
void Dispatch<TEvent>(TEvent @event) where TEvent : DomainEvent;
}
public class DomainEventDispatcher : IEventDispatcher
{
private readonly IServiceProvider _serviceProvider;
public void Dispatch<TEvent>(TEvent @event) where TEvent : DomainEvent
{
var handlers = _serviceProvider
.GetServices<IHandleDomainEvent<TEvent>>();
foreach (var handler in handlers)
{
handler.Handle(@event);
}
}
}Event Patterns
Immediate vs. Deferred
Both drain the events the aggregate already collected — they differ only in when dispatch happens.
// Immediate: Save dispatches the collected events right after its commit
public void PlaceOrder(Order order)
{
_repository.Save(order); // commits, then dispatches order.DomainEvents
}
// Deferred: let the unit of work dispatch once the whole commit succeeds
public void PlaceOrder(Order order)
{
_repository.Save(order); // stages the aggregate and its events
_unitOfWork.Commit(); // commits, then dispatches
}Local vs. Distributed Events
// Local: In-process dispatch of the aggregate's collected events
_dispatcher.Dispatch(new OrderSubmitted(orderId));
// Distributed: Message bus for cross-service communication
_messageBus.Publish(new OrderSubmittedIntegrationEvent(orderId));Integration with Event Sourcing
public class Order
{
private List<DomainEvent> _uncommittedEvents = new();
// Apply events to rebuild state
public void Apply(DomainEvent @event)
{
switch (@event)
{
case OrderCreated e:
Id = e.OrderId;
CustomerId = e.CustomerId;
break;
case OrderLineAdded e:
_lines.Add(new OrderLine(e.ProductId, e.Quantity));
break;
case OrderSubmitted e:
Status = OrderStatus.Submitted;
break;
}
}
// Methods emit events
public void AddItem(ProductId productId, int quantity)
{
var @event = new OrderLineAdded(Id, productId, quantity);
Apply(@event);
_uncommittedEvents.Add(@event);
}
}Common Domain Events
E-Commerce
- OrderCreated, OrderSubmitted, OrderShipped, OrderDelivered
- PaymentReceived, PaymentFailed, RefundIssued
- ProductAddedToCart, CartAbandoned
- InventoryReserved, InventoryReleased
User Management
- UserRegistered, UserActivated, UserDeactivated
- PasswordChanged, EmailVerified
- RoleAssigned, PermissionGranted
Banking
- AccountOpened, AccountClosed
- MoneyDeposited, MoneyWithdrawn
- TransferInitiated, TransferCompleted, TransferFailed
Best Practices
Name Events Clearly
// Good: Past tense, clear meaning
OrderSubmitted
PaymentProcessed
UserRegistered
// Bad: Ambiguous or present tense
OrderChange
ProcessPayment
CreateUserInclude Relevant Data
// Good: Self-contained
public class OrderSubmitted
{
public OrderId OrderId { get; }
public CustomerId CustomerId { get; }
public Money TotalAmount { get; }
public List<OrderLineDto> Items { get; }
}
// Bad: Requires additional queries
public class OrderSubmitted
{
public OrderId OrderId { get; }
// Handlers must query to get details
}Keep Events Immutable
// Good: Readonly properties
public class OrderSubmitted
{
public OrderId OrderId { get; }
public DateTime SubmittedAt { get; }
public OrderSubmitted(OrderId orderId)
{
OrderId = orderId;
SubmittedAt = DateTime.UtcNow;
}
}
// Bad: Mutable
public class OrderSubmitted
{
public OrderId OrderId { get; set; } // Should not be settable!
}Events vs. Callbacks: Observe, Don’t Enforce
Events and lifecycle callbacks look similar — both run extra code when something happens — but they serve opposite purposes, and conflating them is a common mistake.
An event is for observation: a subscriber reacts to a fact that already happened. Subscribers are optional and additive — removing a handler, or having none, doesn’t break the domain operation that emitted the event. That’s exactly why events are the right home for side effects (email, read-model updates, external integration): the operation is correct whether or not anyone is listening.
A callback used to enforce an invariant is the inverse. An invariant must always hold, so it belongs in the aggregate method that mutates state and, for hard guarantees, in the database as a constraint — not in a listener the call site can’t see and that bulk write paths skip. Wiring required domain behaviour into an event handler (or a callback) makes the operation’s correctness depend on something invisible and bypassable.
The rule of thumb:
- Enforce invariants in the aggregate and the database — synchronous, in the transaction, no bypass.
- Emit events for everything that merely needs to know it happened — asynchronous-friendly, optional, retriable.
Dispatch events after the transaction commits (a transactional outbox) so a rolled-back operation never fires events for something that didn’t actually happen.
Related Concepts
- Aggregates - Produce domain events
- Event Sourcing - Store events as source of truth
- Message Queues - Distribute events between systems
- Constraints over Conventions - Why invariants belong in the data layer, not in event handlers or callbacks
- Outbox Pattern - Dispatch events atomically with the state change
- Domain Driven Design - Overall pattern context