Unlock Event-Driven Architecture: Refactoring Monoliths with Events in .NET
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:
- Tight Coupling: OrderService directly depends on 7+ different services
- Violates Single Responsibility: OrderService does way too much
- Hard to Test: Need to mock all dependencies for every test
- Hard to Maintain: Any change affects multiple areas
- Poor Scalability: Can't independently scale different concerns
- 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
- Independent Development: Teams can work on different modules independently
- Independent Testing: Test each module in isolation
- Independent Deployment: In the future, extract modules into separate services
- 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
- Start Simple: Introduce domain events with in-memory dispatching
- Abstract Early: Use IEventPublisher to prepare for future changes
- Define Boundaries: Organize code into modules with clear responsibilities
- 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