A switch over event types is dynamic dispatch. You just wrote it by hand, took on the maintenance yourself, and skipped the help the runtime was ready to give you.
You've written this method. Every event-driven codebase has one:
public Task HandleAsync(BaseEvent @event) => @event switch
{
OrderPlaced e => HandleAsync(e),
PaymentFailed e => HandleAsync(e),
InventoryReserved e => HandleAsync(e),
_ => throw new NotSupportedException(
$"No handler for '{@event.GetType().Name}'.")
};
It works. It's also a lookup table you maintain by hand, keyed on runtime type, mapping each type to the method that should run. That's the exact job overload resolution already does for you. The compiler does it for free, at every call site, and it never forgets to add a case.
Let me show you how to hand that job back to the runtime.
The setup
Say we have a small, closed hierarchy of domain events that we own:
public abstract record BaseEvent;
public sealed record OrderPlaced(Guid OrderId, decimal Total) : BaseEvent;
public sealed record PaymentFailed(Guid OrderId, string Reason) : BaseEvent;
public sealed record InventoryReserved(Guid OrderId, int Qty) : BaseEvent;
A processor needs to route each event to the right handler. The switch above does it, but the pattern-match arm and the target method are saying the same thing twice: "if it's an OrderPlaced, call the OrderPlaced overload." The type check is the dispatch. We just wrote it out longhand.
Let the runtime dispatch it
Cast to dynamic at a single boundary, and let the C# runtime binder pick the overload based on the argument's actual runtime type:
public sealed class EventProcessor
{
// The only boundary where the type is late-bound.
public Task HandleAsync(BaseEvent @event)
{
ArgumentNullException.ThrowIfNull(@event);
return Dispatch((dynamic)@event);
}
private Task Dispatch(OrderPlaced e) =>
// handle the order...
Task.CompletedTask;
private Task Dispatch(PaymentFailed e) =>
// compensate, alert, retry...
Task.CompletedTask;
private Task Dispatch(InventoryReserved e) =>
Task.CompletedTask;
// Least-specific overload: the intentional fallback (more on this below).
private Task Dispatch(BaseEvent e) =>
throw new NotSupportedException(
$"No handler is registered for '{e.GetType().Name}'.");
}
The switch is gone. Adding a fourth event now means adding its record and one Dispatch overload. Nothing central to edit, nothing to forget.
Why the boundary and the handlers have different names
Small thing, but it matters. The public entry point is HandleAsync and the overload set is Dispatch. That's on purpose. If the boundary were also called Dispatch(BaseEvent), it would clash with the fallback overload, because two methods with the same signature are a compile error, and public versus private doesn't break the tie. Giving the boundary its own name gets you out of that, and it reads better anyway. One method's job is "enter the dispatch" and the other set's job is "handle a specific event."
dynamic removes the conditional, not the responsibility
This is the part that makes or breaks the pattern. dynamic deletes your if/switch, but it does not delete the need to handle an event you never wrote a handler for. It only changes how you find out.
With no fallback, an unrecognized type throws a RuntimeBinderException from deep inside the framework. That's a confusing error that doesn't even name your domain. So add the fallback on purpose:
private Task Dispatch(BaseEvent e) =>
throw new NotSupportedException(
$"No handler is registered for '{e.GetType().Name}'.");
Here's why it works. Every event is a BaseEvent, so the binder always has at least the fallback overload available, and it picks the most-derived one that fits:
- Runtime type is
OrderPlaced, soDispatch(OrderPlaced)is more specific thanDispatch(BaseEvent), and the specific handler wins. - Runtime type has no dedicated overload, so only
Dispatch(BaseEvent)fits, and you get yourNotSupportedException, thrown from your code, with a message you control.
Same guarantee the _ arm gave you in the switch, now written as an overload.
How this actually behaves at runtime
Two questions people always ask.
"Isn't dynamic slow?" There's a one-time cost the first time a given runtime type flows through the boundary. The DLR builds a call site and works out the overload. That result gets cached per runtime type. After warm-up, dispatching another OrderPlaced is about as fast as a normal virtual call, and you're not paying reflection costs on every event. For most event pipelines this just doesn't matter. For a genuinely hot path chewing through millions of events a second, benchmark it against a Dictionary<Type, Func<…>> or a source generator before you commit.
"Does dynamic leak everywhere?" No, and this is the discipline that keeps it sane. dynamic lives in exactly one expression: (dynamic)@event. Every Dispatch overload is ordinary, statically typed C#. They get full IntelliSense, full type checking, and they're easy to unit-test on their own. The late binding stays in one small, contained spot instead of spreading through the class.
Sharp edges worth knowing
A few things that bite if you're not ready for them:
-
Null. A null
dynamichas no runtime type for the binder to work with. That's why the boundary guards withArgumentNullException.ThrowIfNullbefore the cast. Skip it and you get a murky runtime error instead of a clear one. -
Return type. The expression
Dispatch((dynamic)@event)is itself typeddynamicat compile time, so returning it from aTask-returning method adds a runtime conversion toTask. Keep every overload returningTask(or aTasksubtype). If a handler ever returns something you can't await, you find out at runtime, not at compile time. -
Tooling goes quiet. "Find all references" on
Dispatch(OrderPlaced)won't show it being called, because statically it isn't. IDE navigation and some analyzers lose the thread across thedynamichop. You're trading a bit of compile-time visibility for a lot less churn.
When this is the right tool, and when it isn't
Use it when your event types are a closed, trusted hierarchy that you own: domain events, internal commands, a message set defined in your own assembly. That's the whole premise. The runtime is choosing among a fixed set of methods you wrote, so late binding is safe, and the fallback covers the "someone added a type and forgot a handler" case.
Do not point it at arbitrary deserialized payloads or plugin-provided types without checking them first. Late-binding on untrusted input is a footgun. You'd be handing the overload resolver whatever type an attacker or a buggy producer put on the wire. Validate or whitelist the type, then dispatch.
What about a type-pattern switch?
Fair question. The modern switch expression over type patterns is a perfectly good alternative:
public Task HandleAsync(BaseEvent @event) => @event switch
{
OrderPlaced e => Dispatch(e),
PaymentFailed e => Dispatch(e),
_ => throw new NotSupportedException(/* ... */)
};
It's explicit, statically visible, and pays no binder cost. But notice it's still a table you keep by hand. Adding an event means editing it, and the _ discard means the compiler won't force you to. It has the same "you have to remember the fallback" property as the dynamic version, just spelled out.
So it comes down to what you want to optimize for:
- Reach for
dynamicwhen you want adding an event to need only a new overload, and you're fine trading some static visibility for that. - Reach for the switch when you want the dispatch table sitting in one readable place and fully visible to your tooling.
Both need the deliberate unknown-event branch. Neither is wrong.
Scaling it up: resolving handlers from DI
The version so far keeps the handler bodies inside the processor. In real code, they usually live in their own classes, resolved from the container, so they can take their own dependencies. This is where dynamic really earns its keep, because the awkward part of doing this with DI is the exact thing the cast fixes.
Define the usual generic handler interface:
public interface IEventHandler<in TEvent> where TEvent : BaseEvent
{
Task HandleAsync(TEvent @event, CancellationToken ct);
}
Now the dispatcher swaps its overload set for a single generic method, and lets dynamic pick the type argument instead of the overload:
public sealed class EventDispatcher(IServiceScopeFactory scopeFactory)
{
// Same one boundary as before.
public Task DispatchAsync(BaseEvent @event, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(@event);
return Handle((dynamic)@event, ct);
}
private async Task Handle<TEvent>(TEvent @event, CancellationToken ct)
where TEvent: BaseEvent
{
await using var scope = scopeFactory.CreateAsyncScope();
var handlers = scope.ServiceProvider
.GetServices<IEventHandler<TEvent>>()
.ToList();
if (handlers.Count == 0)
throw new NotSupportedException(
$"No handler registered for '{typeof(TEvent).Name}'.");
foreach (var handler in handlers)
await handler.HandleAsync(@event, ct);
}
}
When @event is runtime-type OrderPlaced, the binder infers TEvent = OrderPlaced on that generic method, and GetServices<IEventHandler<OrderPlaced>>() resolves the correctly typed handlers. That's the whole point. dynamic is bridging runtime type to generic type parameter, the one gap you can't cross from a static BaseEvent reference. Registration is the plain generic story, and with assembly scanning (Scrutor), adding an event touches the dispatcher zero times:
services.Scan(s => s
.FromAssemblyOf<OrderPlaced>()
.AddClasses(c => c.AssignableTo(typeof(IEventHandler<>)))
.AsImplementedInterfaces()
.WithScopedLifetime());
Two things change once DI is in the picture, and both are worth calling out.
The fallback moves. In the overload version, an unknown event got caught by the most-derived overload that fit, and Dispatch(BaseEvent) threw. Here there's one generic method, so every subtype runs it happily and GetServices just hands back an empty sequence. The "fail loud on an unhandled event" behavior no longer comes for free, so you put it back with the empty-set check above. Same guarantee, different mechanism. It's a count check now, not overload resolution.
Lifetimes are the real trap. Resolving handlers from the container here is fine, not a service-locator smell, because the handler type genuinely isn't known until runtime. That's the textbook case for it, and it's how MediatR dispatches internally. But which provider you resolve from matters a lot. Event processors are usually long-lived, like a queue consumer or a BackgroundService, and resolving scoped handlers off the root provider from a long-lived object is the classic captive-dependency bug that shows up later as an ObjectDisposedException. That's why the dispatcher takes IServiceScopeFactory and opens a fresh scope per event instead of hanging onto one provider for its whole life. If your dispatcher already lives inside a scope, say it's resolved per web request, you can inject the scoped IServiceProvider directly and skip the manual scope.
One footnote for the reflection-minded. The pre-dynamic way to do all of this is typeof(IEventHandler<>).MakeGenericType(@event.GetType()) followed by a reflected invoke, usually behind a cached compiled delegate. It's the same idea written out by hand. dynamic just lets the DLR do the MakeGenericType, the resolution, and the per-type call-site caching for you. Same trade as the rest of the post: you give up "find all references" on the handlers in exchange for never editing the dispatcher.
The takeaway
The switch was never wrong. It was redundant. Picking a method based on the runtime type of an argument is the whole definition of overload resolution, and the runtime already knows how to do it, faster and more reliably than a hand-kept list of case labels.
dynamic just asks it to do that job one moment later than usual. Delete the switch, keep the fallback.
Top comments (0)