Unlock Event-Driven Architecture

When you inherit a legacy monolith application, you often face a tangled web of dependencies where every class knows about every other class. It's like a house party where everyone is shouting to everyone else—chaotic and hard to follow.

Recently, I embarked on a journey to refactor an old monolith application at work. What started as simple cleanup evolved into a complete architectural transformation using domain events. This is the story of how events became the key to unlocking separation of concerns, establishing clear boundaries, and preparing the groundwork for a potential modular monolith.

The Problem: Spaghetti Code in the Wild

Our legacy monolith had classic symptoms:

// ❌ BAD: Tightly coupled, doing too much
public class OrderService
{
    private readonly IEmailService _emailService;
    private readonly IInventoryService _inventoryService;
    private readonly IPaymentService _paymentService;
    private readonly ILoyaltyService _loyaltyService;
    private readonly IAnalyticsService _analyticsService;
    private readonly INotificationService _notificationService;
    
    public async Task CreateOrderAsync(Order order)
    {
        // Save the order
        await _orderRepository.SaveAsync(order);
        
        // Send confirmation email
        await _emailService.SendOrderConfirmationAsync(order);
        
        // Update inventory
        await _inventoryService.ReduceStockAsync(order.Items);
        
        // Process payment
        await _paymentService.ChargeAsync(order.PaymentInfo);
        
        // Update loyalty points
        await _loyaltyService.AddPointsAsync(order.CustomerId, order.Total);
        
        // Track analytics
        await _analyticsService.TrackOrderAsync(order);
        
        // Send push notification
        await _notificationService.NotifyOrderCreatedAsync(order);
    }
}

Problems with this approach:

  1. Tight Coupling: OrderService directly depends on 7+ different services
  2. Violates Single Responsibility: OrderService does way too much
  3. Hard to Test: Need to mock all dependencies for every test
  4. Hard to Maintain: Any change affects multiple areas
  5. Poor Scalability: Can't independently scale different concerns
  6. No Clear Boundaries: Everything knows about everything

Ending with several headaches when trying to add new features or fix bugs.

This is the reality of many monoliths. But there's a better way.

Step 1: Starting Simple with Domain Events

The first step in the refactoring journey was to introduce domain events—simple POCOs that represent something meaningful that happened in our domain.

Defining Domain Events

Domain events are simple classes that describe what happened, not what should happen:

// A simple domain event - just data, no behavior
public record OrderCreatedEvent
{
    public Guid OrderId { get; init; }
    public Guid CustomerId { get; init; }
    public decimal TotalAmount { get; init; }
    public List<OrderItem> Items { get; init; } = new();
    public DateTime CreatedAt { get; init; }
}

public record OrderItem
{
    public string ProductId { get; init; }
    public int Quantity { get; init; }
    public decimal Price { get; init; }
}

Key characteristics of good domain events:

  • Immutable: Use records or init-only properties
  • Past tense naming: OrderCreated, not CreateOrder
  • Self-contained: Include all relevant data
  • Domain-focused: Represent business concepts

Simple In-Memory Event System

Let's start with the simplest possible implementation:

  • Base interface for all events
public interface IDomainEvent
{
    Guid EventId { get; }
    DateTime OccurredAt { get; }
}
  • Base record that all events inherit from
public abstract record DomainEvent : IDomainEvent
{
    public Guid EventId { get; init; } = Guid.NewGuid();
    public DateTime OccurredAt { get; init; } = DateTime.UtcNow;
}
  • Handler interface
public interface IEventHandler<in TEvent> where TEvent : IDomainEvent
{
    Task HandleAsync(TEvent domainEvent, CancellationToken cancellationToken = default);
}
  • Simple in-memory dispatcher
public class InMemoryEventDispatcher
{
    private readonly IServiceProvider _serviceProvider;
    private readonly ILogger<InMemoryEventDispatcher> _logger;

    public InMemoryEventDispatcher(
        IServiceProvider serviceProvider, 
        ILogger<InMemoryEventDispatcher> logger)
    {
        _serviceProvider = serviceProvider;
        _logger = logger;
    }

