Skip to main content
The Intent.DomainEvents module enables the domain event pattern, allowing entities to raise events that can be handled by event handlers throughout your application.

Overview

Domain events help implement:
  • Loose coupling between aggregates
  • Side effects from domain operations
  • Integration between bounded contexts
  • Audit trails and notifications
  • Eventual consistency patterns

Installation

Intent.DomainEvents

Key Components

Domain Event Base

All domain events inherit from a base class:
Domain Event
public abstract class DomainEvent
{
    public DateTimeOffset OccurredOn { get; protected set; } = DateTimeOffset.UtcNow;
}

Has Domain Event Interface

Entities that raise events implement IHasDomainEvent:
IHasDomainEvent
public interface IHasDomainEvent
{
    List<DomainEvent> DomainEvents { get; }
}

public class Customer : IHasDomainEvent
{
    private readonly List<DomainEvent> _domainEvents = new();

    public List<DomainEvent> DomainEvents => _domainEvents;

    public void AddDomainEvent(DomainEvent domainEvent)
    {
        _domainEvents.Add(domainEvent);
    }

    public void RemoveDomainEvent(DomainEvent domainEvent)
    {
        _domainEvents.Remove(domainEvent);
    }

    public void ClearDomainEvents()
    {
        _domainEvents.Clear();
    }
}

Domain Event Service

Domain Event Service
public interface IDomainEventService
{
    Task Publish(DomainEvent domainEvent);
}

Creating Domain Events

Define domain events in the domain designer:
Custom Domain Event
public class CustomerRegisteredEvent : DomainEvent
{
    public CustomerRegisteredEvent(Guid customerId, string email)
    {
        CustomerId = customerId;
        Email = email;
    }

    public Guid CustomerId { get; }
    public string Email { get; }
}

public class OrderPlacedEvent : DomainEvent
{
    public OrderPlacedEvent(Guid orderId, Guid customerId, decimal total)
    {
        OrderId = orderId;
        CustomerId = customerId;
        Total = total;
    }

    public Guid OrderId { get; }
    public Guid CustomerId { get; }
    public decimal Total { get; }
}

Raising Domain Events

Raise events from entity methods:
Raising Events
public class Customer : IHasDomainEvent
{
    public Customer(string name, string email)
    {
        Id = Guid.NewGuid();
        Name = name;
        Email = email;
        
        // Raise domain event
        AddDomainEvent(new CustomerRegisteredEvent(Id, Email));
    }

    public void PlaceOrder(Order order)
    {
        Orders.Add(order);
        
        // Raise domain event
        AddDomainEvent(new OrderPlacedEvent(order.Id, Id, order.Total));
    }
}

Event Handlers

When using with MediatR (Intent.MediatR.DomainEvents module):
Event Handler
public class CustomerRegisteredEventHandler : INotificationHandler<CustomerRegisteredEvent>
{
    private readonly IEmailService _emailService;
    private readonly ILogger<CustomerRegisteredEventHandler> _logger;

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

    public async Task Handle(
        CustomerRegisteredEvent notification, 
        CancellationToken cancellationToken)
    {
        _logger.LogInformation(
            "Sending welcome email to customer {CustomerId}",
            notification.CustomerId);

        await _emailService.SendWelcomeEmailAsync(
            notification.Email,
            cancellationToken);
    }
}

Multiple Handlers

Multiple handlers can respond to the same event:
Multiple Handlers
public class OrderPlacedEventHandler : INotificationHandler<OrderPlacedEvent>
{
    private readonly IInventoryService _inventoryService;

    public async Task Handle(
        OrderPlacedEvent notification, 
        CancellationToken cancellationToken)
    {
        // Reserve inventory
        await _inventoryService.ReserveInventoryAsync(
            notification.OrderId,
            cancellationToken);
    }
}

public class OrderPlacedNotificationHandler : INotificationHandler<OrderPlacedEvent>
{
    private readonly INotificationService _notificationService;

