Mastering Core Domain Logic And Reliable Side Effects With Domain Events Outbox And Sagas
Mastering Core Domain Logic And Reliable Side Effects With Domain Events Outbox And Sagas
In modern software design, managing state changes cleanly while handling side effects reliably is a major challenge. Consider the scenario where we introduce a username requirement for user registration alongside the email, and marketing now wants to send SMS notifications in addition to emails.
The core guiding principle is separation of concerns. The UserService should only be responsible for the state change โ creating the user. It should never orchestrate side effects like sending emails or SMS directly.
Level 1. Logical Decoupling With Domain Events
The first step is inverting control by allowing the UserService to merely announce that something happened, rather than commanding other services. This is where Domain Events come to play.
Example refactor of UserService using Domain Events:
public class UserService(
IUserRepository userRepository,
IDomainEventDispatcher dispatcher,
IUnitOfWork unitOfWork)
{
public async Task RegisterUser(string email, string password)
{
var user = new User(email, password);
var userRegisteredEvent = new UserRegisteredEvent(user.Id, user.Email);
await userRepository.AddAsync(user);
await dispatcher.Dispatch(userRegisteredEvent);
await unitOfWork.SaveChangesAsync();
}
}
Now, handlers for Email notifications, Analytics, and any future features like Loyalty Points are completely separate classes reacting to the Domain Event. This decouples responsibilities and allows easier extensibility.
Level 2. Achieving Reliability With The Outbox Pattern
Logical decoupling alone does not guarantee reliability. The process might fail between persisting the user and dispatching the event, resulting in inconsistent state or lost notifications.
The solution is Atomicity โ saving both the user and the event atomically in a single transaction using the Outbox Pattern. Here, events are saved to an OutboxMessages table alongside the user, then published asynchronously by a background worker.
Implementation example:
public async Task RegisterUser(string email, string password) {
var user = new User(email, password);
var domainEvent = new UserRegisteredEvent(user.Id, user.Email);
using var transaction = dbContext.Database.BeginTransaction();
try {
dbContext.Users.Add(user);
dbContext.OutboxMessages.Add(new OutboxMessage {
Id = Guid.NewGuid(),
Type = nameof(UserRegisteredEvent),
Content = JsonSerializer.Serialize(domainEvent),
OccurredOn = DateTime.UtcNow,
ProcessedOn = null
});
await dbContext.SaveChangesAsync();
await transaction.CommitAsync();
} catch {
await transaction.RollbackAsync();
throw;
}
}
The background worker polls OutboxMessages and reliably publishes events with at-least-once delivery semantics.
Level 3. Distributed Consistency With Sagas
While the Outbox Pattern handles fire-and-forget side effects well, some workflows require strict consistency across distributed services.
For example, if user registration requires creating a crypto-wallet in an external WalletService and failure means user creation must be undone, simple retries are insufficient.
This scenario demands Saga orchestration โ a controlled workflow managing compensations (undo actions) in case of failures, ensuring data consistency across service boundaries.
Summary
Building scalable and reliable systems requires clear boundaries for core domain logic and side effects. Start by decoupling state changes via Domain Events, then add reliability with the Outbox Pattern, and finally achieve distributed consistency using Sagas for complex workflows.
This layered approach ensures maintainable, extensible, and fault-tolerant architecture that can evolve as your business needs grow.