← All Chapters

Chapter 8

Structural Design Patterns

Pages 269-300structural-patternsproxydecoratoradapterdesign-patterns
Ch 1Ch 2Ch 3Ch 4Ch 5Ch 6Ch 7Ch 8Ch 9Ch 10Ch 11Ch 12Ch 13

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

ℹ️Info

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
  }
}
💡Tip

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.

⚠️Warning

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 StackCalculator is true
  • Does not mutate the subject
⚠️Warning

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

AspectCompositionAugmentationBuilt-in Proxy
Original preservedYesNoYes
Intercepts property accessNoNoYes (get/set traps)
Separate wrapper objectYesNoYes
Must delegate all methodsYesNoNo
Performance overheadMinimalNoneSlight (trap dispatch)
instanceof worksNo (unless same class)YesYes

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
ℹ️Info

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

ℹ️Info

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

⚠️Warning

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

ℹ️Info

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

16 quiz · 22 cards · 2 exercises · Ch 8 of 13