DEV Community

Steven Hoang
Steven Hoang

Posted on • Originally published at drunkcoding.net

[.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

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

This creates several problems:

  • Tight Coupling: OrderService depends on every service that needs to react to order creation
  • Testing Difficulties: You must mock all dependent services
  • Poor Scalability: Adding new side effects requires modifying existing code
  • Transaction Boundaries: What if email sending fails? Should we rollback the order?

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();
}
Enter fullscreen mode Exit fullscreen mode

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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

  • Transaction Consistency: Events should only be published if the database transaction succeeds
  • Event Collection: Where to store and collect domain events from entities
  • Publishing Timing: When to publish events (before/after SaveChanges)
  • Boilerplate Code: Significant plumbing code for event infrastructure
  • Testing Complexity: Mocking the entire event infrastructure

What is DKNet.EfCore.Events?

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

Key Features

  • Base Entity Class: Inherit from Entity or Entity<TKey> - no manual implementation needed
  • Simple Event API: Just call AddEvent(eventObject) or AddEvent<TEvent>()
  • Hook-Based Architecture: Uses EF Core Hooks to automatically intercept SaveChanges
  • Zero DbContext Changes: No need to override SaveChangesAsync
  • Transaction Safety: Events published only after successful commits
  • DI-Friendly: Seamless integration with .NET dependency injection

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      │
                └─────────────┘  └─────────────┘  └─────────────┘
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>();
Enter fullscreen mode Exit fullscreen mode

Basic Usage

Define Domain Events

public record OrderPlacedEvent(
    Guid OrderId,
    string OrderNumber,
    decimal Total,
    DateTime PlacedAt);
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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));
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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];
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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; }
}
Enter fullscreen mode Exit fullscreen mode

2. Name Events in Past Tense

Events represent facts that already occurred:

// ✅ Good: OrderPlacedEvent, PaymentProcessedEvent
// ❌ Avoid: PlaceOrderEvent, ProcessPaymentEvent
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

5. Use Strongly-Typed Events

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

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

Conclusion

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

  • Easy Setup: Inherit from Entity, call AddEvent(), configure hooks and MediatR
  • Zero DbContext Changes: Hook-based architecture handles everything automatically
  • Transaction Safety: Events published only after successful database commits
  • MediatR Integration: Seamless integration with MediatR for powerful event handling
  • Clean Architecture: Decouples business logic from side effects
  • DDD Support: First-class domain-driven design with base entity classes

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

Key Benefits

Benefit Description
Reduced Coupling Business logic independent of implementation details
Better Organization Side effects explicitly modeled as events
Easier Testing Mock MediatR notification handlers instead of multiple services
Improved Scalability Add handlers without changing existing code
Transaction Safety Events only published on successful commits
MediatR Integration Leverage MediatR's powerful notification system

References

Related Articles

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

Top comments (0)