    public async Task PublishAsync<TEvent>(TEvent domainEvent, CancellationToken cancellationToken = default) 
        where TEvent : IDomainEvent
    {
        _logger.LogInformation("Publishing event {EventType} with ID {EventId}", 
            typeof(TEvent).Name, domainEvent.EventId);

        // Get all handlers for this event type
        var handlers = _serviceProvider.GetServices<IEventHandler<TEvent>>();
        
        foreach (var handler in handlers)
        {
            try
            {
                await handler.HandleAsync(domainEvent, cancellationToken);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Error handling event {EventType} with handler {HandlerType}", 
                    typeof(TEvent).Name, handler.GetType().Name);
                // Continue processing other handlers
            }
        }
    }
}

Refactoring with Simple Events

Now let's refactor our OrderService:

// ✅ GOOD: OrderService focuses only on order creation
public class OrderService
{
    private readonly IOrderRepository _orderRepository;
    private readonly InMemoryEventDispatcher _eventDispatcher;
    private readonly ILogger<OrderService> _logger;

    public OrderService(
        IOrderRepository orderRepository,
        InMemoryEventDispatcher eventDispatcher,
        ILogger<OrderService> logger)
    {
        _orderRepository = orderRepository;
        _eventDispatcher = eventDispatcher;
        _logger = logger;
    }

    public async Task CreateOrderAsync(Order order, CancellationToken cancellationToken = default)
    {
        // Focus on the core responsibility: creating the order
        await _orderRepository.SaveAsync(order, cancellationToken);
        
        _logger.LogInformation("Order {OrderId} created successfully", order.Id);

        // Publish the event - let other parts of the system react
        var orderCreatedEvent = new OrderCreatedEvent
        {
            OrderId = order.Id,
            CustomerId = order.CustomerId,
            TotalAmount = order.Total,
            Items = order.Items.Select(i => new OrderItem 
            { 
                ProductId = i.ProductId, 
                Quantity = i.Quantity, 
                Price = i.Price 
            }).ToList(),
            CreatedAt = DateTime.UtcNow
        };

        await _eventDispatcher.PublishAsync(orderCreatedEvent, cancellationToken);
    }
}

Creating Event Handlers

Now each concern gets its own handler:

  • Email notification handler:
public class OrderCreatedEmailHandler : IEventHandler<OrderCreatedEvent>
{
    private readonly IEmailService _emailService;
    private readonly ILogger<OrderCreatedEmailHandler> _logger;

    public OrderCreatedEmailHandler(
        IEmailService emailService, 
        ILogger<OrderCreatedEmailHandler> logger)
    {
        _emailService = emailService;
        _logger = logger;
    }

    public async Task HandleAsync(OrderCreatedEvent domainEvent, CancellationToken cancellationToken = default)
    {
        _logger.LogInformation("Sending order confirmation email for order {OrderId}", 
            domainEvent.OrderId);
            
        await _emailService.SendOrderConfirmationAsync(
            domainEvent.OrderId, 
            domainEvent.CustomerId, 
            cancellationToken);
    }
}
  • Inventory handler:
public class OrderCreatedInventoryHandler : IEventHandler<OrderCreatedEvent>
{
    private readonly IInventoryService _inventoryService;
    private readonly ILogger<OrderCreatedInventoryHandler> _logger;

    public OrderCreatedInventoryHandler(
        IInventoryService inventoryService,
        ILogger<OrderCreatedInventoryHandler> logger)
    {
        _inventoryService = inventoryService;
        _logger = logger;
    }

    public async Task HandleAsync(OrderCreatedEvent domainEvent, CancellationToken cancellationToken = default)
    {
        _logger.LogInformation("Reducing inventory for order {OrderId}", domainEvent.OrderId);
        
        await _inventoryService.ReduceStockAsync(domainEvent.Items, cancellationToken);
    }
}
  • Loyalty points handler:
public class OrderCreatedLoyaltyHandler : IEventHandler<OrderCreatedEvent>
{
    private readonly ILoyaltyService _loyaltyService;
    private readonly ILogger<OrderCreatedLoyaltyHandler> _logger;

    public OrderCreatedLoyaltyHandler(
        ILoyaltyService loyaltyService,
        ILogger<OrderCreatedLoyaltyHandler> logger)
    {
        _loyaltyService = loyaltyService;
        _logger = logger;
    }

