Command Pattern
I've seen teams struggle with request handling across distributed systems—tracking what happened, retrying failures, and undoing actions. The Command pattern turns a request into an object, giving you explicit control over how work gets executed, queued, logged, and undone. By the time you're managing production platforms, you'll recognize this pattern everywhere: job queues, event sourcing systems, and audit trails.
What Is the Command Pattern?
The Command pattern wraps a request—any action or operation—as a self-contained object. Instead of calling a function directly, you create a command object that encapsulates the request, the receiver, and any parameters needed. This decoupling between the object that triggers the action and the object that performs it is what makes the pattern so powerful.
I've found this particularly useful when you need to:
- Execute requests at different times (delay execution)
- Queue multiple requests together
- Log and audit every action taken
- Undo or redo operations
- Distribute work across job queues
Think of it as creating a formal "contract" for what should happen, rather than directly invoking behavior.
Queue, Log, Retry or Undo Requests
One of the biggest wins I've seen from the Command pattern is decoupling request submission from request execution. When I was building our ride-hailing platform, we needed to handle ride requests reliably. A rider submits a request, but we can't process it immediately—we need to validate it, queue it, retry on failure, and log everything for audit purposes.
Here's how that looked:
interface Command {
execute(): Promise<void>;
undo?(): Promise<void>;
}
class CreateRideCommand implements Command {
constructor(
private riderId: string,
private pickupLocation: string,
private dropoffLocation: string,
private rideService: RideService,
private auditLog: AuditLog
) {}
async execute(): Promise<void> {
const ride = await this.rideService.createRide(
this.riderId,
this.pickupLocation,
this.dropoffLocation
);
await this.auditLog.log('RIDE_CREATED', {
rideId: ride.id,
riderId: this.riderId,
timestamp: new Date(),
});
}
async undo(): Promise<void> {
await this.rideService.cancelRide(this.riderId);
await this.auditLog.log('RIDE_CANCELLED', {
riderId: this.riderId,
timestamp: new Date(),
});
}
}
class CommandQueue {
private queue: Command[] = [];
private history: Command[] = [];
async enqueue(command: Command): Promise<void> {
this.queue.push(command);
}
async execute(command: Command, retries: number = 3): Promise<void> {
let lastError: Error | null = null;
for (let attempt = 1; attempt <= retries; attempt++) {
try {
await command.execute();
this.history.push(command);
return;
} catch (error) {
lastError = error as Error;
console.log(
`Attempt ${attempt} failed: ${lastError.message}. Retrying...`
);
}
}
throw new Error(
`Command failed after ${retries} attempts: ${lastError?.message}`
);
}
async processQueue(): Promise<void> {
while (this.queue.length > 0) {
const command = this.queue.shift();
if (command) {
await this.execute(command);
}
}
}
async undoLast(): Promise<void> {
const lastCommand = this.history.pop();
if (lastCommand?.undo) {
await lastCommand.undo();
}
}
}
With this structure, I could queue ride requests, process them asynchronously, automatically retry on transient failures, and even undo operations if needed. The audit log captures every action, which is critical for compliance and debugging.
CQRS Commands Are This Pattern
When I moved into more complex domains, I discovered that CQRS (Command Query Responsibility Segregation) is essentially an architectural application of the Command pattern. In CQRS, you separate commands (operations that change state) from queries (operations that read state).
A CQRS command is literally a command object that represents an intent to change the system. For example, in our e-commerce platform, instead of directly calling methods on a user service, we create explicit command objects:
// CQRS command
class UpdateUserProfileCommand implements Command {
constructor(
private userId: string,
private updates: Partial<UserProfile>,
private userRepository: UserRepository,
private eventBus: EventBus
) {}
async execute(): Promise<void> {
const user = await this.userRepository.findById(this.userId);
user.profile.update(this.updates);
await this.userRepository.save(user);
// Publish event for eventual consistency
await this.eventBus.publish(
new UserProfileUpdatedEvent(this.userId, this.updates)
);
}
}
// CQRS query
class GetUserProfileQuery {
constructor(private userId: string) {}
}
class GetUserProfileHandler {
constructor(private userReadModel: UserReadModel) {}
async handle(query: GetUserProfileQuery): Promise<UserProfile> {
return this.userReadModel.findById(query.userId);
}
}
// Command/Query bus pattern
class Bus {
private commandHandlers = new Map<string, Function>();
private queryHandlers = new Map<string, Function>();
registerCommandHandler<T extends Command>(
commandClass: string,
handler: (cmd: T) => Promise<void>
): void {
this.commandHandlers.set(commandClass, handler);
}
registerQueryHandler<T, R>(
queryClass: string,
handler: (query: T) => Promise<R>
): void {
this.queryHandlers.set(queryClass, handler);
}
async dispatch(commandOrQuery: any): Promise<any> {
const className = commandOrQuery.constructor.name;
const handler =
this.commandHandlers.get(className) ||
this.queryHandlers.get(className);
if (!handler) {
throw new Error(`No handler registered for ${className}`);
}
return handler(commandOrQuery);
}
}
This approach gives you a single, clear entry point for all state changes. Every modification flows through a command, making the system more testable and auditable.
Each Command Has One Handler
A principle I've found invaluable is the single responsibility rule: each command type has exactly one handler. This isn't just about clean code—it's about operational clarity. When a command fails, you know exactly which piece of logic to inspect.
I once worked on a travel booking system where multiple commands could affect the same reservation. By assigning each command its own dedicated handler, debugging became straightforward: if a refund fails, it's isolated in the RefundBookingCommandHandler. If a rebooking fails, it's in the RebookFlightCommandHandler.
// Each command has one, and only one, handler
class RefundBookingCommandHandler {
constructor(
private paymentService: PaymentService,
private bookingRepository: BookingRepository
) {}
async handle(command: RefundBookingCommand): Promise<void> {
const booking = await this.bookingRepository.findById(
command.bookingId
);
await this.paymentService.refund(booking.paymentId, booking.totalPrice);
booking.status = 'REFUNDED';
await this.bookingRepository.save(booking);
}
}
class RebookFlightCommandHandler {
constructor(
private flightService: FlightService,
private bookingRepository: BookingRepository
) {}
async handle(command: RebookFlightCommand): Promise<void> {
const booking = await this.bookingRepository.findById(
command.bookingId
);
const newFlight = await this.flightService.findAvailable(
command.departureDate,
command.route
);
booking.flightId = newFlight.id;
await this.bookingRepository.save(booking);
}
}
// Handler registry - type safe
class CommandBus {
private handlers = new Map<
new (...args: any[]) => any,
(cmd: any) => Promise<any>
>();
register<T extends Command>(
commandType: new (...args: any[]) => T,
handler: (cmd: T) => Promise<any>
): void {
this.handlers.set(commandType, handler);
}
async dispatch<T extends Command>(command: T): Promise<any> {
const handler = this.handlers.get(command.constructor as any);
if (!handler) {
throw new Error(
`No handler found for ${command.constructor.name}`
);
}
return handler(command);
}
}
This one-to-one mapping makes your codebase predictable. New team members know exactly where to look, and testing becomes straightforward—you mock a single handler, not multiple pieces of scattered logic.
Great for Job Queues and Audit Trails
In production systems handling high volumes, job queues are essential. I've used the Command pattern extensively with Redis Bull, AWS SQS, and Kafka. Commands serialize naturally into queue messages because they're already self-contained objects.
For audit trails, the same pattern shines. Every command that executes is a timestamped record of what changed and why.
// Job queue integration
interface QueuedCommand {
id: string;
type: string;
payload: Record<string, any>;
createdAt: Date;
executedAt?: Date;
status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED';
error?: string;
}
class JobQueueProcessor {
constructor(
private queue: Bull.Queue<QueuedCommand>,
private commandBus: CommandBus,
private auditRepository: AuditRepository
) {
this.setupProcessor();
}
private setupProcessor(): void {
this.queue.process(async (job) => {
const queuedCommand = job.data;
const auditEntry: AuditEntry = {
id: queuedCommand.id,
commandType: queuedCommand.type,
payload: queuedCommand.payload,
initiatedAt: queuedCommand.createdAt,
status: 'PROCESSING',
};
try {
// Reconstruct command from payload
const command = this.deserializeCommand(
queuedCommand.type,
queuedCommand.payload
);
await this.commandBus.dispatch(command);
auditEntry.status = 'COMPLETED';
auditEntry.completedAt = new Date();
} catch (error) {
auditEntry.status = 'FAILED';
auditEntry.error = (error as Error).message;
auditEntry.completedAt = new Date();
// Decide if we should retry
if (job.attemptsMade < 3) {
throw error; // Bull will retry
}
}
await this.auditRepository.save(auditEntry);
return auditEntry;
});
}
private deserializeCommand(type: string, payload: any): Command {
// Map command types to command constructors
const commandMap: Record<string, new (...args: any[]) => Command> = {
CreateRideCommand,
RefundBookingCommand,
UpdateUserProfileCommand,
// ... more commands
};
const CommandClass = commandMap[type];
if (!CommandClass) {
throw new Error(`Unknown command type: ${type}`);
}
return new CommandClass(...Object.values(payload));
}
async enqueueCommand<T extends Command>(
command: T,
priority: 'high' | 'normal' | 'low' = 'normal'
): Promise<string> {
const queuedCommand: QueuedCommand = {
id: crypto.randomUUID(),
type: command.constructor.name,
payload: this.serializeCommand(command),
createdAt: new Date(),
status: 'PENDING',
};
const job = await this.queue.add(queuedCommand, {
priority: priority === 'high' ? 10 : priority === 'low' ? 1 : 5,
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000,
},
});
return job.id!.toString();
}
private serializeCommand(command: Command): Record<string, any> {
// Extract relevant properties for serialization
return Object.getOwnPropertyNames(command).reduce(
(acc, prop) => {
const value = (command as any)[prop];
if (typeof value !== 'function' && typeof value !== 'object') {
acc[prop] = value;
}
return acc;
},
{} as Record<string, any>
);
}
}
// Audit trail query
class AuditTrailService {
constructor(private auditRepository: AuditRepository) {}
async getCommandHistory(
userId: string,
startDate: Date,
endDate: Date
): Promise<AuditEntry[]> {
return this.auditRepository.findByUserAndDateRange(
userId,
startDate,
endDate
);
}
async getFailedCommands(): Promise<AuditEntry[]> {
return this.auditRepository.findByStatus('FAILED');
}
}
In our SaaS platform, this setup proved invaluable. We could replay failed commands, investigate what happened at 3 AM when something broke, and even show users their action history. Compliance audits became trivial—everything was already logged.
Common Mistakes
Mixing command logic into handlers. Commands should be data; handlers should do work. I've seen codebases where people added complex business logic inside the command class itself, which defeats the purpose.
Creating one handler for multiple commands. This violates the single-responsibility principle and makes your code harder to test. Each command type deserves its own handler.
Forgetting to handle serialization for queued commands. If your command contains service instances or complex objects, they won't serialize to JSON for job queues. Keep commands simple—store IDs, not full objects.
Not designing for idempotency. When you retry commands, you need them to be idempotent. Processing the same command twice shouldn't create duplicate side effects. Use unique IDs and check before acting.
Over-engineering for small domains. The Command pattern adds abstraction. For simple CRUD operations with no queuing or undo requirements, it might be overkill. Use it where it solves real problems.
Key Takeaways
- The Command pattern turns requests into objects, decoupling invocation from execution.
- It enables queuing, retrying, logging, and undoing—essential for production systems.
- CQRS is an architectural application of this pattern, separating commands (mutations) from queries (reads).
- One command type, one handler—this clarity is invaluable in large codebases.
- Commands serialize naturally into job queues and audit logs, making distributed work and compliance easier.
- Keep commands simple (data only) and put business logic in handlers.
- Design for idempotency when commands are retried.
- Use this pattern where it solves problems—not everywhere.






Comments
Loading comments…