Direct service-to-service calls create tight coupling. Order
service calls inventory service, which calls shipping service, which calls notification service—one failure breaks
the entire chain. Domain events eliminate this coupling by publishing state changes that interested parties can
react to, transforming brittle synchronous chains into resilient, decoupled systems.
This guide covers production-ready domain event patterns
that enable loose coupling, better scalability, and easier testing. We’ll build systems where components communicate
through events, not direct calls.
Why Domain Events Transform Architecture
The Direct Coupling Problem
Tightly coupled services suffer from:
- Cascading failures: One service down breaks entire chain
- Performance coupling: Slow service slows all callers
- Deployment coupling: Changes require coordinated deploys
- Testing complexity: Need all services running for tests
- Knowledge coupling: Services know too much about each other
- Poor scalability: Can’t scale components independently
Domain Event Benefits
- Loose coupling: Services don’t know about each other
- Resilience: Failures isolated, retries built-in
- Independent scaling: Scale consumers separately
- Audit trail: Events provide complete history
- Easy testing: Test by publishing events
- Extensibility: Add consumers without changing publishers
Pattern 1: Basic Domain Events
Publish-Subscribe with In-Memory Bus
// Base domain event
public interface IDomainEvent
{
DateTime OccurredAt { get; }
string EventId { get; }
}
// Concrete domain event
public record OrderPlacedEvent(
string OrderId,
string CustomerId,
decimal TotalAmount,
List<OrderItem> Items
) : IDomainEvent
{
public DateTime OccurredAt { get; init; } = DateTime.UtcNow;
public string EventId { get; init; } = Guid.NewGuid().ToString();
}
// Event handler interface
public interface IEventHandler<TEvent> where TEvent : IDomainEvent
{
Task HandleAsync(TEvent domainEvent);
}
// In-memory event bus
public class DomainEventBus
{
private readonly IServiceProvider _serviceProvider;
public DomainEventBus(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public async Task PublishAsync<TEvent>(TEvent domainEvent)
where TEvent : IDomainEvent
{
// Get all handlers for this event type
var handlers = _serviceProvider
.GetServices<IEventHandler<TEvent>>();
// Execute handlers in parallel
var tasks = handlers.Select(h => h.HandleAsync(domainEvent));
await Task.WhenAll(tasks);
}
}
// Order service publishes event
public class OrderService
{
private readonly DomainEventBus _eventBus;
public async Task<Order> PlaceOrderAsync(PlaceOrderRequest request)
{
// Create order
var order = new Order
{
Id = Guid.NewGuid().ToString(),
CustomerId = request.CustomerId,
Items = request.Items,
TotalAmount = CalculateTotal(request.Items)
};
await _orderRepository.SaveAsync(order);
// Publish domain event
await _eventBus.PublishAsync(new OrderPlacedEvent(
order.Id,
order.CustomerId,
order.TotalAmount,
order.Items
));
return order;
}
}
// Multiple independent handlers
public class SendOrderConfirmationHandler : IEventHandler<OrderPlacedEvent>
{
public async Task HandleAsync(OrderPlacedEvent e)
{
// Send email confirmation
await _emailService.SendAsync(e.CustomerId,
"Order Confirmation",
$"Order {e.OrderId} placed");
}
}
public class UpdateInventoryHandler : IEventHandler<OrderPlacedEvent>
{
public async Task HandleAsync(OrderPlacedEvent e)
{
// Reserve inventory
foreach (var item in e.Items)
{
await _inventoryService.ReserveAsync(item.ProductId, item.Quantity);
}
}
}
public class RecordAnalyticsHandler : IEventHandler<OrderPlacedEvent>
{
public async Task HandleAsync(OrderPlacedEvent e)
{
// Track order for analytics
await _analyticsService.TrackOrderAsync(e.OrderId, e.TotalAmount);
}
}
// Benefits:
// - OrderService doesn't know about email, inventory, or analytics
// - Can add new handlers without changing OrderService
// - Each handler can fail independently
// - Easy to test: just publish event and verify handler behavior
Pattern 2: Outbox Pattern for Reliability
Guaranteed Event Publishing
// Event outbox table
public class EventOutbox
{
public string Id { get; set; }
public string EventType { get; set; }
public string EventData { get; set; } // JSON
public DateTime CreatedAt { get; set; }
public DateTime? PublishedAt { get; set; }
public int RetryCount { get; set; }
}
// Save events transactionally with business data
public class OrderService
{
private readonly DbContext _dbContext;
public async Task<Order> PlaceOrderAsync(PlaceOrderRequest request)
{
using var transaction = await _dbContext.Database.BeginTransactionAsync();
try
{
// 1. Save order
var order = new Order { /* ... */ };
_dbContext.Orders.Add(order);
// 2. Save event to outbox (same transaction!)
var orderEvent = new OrderPlacedEvent(/*...*/);
var outboxEvent = new EventOutbox
{
Id = Guid.NewGuid().ToString(),
EventType = nameof(OrderPlacedEvent),
EventData = JsonSerializer.Serialize(orderEvent),
CreatedAt = DateTime.UtcNow
};
_dbContext.EventOutbox.Add(outboxEvent);
// 3. Commit both together
await _dbContext.SaveChangesAsync();
await transaction.CommitAsync();
return order;
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
}
// Background worker publishes events from outbox
public class EventPublisherWorker : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
// Get unpublished events
var events = await _dbContext.EventOutbox
.Where(e => e.PublishedAt == null)
.OrderBy(e => e.CreatedAt)
.Take(100)
.ToListAsync();
foreach (var outboxEvent in events)
{
try
{
// Publish to message bus
await _messageBus.PublishAsync(
outboxEvent.EventType,
outboxEvent.EventData
);
// Mark as published
outboxEvent.PublishedAt = DateTime.UtcNow;
await _dbContext.SaveChangesAsync();
}
catch (Exception ex)
{
// Increment retry count
outboxEvent.RetryCount++;
await _dbContext.SaveChangesAsync();
_logger.LogError(ex, "Failed to publish event {EventId}",
outboxEvent.Id);
}
}
await Task.Delay(1000, stoppingToken);
}
}
}
// Benefits:
// - Events never lost (saved with business data)
// - Guaranteed delivery (retries until published)
// - No distributed transactions needed
Pattern 3: Event Sourcing
Events as Source of Truth
// Domain events
interface DomainEvent {
eventId: string;
aggregateId: string;
occurredAt: Date;
version: number;
}
interface AccountCreatedEvent extends DomainEvent {
type: 'AccountCreated';
accountId: string;
customerId: string;
initialBalance: number;
}
interface MoneyDepositedEvent extends DomainEvent {
type: 'MoneyDeposited';
accountId: string;
amount: number;
}
interface MoneyWithdrawnEvent extends DomainEvent {
type: 'MoneyWithdrawn';
accountId: string;
amount: number;
}
// Aggregate built from events
class BankAccount {
private events: DomainEvent[] = [];
accountId: string;
balance: number = 0;
version: number = 0;
// Replay events to rebuild state
static fromEvents(events: DomainEvent[]): BankAccount {
const account = new BankAccount();
for (const event of events) {
account.apply(event);
}
return account;
}
// Apply event to update state
private apply(event: DomainEvent): void {
switch (event.type) {
case 'AccountCreated':
this.accountId = event.accountId;
this.balance = event.initialBalance;
break;
case 'MoneyDeposited':
this.balance += event.amount;
break;
case 'MoneyWithdrawn':
this.balance -= event.amount;
break;
}
this.version = event.version;
}
// Business logic generates events
deposit(amount: number): void {
if (amount <= 0) {
throw new Error('Amount must be positive');
}
const event: MoneyDepositedEvent = {
type: 'MoneyDeposited',
eventId: crypto.randomUUID(),
aggregateId: this.accountId,
accountId: this.accountId,
amount,
occurredAt: new Date(),
version: this.version + 1
};
this.apply(event);
this.events.push(event);
}
withdraw(amount: number): void {
if (amount > this.balance) {
throw new Error('Insufficient funds');
}
const event: MoneyWithdrawnEvent = {
type: 'MoneyWithdrawn',
eventId: crypto.randomUUID(),
aggregateId: this.accountId,
accountId: this.accountId,
amount,
occurredAt: new Date(),
version: this.version + 1
};
this.apply(event);
this.events.push(event);
}
// Get uncommitted events
getUncommittedEvents(): DomainEvent[] {
return this.events;
}
// Mark events as committed
markEventsAsCommitted(): void {
this.events = [];
}
}
// Event store
class EventStore {
async save(aggregateId: string, events: DomainEvent[]): Promise<void> {
// Save events to database
for (const event of events) {
await db.events.insert({
eventId: event.eventId,
aggregateId: event.aggregateId,
eventType: event.type,
eventData: JSON.stringify(event),
version: event.version,
occurredAt: event.occurredAt
});
}
}
async getEvents(aggregateId: string): Promise<DomainEvent[]> {
// Load all events for aggregate
const rows = await db.events
.where('aggregateId', aggregateId)
.orderBy('version')
.select();
return rows.map(r => JSON.parse(r.eventData));
}
}
// Usage
const account = BankAccount.fromEvents(
await eventStore.getEvents(accountId)
);
account.deposit(100);
account.withdraw(50);
// Save new events
await eventStore.save(accountId, account.getUncommittedEvents());
account.markEventsAsCommitted();
Pattern 4: Saga Pattern for Distributed Transactions
Coordinate Multi-Service Operations
// Saga orchestrator
@Service
public class OrderSaga {
@Autowired
private DomainEventBus eventBus;
@EventHandler
public void handle(OrderPlacedEvent event) {
// Start saga
SagaInstance saga = new SagaInstance(event.getOrderId());
try {
// Step 1: Reserve inventory
eventBus.publish(new ReserveInventoryCommand(
event.getOrderId(),
event.getItems()
));
// Step 2: Process payment
eventBus.publish(new ProcessPaymentCommand(
event.getOrderId(),
event.getCustomerId(),
event.getTotalAmount()
));
// Step 3: Arrange shipping
eventBus.publish(new ArrangeShippingCommand(
event.getOrderId(),
event.getShippingAddress()
));
} catch (Exception e) {
// Compensate on failure
eventBus.publish(new CancelOrderCommand(event.getOrderId()));
}
}
@EventHandler
public void handle(InventoryReservedEvent event) {
// Mark step complete
sagaRepository.updateStep(event.getOrderId(), "inventory", "completed");
}
@EventHandler
public void handle(InventoryReservationFailedEvent event) {
// Compensate: Undo previous steps
eventBus.publish(new ReleasePaymentCommand(event.getOrderId()));
eventBus.publish(new CancelOrderCommand(event.getOrderId()));
}
@EventHandler
public void handle(PaymentProcessedEvent event) {
sagaRepository.updateStep(event.getOrderId(), "payment", "completed");
}
@EventHandler
public void handle(PaymentFailedEvent event) {
// Compensate
eventBus.publish(new ReleaseInventoryCommand(event.getOrderId()));
eventBus.publish(new CancelOrderCommand(event.getOrderId()));
}
@EventHandler
public void handle(ShippingArrangedEvent event) {
// All steps complete!
sagaRepository.updateStep(event.getOrderId(), "shipping", "completed");
eventBus.publish(new OrderCompletedEvent(event.getOrderId()));
}
}
Pattern 5: Event Versioning
Evolve Events Over Time
from dataclasses import dataclass
from typing import Optional
from datetime import datetime
# Version 1
@dataclass
class UserRegisteredEventV1:
version: int = 1
user_id: str
email: str
registered_at: datetime
# Version 2: Added name
@dataclass
class UserRegisteredEventV2:
version: int = 2
user_id: str
email: str
name: str
registered_at: datetime
# Version 3: Split name into first/last
@dataclass
class UserRegisteredEventV3:
version: int = 3
user_id: str
email: str
first_name: str
last_name: str
registered_at: datetime
# Event upcaster
class EventUpcaster:
def upcast(self, event_data: dict) -> dict:
version = event_data.get('version', 1)
# V1 → V2: Add default name
if version == 1:
event_data['name'] = 'Unknown'
event_data['version'] = 2
version = 2
# V2 → V3: Split name
if version == 2:
name_parts = event_data['name'].split(' ', 1)
event_data['first_name'] = name_parts[0]
event_data['last_name'] = name_parts[1] if len(name_parts) > 1 else ''
del event_data['name']
event_data['version'] = 3
return event_data
# Event handler works with latest version
class UserEventHandler:
def __init__(self):
self.upcaster = EventUpcaster()
def handle(self, raw_event: dict):
# Upcast to latest version
event_data = self.upcaster.upcast(raw_event)
# Now work with V3 structure
event = UserRegisteredEventV3(**event_data)
print(f"User {event.first_name} {event.last_name} registered")
# Benefits:
# - Old events still work
# - Handlers only deal with latest version
# - Can replay old events with new code
Real-World Example: E-Commerce Order Flow
Complete Event-Driven System
// Domain Events
public record OrderPlacedEvent(string OrderId, string CustomerId, decimal Total);
public record PaymentProcessedEvent(string OrderId, string PaymentId);
public record InventoryReservedEvent(string OrderId, List<string> ReservationIds);
public record OrderShippedEvent(string OrderId, string TrackingNumber);
public record OrderCompletedEvent(string OrderId);
public record OrderCancelledEvent(string OrderId, string Reason);
// Order Service
public class OrderService
{
public async Task<string> PlaceOrderAsync(PlaceOrderRequest request)
{
var order = new Order
{
Id = Guid.NewGuid().ToString(),
CustomerId = request.CustomerId,
Status = OrderStatus.Pending
};
await _repository.SaveAsync(order);
// Publish event
await _eventBus.PublishAsync(new OrderPlacedEvent(
order.Id,
order.CustomerId,
order.TotalAmount
));
return order.Id;
}
}
// Payment Service (separate microservice)
public class PaymentEventHandler : IEventHandler<OrderPlacedEvent>
{
public async Task HandleAsync(OrderPlacedEvent e)
{
try
{
var payment = await _paymentGateway.ChargeAsync(
e.CustomerId,
e.Total
);
await _eventBus.PublishAsync(new PaymentProcessedEvent(
e.OrderId,
payment.Id
));
}
catch (Exception ex)
{
await _eventBus.PublishAsync(new OrderCancelledEvent(
e.OrderId,
$"Payment failed: {ex.Message}"
));
}
}
}
// Inventory Service
public class InventoryEventHandler : IEventHandler<PaymentProcessedEvent>
{
public async Task HandleAsync(PaymentProcessedEvent e)
{
var order = await _orderClient.GetOrderAsync(e.OrderId);
var reservationIds = new List<string>();
foreach (var item in order.Items)
{
var reservationId = await _inventoryService.ReserveAsync(
item.ProductId,
item.Quantity
);
reservationIds.Add(reservationId);
}
await _eventBus.PublishAsync(new InventoryReservedEvent(
e.OrderId,
reservationIds
));
}
}
// Shipping Service
public class ShippingEventHandler : IEventHandler<InventoryReservedEvent>
{
public async Task HandleAsync(InventoryReservedEvent e)
{
var shipment = await _shippingService.CreateShipmentAsync(e.OrderId);
await _eventBus.PublishAsync(new OrderShippedEvent(
e.OrderId,
shipment.TrackingNumber
));
}
}
// Notification Service
public class NotificationEventHandler :
IEventHandler<OrderPlacedEvent>,
IEventHandler<OrderShippedEvent>,
IEventHandler<OrderCompletedEvent>
{
public async Task HandleAsync(OrderPlacedEvent e)
{
await SendEmail(e.CustomerId, "Order Confirmed", $"Order {e.OrderId}");
}
public async Task HandleAsync(OrderShippedEvent e)
{
await SendEmail(GetCustomer(e.OrderId), "Order Shipped",
$"Tracking: {e.TrackingNumber}");
}
public async Task HandleAsync(OrderCompletedEvent e)
{
await SendEmail(GetCustomer(e.OrderId), "Order Delivered", "Thanks!");
}
}
// Benefits:
// - Order, Payment, Inventory, Shipping all decoupled
// - Each service can scale independently
// - Easy to add new services (just subscribe to events)
// - Failures isolated to individual services
// - Complete audit trail via events
Best Practices
- Events are past tense: “OrderPlaced” not “PlaceOrder”
- Immutable events: Never modify published events
- Idempotent handlers: Handle duplicate events gracefully
- Event versioning: Plan for schema evolution
- Outbox pattern: Ensure events never lost
- Correlation IDs: Track event chains for debugging
- Retry with backoff: Handle transient failures
Common Pitfalls
- Events too large: Keep events lean, reference data by ID
- Synchronous expectations: Events are asynchronous by nature
- Not idempotent: Handlers run multiple times
- Missing correlation ID: Can’t trace event chains
- No versioning strategy: Breaking changes break consumers
- Forgetting outbox: Events lost on crashes
Key Takeaways
- Domain events enable loose coupling between services
- Publish events for significant business state changes
- Use outbox pattern to guarantee event delivery
- Handlers should be idempotent (safe to retry)
- Event sourcing: events are source of truth
- Saga pattern coordinates distributed transactions
- Version events from the start—evolve schemas safely
- Events provide complete audit trail automatically
Domain events are fundamental to building loosely coupled, scalable systems. By communicating through events
instead of direct calls, services become independent, resilient, and easy to evolve. The pattern requires
discipline—events must be immutable, handlers idempotent—but the architectural benefits are transformative.
Discover more from C4: Container, Code, Cloud & Context
Subscribe to get the latest posts sent to your email.