Replacing switch Statements with Action Delegates in C#

Search for a command to run...

No comments yet. Be the first to comment.
Time has the nasty habit of biting you in production when you least expect it. A timestamp that is perfectly suitable for recording when an order was received is a poor way to measure how long a reque

The Missing Half of Distributed Locks

Properties have always given C# developers a clean public API over internal state. For a simple value, an auto property keeps the implementation compact: public string DisplayName { get; set; } = stri

Preparing for the Keys That Outlive Your Algorithms

Microservices can be a sensible response to scale, independent teams and genuinely different deployment needs. They can also be an expensive starting assumption. Imagine an online store that was divid

Most of us start with switch statements and keep using them long after they’ve stopped being comfortable.
That’s not because switch is bad. It’s because switch quietly becomes a coordination mechanism, not a control structure. Over time it starts to own decisions, workflows, validation rules, side effects, and error handling. When that happens, the code still compiles, still works, and still looks reasonable in isolation. It just stops scaling.
Below we’ll look at a different approach, replacing switch statements with action delegates and function maps. Not as a stylistic preference, but as a way to make behaviour explicit, composable, and testable.
We’ll look at where switch breaks down, how delegates change the shape of your code, and how this pattern fits naturally with modern C#, CQRS, and vertical-slice architectures.
switch Starts to HurtAt small scale, a switch is harmless. You inspect a value and choose a branch.
switch (command.Type)
{
case CommandType.Create:
HandleCreate(command);
break;
case CommandType.Update:
HandleUpdate(command);
break;
case CommandType.Delete:
HandleDelete(command);
break;
default:
throw new InvalidOperationException();
}
This looks fine. The problem is not this code. The problem is what it turns into six months later.
The handlers grow logic. Validation leaks into the switch. Logging sneaks in. A permission check gets added for one case but not the others. Then you need async. Then you need retries. Then a new command type arrives and the only safe place to put it is inside the same switch.
What you now have is implicit orchestration. The switch decides what happens and how it happens, but the rules are scattered across cases.
At that point the switch is no longer branching. It’s coordinating.
switchA switch statement encodes behaviour in a closed structure. Every new case requires modifying the same block. That violates the Open/Closed Principle in practice, even if it doesn’t in theory.
More importantly, switch ties decision logic and execution logic together. You cannot reason about behaviour without scanning the entire statement.
Here’s the mental model most switches create:

The switch is the hub. Everything depends on it.
Now contrast that with a delegate-based model.
An Action or Func delegate lets you treat behaviour as data. Instead of asking “which branch should I execute?”, you ask “which behaviour applies to this key?”
The simplest version looks like this:
private readonly Dictionary<CommandType, Action<Command>> _handlers =
new()
{
[CommandType.Create] = HandleCreate,
[CommandType.Update] = HandleUpdate,
[CommandType.Delete] = HandleDelete
};
Execution becomes trivial:
if (!_handlers.TryGetValue(command.Type, out var handler))
{
throw new InvalidOperationException($"Unsupported command type {command.Type}");
}
handler(command);
At first glance this looks like a lateral move. The real difference appears as the code evolves.
Each delegate is now a first-class unit of behaviour. It can be passed around, wrapped, composed, or replaced.
That means validation, logging, permissions, retries, and metrics no longer need to live inside a switch.
For example, you can introduce cross-cutting concerns without touching the dispatch logic.
_handlers[CommandType.Create] =
command => WithLogging(() => HandleCreate(command));
Or with a helper:
Action<Command> WithLogging(Action<Command> inner)
{
return command =>
{
logger.LogInformation("Handling {Type}", command.Type);
inner(command);
};
}
You cannot do this cleanly with a switch without duplicating code or introducing flags and conditionals.
Really, this is not a refactor. It’s a shift in mindset.
A switch is branching logic. A delegate map is dispatch logic.
That difference is important when your system grows.

The dispatcher doesn’t care what handlers do. It only cares that a handler exists.
This separation is subtle but powerful. The dispatcher becomes stable. Handlers evolve independently.
One of the first pain points with large switch statements is async behaviour.
You end up with something like this:
switch (command.Type)
{
case CommandType.Create:
await HandleCreateAsync(command);
break;
case CommandType.Update:
await HandleUpdateAsync(command);
break;
case CommandType.Delete:
HandleDelete(command);
break;
}
Now half the cases are async, half are not, and the switch controls execution semantics.
With delegates, everything normalises naturally.
private readonly Dictionary<CommandType, Func<Command, Task>> _handlers =
new()
{
[CommandType.Create] = HandleCreateAsync,
[CommandType.Update] = HandleUpdateAsync,
[CommandType.Delete] = command =>
{
HandleDelete(command);
return Task.CompletedTask;
}
};
Dispatching becomes uniform.
await _handlers[command.Type](command);
Error handling becomes composable instead of structural.
Validation inside a switch usually looks like conditional noise.
case CommandType.Create:
if (string.IsNullOrEmpty(command.Name))
throw new ValidationException();
HandleCreate(command);
break;
That ties validation to control flow.
With delegates, validation becomes part of the behaviour itself.
_handlers[CommandType.Create] =
command =>
{
ValidateCreate(command);
HandleCreate(command);
};
Even better, you can decorate behaviour.
Action<Command> WithValidation(
Action<Command> inner,
Action<Command> validate)
{
return command =>
{
validate(command);
inner(command);
};
}
Now composition is explicit and reusable.
If you’re using CQRS or vertical slices, this approach fits naturally.
Instead of a central switch on command type, each command registers its own handler.
public interface ICommandHandler
{
CommandType Type { get; }
Task Handle(Command command);
}
Registration becomes data-driven.
var handlers = handlerInstances
.ToDictionary(h => h.Type, h => h.Handle);
Dispatch becomes trivial.
await handlers[command.Type](command);
Switch-heavy code is awkward to test because behaviour is entangled.
Delegate-based code isolates behaviour by default.
You can test a handler in isolation. You can test the dispatcher with a fake delegate. You can inject alternative behaviours for edge cases.
switch Is Still FineThis is not an anti-switch campaign!.
A switch is stil fine when:
The logic is trivial.
The cases are truly symmetric.
The behaviour will not grow.
There are no cross-cutting concerns.
Configuration parsing, small enums, formatting decisions. These are legitimate uses.
The moment behaviour grows, or starts to differ meaningfully between cases, delegates win.
You don’t need a big rewrite.
A practical approach is to extract the switch into a delegate map, then evolve from there.
Start here:
Action<Command> handler = command.Type switch
{
CommandType.Create => HandleCreate,
CommandType.Update => HandleUpdate,
CommandType.Delete => HandleDelete,
_ => throw new InvalidOperationException()
};
handler(command);
Then lift it into a dictionary when it starts to grow.
This lets you keep modern switch expressions while escaping their limitations.
Replacing switch statements with action delegates is perfect for making behaviour explicit, reducing coordination points, and letting systems grow without central bottlenecks.
You end up with code that is:
Easier to extend without modification
Easier to reason about in isolation
Easier to test without scaffolding
Easier to compose with cross-cutting concerns
Most importantly, you stop asking “what does this switch do?” and start asking “what behaviours exist in this system?”
That shift changes how you design code.