Behavioral Intermediate 7 min read

Command Pattern in C#: Records, Handlers, and Undo

Command pattern in C# / .NET 10: encapsulate a request as a record, dispatch it to a handler, and how MediatR is this pattern shipped as a NuGet.

Table of contents
  1. What problem does the Command pattern solve in C#?
  2. How does the textbook Command structure look?
  3. How does MediatR implement the Command pattern?
  4. How do you implement undo / redo with the Command pattern?
  5. When does the Command pattern misfire?
  6. How does Command compare to Strategy and Mediator?
  7. What does a real .NET 10 example look like?
  8. Where should you read next in this series?

The shop adds a "save for later" feature: if checkout fails because of inventory, the cart should be queued and retried after the next restock. Then comes "preview an order" — same cart, computed totals, but no actual charge. Then "replay the last 10 actions for support debugging". Three features, one observation: each user action wants to exist as data — to be queued, simulated, replayed, or audited — not just executed and forgotten.

The Command pattern is the answer. Turn each operation into an object: a record holding the inputs, a handler executing it. Operations become first-class: you can pass them around, store them, log them, retry them, undo them. In modern C# this is the shape every CQRS codebase already uses; the popular library MediatR exists almost entirely to make this pattern ergonomic.

What problem does the Command pattern solve in C#?

The pattern earns its place when the act of "calling a method" must be lifted into something the program can manipulate. Three concrete shapes:

  1. Queueing or scheduling. "Charge this card again in 24 hours if the bank is back up." The action survives the original request.
  2. Audit and replay. Every state change is a Command persisted to an event log. Replaying them rebuilds the state of the system.
  3. Undo / preview. Compute what would happen without committing; or commit and remember enough to reverse.

What is not a Command problem: "I want to swap an algorithm" — that is Strategy. "I want to send a notification to many subscribers" — that is Observer. Command is specifically about one operation as data.

How does the textbook Command structure look?

A Command type carrying inputs, a Handler with one ExecuteAsync method, and (optionally) a Dispatcher that routes a Command to its Handler:

public interface ICommand<TResult> { }

public interface ICommandHandler<in TCmd, TResult>
    where TCmd : ICommand<TResult>
{
    Task<TResult> HandleAsync(TCmd cmd, CancellationToken ct);
}

public sealed record AddToCart(string CartId, string Sku, int Qty)
    : ICommand<AddToCartResult>;

public sealed record AddToCartResult(int NewItemCount, decimal NewTotal);

public sealed class AddToCartHandler : ICommandHandler<AddToCart, AddToCartResult>
{
    private readonly ICartRepository _carts;
    public AddToCartHandler(ICartRepository carts) => _carts = carts;

    public async Task<AddToCartResult> HandleAsync(AddToCart cmd, CancellationToken ct)
    {
        var cart = await _carts.GetAsync(cmd.CartId, ct) ?? throw new NotFoundException();
        cart.Add(cmd.Sku, cmd.Qty);
        await _carts.SaveAsync(cart, ct);
        return new AddToCartResult(cart.Items.Sum(i => i.Quantity), cart.Total);
    }
}

The structural picture:

flowchart LR
    Caller -->|new AddToCart(...)| Cmd[AddToCart record]
    Cmd --> Disp[Dispatcher]
    Disp -->|finds handler| Handler[AddToCartHandler]
    Handler --> Repo[ICartRepository]
    Handler --> Result[AddToCartResult]

The Command itself is just data. The handler is the executor. Separating the two is what makes queueing, replay, and audit possible.

How does MediatR implement the Command pattern?

MediatR is the Command pattern with a small dispatcher and a pipeline. The shapes:

using MediatR;

public sealed record AddToCart(string CartId, string Sku, int Qty)
    : IRequest<AddToCartResult>;

public sealed class AddToCartHandler : IRequestHandler<AddToCart, AddToCartResult>
{
    private readonly ICartRepository _carts;
    public AddToCartHandler(ICartRepository carts) => _carts = carts;

