Skip to content
Go back

[.NET] Simplify Domain Events with DKNet.EfCore.Events

Domain events are a powerful pattern for making implicit side effects explicit in Domain-Driven Design (DDD). When an order is placed, you might need to send emails, update inventory, and notify shipping. How do you handle these concerns cleanly without tangling business logic with infrastructure code?

DKNet.EfCore.Events builds on DKNet.EfCore.Hooks to bring elegant domain event management to EF Core applications. It automatically captures and publishes domain events as part of your database transactions, ensuring consistency while maintaining clean separation of concerns.


Table of Contents

Open Table of Contents

Understanding Domain Events

Domain events capture significant business occurrences that other parts of your application might care about. They represent facts about state changes in your business domain.

Without domain events, side effects are often directly coded into business logic:

public class OrderService(AppDbContext dbContext, IEmailService emailService,
    IInventoryService inventoryService, IShippingService shippingService)
{
    public async Task CreateOrderAsync(Order order)
    {
        await dbContext.Orders.AddAsync(order);
        await dbContext.SaveChangesAsync();

        // Tightly coupled to multiple services
        await emailService.SendConfirmationAsync(order);
        await inventoryService.ReserveItemsAsync(order);
        await shippingService.NotifyAsync(order);
    }
}

This creates several problems:

A traditional approach to domain events might look like this:

public sealed record OrderPlacedEvent(
    Guid OrderId,
    string OrderNumber,
    decimal Total,
    DateTime PlacedAt);

public class Order
{
    private readonly List<object> _domainEvents = new();

    public Guid Id { get; private set; }
    public string OrderNumber { get; private set; } = string.Empty;
    public decimal Total { get; private set; }

    public IReadOnlyCollection<object> DomainEvents => _domainEvents.AsReadOnly();

    public static Order Create(string orderNumber, decimal total)
    {
        var order = new Order
        {
            Id = Guid.NewGuid(),
            OrderNumber = orderNumber,
            Total = total
        };

        // Manually add domain event
        order._domainEvents.Add(new OrderPlacedEvent(
            order.Id, order.OrderNumber, order.Total, DateTime.UtcNow));

        return order;
    }

    public void ClearDomainEvents() => _domainEvents.Clear();
}

But then you’d need to manually handle event collection and publishing:

public class OrderService(AppDbContext dbContext, IMediator mediator)
{
    public async Task CreateOrderAsync(string orderNumber, decimal total)
    {
        var order = Order.Create(orderNumber, total);
        await dbContext.Orders.AddAsync(order);

        // Manual event collection
        var events = order.DomainEvents.ToList();
        order.ClearDomainEvents();

        await dbContext.SaveChangesAsync();

        // Manual event publishing
        foreach (var @event in events)
        {
            await mediator.Publish(@event);
        }
    }
}

This traditional approach requires significant boilerplate code and manual coordination between entity state, event collection, and publishing.


The Challenge with Domain Events

Implementing domain events correctly with EF Core presents several challenges:


What is DKNet.EfCore.Events?

DKNet.EfCore.Events solves these challenges with minimal configuration:

Key Features

How It Works

The EventHook intercepts EF Core’s SaveChanges lifecycle, automatically collecting and publishing events through MediatR:

┌─────────────┐    AddEvent()    ┌──────────────┐
│   Entity    │ ───────────────► │ Event Queue  │
│  (Domain)   │                  │  (In Memory) │
└─────────────┘                  └──────────────┘

                                         │ Collect Events

                                ┌──────────────┐
                                │  EventHook   │◄─── SaveChanges()
                                │ (Intercept)  │
                                └──────────────┘

                                         │ After Successful Commit

                                ┌──────────────┐
                                │ EventPublisher│
                                │  (MediatR)   │
                                └──────────────┘

                                         │ Publish Events

                                ┌──────────────┐
                                │   MediatR    │
                                │ Notification │
                                └──────────────┘

                        ┌────────────────┼────────────────┐
                        │                │                │
                        ▼                ▼                ▼
                ┌─────────────┐  ┌─────────────┐  ┌─────────────┐
                │Email Handler│  │Inventory    │  │Analytics    │
                │             │  │Handler      │  │Handler      │
                └─────────────┘  └─────────────┘  └─────────────┘

Getting Started

Installation

dotnet add package DKNet.EfCore.Abstractions
dotnet add package DKNet.EfCore.Events
dotnet add package DKNet.EfCore.Hooks
dotnet add package MediatR

Configuration

Register the hooks, MediatR, and event publisher in Program.cs:

builder.Services.AddDbContext<AppDbContext>((sp, options) =>
{
    options.UseSqlServer(connectionString)
           .UseHooks(sp); // Enable EF Core Hooks
});

