Most code does things. Command wraps the thing being done into an object. That sounds like a small change, but once an action is an object, you can store it, log it, queue it, and sometimes undo it. CornerStore’s back office is where that pays off.

   
Category Behavioral
Smell fixed S14: Back-office actions run inline with no undo or audit
Code patterns/behavioral/command

The Scene

Sam from the back office has a busy day:

[047] admin.stock.adjust customer=cu-3 sku=KETTLE-1L delta=-4
  stock KETTLE-1L adjusted by -4, now 0
[061] admin.cancel customer=cu-3 order=o-1001
  cancelled o-1001
[069] admin.refund customer=cu-3 order=o-1002
  refunded o-1002

Now imagine Sam meant to type -1 instead of -4. Can it be undone? And at the end of the day, if a manager asks “who changed the kettle stock, and when?”, is there a record anywhere?


The Smell

In the baseline, every back-office action just runs, inline, inside BackOffice:

        // SMELL S14: stock adjustment is an inline back-office action
        // SMELL S12: the caller checks the staff role before changing stock
        if (actor.Role != "staff")
        {
            return "rejected: staff only";
        }

        var stock = inventory.AdjustStock(sku, adjustment);

The action happens, and then it’s gone. There’s no object representing “adjust KETTLE-1L by -4”. Nothing remembers it happened, nothing knows how to reverse it, and nothing could put it in a queue to run later. Adding any of those means more coordination code piled into BackOffice, for every action.


The Idea

Command turns a request into an object that has everything it needs to run: what to do, to what, and with which values. A separate invoker runs commands. Because it handles every command the same way, it can also log them and keep track of what can be undone.

Think of a restaurant order ticket. The waiter doesn’t run into the kitchen and cook. They write a ticket: table 4, one pasta, no cheese. The ticket can be queued, handed to whichever chef is free, kept for the bill, and if the customer changes their mind, crossed out. The ticket is the request.


The Refactor

Every back-office action now implements one contract:

internal interface ICommand
{
    string Name { get; }

    bool CanUndo { get; }

    string Execute();

    void Undo();
}

Stock adjustment becomes a command that knows how to reverse itself:

    public void Undo()
    {
        if (!canUndo)
        {
            throw new InvalidOperationException("This stock adjustment cannot be undone.");
        }

        inventory.AdjustStock(sku, -adjustment);
        canUndo = false;
    }

The invoker runs every command the same way, and keeps an audit entry for each one:

    public string Execute(ICommand command)
    {
        var result = command.Execute();
        auditLog.Add(new CommandAuditEntry(command.Name, result));
        lastCommand = command;
        return result;
    }

And BackOffice now describes an action rather than performing it:

return commandInvoker.Execute(new AdjustStockCommand(inventory, sku, adjustment));
classDiagram
    BackOffice --> CommandInvoker : dispatches
    CommandInvoker --> ICommand : executes
    ICommand <|.. AdjustStockCommand
    ICommand <|.. LookupStockCommand
    ICommand <|.. RefundCommand
    ICommand <|.. CancelCommand
    CommandInvoker --> CommandAuditEntry : records result
    AdjustStockCommand --> InventoryService : adjusts stock
    RefundCommand --> OrderService : refunds order
    CancelCommand --> OrderService : cancels order

Not everything can be undone

This is my favourite detail in this solution. A refund says it can’t be undone, and refuses if you try:

    public void Undo()
    {
        throw new NotSupportedException("A refund has effects that cannot be undone.");
    }

That’s honest design. Once a refund goes out, notifications and loyalty updates have already fired. Pretending you can reverse that would be worse than admitting you can’t. Many “undo” features fail exactly here. The pattern doesn’t magically make every action reversible. It gives each action a place to say whether it is.

The audit log and undo don’t appear in the trading-day transcript, which is how the golden master stays identical. They’re proven by the solution’s own tests instead.


What It Cost

10 files: about 246 lines added and only 14 removed. Four command classes, an interface, an invoker and an audit entry. Every new back-office action is now a new class rather than a new method.

In return, you get audit, undo and the option to queue actions, with no change to how the actions themselves work.


When Not to Use It

  • You don’t need history, undo or queuing. If actions just run and are forgotten, a method call is simpler and clearer.
  • Most actions can’t really be undone. Building an undo framework where only one action out of ten supports it may not be worth it.
  • You need durability. This invoker keeps its log in memory. A real audit trail or job queue needs persistent storage and retry handling, which is infrastructure, not just a pattern.

Easily Confused With

Memento. Both show up when people build undo. Command records the action and knows how to reverse it (“I subtracted 4, so add 4 back”). Memento records the state before the action and restores it (“here’s what the cart looked like”). See Command vs Memento for undo, and the Memento post for the cart’s undo.


Try It Yourself

dotnet build patterns/behavioral/command/Command.slnx
dotnet test patterns/behavioral/command/Command.slnx
dotnet run --project patterns/behavioral/command/src/CornerStore/CornerStore.csproj -- scenarios/trading-day.json

Exercise: look at the tests to see undo in action, then add a CommandInvoker.UndoLast() call after a stock adjustment in a test of your own and check the stock level.

The Command README has the participant table and a sequence diagram. The full change is in DIFF.md.


Previous: Chain of Responsibility · Next: Interpreter