    public async Task HandleAsync(OrderCreatedEvent domainEvent, CancellationToken cancellationToken = default)
    {
        _logger.LogInformation("Adding loyalty points for order {OrderId}", domainEvent.OrderId);
        
        await _loyaltyService.AddPointsAsync(
            domainEvent.CustomerId, 
            domainEvent.TotalAmount, 
            cancellationToken);
    }
}

Registration

// In Program.cs
builder.Services.AddSingleton<InMemoryEventDispatcher>();

// Register all event handlers
builder.Services.AddScoped<IEventHandler<OrderCreatedEvent>, OrderCreatedEmailHandler>();
builder.Services.AddScoped<IEventHandler<OrderCreatedEvent>, OrderCreatedInventoryHandler>();
builder.Services.AddScoped<IEventHandler<OrderCreatedEvent>, OrderCreatedLoyaltyHandler>();

Benefits we achieved:

  • ✅ OrderService is now focused on one thing
  • ✅ Each handler is independent and testable
  • ✅ Easy to add new handlers without modifying existing code
  • ✅ Failures in one handler don't affect others

Step 2: Introducing IEventPublisher for Future Flexibility

The simple in-memory dispatcher works great, but what if we want to switch to a message broker like RabbitMQ, Azure Service Bus, or Kafka in the future? This is where abstraction comes in.

The IEventPublisher Abstraction

So the IEventPublisher create an abstraction that hides implementation details.

public interface IEventPublisher
{
    Task PublishAsync<TEvent>(TEvent domainEvent, CancellationToken cancellationToken = default) 
        where TEvent : IDomainEvent;
}

In-Memory Implementation

The in-memory implementation remains similar, but now it implements the interface:

public class InMemoryEventPublisher : IEventPublisher
{
    private readonly IServiceProvider _serviceProvider;
    private readonly ILogger<InMemoryEventPublisher> _logger;

    public InMemoryEventPublisher(
        IServiceProvider serviceProvider,
        ILogger<InMemoryEventPublisher> logger)
    {
        _serviceProvider = serviceProvider;
        _logger = logger;
    }

    public async Task PublishAsync<TEvent>(TEvent domainEvent, CancellationToken cancellationToken = default) 
        where TEvent : IDomainEvent
    {
        _logger.LogInformation("Publishing event {EventType} with ID {EventId}", 
            typeof(TEvent).Name, domainEvent.EventId);

        var handlers = _serviceProvider.GetServices<IEventHandler<TEvent>>();
        
        var tasks = handlers.Select(handler => 
            ExecuteHandlerAsync(handler, domainEvent, cancellationToken));
        
        await Task.WhenAll(tasks);
    }

    private async Task ExecuteHandlerAsync<TEvent>(
        IEventHandler<TEvent> handler, 
        TEvent domainEvent, 
        CancellationToken cancellationToken) where TEvent : IDomainEvent
    {
        try
        {
            await handler.HandleAsync(domainEvent, cancellationToken);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, 
                "Error in handler {HandlerType} for event {EventType}", 
                handler.GetType().Name, 
                typeof(TEvent).Name);
            // Continue - don't let one handler failure stop others
        }
    }
}

Future: Message Bus Implementation

When you're ready to scale, you can swap implementations.

For this example, let's consider Azure Service Bus:


public class ServiceBusEventPublisher : IEventPublisher
{
    private readonly ServiceBusClient _serviceBusClient;
    private readonly ILogger<ServiceBusEventPublisher> _logger;

    public ServiceBusEventPublisher(
        ServiceBusClient serviceBusClient,
        ILogger<ServiceBusEventPublisher> logger)
    {
        _serviceBusClient = serviceBusClient;
        _logger = logger;
    }

    public async Task PublishAsync<TEvent>(TEvent domainEvent, CancellationToken cancellationToken = default) 
        where TEvent : IDomainEvent
    {
        var topicName = typeof(TEvent).Name.ToLowerInvariant();
        var sender = _serviceBusClient.CreateSender(topicName);

        var message = new ServiceBusMessage(JsonSerializer.Serialize(domainEvent))
        {
            MessageId = domainEvent.EventId.ToString(),
            ContentType = "application/json"
        };

        await sender.SendMessageAsync(message, cancellationToken);
        
        _logger.LogInformation("Event {EventType} published to Service Bus", typeof(TEvent).Name);
    }

}

