Building a Custom Domain Events Dispatcher in .NET

Building a Custom Domain Events Dispatcher in .NET

7 min read··

ddddesign-patternsdotnetevent-driven

Domain events let one part of your system publish an event and other parts subscribe to it, instead of calling email or analytics services directly. You can build an in-process dispatcher in .NET with two small interfaces, DI registration, and a strongly typed handler wrapper, no third-party libraries required.

Unlock the Power of ABP.IO's Core Modules and supercharge your .NET web app development:
✅ SaaS - Build scalable, multi-tenant applications.
✅ Identity & Permission Management - Handle authentication, roles, and claims.
✅ Audit Logging - Track changes and user actions automatically.
✅ Account - Get a full-featured, customizable user account UI.
➡️ Explore ABP.IO Modules which are fully extensible and production-ready.

How to Migrate Your WPF Components to .NET MAUI
Looking to modernize your WPF apps? Discover how to seamlessly migrate your WPF components to .NET MAUI with Telerik's expert guide. Learn best practices, avoid common pitfalls, and future-proof your desktop applications for cross-platform success. Read the full guide.

Domain events are a powerful way to decouple parts of your system. Instead of tightly coupling your logic, you can publish events and have other parts of your code subscribe to those events. This pattern is especially valuable in Domain-Driven Design (DDD) where business logic should remain focused and cohesive.

In this article, we'll walk through how to implement a lightweight, custom domain event dispatcher in .NET. The core dispatching logic should not depend on third-party libraries.

We'll cover:

  • Why you might want to use publish-subscribe in your application
  • How to define basic domain event abstractions
  • How to implement and register handlers
  • How to build a domain events dispatcher
  • Trade-offs and when to consider other options

Let's get started.

Why Domain Events Matter

Before diving into implementation, let's understand the problem domain events solve. Consider this tightly coupled code:

public class UserService
{
    public async Task RegisterUser(string email, string password)
    {
        var user = new User(email, password);
        await _userRepository.SaveAsync(user);

        // Directly coupled to email service
        await _emailService.SendWelcomeEmail(user.Email);

        // Directly coupled to analytics
        await _analyticsService.TrackUserRegistration(user.Id);

        // What if we need to add more features?
        // This method will keep growing...
    }
}

With domain events, we can decouple this:

public class UserService
{
    public async Task RegisterUser(string email, string password)
    {
        var user = new User(email, password);
        await _userRepository.SaveAsync(user);

        // Publish event - let other parts of the system react
        await _domainEventsDispatcher.DispatchAsync(
            [new UserRegisteredDomainEvent(user.Id, user.Email)]);
    }
}

Now the UserService focuses solely on user registration, while other concerns are handled through event handlers.

Basic Abstractions

Let's start by defining two simple interfaces that form the foundation of our event system:

// Marker interface for all domain events.
public interface IDomainEvent
{
    // We could add common properties here like:
    // DateTime OccurredAt { get; }
    // Guid EventId { get; }
}

// Generic interface for handling domain events.
public interface IDomainEventHandler<in T> where T : IDomainEvent
{
    Task Handle(T domainEvent, CancellationToken cancellationToken = default);
}

This design gives us type safety through generic constraints while keeping publishers and handlers completely decoupled. You can add new events or handlers without touching existing code, and everything remains easily testable in isolation.

Implementing Sample Handlers

Let's add some sample handlers that demonstrate how different parts of your system can react to the same event:

// Handles sending welcome emails when users register
internal sealed class SendWelcomeEmailHandler(IEmailService emailService)
    : IDomainEventHandler<UserRegisteredDomainEvent>
{
    public async Task Handle(
        UserRegisteredDomainEvent domainEvent,
        CancellationToken cancellationToken = default)
    {
        // Send welcome email
        var welcomeEmail = new WelcomeEmail(domainEvent.Email, domainEvent.UserId);

        await emailService.SendAsync(welcomeEmail, cancellationToken);
    }
}

