Decorator Pattern
The subclass explosion problem
You start with a DataStream that writes bytes to a file. Then someone needs encrypted writes.
You subclass: EncryptedDataStream. Then someone needs compression. Another subclass:
CompressedDataStream. Supporting both at once forces a third subclass,
CompressedEncryptedDataStream. Add logging and you have eight combinations for three features.
Every new cross-cutting concern doubles the count.
This is the smell the Decorator pattern fixes. Instead of baking optional features into the inheritance hierarchy, you wrap objects at runtime. Each feature lives in its own thin wrapper that delegates to whatever is inside it — another wrapper, or the real object.
How it works
The pattern rests on a single interface that both the real object and every wrapper implement. Because every layer speaks the same contract, a caller cannot tell whether it is talking to the raw object or to ten layers of wrapping. You add features by composing wrappers, not by multiplying subclasses.
The DataStreamDecorator base holds the wrapped instance and forwards calls to it. Subclasses
override only what they change — encryption before and after the delegate call, compression
likewise. Three features remain three classes regardless of how many combinations a caller needs.
A write call through two stacked decorators shows the layered delegation in action.
Bad: subclass per combination
The version that triggers the explosion gives each combination its own class. Adding a fourth feature requires touching every existing combination.
1# Bad: three features produce eight subclasses; adding a fourth needs sixteen.
2class DataStream:
3 def write(self, data: bytes) -> None: ...
4 def read(self) -> bytes: ...
5
6class FileDataStream(DataStream): ...
7class EncryptedDataStream(DataStream): ...
8class CompressedDataStream(DataStream): ...
9class LoggedDataStream(DataStream): ...
10class CompressedEncryptedDataStream(DataStream): ...
11class LoggedEncryptedDataStream(DataStream): ...
12class LoggedCompressedDataStream(DataStream): ...
13class LoggedCompressedEncryptedDataStream(DataStream): ...
14# Every new cross-cutting concern doubles this list.
Good: wrappers composed at runtime
The Decorator version keeps three classes — one per feature — and composes them at the call site. The caller decides which combination it needs; the classes themselves know nothing about each other.
1from abc import ABC, abstractmethod
2
3class DataStream(ABC):
4 @abstractmethod
5 def write(self, data: bytes) -> None: ...
6 @abstractmethod
7 def read(self) -> bytes: ...
8
9
10class FileDataStream(DataStream):
11 def __init__(self, path: str) -> None:
12 self._path = path
13 self._buf = b""
14
15 def write(self, data: bytes) -> None:
16 self._buf = data # simplified; real impl writes to disk
17
18 def read(self) -> bytes:
19 return self._buf
20
21
22class DataStreamDecorator(DataStream, ABC):
23 def __init__(self, wrapped: DataStream) -> None:
24 self._wrapped = wrapped
25
26 def write(self, data: bytes) -> None:
27 self._wrapped.write(data)
28
29 def read(self) -> bytes:
30 return self._wrapped.read()
31
32
33class EncryptionDecorator(DataStreamDecorator):
34 def write(self, data: bytes) -> None:
35 encrypted = bytes(b ^ 0xFF for b in data) # XOR placeholder
36 self._wrapped.write(encrypted)
37
38 def read(self) -> bytes:
39 return bytes(b ^ 0xFF for b in self._wrapped.read())
40
41
42class CompressionDecorator(DataStreamDecorator):
43 def write(self, data: bytes) -> None:
44 import zlib
45 self._wrapped.write(zlib.compress(data))
46
47 def read(self) -> bytes:
48 import zlib
49 return zlib.decompress(self._wrapped.read())
50
51
52# Compose at the call site — no new class needed.
53stream = CompressionDecorator(EncryptionDecorator(FileDataStream("/tmp/out")))
54stream.write(b"order:123:confirmed")
Wrapping order matters
The order in which decorators are stacked determines the behavior, and different orders are
not interchangeable. Consider the write path with compression and encryption. If you wrap
compression inside encryption — EncryptionDecorator(CompressionDecorator(file)) — data is
first compressed, then the compressed bytes are encrypted and written. Reading reverses both
steps in order: decrypt, then decompress. If instead you wrap encryption inside compression —
CompressionDecorator(EncryptionDecorator(file)) — data is first encrypted, then the
ciphertext is compressed. Because encrypted data is close to random, a general-purpose
compressor gains almost nothing and may even expand it. The resulting files from these two
orderings are structurally different; a reader configured for one order cannot decode output
produced by the other.
Choosing the order is a design decision, not a detail. Compress-then-encrypt is the standard approach in protocols such as TLS, because compressing before encryption preserves the redundancy patterns that compressors exploit. Document the intended stack order wherever you construct the chain so that the reader and writer always agree.
When NOT to use this
Use the Decorator pattern when the set of features is open-ended and their combinations are unpredictable. If you know at compile time that you will always have exactly two configurations — say, a plain connection and a TLS-wrapped one — two subclasses are simpler and easier to follow. Decorators add indirection; tracing a bug through four layers of wrapping in a debugger is genuinely painful.
Avoid it also when the interface is large. If DataStream exposed twenty methods, every
decorator would need to forward all twenty even if it cares about only one. That boilerplate is
the signal to look for a different approach — composition through a field rather than the
wrapping contract, or narrowing the interface so decorators stay lean.