How the decorator pattern powers real-world Javascript systems
The Decorator Pattern is a structural design pattern that lets us add new behavior to an object without changing its original code. Instead of creating subclasses, we "wrap" the object with another object that extends its behavior. In this blog post we are going to look at decorator pattern from a simple example then move to advanced use cases where we apply the decorator pattern in a banking application to audit logs for compliance, perform retries for reliability, cache for performance and monitor metrics for observability.
Decorator pattern explained using the classic coffee example
The Decorator Pattern allows you to add responsibilities to objects dynamically without modifying their core implementation. In the code below instead of creating multiple subclasses like MilkCoffee or SugarCoffee, we wrap the base object (Coffee) with decorator objects (MilkDecorator) that extend its behavior. This makes the design more flexible and composable: as we can add milk, sugar, or other features independently, stacking decorators as needed. Below, a plain coffee costs 5, but once wrapped in a MilkDecorator, the price increases and the description updates, all without altering the original Coffee class.
// Base component: plain coffee
class Coffee {
cost() { return 5; } // Base price
description() { return "Plain coffee"; } // Base description
}
// Decorator: adds milk to any coffee
class MilkDecorator {
constructor(coffee) {
this.coffee = coffee; // Wrap the original coffee
}
// New cost = wrapped coffee cost + 2
cost() { return this.coffee.cost() + 2; }
// New description = wrapped coffee description + ", milk"
description() { return this.coffee.description() + ", milk"; }
}
// Client usage
let coffee = new Coffee(); // Start with plain coffee
coffee = new MilkDecorator(coffee); // Wrap it with milk
console.log(coffee.cost()); // 7 (5 + 2)
console.log(coffee.description()); // Plain coffee, milk
Deeper dive; banking service.
Here we will be building a TransferService that moves money between accounts with key requirements such as;
- audit Logging where every transaction must be logged for compliance.
- feature flags where logging should be toggleable per environment (production, test...).
- retrying failed operations for resilience.
- caching expensive calls for performance.
- emitting metrics for monitoring. Lets work on that example;
1. Pure Domain Logic
Our banking logic should know nothing about retries, logs or metrics making it easy to test in isolation, audit for compliance (no hidden side effects) and future-proof where logic evolves independently from infrastructure.
// Core banking service
class TransferService {
async transfer(from, to, amount) {
// Pure domain logic
if (amount <= 0) throw new Error("Invalid transfer");
// In real systems: DB operations, balances, etc.
return { status: "ok", from, to, amount };
}
}
2. Decorators for cross-cutting concerns
Each concern is its own class, wrapping the service
- a. Audit Logger - compliance & regulations (real-world pain point).
// Decorator: Adds audit logging *if enabled*
class AuditLoggerDecorator {
constructor(service, enabled = true) {
this.service = service; // inject the core service
this.enabled = enabled; // flag-controlled behavior
}
async transfer(from, to, amount) {
if (this.enabled) {
console.log(`[AUDIT] ${from} → ${to} : $${amount}`);
}
// Always delegate to the core service
return this.service.transfer(from, to, amount);
}
}
- b. Retry → network resilience in distributed systems. In real distributed systems, failures happen. Instead of rewriting retry logic in every service, we add a retry decorator.
// Decorator: Adds retry capability for transient failures
class RetryDecorator {
constructor(service, retries = 3) {
this.service = service;
this.retries = retries;
}
async transfer(from, to, amount) {
// Try up to N times
for (let i = 0; i < this.retries; i++) {
try {
// Attempt the transfer
return await this.service.transfer(from, to, amount);
} catch (err) {
// On last attempt, rethrow error
if (i === this.retries - 1) throw err;
}
}
}
}
- c. Cache - latency/performance in high-traffic banking We may want to cache small transfers (to avoid hitting downstream services too often). Again, a decorator isolates this.
// Decorator: Adds simple in-memory caching
class CachingDecorator {
constructor(service) {
this.service = service;
this.cache = new Map(); // naive in-memory cache
}
async transfer(from, to, amount) {
const key = `${from}-${to}-${amount}`;
// If result exists in cache, return it
if (this.cache.has(key)) {
console.log(`[CACHE HIT] ${key}`);
return this.cache.get(key);
}
// Otherwise, perform the transfer and store result
const result = await this.service.transfer(from, to, amount);
this.cache.set(key, result);
return result;
}
}
- d. Metrics - observability & incident response To monitor health of the system, we need to track metrics.
// Decorator: Collects metrics for monitoring
class MetricsDecorator {
constructor(service) {
this.service = service;
this.count = 0;
}
async transfer(from, to, amount) {
// Increment total transfer count
this.count++;
// Emit a metric (could be sent to Prometheus, Datadog, etc.)
console.log(`[METRICS] Total transfers so far: ${this.count}`);
// Delegate to the wrapped service
return this.service.transfer(from, to, amount);
}
}
- f. Composing decorators at runtime
Here it all comes together we are able to wire up different stacks depending on environment (prod/dev/test).
// Base service
let service = new TransferService();
// Environment-driven feature flags
const AUDIT_ENABLED = process.env.NODE_ENV === "production";
const CACHE_ENABLED = process.env.NODE_ENV !== "test";
const METRICS_ENABLED = true;
// Apply decorators conditionally
if (AUDIT_ENABLED) service = new AuditLoggerDecorator(service, true);
service = new RetryDecorator(service, 2); // retries everywhere
if (CACHE_ENABLED) service = new CachingDecorator(service);
if (METRICS_ENABLED) service = new MetricsDecorator(service);
// Usage
await service.transfer("Koome", "Baraka", 500);
advantages
- cross-cutting Concerns Stay Orthogonal (Audit, retries, caching, metrics are isolated) thus we can them in/out at runtime.
- feature flags become first-class citizens as flags map directly to decorators thus no more if (prod) scattered everywhere.
- compliance-ready Without Pollution as the core banking logic is pure while decorators handle compliance and middleware.
- Composable runtime behavior (In prod: AuditLogger - Retry - Cache - Metrics - Service), in dev: (retry - cache - metrics - service) and (in test: retry - service).
3. Higher-order functions for decorators
Decorators don’t have to be classes — in JavaScript, higher-order functions can achieve the same flexibility.
// HOF: Audit logger
const withAudit = (service, enabled = true) => ({
async transfer(from, to, amount) {
if (enabled) console.log(`[AUDIT] ${from} → ${to} : $${amount}`);
return service.transfer(from, to, amount);
}
});
// HOF: Retry
const withRetry = (service, retries = 3) => ({
async transfer(from, to, amount) {
for (let i = 0; i < retries; i++) {
try {
return await service.transfer(from, to, amount);
} catch (err) {
if (i === retries - 1) throw err;
}
}
}
});
Composition then becomes function wrapping:
// Start with pure service
let service = new TransferService();
// Compose behaviors with HOFs
service = withAudit(service, AUDIT_ENABLED);
service = withRetry(service, 2);
// Usage
await service.transfer("Koome", "Baraka", 250);
In banking, healthcare, or enterprise SaaS, code must survive audits, adapt to runtime changes, and evolve without rewrites. The Decorator Pattern doesn’t just add behavior — it enforces architectural hygiene. Think of decorators as middleware for your objects.
javascript book
If this interested you, check out my Javascript Book