// Handles analytics tracking for new user registrations
internal sealed class TrackUserRegistrationHandler(IAnalyticsService analyticsService)
    : IDomainEventHandler<UserRegisteredDomainEvent>
{
    public async Task Handle(
        UserRegisteredDomainEvent domainEvent,
        CancellationToken cancellationToken = default)
    {
        // Track registration in analytics
        await analyticsService.TrackEvent(
            "user_registered",
            new
            {
                user_id = domainEvent.UserId,
                registration_date = domainEvent.RegisteredAt
            },
            cancellationToken);
    }
}

To make this work, we need to register our handlers with the DI container.

Here's how to do it manually:

// In your Program.cs or Startup.cs
services.AddScoped<IDomainEventHandler<UserRegisteredDomainEvent>, SendWelcomeEmailHandler>();
services.AddScoped<IDomainEventHandler<UserRegisteredDomainEvent>, TrackUserRegistrationHandler>();

Or you can automate this registration using assembly scanning with Scrutor:

services.Scan(scan => scan.FromAssembliesOf(typeof(DependencyInjection))
    .AddClasses(classes => classes.AssignableTo(typeof(IDomainEventHandler<>)), publicOnly: false)
    .AsImplementedInterfaces()
    .WithScopedLifetime());

The important thing is that multiple handlers can react to the same event.

The Dispatcher (Strongly Typed)

Now we need something to orchestrate calling the handlers. The dispatcher will take the domain events and call the appropriate handlers for each event.

public interface IDomainEventsDispatcher
{
    Task DispatchAsync(
        IEnumerable<IDomainEvent> domainEvents,
        CancellationToken cancellationToken = default);
}

internal sealed class DomainEventsDispatcher(IServiceProvider serviceProvider)
    : IDomainEventsDispatcher
{
    private static readonly ConcurrentDictionary<Type, Type> HandlerTypeDictionary = new();
    private static readonly ConcurrentDictionary<Type, Type> WrapperTypeDictionary = new();

    public async Task DispatchAsync(
        IEnumerable<IDomainEvent> domainEvents,
        CancellationToken cancellationToken = default)
    {
        foreach (IDomainEvent domainEvent in domainEvents)
        {
            using IServiceScope scope = serviceProvider.CreateScope();

            Type domainEventType = domainEvent.GetType();

            Type handlerType = HandlerTypeDictionary.GetOrAdd(
                domainEventType,
                et => typeof(IDomainEventHandler<>).MakeGenericType(et));

            IEnumerable<object?> handlers = scope.ServiceProvider.GetServices(handlerType);

            foreach (object? handler in handlers)
            {
                if (handler is null) continue;

                var handlerWrapper = HandlerWrapper.Create(handler, domainEventType);

                await handlerWrapper.Handle(domainEvent, cancellationToken);
            }
        }
    }

    // Abstract base class for strongly-typed handler wrappers
    private abstract class HandlerWrapper
    {
        public abstract Task Handle(IDomainEvent domainEvent, CancellationToken cancellationToken);

        public static HandlerWrapper Create(object handler, Type domainEventType)
        {
            Type wrapperType = WrapperTypeDictionary.GetOrAdd(
                domainEventType,
                et => typeof(HandlerWrapper<>).MakeGenericType(et));

            return (HandlerWrapper)Activator.CreateInstance(wrapperType, handler)!;
        }
    }

    // Generic wrapper that provides strong typing for handler invocation
    private sealed class HandlerWrapper<T>(object handler) : HandlerWrapper where T : IDomainEvent
    {
        private readonly IDomainEventHandler<T> _handler = (IDomainEventHandler<T>)handler;

        public override async Task Handle(
            IDomainEvent domainEvent,
            CancellationToken cancellationToken)
        {
            await _handler.Handle((T)domainEvent, cancellationToken);
        }
    }
}

The dispatcher uses a wrapper to eliminate reflection during handler execution while maintaining type safety. When we encounter a UserRegisteredDomainEvent, we create a HandlerWrapper<UserRegisteredDomainEvent> that holds a strongly-typed reference to IDomainEventHandler<UserRegisteredDomainEvent>. The wrapper casts the generic IDomainEvent to the specific event type at runtime, but the actual handler invocation uses compile-time types.

This gives us the performance benefits of avoiding reflection in the hot path (handler execution) while only using reflection once during wrapper creation. The trade-off is additional complexity, but the performance gain is significant if you're dispatching many events.

