Command Pattern in C#: Encapsulate Requests as Objects

The Command design pattern is a behavioral pattern that turns a request into a stand-alone object containing all information about the operation. This article explains the fundamentals of the Command pattern, its architectural components, and how to implement it in C# to decouple the sender of a request from its receiver, enabling features like parameterization, operation queuing, and undo/redo functionality.

Core Components of the Command Pattern

The Command pattern decouples the object that invokes an operation from the object that actually knows how to perform it. It consists of five essential components:

  1. Command Interface: Declares an execution method (typically Execute() and optionally Undo()).
  2. Concrete Command: Implements the command interface and binds an action to a specific receiver method.
  3. Receiver: Contains the business logic and knows how to execute the actual task.
  4. Invoker: Holds and triggers the command object without needing details on how the operation is performed.
  5. Client: Instantiates the receiver, commands, and invoker, then links them together.

Implementing the Command Pattern in C

The following example demonstrates encapsulating light switch operations into command objects.

1. The Command Interface

Define a contract that all concrete commands must follow.

public interface ICommand
{
    void Execute();
    void Undo();
}

2. The Receiver

The receiver executes the underlying business logic.

public class Light
{
    public bool IsOn { get; private set; }

    public void TurnOn()
    {
        IsOn = true;
        Console.WriteLine("The light is now ON.");
    }

    public void TurnOff()
    {
        IsOn = false;
        Console.WriteLine("The light is now OFF.");
    }
}

3. The Concrete Commands

Each concrete command wraps a receiver and specifies which action to call during execution or reversal.

public class TurnOnCommand : ICommand
{
    private readonly Light _light;

    public TurnOnCommand(Light light)
    {
        _light = light;
    }

    public void Execute()
    {
        _light.TurnOn();
    }

    public void Undo()
    {
        _light.TurnOff();
    }
}

public class TurnOffCommand : ICommand
{
    private readonly Light _light;

    public TurnOffCommand(Light light)
    {
        _light = light;
    }

    public void Execute()
    {
        _light.TurnOff();
    }

    public void Undo()
    {
        _light.TurnOn();
    }
}

4. The Invoker

The invoker accepts commands and triggers them. It can maintain a history stack to support undo mechanisms.

public class RemoteControl
{
    private readonly Stack<ICommand> _commandHistory = new();

    public void Submit(ICommand command)
    {
        command.Execute();
        _commandHistory.Push(command);
    }

    public void UndoLastAction()
    {
        if (_commandHistory.Count > 0)
        {
            var command = _commandHistory.Pop();
            command.Undo();
        }
        else
        {
            Console.WriteLine("No actions to undo.");
        }
    }
}

5. The Client

The client wires the components together and executes the workflow.

class Program
{
    static void Main()
    {
        // 1. Create the Receiver
        Light livingRoomLight = new Light();

        // 2. Create the Commands
        ICommand lightOn = new TurnOnCommand(livingRoomLight);
        ICommand lightOff = new TurnOffCommand(livingRoomLight);

        // 3. Create the Invoker
        RemoteControl remote = new RemoteControl();

        // 4. Execute requests
        remote.Submit(lightOn);      // Output: The light is now ON.
        remote.Submit(lightOff);     // Output: The light is now OFF.

        // 5. Undo requests
        remote.UndoLastAction();     // Output: The light is now ON.
        remote.UndoLastAction();     // Output: The light is now OFF.
    }
}

Why Encapsulate Requests as Objects?

  • Single Responsibility Principle: The class issuing the request is independent of the class processing the logic.
  • Open/Closed Principle: You can introduce new commands without breaking existing client or receiver code.
  • Reversibility and Queuing: Because the request is an object, it can be serialized, queued for asynchronous execution, logged to an audit trail, or stored on a stack for multi-level undo/redo operations.