Command Pattern: Turning Requests into Objects, Undo/Redo and Queuing
Command packages a request as an object, so an invoker can run it, undo it, queue it and log it without knowing what it does. An undo/redo stack and a MacroCommand in Bookstore's catalog editor, System.Windows.Input.ICommand in the BCL, serializing commands with System.Text.Json and running them in the background through a Channel, how a GoF command differs from a CQRS command, MediatR 13's license and a library-free dispatcher.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
In Bookstore's catalog editor the product team changes prices, fixes book titles, removes books from the catalog, and wants to undo every step with Ctrl+Z. An undone step can be reapplied with Ctrl+Y. This is the class that keeps the editor's history:
namespace Bookstore.Catalog;
// Invoker: runs the command and keeps the history; doesn't know what it does
public sealed class CommandHistory
{
private readonly Stack<ICatalogCommand> _undo = new();
private readonly Stack<ICatalogCommand> _redo = new();
public void Run(ICatalogCommand command)
{
command.Execute();
_undo.Push(command);
_redo.Clear();
}
public string? Undo()
{
if (!_undo.TryPop(out ICatalogCommand? command))
return null;
command.Undo();
_redo.Push(command);
return command.Description;
}
public string? Redo()
{
if (!_redo.TryPop(out ICatalogCommand? command))
return null;
command.Execute();
_undo.Push(command);
return command.Description;
}
public override string ToString() => $"undo: {_undo.Count}, redo: {_redo.Count}";
}
Three edits are made, two are undone, one is redone, then another book's price is changed, and finally one more redo is requested. What does the last Redo reapply: the removal of Emma, or nothing?
public static class HistoryDemo
{
public static void Run()
{
BookCatalog catalog = SampleCatalog.Create();
CommandHistory history = new();
history.Run(new ChangePrice(catalog, "978-0441013593", 14.99m));
history.Run(new RenameBook(catalog, "978-0547928227", "The Hobbit (Illustrated)"));
history.Run(new RemoveBook(catalog, "978-0141439518"));
Console.WriteLine($"after 3 edits: {catalog}");
Console.WriteLine($"undo: {history.Undo()}");
Console.WriteLine($"undo: {history.Undo()}");
Console.WriteLine($"after 2 undos: {catalog} [{history}]");
Console.WriteLine($"redo: {history.Redo()}");
history.Run(new ChangePrice(catalog, "978-0765326355", 16.00m));
Console.WriteLine($"after a new edit: [{history}], redo: {history.Redo() ?? "nothing to redo"}");
Console.WriteLine($"final: {catalog}");
}
}
Why Did the Redo Stack Empty?
after 3 edits: Dune 14.99 | The Hobbit (Illustrated) 15.00 | The Way of Kings 18.00
undo: remove 978-0141439518
undo: rename 978-0547928227 -> The Hobbit (Illustrated)
after 2 undos: Emma 8.50 | Dune 14.99 | The Hobbit 15.00 | The Way of Kings 18.00 [undo: 1, redo: 2]
redo: rename 978-0547928227 -> The Hobbit (Illustrated)
after a new edit: [undo: 3, redo: 0], redo: nothing to redo
final: Emma 8.50 | Dune 14.99 | The Hobbit (Illustrated) 15.00 | The Way of Kings 16.00
Nothing. The moment a new edit arrives, Run clears the redo stack and the removal of Emma is erased from history. That's not a bug; it's how every text editor you use behaves: undone steps are like "an alternative branch of history", and taking a new step loses that branch. Without clearing it, redo would run a command that assumes an old state (such as removing a book that no longer exists) on a catalog that has changed.
The really interesting part is how little CommandHistory knows. Is a price changing, is a book being deleted, which field returns to which value; it knows none of it. All it has are objects it can tell to Execute and Undo. What makes that possible is representing every edit not as a method call but as an object. That idea is the topic of this post: the Command pattern. We'll cover its roles in GoF, undo/redo and the macro command that undoes several steps as one, .NET's own ICommand, serializing a command to JSON (JavaScript Object Notation) and running it in the background through a queue, and its relationship to the "command" in CQRS (Command Query Responsibility Segregation).
The GoF Definition: Turning a Request into an Object
Command is one of the behavioral patterns in the GoF (Gang of Four: Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides' 1994 book Design Patterns: Elements of Reusable Object-Oriented Software) catalog. Its intent, in my own words: package a request as an object, so that you can parameterize clients with different requests, queue and log requests, and support undoable operations. There are four benefits in one sentence, and the sections of this post follow them in turn.
There are five roles. The Command declares an interface for executing an operation (ICatalogCommand). A ConcreteCommand binds a receiver to an action and implements Execute by calling the corresponding operation on the receiver (ChangePrice, RenameBook, RemoveBook). The Receiver is the object that knows how to actually do the work (BookCatalog). The Invoker asks the command to carry out the request and doesn't know what it does (CommandHistory). The Client creates the ConcreteCommand and sets its receiver (the demo code in the example). The book's example is a text editor's menus: every MenuItem holds a command and, when clicked, only calls its Execute; the menu doesn't know the difference between "paste" and "open document".
Commands and Undo
The catalog, as the receiver, is completely unaware of the commands; it's an ordinary domain class:
namespace Bookstore.Catalog;
public sealed class Book(string isbn, string title, string category, decimal price)
{
public string Isbn { get; } = isbn;
public string Title { get; set; } = title;
public string Category { get; } = category;
public decimal Price { get; set; } = price;
}
// Receiver: the side that actually does the work; it knows nothing about commands
public sealed class BookCatalog
{
private readonly Dictionary<string, Book> _books = [];
public Book this[string isbn] => _books[isbn];
public IEnumerable<Book> InCategory(string category) => _books.Values.Where(book => book.Category == category);
public void Add(Book book) => _books.Add(book.Isbn, book);
public Book Remove(string isbn) => _books.Remove(isbn, out Book? book) ? book : throw new KeyNotFoundException(isbn);
public override string ToString() =>
string.Join(" | ", _books.Values.OrderBy(book => book.Isbn).Select(book => $"{book.Title} {book.Price}"));
}
namespace Bookstore.Catalog;
public interface ICatalogCommand
{
string Description { get; }
void Execute();
void Undo();
}
public sealed class ChangePrice(BookCatalog catalog, string isbn, decimal newPrice) : ICatalogCommand
{
private decimal _oldPrice;
public string Description => $"price {isbn} -> {newPrice}";
public void Execute()
{
// Capture the state Undo needs at the moment of Execute
_oldPrice = catalog[isbn].Price;
catalog[isbn].Price = newPrice;
}
public void Undo() => catalog[isbn].Price = _oldPrice;
}
public sealed class RenameBook(BookCatalog catalog, string isbn, string newTitle) : ICatalogCommand
{
private string _oldTitle = "";
public string Description => $"rename {isbn} -> {newTitle}";
public void Execute()
{
_oldTitle = catalog[isbn].Title;
catalog[isbn].Title = newTitle;
}
public void Undo() => catalog[isbn].Title = _oldTitle;
}
public sealed class RemoveBook(BookCatalog catalog, string isbn) : ICatalogCommand
{
private Book? _removed;
public string Description => $"remove {isbn}";
public void Execute() => _removed = catalog.Remove(isbn);
public void Undo() => catalog.Add(_removed!);
}
Every command carries the information needed to undo itself: ChangePrice the old price, RenameBook the old title, RemoveBook the removed Book object itself. Capturing that information at Execute time rather than in the constructor matters: the catalog may have changed between the moment the command is created and the moment it runs, and redo may run the same command a second time. "The previous value" is the value at the time of execution.
This form of undo works as long as you can write the inverse of every operation. For some operations writing the inverse is hard or impossible (running an image through a filter, overwriting the result of a complex calculation); then the command takes a snapshot of the object before the change and restores it on undo. That's the topic of the upcoming Memento post in this series; Command and Memento are often used together.
MacroCommand: Undoing Several Steps as One
During a campaign the product team applies a 20 percent discount to every book in the Fantasy category. In the user's eyes it's a single operation; pressing Ctrl+Z once should bring every price back. GoF's MacroCommand solves this by putting commands inside a command; it's the structure from this series' Composite post applied to commands:
// Composite command: several commands are undone as a single step
public sealed class MacroCommand(string description, IReadOnlyList<ICatalogCommand> steps) : ICatalogCommand
{
public string Description => $"{description} ({steps.Count} steps)";
public void Execute()
{
foreach (ICatalogCommand step in steps)
step.Execute();
}
public void Undo()
{
// Undo in reverse order
for (int i = steps.Count - 1; i >= 0; i--)
steps[i].Undo();
}
}
public static class MacroDemo
{
public static void Run()
{
BookCatalog catalog = SampleCatalog.Create();
CommandHistory history = new();
// Campaign: 20 percent off in the Fantasy category, as a single step
ICatalogCommand[] steps =
[
.. catalog.InCategory("Fantasy").Select(book => new ChangePrice(catalog, book.Isbn, Math.Round(book.Price * 0.8m, 2)))
];
history.Run(new MacroCommand("fantasy -20%", steps));
Console.WriteLine($"campaign: {catalog}");
Console.WriteLine($"undo: {history.Undo()}");
Console.WriteLine($"restored: {catalog}");
}
}
campaign: Emma 8.50 | Dune 12.99 | The Hobbit 12.00 | The Way of Kings 14.40
undo: fantasy -20% (2 steps)
restored: Emma 8.50 | Dune 12.99 | The Hobbit 15.00 | The Way of Kings 18.00
CommandHistory didn't change; it ran and undid the macro like any other command. Undoing in reverse order matters: if the steps depend on each other (first change the price, then add a campaign label based on that price), undoing must also go from last to first.
Command in .NET: ICommand and Delegates
.NET's BCL (Base Class Library) has an interface that's a one-to-one counterpart of GoF's command: System.Windows.Input.ICommand. Despite the "Windows" in its name, it lives in the System.ObjectModel assembly and is available in every .NET application. In XAML (Extensible Application Markup Language) based UI (User Interface) frameworks, buttons bind to this interface; WPF (Windows Presentation Foundation), .NET MAUI (Multi-platform App UI) and Avalonia are the main ones. The interface is a cornerstone of the MVVM (Model-View-ViewModel) approach. Today's version of GoF's menu example:
using System.Windows.Input;
namespace Bookstore.Ui;
// The command contract in the BCL: buttons in XAML-based UI frameworks bind to it
public sealed class RelayCommand(Action execute, Func<bool> canExecute) : ICommand
{
public event EventHandler? CanExecuteChanged;
public bool CanExecute(object? parameter) => canExecute();
public void Execute(object? parameter)
{
if (CanExecute(parameter))
execute();
}
public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
public static class RelayDemo
{
public static void Run()
{
bool hasChanges = false;
RelayCommand save = new(() => Console.WriteLine(" saved"), () => hasChanges);
save.CanExecuteChanged += (_, _) => Console.WriteLine($" button enabled: {save.CanExecute(null)}");
Console.WriteLine($"button enabled: {save.CanExecute(null)}");
save.Execute(null);
hasChanges = true;
save.RaiseCanExecuteChanged();
save.Execute(null);
}
}
button enabled: False
button enabled: True
saved
The button is the invoker; as long as CanExecute is false it shows itself as disabled, and when the CanExecuteChanged event arrives it asks again. RelayCommand itself does something interesting: instead of writing the ConcreteCommand as a class, it takes the behavior as two delegates. That's exactly the observation in the 1996 talk "Design Patterns in Dynamic Languages" by Peter Norvig (Google's former director of research): in languages with first-class functions, Command, Strategy, Template Method and Visitor either become invisible or much simpler; according to Norvig, 16 of the 23 GoF patterns are expressed more simply in languages like Lisp or Dylan. That's why in C# an Action or a Func is often the shortest command. For delegates themselves, see the "Delegates, Func and Action" post in the Advanced C# series.
But a delegate has limits: carrying the state needed for undo and a description of the operation, and, as we'll see in the next section, being serialized. You can't turn a lambda into JSON. That's the strongest reason to write a command as a class.
Making the Command Data: Serialize, Queue and Background
When an order is completed, shipping must be booked and the invoice email sent. These jobs are slow and depend on external services; we don't want to hold up the HTTP (Hypertext Transfer Protocol) request. If we design the command as a record that only carries data, we can serialize it, drop it into a queue and run it in another process (or in the background of the same process). The receiver and the execution logic are no longer inside the command but in a separate handler:
using System.Text.Json.Serialization;
namespace Bookstore.Jobs;
// The command is now data: it can be serialized and queued, logged and retried
[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]
[JsonDerivedType(typeof(ShipOrder), "ship-order")]
[JsonDerivedType(typeof(SendInvoiceEmail), "send-invoice-email")]
public abstract record BackgroundCommand(Guid CommandId);
public sealed record ShipOrder(Guid CommandId, Guid OrderId, string Carrier) : BackgroundCommand(CommandId);
public sealed record SendInvoiceEmail(Guid CommandId, string InvoiceNo, string Email) : BackgroundCommand(CommandId);
public interface ICommandHandler<in TCommand> where TCommand : BackgroundCommand
{
Task HandleAsync(TCommand command, CancellationToken ct);
}
public sealed class ShipOrderHandler : ICommandHandler<ShipOrder>
{
public Task HandleAsync(ShipOrder command, CancellationToken ct)
{
Console.WriteLine($" shipping order {command.OrderId.ToString()[..8]} with {command.Carrier}");
return Task.CompletedTask;
}
}
public sealed class SendInvoiceEmailHandler : ICommandHandler<SendInvoiceEmail>
{
public Task HandleAsync(SendInvoiceEmail command, CancellationToken ct)
{
Console.WriteLine($" emailing {command.InvoiceNo} to {command.Email}");
return Task.CompletedTask;
}
}
using Microsoft.Extensions.DependencyInjection;
namespace Bookstore.Jobs;
// A library-free dispatcher: one line per command, no reflection, go-to-definition works
public sealed class CommandDispatcher(IServiceScopeFactory scopes)
{
public async Task DispatchAsync(BackgroundCommand command, CancellationToken ct)
{
using IServiceScope scope = scopes.CreateScope();
Task handling = command switch
{
ShipOrder ship => Handle(scope, ship, ct),
SendInvoiceEmail email => Handle(scope, email, ct),
_ => throw new NotSupportedException($"No handler for {command.GetType().Name}")
};
await handling;
}
private static Task Handle<TCommand>(IServiceScope scope, TCommand command, CancellationToken ct)
where TCommand : BackgroundCommand =>
scope.ServiceProvider.GetRequiredService<ICommandHandler<TCommand>>().HandleAsync(command, ct);
}
using System.Text.Json;
using System.Threading.Channels;
using Bookstore.Jobs;
using Microsoft.Extensions.DependencyInjection;
ServiceCollection services = new();
services.AddScoped<ICommandHandler<ShipOrder>, ShipOrderHandler>();
services.AddScoped<ICommandHandler<SendInvoiceEmail>, SendInvoiceEmailHandler>();
services.AddSingleton<CommandDispatcher>();
using ServiceProvider provider = services.BuildServiceProvider();
// In real life a message queue or an outbox table; here an in-memory Channel
Channel<string> queue = Channel.CreateUnbounded<string>();
// Consumer: reads the JSON, turns it into a command, dispatches it to a handler
Task consumer = Task.Run(async () =>
{
CommandDispatcher dispatcher = provider.GetRequiredService<CommandDispatcher>();
await foreach (string json in queue.Reader.ReadAllAsync())
{
Console.WriteLine($"<- {json}");
try
{
BackgroundCommand command = JsonSerializer.Deserialize<BackgroundCommand>(json, JsonSerializerOptions.Web)!;
await dispatcher.DispatchAsync(command, CancellationToken.None);
}
catch (JsonException ex)
{
Console.WriteLine($" dead-letter: {ex.Message}");
}
}
});
// Producer: the order endpoint doesn't do the work itself; it drops the command into the queue
Guid orderId = Guid.Parse("0199a3b2-7c1e-7d40-9a51-3f2e8b6c1d24");
BackgroundCommand[] commands =
[
new ShipOrder(Guid.Parse("0199a3b2-7c1e-7d40-9a51-000000000001"), orderId, "DHL"),
new SendInvoiceEmail(Guid.Parse("0199a3b2-7c1e-7d40-9a51-000000000002"), "INV-2026-00042", "ada@example.com")
];
foreach (BackgroundCommand command in commands)
await queue.Writer.WriteAsync(JsonSerializer.Serialize(command, JsonSerializerOptions.Web));
// The new version's producer sends a command the old version's consumer doesn't know
await queue.Writer.WriteAsync("""{"type":"cancel-order","commandId":"0199a3b2-7c1e-7d40-9a51-000000000003","orderId":"0199a3b2-7c1e-7d40-9a51-3f2e8b6c1d24"}""");
queue.Writer.Complete();
await consumer;
<- {"type":"ship-order","orderId":"0199a3b2-7c1e-7d40-9a51-3f2e8b6c1d24","carrier":"DHL","commandId":"0199a3b2-7c1e-7d40-9a51-000000000001"}
shipping order 0199a3b2 with DHL
<- {"type":"send-invoice-email","invoiceNo":"INV-2026-00042","email":"ada@example.com","commandId":"0199a3b2-7c1e-7d40-9a51-000000000002"}
emailing INV-2026-00042 to ada@example.com
<- {"type":"cancel-order","commandId":"0199a3b2-7c1e-7d40-9a51-000000000003","orderId":"0199a3b2-7c1e-7d40-9a51-3f2e8b6c1d24"}
dead-letter: Read unrecognized type discriminator id 'cancel-order'. Path: $ | LineNumber: 0 | BytePositionInLine: 35.
Notice three things. First, with [JsonPolymorphic] and [JsonDerivedType], System.Text.Json writes a type field into each command's JSON; the consumer looks at that field to turn it back into the right type. Second, the last message: the producer's new version sends cancel-order, but the consumer's old version doesn't recognize that type. The moment you make a command data, that data becomes a contract and needs versioning; deploy the consumer before the producer, don't lose an unrecognized message (move it to a dead-letter queue), and when adding and removing fields, remember that old messages are still waiting in the queue. Third, every command carries a CommandId. Queues deliver a message at least once, so the same command can arrive twice; the handler should be able to ask "have I processed this already?" with that id. We covered how to do that in detail in the "Idempotency Key" post of the Solution Patterns series. For the details of building a producer/consumer with Channel<T>, see the "Producer/Consumer with Channels" post in the C# Async series.
The CQRS Command, MediatR and a Library-Free Dispatcher
This last form has moved one step away from GoF's command. In GoF the command knows its receiver and implements Execute itself; here the command is just data carrying an intent, and the code that does the work lives in the handler. The "command" in CQRS and in libraries like MediatR has this second meaning: a request to change the system's state, as a data object, that goes to exactly one handler. What they share is turning a request into an object; the difference is where the behavior lives. A GoF command is ideal for undo and macros because behavior and undo information are together; a CQRS command is ideal for serializing, queuing, logging and retrying because it's pure data. The architectural side of CQRS isn't this series' topic but that of the upcoming Software Architecture series.
The CommandDispatcher above is deliberately library-free: one line in the switch per command type, no reflection, go-to-definition takes you all the way to the handler, and if a new command is added but not added to the dispatcher, it's noticed immediately with a NotSupportedException on the first message. The cost is adding one line for every new command. MediatR removes that line with reflection and assembly scanning and adds pipeline behaviors on top. But there's a piece of information that changes over time: according to Jimmy Bogard's announcement of 2 July 2025, MediatR 13.0 and later are released under a dual license (RPL 1.5, the Reciprocal Public License, or Lucky Penny Software's commercial license); there's a free Community edition for companies with less than 5 million USD (United States Dollar) in annual revenue, non-profits, education and non-production environments, while older versions stay under the license they were released with. We'll discuss separately in this series' Mediator post whether you really need the library; for most applications thirty lines like the ones above are enough.
Common Pitfalls
Turning every method call into a command. Creating a new CancelOrderCommand(id) and handing it to a dispatcher instead of calling orderService.Cancel(id) only makes sense if the command itself buys you something (a queue, undo, logging, retries, a shared pipeline). If it doesn't, the person reading the code has to follow a class hierarchy and a dispatcher to find out where a method call goes.
Undoing the irreversible. Undo can't recall a sent email or quietly put back money that has been charged. For commands that affect the outside world, design a compensation command instead of undo: "refund", "send a correction email". Use the undo stack only for the application's own state.
Capturing undo state at the wrong moment. Reading the old value in the constructor overwrites changes made after the command was created. Capture it at Execute time.
Live references inside the command. RemoveBook holds the Book object it removed; if someone changes that object in the meantime, undo puts back the changed version. Commands that will be serialized should have no live references at all: carry ids and values instead of a DbContext, an HttpContext or an entity.
Unbounded history. In a long session the undo stack accumulates every command and the state it holds in memory. Keep the history bounded (the last 100 steps, say), or store a diff instead of a snapshot for large state.
When Not to Use It
If the operation runs immediately, in the same place, and there's no need to undo, queue, log or retry it, a method call is enough. Don't write a command class for a callback used in a single place; pass an Action and move on. And don't set up a dispatcher today because "we might queue this later"; on the day the order endpoint really slows down or an external service really becomes unreliable, making the command data is a few hours of work, while an unnecessary dispatcher is a navigation cost paid every day.
A Small Experiment
★☆☆ Add a limit to CommandHistory: when the history exceeds 3 steps, the oldest command should drop off. Is Stack<T> suitable for this, or do you need LinkedList<T>?
★★☆ Add a TryMerge method to ChangePrice that merges two consecutive commands changing the same book's price into one (like text editors collecting typed characters into a single undo step). After undo, what should the price return to?
★★★ Change the consumer so that it doesn't run the handler when a message with the same CommandId arrives a second time. Why isn't keeping processed ids in memory enough, and when keeping them in a database table, which order (write the id first, or do the work first) causes which failure?
In the next post we move on to two behavioral patterns built into C# itself: Iterator and Interpreter. We'll look at the iterator behind yield return, reading a paged API (Application Programming Interface) with IAsyncEnumerable<T>, and an interpreter that compiles a small rule language into an expression tree.