    public async Task<AddToCartResult> Handle(AddToCart cmd, CancellationToken ct)
    {
        var cart = await _carts.GetAsync(cmd.CartId, ct) ?? throw new NotFoundException();
        cart.Add(cmd.Sku, cmd.Qty);
        await _carts.SaveAsync(cart, ct);
        return new AddToCartResult(cart.Items.Sum(i => i.Quantity), cart.Total);
    }
}

// Composition root
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblyContaining<Program>());

// Caller
[ApiController, Route("cart")]
public sealed class CartController : ControllerBase
{
    private readonly IMediator _mediator;
    public CartController(IMediator m) => _mediator = m;

    [HttpPost("add")]
    public Task<AddToCartResult> Add(AddToCart cmd, CancellationToken ct)
        => _mediator.Send(cmd, ct);
}

IRequest<TResult> is the Command. IRequestHandler<TRequest, TResult> is the executor. IMediator.Send is the dispatcher. MediatR adds pipelines (IPipelineBehavior<>) so you can plug in Chain of Responsibility behaviours like validation and logging once for all commands. Recognising the underlying pattern keeps you from learning MediatR as a magic box.

How do you implement undo / redo with the Command pattern?

Two ingredients: an executed Command must carry enough data to be reversed, and you keep a stack of executed commands.

public interface IReversibleCommand
{
    Task ExecuteAsync(CancellationToken ct);
    Task UndoAsync(CancellationToken ct);
}

public sealed class AddToCartCommand : IReversibleCommand
{
    private readonly Cart _cart;
    private readonly LineItem _line;

    public AddToCartCommand(Cart cart, LineItem line) => (_cart, _line) = (cart, line);

    public Task ExecuteAsync(CancellationToken ct) { _cart.Add(_line); return Task.CompletedTask; }
    public Task UndoAsync   (CancellationToken ct) { _cart.Remove(_line); return Task.CompletedTask; }
}

public sealed class CommandHistory
{
    private readonly Stack<IReversibleCommand> _stack = new();

    public async Task ExecuteAsync(IReversibleCommand cmd, CancellationToken ct)
    {
        await cmd.ExecuteAsync(ct);
        _stack.Push(cmd);
    }

    public async Task UndoAsync(CancellationToken ct)
    {
        if (_stack.TryPop(out var cmd)) await cmd.UndoAsync(ct);
    }
}

The shape works for cart edits and any other clearly-reversible operation. It does not work for "send email" or "delete file" — the real world does not let you take those back. Use undo for things the application owns.

For undo across processes (a multi-user document editor), the same pattern combines with Memento — the Memento captures a state snapshot, the Command knows how to apply or revert it.

When does the Command pattern misfire?

Three traps:

How does Command compare to Strategy and Mediator?

Pattern What is the object? Lifetime Typical caller code
Command One intended action with its inputs Short (one execution) mediator.Send(new AddToCart(...))
Strategy One algorithm you can apply many times Long (injected once) _strategy.Apply(state)
Mediator A hub that routes Commands/messages Long (a service) mediator.Send(...) (dispatcher itself)

The cleanest sentence: Command is what to do; Strategy is how to do it; Mediator is who does the routing. A typical CQRS app uses all three: Mediator dispatches Commands, each handler may use a Strategy internally to compute one piece of the result.

What does a real .NET 10 example look like?

A minimal MediatR setup with a validation pipeline (Chain of Responsibility) on top, plus a manual CommandHistory for in-memory undo:

public sealed record AddToCart(string CartId, string Sku, int Qty)
    : IRequest<AddToCartResult>;
public sealed record RemoveFromCart(string CartId, string Sku)
    : IRequest<RemoveFromCartResult>;

public sealed record AddToCartResult(int NewItemCount, decimal NewTotal);
public sealed record RemoveFromCartResult(int NewItemCount);

