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
- What problem does the Command pattern solve in C#?
- How does the textbook Command structure look?
- How does MediatR implement the Command pattern?
- How do you implement undo / redo with the Command pattern?
- When does the Command pattern misfire?
- How does Command compare to Strategy and Mediator?
- What does a real .NET 10 example look like?
- 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:
- Queueing or scheduling. "Charge this card again in 24 hours if the bank is back up." The action survives the original request.
- Audit and replay. Every state change is a Command persisted to an event log. Replaying them rebuilds the state of the system.
- 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:
- One-Command-per-method ceremony. Every method becomes a record
- handler + DTO that just forwards arguments. The pattern is now pure overhead. Use Commands when something else (a queue, a log, an undo stack) consumes the Command. Otherwise, plain method calls win.
- Mixing Commands and Queries. A Command should change state and
often returns minimal data ("done"). A Query should not change
state and often returns rich data. Mixing them —
SubmitOrderthat returns the entire order DTO — quietly violates CQRS. Keep the contracts separate. - Undo that does not really undo.
DeleteOrderCommand.Undo()cannot bring back rows another transaction has already deleted. Treat undo as best-effort with explicit guarantees, or store enough snapshot data to reconstruct.
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.
Where should you read next in this series?
- Previous: Chain of Responsibility — the pipeline shape MediatR's behaviours use.
- Next: Interpreter — when the Command itself becomes a small language to evaluate.
- Cross-reference: Mediator — the hub that dispatches Commands; MediatR is both patterns combined.
- Cross-reference: Memento — for cross-process undo, capture state with Memento and reverse with Command.
- Decision tree: How to choose the right design pattern.
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?
Is MediatR really the Command pattern?
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?
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.