    public async Task Handle(
        OrderPlacedEvent notification, 
        CancellationToken cancellationToken)
    {
        // Send notification
        await _notificationService.NotifyCustomerAsync(
            notification.CustomerId,
            $"Order {notification.OrderId} placed successfully",
            cancellationToken);
    }
}

public class OrderPlacedAuditHandler : INotificationHandler<OrderPlacedEvent>
{
    private readonly IAuditService _auditService;

    public async Task Handle(
        OrderPlacedEvent notification, 
        CancellationToken cancellationToken)
    {
        // Log audit trail
        await _auditService.LogAsync(
            "OrderPlaced",
            notification.OrderId,
            notification,
            cancellationToken);
    }
}

Event Publishing

Automatic Publishing with EF Core

When using Intent.EntityFrameworkCore.Interop.DomainEvents, events are automatically published on SaveChanges:
Automatic Publishing
public class ApplicationDbContext : DbContext
{
    private readonly IDomainEventService _domainEventService;

    public override async Task<int> SaveChangesAsync(
        CancellationToken cancellationToken = default)
    {
        // Collect domain events before saving
        var domainEvents = ChangeTracker
            .Entries<IHasDomainEvent>()
            .SelectMany(x => x.Entity.DomainEvents)
            .ToList();

        // Save changes
        var result = await base.SaveChangesAsync(cancellationToken);

        // Publish domain events after successful save
        foreach (var domainEvent in domainEvents)
        {
            await _domainEventService.Publish(domainEvent);
        }

        // Clear events
        foreach (var entry in ChangeTracker.Entries<IHasDomainEvent>())
        {
            entry.Entity.ClearDomainEvents();
        }

        return result;
    }
}

Manual Publishing

Manual Publishing
public class CustomerService
{
    private readonly ICustomerRepository _repository;
    private readonly IDomainEventService _domainEventService;

    public async Task RegisterCustomerAsync(string name, string email)
    {
        var customer = new Customer(name, email);
        
        _repository.Add(customer);
        await _repository.UnitOfWork.SaveChangesAsync();

        // Manually publish events
        foreach (var domainEvent in customer.DomainEvents)
        {
            await _domainEventService.Publish(domainEvent);
        }
        
        customer.ClearDomainEvents();
    }
}

Event Ordering

Control event handling order when needed:
Event Ordering
public class HighPriorityHandler : INotificationHandler<OrderPlacedEvent>
{
    public int Order => 1; // Executes first

    public async Task Handle(
        OrderPlacedEvent notification, 
        CancellationToken cancellationToken)
    {
        // Critical operations
    }
}

public class LowPriorityHandler : INotificationHandler<OrderPlacedEvent>
{
    public int Order => 100; // Executes later

    public async Task Handle(
        OrderPlacedEvent notification, 
        CancellationToken cancellationToken)
    {
        // Non-critical operations
    }
}

Practical Examples

Order Processing Workflow

Order Workflow
public class Order : IHasDomainEvent
{
    public void Submit()
    {
        if (Status != OrderStatus.Draft)
            throw new InvalidOperationException("Only draft orders can be submitted");

        Status = OrderStatus.Submitted;
        SubmittedDate = DateTime.UtcNow;
        
        AddDomainEvent(new OrderSubmittedEvent(Id, CustomerId, Total));
    }

    public void Approve()
    {
        if (Status != OrderStatus.Submitted)
            throw new InvalidOperationException("Only submitted orders can be approved");

        Status = OrderStatus.Approved;
        ApprovedDate = DateTime.UtcNow;
        
        AddDomainEvent(new OrderApprovedEvent(Id, CustomerId));
    }

    public void Ship(string trackingNumber)
    {
        if (Status != OrderStatus.Approved)
            throw new InvalidOperationException("Only approved orders can be shipped");

        Status = OrderStatus.Shipped;
        ShippedDate = DateTime.UtcNow;
        TrackingNumber = trackingNumber;
        
        AddDomainEvent(new OrderShippedEvent(Id, CustomerId, trackingNumber));
    }
}

