Design Patterns, Part 2: Decorator, Adapter, Command
Part 1 covered Factory, Strategy, and Observer - patterns that manage creation, swap algorithms, and decouple event sources from their listeners. This part covers three more that you’ll encounter with similar frequency.
Decorator - adding behavior without subclassing
The problem: you have a class that does something, and you want to add behavior to it. The instinct is inheritance - make a subclass that overrides a method. This works once. When you need multiple independent additions, it falls apart. A LoggedCachedRateLimitedService that inherits from three levels of subclasses is not a design; it’s an accident.
The pattern: wrap the original object in another object that implements the same interface. The wrapper adds behavior before or after delegating to the original.
interface DataSource {
read(): string;
write(data: string): void;
}
class FileDataSource implements DataSource {
constructor(private filename: string) {}
read(): string {
return fs.readFileSync(this.filename, 'utf8');
}
write(data: string): void {
fs.writeFileSync(this.filename, data);
}
}
// Decorator: adds encryption, delegates everything else
class EncryptedDataSource implements DataSource {
constructor(private wrapped: DataSource) {}
read(): string {
return decrypt(this.wrapped.read());
}
write(data: string): void {
this.wrapped.write(encrypt(data));
}
}
// Decorator: adds compression, delegates everything else
class CompressedDataSource implements DataSource {
constructor(private wrapped: DataSource) {}
read(): string {
return decompress(this.wrapped.read());
}
write(data: string): void {
this.wrapped.write(compress(data));
}
}
// Compose behaviors at runtime, in any combination
const source = new EncryptedDataSource(
new CompressedDataSource(
new FileDataSource('data.bin')
)
);
source.write('sensitive data'); // compressed, then encrypted, then written
source.read(); // read, then decrypted, then decompressed
Each decorator is independent. You can add compression without encryption, add both, add either in either order. No inheritance hierarchy needed. Adding a new behavior (say, logging) means adding one class - not modifying any existing one.
Where you’ve seen this: Node’s stream pipeline. Express middleware is a functional variant of Decorator - each middleware wraps the next handler. Python’s @functools.lru_cache decorator is exactly this pattern. Java I/O streams (BufferedReader(new FileReader(...))) are a classic example. React higher-order components were Decorator before hooks.
The key recognition: if you’re stacking behaviors that are individually useful and combinable, and inheritance is producing a class-name explosion, Decorator is the pattern.
Adapter - making incompatible things work together
The problem: you have code that expects one interface, and a dependency that provides a different one. You can’t change the dependency (it’s a third-party library, a legacy system, a service you don’t own). You don’t want to scatter translation logic throughout your codebase.
The pattern: write a class that implements the interface your code expects, and internally translates to whatever the dependency provides.
// What your application expects
interface PaymentGateway {
charge(amountCents: number, currency: string, token: string): Promise<string>;
refund(transactionId: string): Promise<void>;
}
// What Stripe's SDK actually provides (simplified)
class StripeSDK {
async createCharge(opts: {
amount: number;
currency: string;
source: string;
}): Promise<{ id: string }> { /* ... */ }
async createRefund(opts: { charge: string }): Promise<void> { /* ... */ }
}
// Adapter: translates your interface to Stripe's
class StripeAdapter implements PaymentGateway {
constructor(private stripe: StripeSDK) {}
async charge(amountCents: number, currency: string, token: string): Promise<string> {
const result = await this.stripe.createCharge({
amount: amountCents,
currency,
source: token,
});
return result.id;
}
async refund(transactionId: string): Promise<void> {
await this.stripe.createRefund({ charge: transactionId });
}
}
// Your application code only knows about PaymentGateway
class OrderService {
constructor(private payments: PaymentGateway) {}
async checkout(cart: Cart, token: string) {
const txId = await this.payments.charge(cart.totalCents, 'usd', token);
return { transactionId: txId };
}
}
// Wire it together once
const service = new OrderService(new StripeAdapter(new StripeSDK()));
If you switch from Stripe to Braintree, you write a BraintreeAdapter and change one line of wiring. OrderService doesn’t change. The rest of the codebase doesn’t change.
Where you’ve seen this: ORM database adapters (the same query API, different databases underneath). Storage adapters that make S3 and local disk look the same. Any library that ships with pluggable backends uses Adapter internally. When you write a wrapper around a third-party API, you’re writing an Adapter.
The distinction from Facade: Facade simplifies a complex interface. Adapter translates between two different interfaces. Facade reduces complexity. Adapter bridges incompatibility.
Command - encapsulating actions as objects
The problem: you need to do more than just execute an operation. You need to queue it, log it, undo it, replay it, or send it somewhere else for execution. A plain function call can’t carry that extra behavior. Once it’s called, it’s gone.
The pattern: represent an action as an object. The object knows how to execute the action, and optionally how to undo it.
interface Command {
execute(): void;
undo(): void;
}
class TextEditor {
private content = '';
private history: Command[] = [];
executeCommand(command: Command) {
command.execute();
this.history.push(command);
}
undoLast() {
const command = this.history.pop();
command?.undo();
}
getContent() {
return this.content;
}
setContent(text: string) {
this.content = text;
}
}
class InsertTextCommand implements Command {
private previousContent: string = '';
constructor(
private editor: TextEditor,
private text: string,
private position: number
) {}
execute() {
this.previousContent = this.editor.getContent();
const current = this.editor.getContent();
this.editor.setContent(
current.slice(0, this.position) + this.text + current.slice(this.position)
);
}
undo() {
this.editor.setContent(this.previousContent);
}
}
class DeleteTextCommand implements Command {
private deletedText: string = '';
constructor(
private editor: TextEditor,
private position: number,
private length: number
) {}
execute() {
const current = this.editor.getContent();
this.deletedText = current.slice(this.position, this.position + this.length);
this.editor.setContent(
current.slice(0, this.position) + current.slice(this.position + this.length)
);
}
undo() {
const current = this.editor.getContent();
this.editor.setContent(
current.slice(0, this.position) + this.deletedText + current.slice(this.position)
);
}
}
const editor = new TextEditor();
editor.executeCommand(new InsertTextCommand(editor, 'Hello, world', 0));
editor.executeCommand(new DeleteTextCommand(editor, 7, 5)); // deletes 'world'
editor.undoLast(); // 'world' comes back
The editor doesn’t know how to insert or delete text - it knows how to execute commands. Each command knows how to do and undo one thing. Undo/redo is a stack of commands.
Where you’ve seen this: Every undo system. Database transaction logs - each transaction is a command that can be rolled back. Job queues where jobs are serialized and replayed. Redux actions are commands (though without built-in undo). The CQRS pattern at a higher level is Command applied to an entire architecture. git revert creates the inverse commit - an undo command in a command history.
Looking at all six patterns together, a shape emerges. Most of them solve a version of the same problem: separating the thing that decides what from the thing that does how.
Factory separates object creation from usage. Strategy separates algorithm selection from execution. Observer separates event occurrence from reaction. Decorator separates capability layering from core behavior. Adapter separates interface expectations from implementation. Command separates the description of an action from its execution.
This separation is what makes code testable, extensible, and maintainable. Not because it follows rules, but because it gives each piece of code a narrower job.