Don't forget to register the dispatcher with DI:

services.AddTransient<IDomainEventsDispatcher, DomainEventsDispatcher>();

Usage Example

Here's how to use the domain events dispatcher in your application:

public class UserController(
    IUserService userService,
    IDomainEventsDispatcher domainEventsDispatcher) : ControllerBase
{
    [HttpPost("register")]
    public async Task<IActionResult> Register([FromBody] RegisterUserRequest request)
    {
        try
        {
            // Create the user
            var user = await userService.CreateUserAsync(request.Email, request.Password);

            // Publish the domain event
            var userRegisteredEvent = new UserRegisteredDomainEvent(user.Id, user.Email);

            await domainEventsDispatcher.DispatchAsync([userRegisteredEvent]);

            return Ok(new { UserId = user.Id, Message = "User registered successfully" });
        }
        catch (Exception ex)
        {
            return BadRequest(new { Error = ex.Message });
        }
    }
}

You could also integrate domain events directly into your domain entities.

Limitations and Tradeoffs

This implementation runs entirely in-process, which has important implications. All handlers execute synchronously within the same request context, but each gets its own DI scope. This means:

  • Immediate feedback: If any handler fails, the exception bubbles up to the caller immediately. No silent failures or eventual consistency surprises.

  • Caller control: The code that dispatches events decides how to handle failures — rollback transactions, retry operations, or continue despite errors. The dispatcher doesn't make these decisions for you.

  • Reliability concerns: If the process crashes after some handlers succeed but before others complete, there's no automatic recovery. Events aren't persisted or retried.

For critical side effects that can't be lost, consider the Outbox pattern. Instead of dispatching events immediately, store them alongside your business data in the same transaction. A background service can later retry failed events, ensuring nothing gets lost. This decouples reliability from performance — your main operation completes quickly while events are processed reliably in the background.

Wrapping Up

Domain events are a powerful pattern for decoupling business logic, and you don't need a heavyweight framework to use them effectively. The implementation we've built here provides a solid foundation that you can extend as your needs grow.

The beauty of rolling your own solution is that you understand every piece, making debugging and customization straightforward. This pattern fits excellently in Domain-Driven Design and Clean Architecture systems where decoupling business logic is crucial.

For systems requiring bulletproof reliability or cross-service communication, invest in proper message infrastructure. But for many applications, this simple approach hits the sweet spot between coupling and complexity.

The key insight is understanding your trade-offs upfront rather than discovering them in production. Start simple, measure what matters, and evolve based on real requirements.


Frequently Asked Questions

What are domain events?

Domain events decouple parts of your system. Instead of one service calling email, analytics, and other services directly, it publishes an event and other parts of the code subscribe to it. The pattern is especially valuable in Domain-Driven Design, where business logic should remain focused and cohesive.

How do you implement domain events in .NET without MediatR?

Define a marker IDomainEvent interface and a generic IDomainEventHandler interface, register handlers with the DI container manually or through Scrutor assembly scanning, and build a dispatcher that resolves every handler for each event type and invokes them. The core dispatching logic needs no third-party libraries.

Can multiple handlers react to the same domain event?

Yes. Register several IDomainEventHandler implementations for the same event type and the dispatcher resolves and invokes all of them. In the example, a welcome email handler and an analytics tracking handler both react to the same UserRegisteredDomainEvent.

How do you avoid reflection when dispatching domain events?

Use a strongly typed handler wrapper. Reflection with MakeGenericType and Activator.CreateInstance runs once per event type when the wrapper is created and is cached in a ConcurrentDictionary, while the actual handler invocation uses compile-time types. The hot path stays reflection-free.

What are the limitations of in-process domain events?

All handlers execute synchronously within the same request context. A failing handler bubbles its exception up to the caller, and if the process crashes after some handlers succeed there is no automatic recovery, because events are not persisted or retried.

When should you use the outbox pattern instead of in-process dispatch?

Use the outbox pattern for critical side effects that cannot be lost. Events are stored alongside business data in the same transaction, and a background service processes and retries them later, so the main operation completes quickly while events are handled reliably.

Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 66,000+ engineers who are improving their skills every Saturday morning.