Chapter 8: Structural Design Patterns
Summary
Structural design patterns are concerned with the composition of objects and their relationships. This chapter covers three fundamental structural patterns: Proxy, Decorator, and Adapter. All three involve wrapping an object, but each has a distinct intent: proxies control access, decorators add behavior, and adapters convert interfaces.
The chapter also explores reactive programming concepts through the Change Observer pattern and introduces LevelDB/LevelUP, a key-value database commonly adopted in the Node.js ecosystem.
A recurring theme is that Node.js and JavaScript provide multiple implementation techniques for these patterns. You can use object composition (wrapping), object augmentation (monkey-patching), or the built-in ES2015 Proxy object. The choice depends on what you need to intercept and how much you want to preserve the original object.
Key Concepts
The Proxy Pattern
Definition A proxy is an object that controls access to another object called the subject. The proxy and the subject have an identical interface, and this allows us to swap one for the other transparently. The alternative name for this pattern is surrogate.
The proxy intercepts all or some operations on the subject, augmenting or complementing their behavior. It wraps an actual instance of the subject, preserving its internal state.
Common use cases:
- Data validation -- validate input before forwarding to the subject
- Security / access control -- verify authorization before allowing operations
- Caching -- keep an internal cache so proxied operations execute on the subject only if data is not yet cached
- Lazy initialization -- delay creating the subject until it is really necessary
- Logging -- intercept method invocations and record them as they happen
- Remote objects -- make a remote object appear local
The StackCalculator Example
The book uses a StackCalculator class throughout the proxy section:
class StackCalculator {
constructor() {
this.stack = []
}
putValue(value) { this.stack.push(value) }
getValue() { return this.stack.pop() }
peekValue() { return this.stack[this.stack.length - 1] }
clear() { this.stack = [] }
divide() {
const divisor = this.getValue()
const dividend = this.getValue()
const result = dividend / divisor
this.putValue(result)
return result
}
multiply() {
const multiplicand = this.getValue()
const multiplier = this.getValue()
const result = multiplier * multiplicand
this.putValue(result)
return result
}
}
In JavaScript, dividing by 0 returns Infinity rather than throwing. The goal
of the proxy is to make divide() throw an explicit error for division by zero.
Proxy Implementation Techniques
1. Object Composition
class SafeCalculator {
constructor(calculator) {
this.calculator = calculator
}
// proxied method
divide() {
const divisor = this.calculator.peekValue()
if (divisor === 0) {
throw Error('Division by 0')
}
return this.calculator.divide()
}
// delegated methods
putValue(value) { return this.calculator.putValue(value) }
getValue() { return this.calculator.getValue() }
peekValue() { return this.calculator.peekValue() }
clear() { return this.calculator.clear() }
multiply() { return this.calculator.multiply() }
}
The proxy creates a new object, holds a reference to the subject, and delegates calls. The original subject is preserved untouched. Downside: you must manually delegate every method. The book also shows a factory function form:
function createSafeCalculator(calculator) {
return {
divide() {
const divisor = calculator.peekValue()
if (divisor === 0) throw Error('Division by 0')
return calculator.divide()
},
putValue(value) { return calculator.putValue(value) },
getValue() { return calculator.getValue() },
// ... all other methods delegated
}
}
The delegates library
Having to delegate many methods for complex classes is tedious. The book
mentions the delegates library (nodejsdp.link/delegates) which generates
delegation methods automatically. A more modern alternative is the built-in
Proxy object.
2. Object Augmentation (Monkey-Patching)
function patchToSafeCalculator(calculator) {
const divideOrig = calculator.divide
calculator.divide = () => {
const divisor = calculator.peekValue()
if (divisor === 0) {
throw Error('Division by 0')
}
return divideOrig.apply(calculator)
}
return calculator
}
Directly replaces the method on the subject. Simple -- you only need to patch
the methods you care about (no need to delegate multiply() etc.). But the
original object is mutated.
Mutation danger The book explicitly warns: mutations should be avoided when the subject is shared with other parts of the codebase. If you monkey-patch the calculator, dividing by zero on the original instance will now throw too -- the original behavior is lost. Use this technique only in controlled/private scopes.
3. Built-in Proxy Object (ES2015)
const safeCalculatorHandler = {
get: (target, property) => {
if (property === 'divide') {
return function () {
const divisor = target.peekValue()
if (divisor === 0) {
throw Error('Division by 0')
}
return target.divide()
}
}
return target[property]
}
}
const calculator = new StackCalculator()
const safeCalculator = new Proxy(calculator, safeCalculatorHandler)
The most powerful technique. The handler defines traps that intercept fundamental operations. Key advantages:
- Intercepts property access, not just method calls
- Automatically delegates all non-intercepted properties (
return target[property]) - Inherits the prototype of the subject:
safeCalculator instanceof StackCalculatoristrue - Does not mutate the subject
Cannot be polyfilled The Proxy object cannot be fully transpiled or polyfilled. Some traps can only be implemented at the runtime level. Something to be aware of for old browsers or old Node.js versions.
Comparison of Proxy Techniques
| Aspect | Composition | Augmentation | Built-in Proxy |
|---|---|---|---|
| Original preserved | Yes | No | Yes |
| Intercepts property access | No | No | Yes (get/set traps) |
| Separate wrapper object | Yes | No | Yes |
| Must delegate all methods | Yes | No | No |
| Performance overhead | Minimal | None | Slight (trap dispatch) |
instanceof works | No (unless same class) | Yes | Yes |
Creating a Logging Writable Stream
A practical proxy that wraps a Writable stream to log everything being written:
export function createLoggingWritable(writable) {
return new Proxy(writable, {
get(target, propKey, receiver) {
if (propKey === 'write') {
return function (...args) {
const [chunk] = args
console.log('Writing', chunk)
return writable.write(...args)
}
}
return target[propKey]
}
})
}
Usage:
const writable = createWriteStream('test.txt')
const writableProxy = createLoggingWritable(writable)
writableProxy.write('First chunk') // logs: Writing First chunk
writableProxy.write('Second chunk') // logs: Writing Second chunk
writable.write('This is not logged') // no log -- bypasses proxy
writableProxy.end()
Change Observer with Proxy
The Change Observer pattern is a design pattern in which an object notifies
observers of any state changes. The book uses a set trap to create observable
objects:
export function createObservable(target, observer) {
const observable = new Proxy(target, {
set(obj, prop, value) {
if (value !== obj[prop]) {
const prev = obj[prop]
obj[prop] = value
observer({ prop, prev, curr: value })
}
return true
}
})
return observable
}
The invoice example auto-recalculates the total:
function calculateTotal(invoice) {
return invoice.subtotal - invoice.discount + invoice.tax
}
const invoice = { subtotal: 100, discount: 10, tax: 20 }
const obsInvoice = createObservable(invoice, ({ prop, prev, curr }) => {
obsInvoice.total = calculateTotal(obsInvoice)
console.log(`${prop}: ${prev} -> ${curr} (total: ${obsInvoice.total})`)
})
obsInvoice.total = calculateTotal(obsInvoice)
obsInvoice.subtotal = 200 // auto-recalculates total
obsInvoice.discount = 20 // auto-recalculates total
Change Observer vs Observer Pattern The Change Observer pattern detects property mutations on a specific object. The Observer pattern (Ch3) uses EventEmitter to propagate events through the system. They are different patterns despite similar names.
The Decorator Pattern
Definition A decorator dynamically augments the behavior of an existing object. Unlike the proxy, which controls access without changing the interface, the decorator adds new methods or enhances existing ones.
Same three implementation techniques as Proxy (composition, augmentation, built-in Proxy), but the intent is to extend the object rather than merely control access to it.
Decorating a LevelUP Database
LevelUP is a Node.js wrapper around LevelDB (a key-value store by Google).
The decorator pattern fits naturally because you can wrap db.put() to add
validation without modifying LevelUP's source:
function levelUpCheck(db) {
const origPut = db.put
db.put = function (key, val, opts, cb) {
if (typeof val !== 'object' || !val.timestamp) {
return process.nextTick(
() => cb(new Error('Objects must have a timestamp'))
)
}
origPut.call(this, key, val, opts, cb)
}
return db
}
LevelUP Plugin Pattern
LevelUP's ecosystem relies heavily on decoration. Plugins receive a db
instance and augment it. Decorators can be stacked -- each wraps the result
of the previous:
const db = pluginB(pluginA(level('./mydb')))
The Line Between Proxy and Decorator
The Distinction Is About Intent The implementation techniques are identical. The difference is semantic:
- Proxy: same interface, controls access (validation, caching, logging)
- Decorator: augments or extends the interface (adds new methods, enhances behavior)
In practice, many implementations blur the line. The key question: are you adding new capabilities (decorator) or controlling existing ones (proxy)?
The Adapter Pattern
Definition An adapter converts the interface of a component (the adaptee) into a different interface that the client expects. It lets objects with incompatible interfaces work together.
Unlike proxy and decorator, the adapter changes the interface. The client calls adapter methods (interface B), and the adapter translates them into calls on the adaptee (interface A).
LevelUP Through the Filesystem API
A concrete adapter example: making LevelUP's key-value store accessible through
Node.js fs-like methods:
import { resolve } from 'path'
export function createFSAdapter(db) {
return {
readFile(filename, options, callback) {
if (typeof options === 'function') {
callback = options
}
db.get(resolve(filename), { valueEncoding: 'binary' },
(err, value) => {
if (err) {
if (err.type === 'NotFoundError') {
err = new Error(`ENOENT, open '${filename}'`)
err.code = 'ENOENT'
err.errno = 34
err.path = filename
}
return callback(err)
}
callback(null, value)
}
)
},
writeFile(filename, contents, options, callback) {
if (typeof options === 'function') {
callback = options
}
db.put(resolve(filename), contents, {
valueEncoding: 'binary'
}, callback)
}
}
}
The adapter even translates LevelUP's NotFoundError into the familiar
ENOENT error code that Node.js fs consumers expect.
Structural Pattern Selection Guide
Do you want to...
|-- Control access to an object (same interface)? --> Proxy
|-- Add new behavior to an object? --> Decorator
|-- Convert one interface to another? --> Adapter
Mind Map
Connections
- Previous: Chapter 7 -- Creational Design Patterns
- Next: Chapter 9 -- Behavioral Design Patterns
- Change Observer (Proxy set trap) vs Observer pattern in Chapter 3
- Streams in Chapter 6 are often proxied/decorated (createLoggingWritable)
- LevelUP plugin decoration is a real-world Node.js pattern