Delete the Event Switch: Runtime Dispatch with dynamic in C#
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`, so `Dispatch(OrderPlaced)` is more specific than `Dispatch(BaseEvent)`, and the specific handler wins.
* Runtime type has no dedicated overload, so only `Dispatch(BaseEvent)` fits, and you get your `NotSupportedException`, 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 `dynamic` has no runtime type for the binder to work with. That's why the boundary guards with `ArgumentNullException.ThrowIfNull` before 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 typed `dynamic` at compile time, so returning it from a `Task`-returning method adds a runtime conversion to `Task`. Keep every overload returning `Task` (or a `Task` subtype). 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 the `dynamic` hop. 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 **`dynamic`** when 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.