Command Pattern — FixIt Pro Series #14
Assign, Pause, Cancel, Complete; encapsulating job actions as undoable command objects. Queue them, replay them, undo them. Learn the Command pattern in C# and TypeScript.
Series: Design Patterns with FixIt Pro · Episode 14 / 22 · Behavioral Pattern
Previous: #13 — Chain of Responsibility Pattern
The Scenario
FixIt Pro's operations team manages job cards throughout their lifecycle. They assign handymen, pause jobs when parts aren't available, cancel jobs at client request, and mark jobs complete when the work is done.
Right now these actions are method calls scattered across the codebase:
registry.AssignHandyman(jobId, handyman);
registry.UpdateStatus(jobId, "Paused");
registry.UpdateStatus(jobId, "Cancelled");
registry.UpdateStatus(jobId, "Completed");
Three problems with this approach:
- No undo — if a supervisor accidentally cancels the wrong job, there's no way to reverse it without manually looking up the previous state
- No history — there's no audit trail of what actions were performed, by whom, and when
- No queuing — actions need to be batched, deferred, or replayed during shift handover, but the current design makes that impossible
The Command pattern solves all three by turning each action into an object.
What Is the Command Pattern?
Encapsulate a request as an object, thereby letting you parameterise clients with different requests, queue or log requests, and support undoable operations.
Instead of calling a method directly, you create a command object that knows how to execute and how to undo that action. The invoker holds and fires commands without knowing what they do. The receiver does the actual work.
The five participants
| Role | FixIt Pro equivalent |
|---|---|
| Command interface | ICommand — declares Execute() and Undo() |
| Concrete Commands | AssignHandymanCommand, PauseJobCommand, CancelJobCommand, CompleteJobCommand |
| Receiver | JobCard — the object that actually changes state |
| Invoker | JobActionInvoker — holds, executes, and undoes commands |
| Client | Program — creates commands and gives them to the invoker |
C# Implementation
// ── Receiver ───────────────────────────────────────────────
public class JobCard
{
public string JobId { get; init; } = Guid.NewGuid().ToString()[..8];
public string Title { get; init; } = string.Empty;
public string Category { get; init; } = string.Empty;
public string Status { get; set; } = "Draft";
public string? Assignee { get; set; }
public override string ToString() =>
$"Job #{JobId} '{Title}' [{Category}] — Status: {Status}" +
(Assignee is not null ? $" | Assignee: {Assignee}" : "");
}
// ── Command Interface ──────────────────────────────────────
public interface ICommand
{
void Execute();
void Undo();
}
// ── Concrete Commands ──────────────────────────────────────
public class AssignHandymanCommand : ICommand
{
private readonly JobCard _job;
private readonly string _handyman;
private string? _previousAssignee;
private string _previousStatus = string.Empty;
public AssignHandymanCommand(JobCard job, string handyman)
{
_job = job;
_handyman = handyman;
}
public void Execute()
{
_previousAssignee = _job.Assignee;
_previousStatus = _job.Status;
_job.Assignee = _handyman;
_job.Status = "Assigned";
Console.WriteLine($" ▶ Assigned '{_handyman}' to {_job}");
}
public void Undo()
{
_job.Assignee = _previousAssignee;
_job.Status = _previousStatus;
Console.WriteLine($" ↩ Undone assignment — {_job}");
}
}
public class PauseJobCommand : ICommand
{
private readonly JobCard _job;
private readonly string _reason;
private string _previousStatus = string.Empty;
public PauseJobCommand(JobCard job, string reason)
{
_job = job;
_reason = reason;
}
public void Execute()
{
_previousStatus = _job.Status;
_job.Status = "Paused";
Console.WriteLine($" ▶ Paused {_job} — Reason: {_reason}");
}
public void Undo()
{
_job.Status = _previousStatus;
Console.WriteLine($" ↩ Unpaused — {_job}");
}
}
public class CancelJobCommand : ICommand
{
private readonly JobCard _job;
private string _previousStatus = string.Empty;
private string? _previousAssignee;
public CancelJobCommand(JobCard job)
{
_job = job;
}
public void Execute()
{
_previousStatus = _job.Status;
_previousAssignee = _job.Assignee;
_job.Status = "Cancelled";
_job.Assignee = null;
Console.WriteLine($" ▶ Cancelled {_job}");
}
public void Undo()
{
_job.Status = _previousStatus;
_job.Assignee = _previousAssignee;
Console.WriteLine($" ↩ Restored cancelled job — {_job}");
}
}
public class CompleteJobCommand : ICommand
{
private readonly JobCard _job;
private string _previousStatus = string.Empty;
public CompleteJobCommand(JobCard job)
{
_job = job;
}
public void Execute()
{
_previousStatus = _job.Status;
_job.Status = "Completed";
Console.WriteLine($" ▶ Completed {_job}");
}
public void Undo()
{
_job.Status = _previousStatus;
Console.WriteLine($" ↩ Reverted completion — {_job}");
}
}
// ── Invoker ────────────────────────────────────────────────
public class JobActionInvoker
{
private readonly Stack<ICommand> _history = new();
private readonly Queue<ICommand> _queue = new();
// Execute immediately and record in history
public void Execute(ICommand command)
{
command.Execute();
_history.Push(command);
}
// Undo the last executed command
public void Undo()
{
if (_history.Count == 0)
{
Console.WriteLine(" ⚠️ Nothing to undo.");
return;
}
var last = _history.Pop();
last.Undo();
}
// Queue a command for deferred execution
public void Enqueue(ICommand command) => _queue.Enqueue(command);
// Execute all queued commands
public void FlushQueue()
{
Console.WriteLine($"\n [Invoker] Flushing {_queue.Count} queued command(s)...");
while (_queue.Count > 0)
Execute(_queue.Dequeue());
}
public int HistoryCount => _history.Count;
public int QueueCount => _queue.Count;
}
// ── Client Code ────────────────────────────────────────────
class Program
{
static void Main()
{
var invoker = new JobActionInvoker();
var job = new JobCard
{
Title = "Replace burst pipe under sink",
Category = "Plumbing"
};
Console.WriteLine($"Initial state: {job}\n");
// ── Execute actions ────────────────────────────────
Console.WriteLine("=== Executing Actions ===");
invoker.Execute(new AssignHandymanCommand(job, "James Nghipandua"));
invoker.Execute(new PauseJobCommand(job, "Waiting for parts"));
invoker.Execute(new AssignHandymanCommand(job, "Peter Nangolo"));
invoker.Execute(new CompleteJobCommand(job));
Console.WriteLine($"\nCurrent state: {job}");
Console.WriteLine($"History depth: {invoker.HistoryCount}");
// ── Undo last two actions ──────────────────────────
Console.WriteLine("\n=== Undoing Last 2 Actions ===");
invoker.Undo();
invoker.Undo();
Console.WriteLine($"\nAfter undo: {job}");
// ── Queue actions for shift handover ───────────────
Console.WriteLine("\n=== Queuing Actions for Shift Handover ===");
invoker.Enqueue(new AssignHandymanCommand(job, "Anna Nghoshi"));
invoker.Enqueue(new CompleteJobCommand(job));
Console.WriteLine($" {invoker.QueueCount} actions queued.");
invoker.FlushQueue();
Console.WriteLine($"\nFinal state: {job}");
}
}
Output:
Initial state: Job #f3a12b 'Replace burst pipe under sink' [Plumbing] — Status: Draft
=== Executing Actions ===
▶ Assigned 'James Nghipandua' to Job #f3a12b '...' [Plumbing] — Status: Assigned | Assignee: James Nghipandua
▶ Paused Job #f3a12b '...' [Plumbing] — Status: Paused | Assignee: James Nghipandua — Reason: Waiting for parts
▶ Assigned 'Peter Nangolo' to Job #f3a12b '...' [Plumbing] — Status: Assigned | Assignee: Peter Nangolo
▶ Completed Job #f3a12b '...' [Plumbing] — Status: Completed | Assignee: Peter Nangolo
Current state: Job #f3a12b '...' [Plumbing] — Status: Completed | Assignee: Peter Nangolo
History depth: 4
=== Undoing Last 2 Actions ===
↩ Reverted completion — Job #f3a12b '...' [Plumbing] — Status: Assigned | Assignee: Peter Nangolo
↩ Undone assignment — Job #f3a12b '...' [Plumbing] — Status: Paused | Assignee: James Nghipandua
After undo: Job #f3a12b '...' [Plumbing] — Status: Paused | Assignee: James Nghipandua
=== Queuing Actions for Shift Handover ===
2 actions queued.
[Invoker] Flushing 2 queued command(s)...
▶ Assigned 'Anna Nghoshi' to Job #f3a12b '...' [Plumbing] — Status: Assigned | Assignee: Anna Nghoshi
▶ Completed Job #f3a12b '...' [Plumbing] — Status: Completed | Assignee: Anna Nghoshi
Final state: Job #f3a12b '...' [Plumbing] — Status: Completed | Assignee: Anna Nghoshi
TypeScript Implementation
// ── Model/JobCard.ts ───────────────────────────────────────
const shortId = () => Math.random().toString(36).slice(2, 10);
export class JobCard {
readonly jobId: string = shortId();
status: string = "Draft";
assignee?: string;
constructor(
readonly title: string,
readonly category: string
) {}
toString(): string {
return `Job #${this.jobId} '${this.title}' [${this.category}] — Status: ${this.status}` +
(this.assignee ? ` | Assignee: ${this.assignee}` : "");
}
}
// ── Command/ICommand.ts ────────────────────────────────────
export interface ICommand {
execute(): void;
undo(): void;
}
// ── Command/AssignHandymanCommand.ts ───────────────────────
import { ICommand } from "./ICommand";
import { JobCard } from "../Model/JobCard";
export class AssignHandymanCommand implements ICommand {
private previousAssignee?: string;
private previousStatus: string = "";
constructor(
private readonly job: JobCard,
private readonly handyman: string
) {}
execute(): void {
this.previousAssignee = this.job.assignee;
this.previousStatus = this.job.status;
this.job.assignee = this.handyman;
this.job.status = "Assigned";
console.log(` ▶ Assigned '${this.handyman}' to ${this.job}`);
}
undo(): void {
this.job.assignee = this.previousAssignee;
this.job.status = this.previousStatus;
console.log(` ↩ Undone assignment — ${this.job}`);
}
}
// ── Command/PauseJobCommand.ts ─────────────────────────────
import { ICommand } from "./ICommand";
import { JobCard } from "../Model/JobCard";
export class PauseJobCommand implements ICommand {
private previousStatus: string = "";
constructor(
private readonly job: JobCard,
private readonly reason: string
) {}
execute(): void {
this.previousStatus = this.job.status;
this.job.status = "Paused";
console.log(` ▶ Paused ${this.job} — Reason: ${this.reason}`);
}
undo(): void {
this.job.status = this.previousStatus;
console.log(` ↩ Unpaused — ${this.job}`);
}
}
// ── Command/CancelJobCommand.ts ────────────────────────────
import { ICommand } from "./ICommand";
import { JobCard } from "../Model/JobCard";
export class CancelJobCommand implements ICommand {
private previousStatus: string = "";
private previousAssignee?: string;
constructor(private readonly job: JobCard) {}
execute(): void {
this.previousStatus = this.job.status;
this.previousAssignee = this.job.assignee;
this.job.status = "Cancelled";
this.job.assignee = undefined;
console.log(` ▶ Cancelled ${this.job}`);
}
undo(): void {
this.job.status = this.previousStatus;
this.job.assignee = this.previousAssignee;
console.log(` ↩ Restored cancelled job — ${this.job}`);
}
}
// ── Command/CompleteJobCommand.ts ──────────────────────────
import { ICommand } from "./ICommand";
import { JobCard } from "../Model/JobCard";
export class CompleteJobCommand implements ICommand {
private previousStatus: string = "";
constructor(private readonly job: JobCard) {}
execute(): void {
this.previousStatus = this.job.status;
this.job.status = "Completed";
console.log(` ▶ Completed ${this.job}`);
}
undo(): void {
this.job.status = this.previousStatus;
console.log(` ↩ Reverted completion — ${this.job}`);
}
}
// ── Invoker/JobActionInvoker.ts ────────────────────────────
import { ICommand } from "../Command/ICommand";
export class JobActionInvoker {
private history: ICommand[] = [];
private queue: ICommand[] = [];
execute(command: ICommand): void {
command.execute();
this.history.push(command);
}
undo(): void {
if (this.history.length === 0) {
console.log(" ⚠️ Nothing to undo.");
return;
}
const last = this.history.pop()!;
last.undo();
}
enqueue(command: ICommand): void { this.queue.push(command); }
flushQueue(): void {
console.log(`\n [Invoker] Flushing ${this.queue.length} queued command(s)...`);
while (this.queue.length > 0)
this.execute(this.queue.shift()!);
}
get historyCount(): number { return this.history.length; }
get queueCount(): number { return this.queue.length; }
}
// ── App.ts ─────────────────────────────────────────────────
import { JobCard } from "./Model/JobCard";
import { JobActionInvoker } from "./Invoker/JobActionInvoker";
import { AssignHandymanCommand } from "./Command/AssignHandymanCommand";
import { PauseJobCommand } from "./Command/PauseJobCommand";
import { CompleteJobCommand } from "./Command/CompleteJobCommand";
const invoker = new JobActionInvoker();
const job = new JobCard("Replace burst pipe under sink", "Plumbing");
console.log(`Initial state: ${job}\n`);
console.log("=== Executing Actions ===");
invoker.execute(new AssignHandymanCommand(job, "James Nghipandua"));
invoker.execute(new PauseJobCommand(job, "Waiting for parts"));
invoker.execute(new AssignHandymanCommand(job, "Peter Nangolo"));
invoker.execute(new CompleteJobCommand(job));
console.log(`\nCurrent state: ${job}`);
console.log(`History depth: ${invoker.historyCount}`);
console.log("\n=== Undoing Last 2 Actions ===");
invoker.undo();
invoker.undo();
console.log(`\nAfter undo: ${job}`);
console.log("\n=== Queuing Actions for Shift Handover ===");
invoker.enqueue(new AssignHandymanCommand(job, "Anna Nghoshi"));
invoker.enqueue(new CompleteJobCommand(job));
console.log(` ${invoker.queueCount} actions queued.`);
invoker.flushQueue();
console.log(`\nFinal state: ${job}`);
C# vs TypeScript — Key Differences
| Aspect | C# | TypeScript |
|---|---|---|
| Undo stack | Stack<ICommand> — built-in LIFO |
ICommand[] used as stack via push/pop |
| Command queue | Queue<ICommand> — built-in FIFO |
ICommand[] used as queue via push/shift |
| Nullable field | string? _previousAssignee |
previousAssignee?: string |
| Null reset | _job.Assignee = null |
this.job.assignee = undefined |
| Non-null assertion | Not needed — Stack.Pop() throws if empty |
this.history.pop()! non-null assertion |
C# has dedicated Stack<T> and Queue<T> types in the standard library. TypeScript uses arrays for both — push/pop for stack behaviour, push/shift for queue behaviour. Either works; the C# types are more semantically explicit.
Three Things Commands Unlock
The real power of the Command pattern isn't just encapsulation — it's what encapsulation enables:
1. Undo / Redo
Each command stores the state it needs to reverse itself. Stack-based history makes undo trivial — pop the last command, call Undo().
2. Command Queue / Deferred Execution Commands can be created now and executed later. FixIt Pro's shift handover queues actions for the incoming team to flush when they're ready.
3. Macro Commands
A MacroCommand implements ICommand and holds a list of other commands. Execute() runs all of them in sequence. Undo() reverses them all in reverse order. One command to rule them all.
public class MacroCommand : ICommand
{
private readonly List<ICommand> _commands;
public MacroCommand(params ICommand[] commands) =>
_commands = commands.ToList();
public void Execute() => _commands.ForEach(c => c.Execute());
public void Undo() => _commands.AsEnumerable().Reverse()
.ToList().ForEach(c => c.Undo());
}
When to Use the Command
Use it when:
- You need undoable operations
- You want to queue, schedule, or log operations
- You want to parameterise objects with actions (pass commands as arguments)
- You need macro commands, composite actions that behave as a unit
Avoid it when:
- The action is simple and one-directional with no need for undo or queuing
- The overhead of a command class per action outweighs the benefit of a simple delegate or lambda may suffice
Real-World Takeaway
The Command pattern is behind every text editor's undo/redo system, each keystroke is a command object pushed to a stack. Git commits are Command objects, each one knows how to apply and revert a change. In .NET, IDbCommand is a Command pattern implementation for database operations. Task queues (Hangfire, Azure Service Bus, RabbitMQ) store and execute Command objects asynchronously. In frontend, Redux actions are commands, pure objects describing what happened, processed by a reducer.
In FixIt Pro, an operations supervisor who accidentally cancels the wrong job hits undo. A shift handover queues the next team's first actions before anyone logs in. A macro command assigns, schedules, and notifies in a single atomic operation. Three capabilities, one pattern.
Repo Structure for This Episode
github.com/antonlungameni/fixit-pro-design-patterns
fixit-pro-design-patterns/
├── csharp/Behavioral/14-Command/
│ ├── Model/JobCard.cs
│ ├── Command/ICommand.cs
│ ├── Command/AssignHandymanCommand.cs
│ ├── Command/PauseJobCommand.cs
│ ├── Command/CancelJobCommand.cs
│ ├── Command/CompleteJobCommand.cs
│ ├── Command/MacroCommand.cs
│ ├── Invoker/JobActionInvoker.cs
│ └── Program.cs
└── typescript/Behavioral/14-Command/
├── Model/JobCard.ts
├── Command/ICommand.ts
├── Command/AssignHandymanCommand.ts
├── Command/PauseJobCommand.ts
├── Command/CancelJobCommand.ts
├── Command/CompleteJobCommand.ts
├── Invoker/JobActionInvoker.ts
└── App.ts
Previous: #13 — Chain of Responsibility Pattern
Next up: #15 — Iterator Pattern
Traversing a handyman's job card queue without exposing the internal list structure and why you've been using iterators every time you write a for...of loop.