# Replacing switch Statements with Action Delegates in C#

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 wit`h 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.

```csharp
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:

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1768244347683/238ae638-d6a0-402d-b185-707ae3b641a3.png align="center")

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:

```csharp
private readonly Dictionary<CommandType, Action<Command>> _handlers =
    new()
    {
        [CommandType.Create] = HandleCreate,
        [CommandType.Update] = HandleUpdate,
        [CommandType.Delete] = HandleDelete
    };
```

Execution becomes trivial:

```csharp
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.

```csharp
_handlers[CommandType.Create] =
    command => WithLogging(() => HandleCreate(command));
```

Or with a helper:

```csharp
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.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1768245219937/9292af85-596b-45ad-a811-a36e8af1273f.png align="center")

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:

```csharp
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.

```csharp
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.

```csharp
await _handlers[command.Type](command);
```

Error handling becomes composable instead of structural.

## Validation as Behaviour

Validation inside a switch usually looks like conditional noise.

```csharp
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.

```csharp
_handlers[CommandType.Create] =
    command =>
    {
        ValidateCreate(command);
        HandleCreate(command);
    };
```

Even better, you can *decorate* behaviour.

```csharp
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.

```csharp
public interface ICommandHandler
{
    CommandType Type { get; }
    Task Handle(Command command);
}
```

Registration becomes data-driven.

```csharp
var handlers = handlerInstances
    .ToDictionary(h => h.Type, h => h.Handle);
```

Dispatch becomes trivial.

```csharp
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:

```csharp
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.
