Loading content...
The Decorator pattern solves the behavior extension problem through a deceptively simple mechanism: wrapping objects inside other objects that add behavior. Instead of modifying a class to add functionality, we enclose an object in a decorator that provides the new capability while delegating core operations to the wrapped object.
This approach transforms the problem from building combinations to composing layers. Each decorator adds one responsibility. Stack decorators to combine responsibilities. The result: linear complexity (n classes for n features) instead of exponential (2ⁿ classes for n features).
By the end of this page, you will understand the structural mechanics of the Decorator pattern, know its key participants and their relationships, and be able to implement decorators that compose cleanly. You'll see how wrapping enables flexible runtime behavior composition while maintaining type safety.
At its core, the Decorator pattern uses object composition to extend behavior. A decorator object contains another object (the wrapped component) and implements the same interface as that object. When methods are called on the decorator, it:
This creates a layered architecture where each layer adds its own behavior while preserving the contract of the original interface.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162
// Step 1: Define the component interfaceinterface Component { operation(): string;} // Step 2: Implement a concrete componentclass ConcreteComponent implements Component { operation(): string { return "ConcreteComponent"; }} // Step 3: Create a base decorator that wraps any Componentabstract class Decorator implements Component { protected wrapped: Component; constructor(component: Component) { this.wrapped = component; } // Delegate to wrapped component by default operation(): string { return this.wrapped.operation(); }} // Step 4: Create concrete decorators that add behaviorclass BehaviorA extends Decorator { operation(): string { // Add behavior AROUND the delegated call return `BehaviorA(${super.operation()})`; }} class BehaviorB extends Decorator { operation(): string { return `BehaviorB(${super.operation()})`; }} class BehaviorC extends Decorator { operation(): string { return `BehaviorC(${super.operation()})`; }} // Step 5: Compose decorators at runtimeconst base = new ConcreteComponent();console.log(base.operation());// Output: "ConcreteComponent" const withA = new BehaviorA(base);console.log(withA.operation());// Output: "BehaviorA(ConcreteComponent)" const withAB = new BehaviorB(new BehaviorA(base));console.log(withAB.operation());// Output: "BehaviorB(BehaviorA(ConcreteComponent))" const withABC = new BehaviorC(new BehaviorB(new BehaviorA(base)));console.log(withABC.operation());// Output: "BehaviorC(BehaviorB(BehaviorA(ConcreteComponent)))"Notice how decorators nest like parentheses: BehaviorC(BehaviorB(BehaviorA(component))). The outermost decorator executes first, then delegates inward. Results propagate back outward. This nesting is the source of the pattern's flexibility—any combination is just a different nesting arrangement.
The Decorator pattern involves four key participants, each with a distinct role in enabling flexible composition:
| Participant | Role | Responsibility |
|---|---|---|
| Component | Interface/Abstract Class | Defines the interface that both concrete components and decorators implement. This shared interface is what makes decorators transparent to clients. |
| ConcreteComponent | Core Implementation | Provides the base functionality being extended. This is the 'real' object that ultimately does the core work. |
| Decorator | Abstract Wrapper | Base class for all decorators. Holds a reference to a Component and implements the Component interface by delegating to the wrapped object. |
| ConcreteDecorator | Behavior Extension | Adds specific behavior before/after/around delegation. Each concrete decorator represents one atomic enhancement. |
The UML structure:
┌─────────────────┐ ┌─────────────────┐
│ Component │◄───────│ Client │
│ (interface) │ └─────────────────┘
├─────────────────┤
│ + operation() │
└────────┬────────┘
│
│ implements
┌────┴────┐
│ │
▼ ▼
┌─────────┐ ┌─────────────────────┐
│Concrete │ │ Decorator │
│Component│ │ (abstract) │
├─────────┤ ├─────────────────────┤
│+operation│ │ - wrapped: Component│───┐
└─────────┘ │ + operation() │ │
└──────────┬──────────┘ │
│ │
┌──────────────┼──────────────┘
│ │ extends wraps
│ ┌────┴────┐ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────┐┌──────────┐ │
│ │DecoratorA││DecoratorB│ │
│ ├──────────┤├──────────┤ │
└──►│+operation││+operation│◄─┘
└──────────┘└──────────┘
The key insight: both ConcreteComponent and Decorator implement Component. This means decorators can wrap components OR other decorators—enabling unbounded stacking.
Let's implement a real-world example: the stream processing system from the previous page. We'll build a composable I/O stream with decoration for buffering, compression, and encryption.
1234567891011121314151617181920
/** * Component interface: defines the contract all streams must fulfill. * Both concrete streams and decorators implement this interface. */interface DataStream { /** * Write data to the stream */ write(data: Buffer): void; /** * Read data from the stream */ read(): Buffer; /** * Close the stream and release resources */ close(): void;}1234567891011121314151617181920212223242526272829303132333435363738
/** * ConcreteComponent: the core implementation that does actual I/O. * This is what decorators ultimately wrap. */class FileStream implements DataStream { private filePath: string; private buffer: Buffer[] = []; private isOpen: boolean = true; constructor(filePath: string) { this.filePath = filePath; console.log(`[FileStream] Opened: ${filePath}`); } write(data: Buffer): void { if (!this.isOpen) { throw new Error("Stream is closed"); } // In reality: fs.writeSync or similar this.buffer.push(data); console.log(`[FileStream] Wrote ${data.length} bytes`); } read(): Buffer { if (!this.isOpen) { throw new Error("Stream is closed"); } // In reality: fs.readSync or similar const data = this.buffer.shift() ?? Buffer.alloc(0); console.log(`[FileStream] Read ${data.length} bytes`); return data; } close(): void { this.isOpen = false; console.log(`[FileStream] Closed: ${this.filePath}`); }}12345678910111213141516171819202122232425
/** * Abstract Decorator: base class for all stream decorators. * Implements DataStream by delegating to the wrapped stream. * Subclasses override methods to add behavior. */abstract class StreamDecorator implements DataStream { protected wrapped: DataStream; constructor(stream: DataStream) { this.wrapped = stream; } // Default: delegate everything to wrapped stream write(data: Buffer): void { this.wrapped.write(data); } read(): Buffer { return this.wrapped.read(); } close(): void { this.wrapped.close(); }}123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
/** * ConcreteDecorator: BufferedStream * Adds buffering capability to reduce I/O operations. */class BufferedStream extends StreamDecorator { private writeBuffer: Buffer[] = []; private readonly bufferSize: number; constructor(stream: DataStream, bufferSize: number = 8192) { super(stream); this.bufferSize = bufferSize; } write(data: Buffer): void { console.log(`[BufferedStream] Buffering ${data.length} bytes`); this.writeBuffer.push(data); const totalSize = this.writeBuffer.reduce((sum, b) => sum + b.length, 0); if (totalSize >= this.bufferSize) { this.flush(); } } private flush(): void { if (this.writeBuffer.length === 0) return; const combined = Buffer.concat(this.writeBuffer); console.log(`[BufferedStream] Flushing ${combined.length} bytes`); this.wrapped.write(combined); this.writeBuffer = []; } close(): void { this.flush(); // Ensure remaining data is written super.close(); }} /** * ConcreteDecorator: CompressedStream * Adds compression to reduce data size. */class CompressedStream extends StreamDecorator { write(data: Buffer): void { const compressed = this.compress(data); console.log(`[CompressedStream] Compressed ${data.length} → ${compressed.length} bytes`); this.wrapped.write(compressed); } read(): Buffer { const compressed = this.wrapped.read(); const decompressed = this.decompress(compressed); console.log(`[CompressedStream] Decompressed ${compressed.length} → ${decompressed.length} bytes`); return decompressed; } private compress(data: Buffer): Buffer { // In reality: zlib.gzipSync(data) or similar // Simulated compression: just return the data return data; } private decompress(data: Buffer): Buffer { // In reality: zlib.gunzipSync(data) or similar return data; }} /** * ConcreteDecorator: EncryptedStream * Adds encryption for data security. */class EncryptedStream extends StreamDecorator { private readonly key: Buffer; constructor(stream: DataStream, encryptionKey: Buffer) { super(stream); this.key = encryptionKey; } write(data: Buffer): void { const encrypted = this.encrypt(data); console.log(`[EncryptedStream] Encrypted ${data.length} bytes`); this.wrapped.write(encrypted); } read(): Buffer { const encrypted = this.wrapped.read(); const decrypted = this.decrypt(encrypted); console.log(`[EncryptedStream] Decrypted ${encrypted.length} bytes`); return decrypted; } private encrypt(data: Buffer): Buffer { // In reality: crypto.createCipheriv() or similar return data; } private decrypt(data: Buffer): Buffer { // In reality: crypto.createDecipheriv() or similar return data; }}Notice that each decorator focuses on ONE responsibility. BufferedStream only buffers. CompressedStream only compresses. EncryptedStream only encrypts. This adherence to the Single Responsibility Principle is what makes the pattern so maintainable—each piece is independently testable and modifiable.
The true power of the Decorator pattern emerges when we compose decorators dynamically. Since every decorator is also a DataStream, we can stack them in any order, in any combination, at runtime:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374
const encryptionKey = Buffer.from('0123456789abcdef'); // Scenario 1: Just buffering (for large writes)const bufferedOnly = new BufferedStream( new FileStream('/data/output.dat'));// Chain: BufferedStream → FileStream // Scenario 2: Compressed and buffered (for archiving)const compressedBuffered = new BufferedStream( new CompressedStream( new FileStream('/data/archive.gz') ));// Chain: BufferedStream → CompressedStream → FileStream// Order: Buffer batches → Compress batched data → Write // Scenario 3: Encrypted then compressed (secure transfer)const secureCompressed = new CompressedStream( new EncryptedStream( new FileStream('/data/secure.enc'), encryptionKey ));// Chain: CompressedStream → EncryptedStream → FileStream// Note: Compressing AFTER encryption is inefficient! (See next example) // Scenario 4: Compressed then encrypted (optimal order)const optimalSecure = new EncryptedStream( new CompressedStream( new FileStream('/data/optimal.enc') ), encryptionKey);// Chain: EncryptedStream → CompressedStream → FileStream// Order: Encrypt → Compress → Write// BUT the data flow is: Write input → Decrypt at write → Decompress → File// Actually for writes: Input → Encrypt → (inner) → Compress → (inner) → File // Scenario 5: Full stack with runtime configurationfunction createStream(config: { path: string; buffered?: boolean; compressed?: boolean; encrypted?: boolean; encryptionKey?: Buffer;}): DataStream { let stream: DataStream = new FileStream(config.path); // Build from inside out (file → compression → encryption → buffering) if (config.compressed) { stream = new CompressedStream(stream); } if (config.encrypted && config.encryptionKey) { stream = new EncryptedStream(stream, config.encryptionKey); } if (config.buffered) { stream = new BufferedStream(stream); } return stream;} // Usage: Configuration-driven stream creationconst customStream = createStream({ path: '/data/custom.dat', buffered: true, compressed: true, encrypted: true, encryptionKey});// Chain: BufferedStream → EncryptedStream → CompressedStream → FileStreamCritical insight: decoration order affects behavior.
In the examples above, the order of wrapping determines the order of processing. Consider writing data through BufferedStream → EncryptedStream → CompressedStream → FileStream:
Reading reverses the process—each decorator transforms on the way back up the chain.
When constructing decorator chains: the OUTER decorator acts FIRST on writes, but LAST on reads. Think of it like function composition: f(g(h(x))) applies h first, then g, then f. For writes, each layer transforms data before passing inward. For reads, each layer transforms data after receiving from inner layers.
A key property of the Decorator pattern is transparency: clients interact with decorated objects exactly as they would with the original. The decoration is invisible to code that expects a Component.
1234567891011121314151617181920212223242526272829303132
// Client code that uses DataStreamfunction processData(stream: DataStream, data: Buffer[]): void { // This function has NO IDEA whether stream is: // - A plain FileStream // - A heavily decorated BufferedEncryptedCompressedStream // - Or any other combination for (const chunk of data) { stream.write(chunk); // Works with any DataStream } stream.close(); // Clean up, regardless of decorations} // Usage 1: Plain file streamconst plainStream = new FileStream('/data/plain.txt');processData(plainStream, dataChunks); // Usage 2: Fully decorated stream const decoratedStream = new BufferedStream( new EncryptedStream( new CompressedStream( new FileStream('/data/secure.enc') ), encryptionKey ));processData(decoratedStream, dataChunks); // Same interface! // The client code doesn't change at all.// This is the Liskov Substitution Principle in action:// decorated objects are substitutable for the originals.decoratedStream === originalStream is falsestream instanceof FileStream returns false for decorated streamsLet's add more decorators to our stream system. Notice how each new decorator is an independent class—no modification to existing code is required:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
/** * LoggingStream: Records all operations for auditing/debugging. * Adding this required NO changes to FileStream, BufferedStream, etc. */class LoggingStream extends StreamDecorator { private readonly logPrefix: string; constructor(stream: DataStream, prefix: string = 'STREAM') { super(stream); this.logPrefix = prefix; } write(data: Buffer): void { console.log(`[${this.logPrefix}] WRITE: ${data.length} bytes`); const start = Date.now(); this.wrapped.write(data); const elapsed = Date.now() - start; console.log(`[${this.logPrefix}] WRITE complete in ${elapsed}ms`); } read(): Buffer { console.log(`[${this.logPrefix}] READ: starting`); const start = Date.now(); const data = this.wrapped.read(); const elapsed = Date.now() - start; console.log(`[${this.logPrefix}] READ: ${data.length} bytes in ${elapsed}ms`); return data; } close(): void { console.log(`[${this.logPrefix}] CLOSE: releasing resources`); super.close(); }} /** * ChecksumStream: Computes and verifies data integrity. */class ChecksumStream extends StreamDecorator { private writeChecksum: number = 0; private readChecksum: number = 0; write(data: Buffer): void { const checksum = this.computeCRC32(data); this.writeChecksum ^= checksum; console.log(`[ChecksumStream] Computed checksum: 0x${checksum.toString(16)}`); this.wrapped.write(data); } read(): Buffer { const data = this.wrapped.read(); const checksum = this.computeCRC32(data); this.readChecksum ^= checksum; console.log(`[ChecksumStream] Verified checksum: 0x${checksum.toString(16)}`); return data; } getWriteChecksum(): number { return this.writeChecksum; } getReadChecksum(): number { return this.readChecksum; } private computeCRC32(data: Buffer): number { // Simplified CRC32 for illustration let crc = 0xFFFFFFFF; for (const byte of data) { crc ^= byte; } return crc >>> 0; }} /** * RateLimitedStream: Controls throughput. */class RateLimitedStream extends StreamDecorator { private readonly bytesPerSecond: number; private bytesWrittenThisSecond: number = 0; private lastSecondTimestamp: number = Date.now(); constructor(stream: DataStream, bytesPerSecond: number = 1024 * 1024) { super(stream); this.bytesPerSecond = bytesPerSecond; } write(data: Buffer): void { const now = Date.now(); // Reset counter each second if (now - this.lastSecondTimestamp >= 1000) { this.bytesWrittenThisSecond = 0; this.lastSecondTimestamp = now; } // Enforce rate limit if (this.bytesWrittenThisSecond + data.length > this.bytesPerSecond) { const waitTime = 1000 - (now - this.lastSecondTimestamp); console.log(`[RateLimitedStream] Throttling for ${waitTime}ms`); // In reality: await sleep(waitTime) } this.bytesWrittenThisSecond += data.length; this.wrapped.write(data); }}The Open/Closed Principle in action:
We added three new capabilities (logging, checksumming, rate limiting) without modifying a single line in FileStream, BufferedStream, CompressedStream, or EncryptedStream. Each existing class remains stable while the system gains new features.
With 6 decorators, we have access to 2⁶ = 64 possible combinations—but we only wrote 6 classes, not 64. This is the Decorator pattern's key advantage: linear class count for exponential combinations.
Adding decorator #7 requires writing 1 new class to access 128 combinations. Adding decorator #10 requires writing 1 new class to access 1024 combinations. The pattern scales magnificently because each decorator is independent and composable.
The abstract Decorator (or StreamDecorator in our example) base class serves a crucial purpose: it provides a default forwarding implementation for all component methods. This means concrete decorators only need to override the methods they care about, rather than implementing every interface method.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
// WITHOUT base decorator class:// Every decorator must implement ALL interface methods class ManualLoggingStream implements DataStream { protected wrapped: DataStream; constructor(stream: DataStream) { this.wrapped = stream; } // Must implement ALL methods, even if just forwarding write(data: Buffer): void { console.log(`Writing ${data.length} bytes`); this.wrapped.write(data); // Boilerplate } read(): Buffer { const data = this.wrapped.read(); // Boilerplate console.log(`Read ${data.length} bytes`); return data; } close(): void { this.wrapped.close(); // Pure boilerplate } // If DataStream had 10 methods, we'd need 10 implementations // Even if we only want to add logging to write()!} // WITH base decorator class:// Only override what you need class CleanLoggingStream extends StreamDecorator { // Inherits: constructor, write(), read(), close() // All forward to wrapped by default // Only override write to add logging write(data: Buffer): void { console.log(`Writing ${data.length} bytes`); super.write(data); // Uses inherited forwarding } // read() and close() work automatically via inheritance // Zero boilerplate for methods we don't modify!}Interface evolution benefits:
The base decorator also insulates concrete decorators from interface changes. If we add a new method to DataStream—say, flush(): void—we only need to add the forwarding implementation in StreamDecorator. All existing concrete decorators automatically inherit it and continue working.
Without the base class, adding one method would require modifying every concrete decorator—a violation of the Open/Closed Principle.
Always create an abstract base decorator that forwards all calls to the wrapped component. Concrete decorators then extend this base and override only the methods they enhance. This minimizes boilerplate and isolates decorators from interface evolution.
Let's consolidate the key insights from this page:
| Aspect | What It Means |
|---|---|
| Intent | Attach additional responsibilities to an object dynamically. Decorators provide a flexible alternative to subclassing for extending functionality. |
| Mechanism | Wrapping—a decorator holds a component and forwards calls while adding behavior |
| Key Property | Transparency—decorated objects are substitutable for the original component type |
| Scalability | Linear: n decorators provide 2ⁿ combinations with only n classes |
| Flexibility | Runtime composition—decide which behaviors to apply when objects are created or during execution |
What's next:
Now that we understand how the Decorator pattern works, the next page explores a critical comparison: Decorator vs Inheritance. We'll examine when decoration is superior, when inheritance is appropriate, and how to choose between them for specific design situations.
You now understand the structural mechanics of the Decorator pattern—how wrapping enables flexible composition, why the base decorator matters, and how runtime construction creates any combination of behaviors. The next page compares this approach directly with inheritance to clarify when each is appropriate.