Tips and Tricks – Implement Domain Events for Loose Coupling

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.

Leave a comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.