// Register MediatR
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));

// Register the Event Publisher with MediatR
builder.Services.AddScoped<IEventPublisher, MediatREventPublisher>();
builder.Services.AddEventPublisher<AppDbContext, MediatREventPublisher>();

Basic Usage

Define Domain Events

public record OrderPlacedEvent(
    Guid OrderId,
    string OrderNumber,
    decimal Total,
    DateTime PlacedAt);

Create Entities

Inherit from Entity base class:

using DKNet.EfCore.Abstractions.Entities;

public class Order : Entity
{
    public string OrderNumber { get; private set; } = string.Empty;
    public decimal Total { get; private set; }

    public static Order Create(string orderNumber, decimal total)
    {
        var order = new Order
        {
            OrderNumber = orderNumber,
            Total = total
        };

        // Add domain event
        order.AddEvent(new OrderPlacedEvent(
            order.Id, order.OrderNumber, order.Total, DateTime.UtcNow));

        return order;
    }
}

DbContext

Your DbContext requires no modifications:

public class AppDbContext : DbContext
{
    public DbSet<Order> Orders => Set<Order>();

    // No event-specific code needed!
    // EventHook handles everything automatically
}

Practical Example: E-Commerce Order System

Domain Events

public record OrderPlacedEvent(Guid OrderId, Guid CustomerId, string OrderNumber, decimal Total);
public record OrderConfirmedEvent(Guid OrderId, DateTime ConfirmedAt);
public record OrderShippedEvent(Guid OrderId, string TrackingNumber);

Order Entity

public class Order : Entity
{
    private readonly List<OrderItem> _items = new();

    public Guid CustomerId { get; private set; }
    public string OrderNumber { get; private set; } = string.Empty;
    public OrderStatus Status { get; private set; }
    public string? TrackingNumber { get; private set; }

    public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();
    public decimal Total => _items.Sum(i => i.Price * i.Quantity);

    public static Order Create(Guid customerId, string orderNumber, List<OrderItem> items)
    {
        var order = new Order
        {
            CustomerId = customerId,
            OrderNumber = orderNumber,
            Status = OrderStatus.Pending
        };

        order._items.AddRange(items);
        order.AddEvent(new OrderPlacedEvent(order.Id, customerId, orderNumber, order.Total));
        return order;
    }

    public void Confirm()
    {
        if (Status != OrderStatus.Pending)
            throw new InvalidOperationException("Only pending orders can be confirmed");

        Status = OrderStatus.Confirmed;
        AddEvent(new OrderConfirmedEvent(Id, DateTime.UtcNow));
    }

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

        Status = OrderStatus.Shipped;
        TrackingNumber = trackingNumber;
        AddEvent(new OrderShippedEvent(Id, trackingNumber));
    }
}

MediatR Event Handlers

With MediatR, you can create notification handlers for your domain events:

// Send confirmation email when order is placed
public class OrderPlacedEmailHandler(IEmailService emailService) : INotificationHandler<OrderPlacedEvent>
{
    public async Task Handle(OrderPlacedEvent notification, CancellationToken ct)
    {
        await emailService.SendOrderConfirmationAsync(
            notification.CustomerId, notification.OrderNumber, notification.Total, ct);
    }
}

// Update inventory when order is placed
public class OrderPlacedInventoryHandler(IInventoryService inventoryService) : INotificationHandler<OrderPlacedEvent>
{
    public async Task Handle(OrderPlacedEvent notification, CancellationToken ct)
    {
        await inventoryService.ReserveItemsForOrderAsync(notification.OrderId, ct);
    }
}

Service Layer

public class OrderService(AppDbContext dbContext)
{
    public async Task<Guid> CreateOrderAsync(
        Guid customerId, List<OrderItem> items, CancellationToken ct = default)
    {
        var orderNumber = GenerateOrderNumber();
        var order = Order.Create(customerId, orderNumber, items);

        await dbContext.Orders.AddAsync(order, ct);
        await dbContext.SaveChangesAsync(ct);
        // Events are automatically published via MediatR

        return order.Id;
    }

    public async Task ConfirmOrderAsync(Guid orderId, CancellationToken ct = default)
    {
        var order = await dbContext.Orders.FindAsync(new object[] { orderId }, ct);
        order?.Confirm();
        await dbContext.SaveChangesAsync(ct);
        // OrderConfirmedEvent automatically published via MediatR
    }

    private static string GenerateOrderNumber() =>
        $"ORD-{DateTime.UtcNow:yyyyMMdd}-{Guid.NewGuid():N}"[..20];
}

Advanced Features

Event Type Mapping with Mapster

Add event types instead of instances for automatic mapping:

//The Entity is an abstract class from DKNet.EfCore.Abstractions.Entities
public class Order : Entity
{
    public static Order Create(string orderNumber, decimal total)
    {
        var order = new Order { OrderNumber = orderNumber, Total = total };

        // Add event TYPE instead of instance
        order.AddEvent<OrderPlacedEvent>();
        return order;
    }
}

Configure Mapster for automatic mapping:

// Map Order entity to OrderPlacedEvent
TypeAdapterConfig.GlobalSettings.NewConfig<Order, OrderPlacedEvent>()
    .Map(dest => dest.OrderId, src => src.Id)
    .Map(dest => dest.OrderNumber, src => src.OrderNumber);

Conditional Event Handling

Handle events based on business rules:

public class HighValueOrderHandler(INotificationService notificationService) : INotificationHandler<OrderPlacedEvent>
{
    public async Task Handle(OrderPlacedEvent notification, CancellationToken ct)
    {
        if (notification.Total < 1000) return; // Only handle orders over $1000

        await notificationService.NotifyManagerAsync(
            $"High value order: {notification.OrderNumber} - {notification.Total:C}", ct);
    }
}

Integration with MediatR

The library integrates seamlessly with MediatR through the IEventPublisher interface:

//The IEventPublisher interface is from DKNet.EfCore.Abstractions.Events
public class MediatREventPublisher(IMediator mediator) : IEventPublisher
{
    public async Task PublishAsync(object eventObj, CancellationToken cancellationToken = default)
    {
        // MediatR will automatically find and invoke all INotificationHandler<T> implementations
        await mediator.Publish(eventObj, cancellationToken);
    }
}

Then create MediatR notification handlers:

public class OrderPlacedNotificationHandler(IEmailService emailService, IInventoryService inventoryService)
    : INotificationHandler<OrderPlacedEvent>
{
    public async Task Handle(OrderPlacedEvent notification, CancellationToken ct)
    {
        // Send email confirmation
        await emailService.SendOrderConfirmationAsync(notification, ct);

        // Reserve inventory items
        await inventoryService.ReserveItemsAsync(notification.OrderId, ct);
    }
}

Best Practices

1. Keep Events Immutable

Always use records or readonly properties:

// ✅ Good
public record OrderPlacedEvent(Guid OrderId, decimal Total);

// ❌ Avoid
public class OrderPlacedEvent
{
    public Guid OrderId { get; set; }
    public decimal Total { get; set; }
}

2. Name Events in Past Tense

Events represent facts that already occurred:

// ✅ Good: OrderPlacedEvent, PaymentProcessedEvent
// ❌ Avoid: PlaceOrderEvent, ProcessPaymentEvent

3. Keep Handlers Focused

Each MediatR notification handler should have a single responsibility:

// ✅ Good: One responsibility
public class OrderPlacedEmailHandler(IEmailService emailService) : INotificationHandler<OrderPlacedEvent>
{
    public Task Handle(OrderPlacedEvent notification, CancellationToken ct)
        => emailService.SendConfirmationAsync(notification, ct);
}

4. Handle Failures Gracefully

public class OrderPlacedEmailHandler(IEmailService emailService, ILogger<OrderPlacedEmailHandler> logger)
    : INotificationHandler<OrderPlacedEvent>
{
    public async Task Handle(OrderPlacedEvent notification, CancellationToken ct)
    {
        try
        {
            await emailService.SendAsync(notification, ct);
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Failed to send email for order {OrderId}", notification.OrderId);
            // Consider retry logic or dead letter queue
        }
    }
}

5. Use Strongly-Typed Events

// ✅ Good
public record OrderPlacedEvent(Guid OrderId, decimal Total);

// ❌ Avoid
public record GenericEvent(string EventType, Dictionary<string, object> Data);

Conclusion

DKNet.EfCore.Events simplifies domain event implementation in .NET applications by providing:

The library integrates seamlessly into existing EF Core applications with MediatR, making it perfect for building scalable, event-driven architectures.

Key Benefits

BenefitDescription
Reduced CouplingBusiness logic independent of implementation details
Better OrganizationSide effects explicitly modeled as events
Easier TestingMock MediatR notification handlers instead of multiple services
Improved ScalabilityAdd handlers without changing existing code
Transaction SafetyEvents only published on successful commits
MediatR IntegrationLeverage MediatR’s powerful notification system

References



Thank You

Thank you for reading! I hope this guide helps you build better event-driven applications with Entity Framework Core. Feel free to explore the DKNet.EfCore.Events library and share your feedback! 🌟

Steven | GitHub


Share this post on:

Next Post
[.NET] Simplify EF Core Lifecycle Management with DKNet.EfCore.Hooks