Skip to main content

Command Palette

Search for a command to run...

3 Ways of dispatching Domain Events

Published
3 min read

Let's say you are implementing an event sourcing solution, where you send domain events to aggregates to achieve their final state. The pseudo-code might look like this.

var aggregate = new Person { Id = 1 };

foreach (var @event in domainEvents)
{
    aggrgate.Handle(@event);
}

There are different types of domain events for each aggregate. For example, they might inherit from a base class or record.

abstract record DomainEvent
{
    public long AggregateId { get; set; }
}

record NameUpdatedEvent (Name Name) : DomainEvent;
record Name(string FirstName, string LastName);

record AddressUpdatedEvent(Address Address) : DomainEvent;
record Address(
        string Street,
        string City,
        string State,
        string ZipCode);

record PhoneNumberUpdatedEvent(PhoneNumber PhoneNumber, PhoneNumber MobileNumber): DomainEvent;
record PhoneNumber(string Number);

I know three ways to implement dispatcher logic in C#. The two obvious methods are hand-coded and reflection-based. The third method, which I discovered in the book Learning Domain-Driven Design, uses the dynamic feature. The book is really great, by the way, and I highly recommend it.

I was curious about the performance of these methods, so I created a benchmark and was surprised by the results.

Hand-coded

This method is probably the easiest to understand and straightforward to implement.

public override void Handle(DomainEvent domainEvent)
{
    if (domainEvent is NameUpdatedEvent nameUpdatedEvent)
    {
        Handle(nameUpdatedEvent);
        return;
    }

    if (domainEvent is AddressUpdatedEvent addressUpdatedEvent)
    {
        Handle(addressUpdatedEvent);
        return;
    }

    if (domainEvent is PhoneNumberUpdatedEvent phoneNumberUpdatedEvent)
    {
        Handle(phoneNumberUpdatedEvent);
        return;
    }
}

This approach might seem inefficient, especially if there are many events. However, for a larger solution, using source generation could be a good option. By looking at the signatures of the available Handle methods, you can determine what the dispatch method for the base type should look like.

Most people would expect this to be the fastest method, and it is. I dispatched 3 × 1,000,000 events and got the following results using Benchmark.NET.

MethodMeanErrorStdDev
Profile32.70 ms0.642 ms0.659 ms

Reflection-based

I created a static dictionary of dispatchers at startup to quickly look up when dispatching an event.

private static readonly Dictionary<Type, Func<object, object, object?>> _handleMethods = typeof(PersonWithReflectionBasedDispatcher).GetMethods(BindingFlags.Instance | BindingFlags.NonPublic)
    .Where(m =>
        m.Name == "Handle" && m.GetParameters().Length == 1 &&
        m.GetParameters()[0].ParameterType != typeof(DomainEvent))
    .ToDictionary(
        m => m.GetParameters()[0].ParameterType,
        m => new Func<object, object, object?>((instance, @event) => m.Invoke(instance, [@event])));

public override void Handle(DomainEvent domainEvent)
{
    _handleMethods[domainEvent.GetType()](this, domainEvent);
}

This is of course somewhat slower than the manual implementation. About 4 times slower indeed.

MethodMeanErrorStdDev
Profile152.9 ms1.98 ms1.86 ms

Dynamic

This part was interesting to me. I actually expected it to be a bit faster than reflection. One good thing about the dynamic solution is its simplicity. Let's take a look.

public override void Handle(DomainEvent domainEvent)
{
    Handle((dynamic)domainEvent);
}

It is slightly faster, but not by much.

MethodMeanErrorStdDev
Profile143.6 ms1.90 ms1.78 ms

Conclusion

All implementations are extremely fast. We dispatched 3 million events in less than a second, so I believe all of them are effective. If I had a larger solution, I would consider a code generation approach. However, for smaller solutions, I would likely choose the dynamic solution over the reflection-based one.

Dynamic is rarely used in C# development, but I think this is a valid use case where it reduces implementation hassle and results in clear and concise code.

The solution is available here.