// Handlers
public class OrderSubmittedEventHandler : INotificationHandler<OrderSubmittedEvent>
{
    public async Task Handle(OrderSubmittedEvent notification, CancellationToken cancellationToken)
    {
        // Send to approval queue
        // Check inventory
        // Notify customer
    }
}

public class OrderApprovedEventHandler : INotificationHandler<OrderApprovedEvent>
{
    public async Task Handle(OrderApprovedEvent notification, CancellationToken cancellationToken)
    {
        // Reserve inventory
        // Send to warehouse
        // Update customer credit
    }
}

public class OrderShippedEventHandler : INotificationHandler<OrderShippedEvent>
{
    public async Task Handle(OrderShippedEvent notification, CancellationToken cancellationToken)
    {
        // Send tracking email
        // Update inventory
        // Schedule delivery notification
    }
}

Integration Events

Convert domain events to integration events:
Integration Events
public class OrderPlacedIntegrationHandler : INotificationHandler<OrderPlacedEvent>
{
    private readonly IEventBus _eventBus;

    public async Task Handle(
        OrderPlacedEvent notification, 
        CancellationToken cancellationToken)
    {
        // Publish to message bus for other bounded contexts
        var integrationEvent = new OrderPlacedIntegrationEvent
        {
            OrderId = notification.OrderId,
            CustomerId = notification.CustomerId,
            Total = notification.Total,
            OccurredOn = notification.OccurredOn
        };

        await _eventBus.PublishAsync(integrationEvent, cancellationToken);
    }
}

Error Handling

Error Handling
public class ResilientEventHandler : INotificationHandler<CustomerRegisteredEvent>
{
    private readonly IEmailService _emailService;
    private readonly ILogger _logger;

    public async Task Handle(
        CustomerRegisteredEvent notification, 
        CancellationToken cancellationToken)
    {
        try
        {
            await _emailService.SendWelcomeEmailAsync(
                notification.Email,
                cancellationToken);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, 
                "Failed to send welcome email to {Email}", 
                notification.Email);
            
            // Don't throw - allow other handlers to execute
            // Could also queue for retry
        }
    }
}

Testing

Testing
public class CustomerTests
{
    [Fact]
    public void Constructor_ShouldRaiseCustomerRegisteredEvent()
    {
        // Arrange
        var name = "John Doe";
        var email = "[email protected]";

        // Act
        var customer = new Customer(name, email);

        // Assert
        var domainEvent = customer.DomainEvents
            .OfType<CustomerRegisteredEvent>()
            .Single();
        
        Assert.Equal(customer.Id, domainEvent.CustomerId);
        Assert.Equal(email, domainEvent.Email);
    }

    [Fact]
    public async Task Handler_ShouldSendWelcomeEmail()
    {
        // Arrange
        var mockEmailService = new Mock<IEmailService>();
        var handler = new CustomerRegisteredEventHandler(
            mockEmailService.Object,
            Mock.Of<ILogger<CustomerRegisteredEventHandler>>());
        
        var domainEvent = new CustomerRegisteredEvent(
            Guid.NewGuid(),
            "[email protected]");

        // Act
        await handler.Handle(domainEvent, CancellationToken.None);

        // Assert
        mockEmailService.Verify(
            x => x.SendWelcomeEmailAsync(
                "[email protected]", 
                It.IsAny<CancellationToken>()), 
            Times.Once);
    }
}

Best Practices

Immutable Events

Make domain events immutable with readonly properties to prevent tampering.

Clear Naming

Use past-tense names (CustomerRegistered, OrderPlaced) to indicate completed actions.

Event Data

Include sufficient data in events to avoid additional queries in handlers.

Idempotent Handlers

Design handlers to be idempotent in case events are processed multiple times.

Additional Resources

Build docs developers (and LLMs) love