Skip to main content

Command Palette

Search for a command to run...

Replacing switch Statements with Action Delegates in C#

Updated
6 min read
Replacing switch Statements with Action Delegates in C#
P
Senior Software Engineer specialising in cloud architecture, distributed systems, and modern .NET development, with over two decades of experience designing and delivering enterprise platforms in financial, insurance, and high-scale commercial environments. My focus is on building systems that are reliable, scalable, and maintainable over the long term. I’ve led modernisation initiatives moving legacy platforms to cloud-native Azure architectures, designed high-throughput streaming solutions to eliminate performance bottlenecks, and implemented secure microservices environments using container-based deployment models and event-driven integration patterns. From an architecture perspective, I have strong practical experience applying approaches such as Vertical Slice Architecture, Domain-Driven Design, Clean Architecture, and Hexagonal Architecture. I’m particularly interested in modular system design that balances delivery speed with long-term sustainability, and I enjoy solving complex problems involving distributed workflows, performance optimisation, and system reliability. I enjoy mentoring engineers, contributing to architectural decisions, and helping teams simplify complex systems into clear, maintainable designs. I’m always open to connecting with other engineers, architects, and technology leaders working on modern cloud and distributed system challenges.

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.

Why switch Starts to Hurt

At 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.

The Structural Problem with switch

A 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.

Introducing Action Delegates as Behaviour Maps

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.

Behaviour Becomes Explicit

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.

From Branching to Dispatching

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.

Async and Error Handling Stop Being Special

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 as Behaviour

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.

A CQRS-Friendly Pattern

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);

Testing Improves Without Trying

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.

When switch Is Still Fine

This 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.

A Migration Strategy That Doesn’t Hurt

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.