public sealed class AddToCartHandler : IRequestHandler<AddToCart, AddToCartResult>
{
    private readonly ICartRepository _carts;
    public AddToCartHandler(ICartRepository carts) => _carts = carts;
    public async Task<AddToCartResult> Handle(AddToCart cmd, CancellationToken ct)
    {
        var cart = await _carts.GetAsync(cmd.CartId, ct) ?? throw new NotFoundException();
        cart.Add(cmd.Sku, cmd.Qty);
        await _carts.SaveAsync(cart, ct);
        return new AddToCartResult(cart.ItemCount, cart.Total);
    }
}

// Pipeline: validation behaviour runs before every command
public sealed class ValidationBehavior<TReq, TRes> : IPipelineBehavior<TReq, TRes>
    where TReq : IRequest<TRes>
{
    private readonly IEnumerable<IValidator<TReq>> _validators;
    public ValidationBehavior(IEnumerable<IValidator<TReq>> v) => _validators = v;

    public async Task<TRes> Handle(TReq req, RequestHandlerDelegate<TRes> next, CancellationToken ct)
    {
        foreach (var v in _validators)
        {
            var result = await v.ValidateAsync(req, ct);
            if (!result.IsValid)
                throw new ValidationException(result.Errors);
        }
        return await next();
    }
}

// Composition root
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblyContaining<Program>());
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));

// Caller
[ApiController, Route("cart")]
public sealed class CartController : ControllerBase
{
    private readonly IMediator _mediator;
    public CartController(IMediator m) => _mediator = m;

    [HttpPost("add")]
    public Task<AddToCartResult> Add(AddToCart cmd, CancellationToken ct) => _mediator.Send(cmd, ct);

    [HttpPost("remove")]
    public Task<RemoveFromCartResult> Remove(RemoveFromCart cmd, CancellationToken ct) => _mediator.Send(cmd, ct);
}

// Test
[Fact]
public async Task AddToCart_increments_count()
{
    var carts = new InMemoryCartRepository();
    var sut = new AddToCartHandler(carts);
    var result = await sut.Handle(new AddToCart("c1", "BOOK", 1), default);
    Assert.Equal(1, result.NewItemCount);
}

What you read on the page: each user action is a record. Each handler does exactly one thing. The pipeline runs cross-cutting concerns once, for every command. Adding a "preview", "queue", or "audit" feature means consuming the Commands, not rewriting the business logic.

A practical observation: Command is the most-used pattern in modern .NET that nobody calls by name. Every CQRS handler, every MediatR IRequest, every record you Send to a dispatcher — all Command. Knowing the pattern lets you read those frameworks faster and design your own dispatchers when no library fits.

Frequently asked questions

How is Command different from Strategy?
Strategy and Command both turn behaviour into an object, but the purpose differs. Strategy is how to do one thing — pick the discount algorithm. Command is what to do once — apply this discount, in this cart, right now. Strategies are usually long-lived and reused; Commands are usually short-lived and represent a single user action.
Is MediatR really the Command pattern?
Yes. IRequest<TResult> is a Command (the data of what to do); IRequestHandler<TRequest, TResult> is the executor; IMediator.Send(...) is the dispatcher. The library adds pipelines (validation, logging) on top, but the underlying shape is exactly Command. CQRS frameworks usually call commands 'Commands' and queries 'Queries' — both are the pattern with different return semantics.
How do you implement undo with the Command pattern?
Each Command carries enough information to reverse itself, or its handler returns an inverse Command. After execution, push the executed Command (or its inverse) onto a stack; Undo() pops and re-runs in reverse. The pattern only works cleanly when commands are deterministic and their effects are reversible — DeleteFile may not be.
When does the Command pattern feel like ceremony in modern C#?
When every operation has one Command, one Handler, and one DTO that all carry the same fields. The pattern earns its keep when you need queueing, logging, retry, replay, or undo. Without those, an ordinary method on an application service is fewer files and easier to follow.