Configuration and Flexibility

Now we can leverage DI in Program.cs in order to easily swap implementations:


var eventPublisherType = builder.Configuration["EventPublisher:Type"] ?? "InMemory";

switch (eventPublisherType)
{
    case "InMemory":
        builder.Services.AddScoped<IEventPublisher, InMemoryEventPublisher>();
        break;
    
    case "ServiceBus":
        builder.Services.AddSingleton(_ => 
            new ServiceBusClient(builder.Configuration["Azure:ServiceBus:ConnectionString"]));
        builder.Services.AddScoped<IEventPublisher, ServiceBusEventPublisher>();
        break;
}

Now our OrderService is completely decoupled from the messaging implementation:

public class OrderService
{
    private readonly IOrderRepository _orderRepository;
    private readonly IEventPublisher _eventPublisher; // Abstract interface
    private readonly ILogger<OrderService> _logger;

    public OrderService(
        IOrderRepository orderRepository,
        IEventPublisher eventPublisher,
        ILogger<OrderService> logger)
    {
        _orderRepository = orderRepository;
        _eventPublisher = eventPublisher;
        _logger = logger;
    }

    public async Task CreateOrderAsync(Order order, CancellationToken cancellationToken = default)
    {
        await _orderRepository.SaveAsync(order, cancellationToken);
        
        var orderCreatedEvent = new OrderCreatedEvent
        {
            OrderId = order.Id,
            CustomerId = order.CustomerId,
            TotalAmount = order.Total,
            Items = order.Items.Select(i => new OrderItem 
            { 
                ProductId = i.ProductId, 
                Quantity = i.Quantity, 
                Price = i.Price 
            }).ToList()
        };

        // Implementation detail is hidden behind the interface
        await _eventPublisher.PublishAsync(orderCreatedEvent, cancellationToken);
    }
}

Step 3: Separation of Concerns and Defining Boundaries

Events naturally create boundaries in your system. Each bounded context or module can publish and subscribe to events without knowing about each other's internals.

Benefits of Clear Boundaries

  1. Independent Development: Teams can work on different modules independently
  2. Independent Testing: Test each module in isolation
  3. Independent Deployment: In the future, extract modules into separate services
  4. Clear Dependencies: Events define explicit contracts between modules

The event-driven architecture we've built is the foundation for a modular monolith. A modular monolith is a single deployable application with well-defined internal boundaries.

Conclusion: The Journey and the Destination

Refactoring a legacy monolith doesn't happen overnight. The journey I described—from tightly coupled spaghetti code to an event-driven architecture with clear boundaries is worth the effort.

The Progression

  1. Start Simple: Introduce domain events with in-memory dispatching
  2. Abstract Early: Use IEventPublisher to prepare for future changes
  3. Define Boundaries: Organize code into modules with clear responsibilities
  4. Open Possibilities: A modular monolith can evolve into microservices

Key Takeaways

  • Events Decouple: They break direct dependencies between components
  • Events Define Contracts: They establish clear boundaries between modules
  • Events Enable Scale: They prepare your system for distributed architecture
  • Events Improve Testability: Each component can be tested in isolation
  • Events Tell a Story: They represent what happened in your domain

What We Achieved

Separation of Concerns: Each module has a single, well-defined responsibility
Clear Boundaries: Modules communicate only through events
Flexibility: Can swap event publishers without changing business logic
Testability: Easy to test modules in isolation
Scalability: Ready to extract modules into microservices when needed
Maintainability: Changes in one module don't ripple through the system

The event-driven architecture transformed our monolith from a tangled mess into a well-organized, maintainable system with clear boundaries. And the best part? We're now ready to evolve into a modular monolith or even microservices when the business needs it.

Lesson learned: You don't need to solve all problems on day one. Start simple with in-memory events, establish clear boundaries, and let your architecture evolve as your needs grow. The abstraction layer (IEventPublisher) gives you the flexibility to make that evolution smooth and painless.

See you next time on www.devskillsunlock.com