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: Express middleware is Decorator applied functionally - each app.use() call wraps the next handler in a layer that can add behavior before or after passing control:
// This is Decorator. Each middleware wraps the next.
app.use(cors());
app.use(rateLimit({ windowMs: 60000, max: 100 }));
app.use(authenticate());
app.use(logRequest());
app.post('/orders', createOrder);
Each middleware is independent, composable, and doesn’t know what the others do. Python’s @functools.lru_cache is the same idea applied as a language feature. Java I/O streams (new BufferedReader(new InputStreamReader(new FileInputStream(...)))) are classic Decorator composition. React higher-order components were this pattern 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: Every ORM ships with database adapters - Prisma, Sequelize, SQLAlchemy all expose a unified query API while translating to MySQL, Postgres, or SQLite underneath. Storage clients abstract S3, GCS, and local disk behind the same interface. When you use Passport.js for authentication, each strategy (GitHub, Google, local) is an Adapter that makes different OAuth providers look the same to your application.
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: Redux actions are Command - a plain object describing what happened that can be logged, replayed, and time-traveled through in Redux DevTools. BullMQ and Sidekiq serialize jobs as command objects that workers execute later, retry on failure, and can be inspected in a queue. Database migration tools (Flyway, Alembic) are a history of commands where down() is the undo. git revert creates the inverse commit - literally a Command with an undo() implementation. The CQRS pattern applies Command at an architectural level, separating write operations (commands) from read operations (queries) into separate models.
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.