← All Chapters

Chapter 9

Behavioral Design Patterns

Pages 301-354design-patternsbehavioralstrategystatetemplate
Ch 1Ch 2Ch 3Ch 4Ch 5Ch 6Ch 7Ch 8Ch 9Ch 10Ch 11Ch 12Ch 13

Chapter 9: Behavioral Design Patterns

Summary

Behavioral design patterns concern how objects interact and distribute responsibility. While creational patterns deal with construction and structural patterns deal with composition, behavioral patterns focus on communication between objects and algorithms that assign responsibilities.

The book notes that JavaScript's implementation of these patterns can be radically different from pure OO languages. The Iterator pattern uses protocols rather than class hierarchies, and the Middleware pattern has become such a standard in Node.js that it can be considered a pattern of its own (distinct from the GoF Chain of Responsibility).

This chapter covers six patterns: Strategy (swap algorithms), State (swap behavior based on internal state), Template (define an algorithm skeleton), Iterator (standardized traversal with native JS protocols), Middleware (processing pipelines), and Command (reify actions into objects).

Key Concepts

Strategy Pattern

ℹ️Info

Intent Enable a component (the context) to support variations in its logic by extracting the variable parts into separate, interchangeable objects called strategies. The context implements the common logic of a family of algorithms, while a strategy implements the mutable parts.

Multi-format Configuration Objects

The book's example: a Config class that can load/save configuration in different formats (JSON, INI, YAML) by delegating serialization to a strategy:

import { promises as fs } from 'fs'
import objectPath from 'object-path'

export class Config {
  constructor(formatStrategy) {                    // (1)
    this.data = {}
    this.formatStrategy = formatStrategy
  }

  get(configPath) {                                // (2)
    return objectPath.get(this.data, configPath)
  }

  set(configPath, value) {                         // (2)
    return objectPath.set(this.data, configPath, value)
  }

  async load(filePath) {                           // (3)
    console.log(`Deserializing from ${filePath}`)
    this.data = this.formatStrategy.deserialize(
      await fs.readFile(filePath, 'utf-8')
    )
  }

  async save(filePath) {                           // (3)
    console.log(`Serializing to ${filePath}`)
    await fs.writeFile(filePath,
      this.formatStrategy.serialize(this.data))
  }
}

Strategies implement the variable part:

import ini from 'ini'

export const iniStrategy = {
  deserialize: data => ini.parse(data),
  serialize: data => ini.stringify(data)
}

export const jsonStrategy = {
  deserialize: data => JSON.parse(data),
  serialize: data => JSON.stringify(data, null, '  ')
}

Usage: new Config(jsonStrategy) vs new Config(iniStrategy). The context is unchanged; only the strategy varies.

💡Tip

Passport.js -- real-world Strategy pattern Passport separates common authentication logic from the actual authentication step. Strategies implement specific schemes (OAuth, local DB, etc.). This allows supporting a virtually unlimited number of authentication services.

ℹ️Info

Strategy vs Adapter The Strategy pattern's structure looks similar to the Adapter pattern. But the adapter does not add behavior -- it just makes something available under another interface. In Strategy, both the context and the strategy implement different parts of an algorithm; both are essential.

State Pattern

ℹ️Info

Intent The State pattern is a specialization of the Strategy pattern where the strategy changes depending on the state of the context. Unlike Strategy where the selection is typically static, in State the strategy is dynamic and can change during the lifetime of the context.

Implementing a Basic Failsafe Socket

A TCP client that doesn't fail when connection is lost -- it queues data while offline and flushes it when the connection is re-established:

import { OfflineState } from './offlineState.js'
import { OnlineState } from './onlineState.js'

export class FailsafeSocket {
  constructor(options) {
    this.options = options
    this.queue = []
    this.currentState = null
    this.socket = null
    this.states = {
      offline: new OfflineState(this),
      online: new OnlineState(this)
    }
    this.changeState('offline')
  }

  changeState(state) {
    console.log(`Activating state: ${state}`)
    this.currentState = this.states[state]
    this.currentState.activate()
  }

  send(data) {
    this.currentState.send(data)      // delegate to current state
  }
}

OfflineState -- queues data, keeps retrying connection:

import jsonOverTcp from 'json-over-tcp-2'

export class OfflineState {
  constructor(failsafeSocket) {
    this.failsafeSocket = failsafeSocket
  }

  send(data) {
    this.failsafeSocket.queue.push(data)  // buffer while offline
  }

  activate() {
    const retry = () => {
      setTimeout(() => this.activate(), 1000)
    }
    console.log('Trying to connect...')
    this.failsafeSocket.socket = jsonOverTcp.connect(
      this.failsafeSocket.options,
      () => {
        console.log('Connection established')
        this.failsafeSocket.socket.removeListener('error', retry)
        this.failsafeSocket.changeState('online')
      }
    )
    this.failsafeSocket.socket.once('error', retry)
  }
}

OnlineState -- sends data immediately, flushes queue, transitions to offline on error:

export class OnlineState {
  constructor(failsafeSocket) {
    this.failsafeSocket = failsafeSocket
    this.hasDisconnected = false
  }

  send(data) {
    this.failsafeSocket.queue.push(data)
    this._safeWrite(data)
  }

  activate() {
    this.hasDisconnected = false
    for (const data of this.failsafeSocket.queue) {
      this._safeWrite(data)             // flush queued data
    }
    this.failsafeSocket.socket.once('error', () => {
      this.hasDisconnected = true
      this.failsafeSocket.changeState('offline')
    })
  }

  _safeWrite(data) {
    this.failsafeSocket.socket.write(data, (err) => {
      if (!this.hasDisconnected && !err) {
        this.failsafeSocket.queue.shift()
      }
    })
  }
}
⚠️Warning

State vs Strategy Strategy: the client selects the strategy externally, typically static. State: transitions happen internally based on the context's own logic, dynamic throughout lifetime. State objects are aware of each other and trigger transitions; strategies are independent and oblivious to each other.

Template Pattern

ℹ️Info

Intent Define the skeleton of an algorithm in a base class, deferring certain steps to subclasses. Template method lets subclasses redefine specific steps without changing the algorithm's structure. Uses inheritance.

The book reimplements Config as ConfigTemplate:

import { promises as fsPromises } from 'fs'
import objectPath from 'object-path'

export class ConfigTemplate {
  async load(file) {
    console.log(`Deserializing from ${file}`)
    this.data = this._deserialize(
      await fsPromises.readFile(file, 'utf-8'))
  }

  async save(file) {
    console.log(`Serializing to ${file}`)
    await fsPromises.writeFile(file, this._serialize(this.data))
  }

  get(path) { return objectPath.get(this.data, path) }
  set(path, value) { return objectPath.set(this.data, path, value) }

  _serialize() {
    throw new Error('_serialize() must be implemented')
  }

  _deserialize() {
    throw new Error('_deserialize() must be implemented')
  }
}

Concrete subclasses:

export class JsonConfig extends ConfigTemplate {
  _deserialize(data) { return JSON.parse(data) }
  _serialize(data) { return JSON.stringify(data, null, '  ') }
}

export class IniConfig extends ConfigTemplate {
  _deserialize(data) { return ini.parse(data) }
  _serialize(data) { return ini.stringify(data) }
}
💡Tip

Template vs Strategy With Template, the format logic is baked into the class itself via inheritance, rather than being chosen at runtime via composition. The complete component is determined the moment the concrete class is defined.

💡Tip

Template pattern in the wild We already saw this in Chapter 6: Node.js stream classes use Template. To create a custom stream, you extend the base class and implement _write(), _read(), _transform(), or _flush().

Iterator Pattern

The iterator is built into JavaScript as a protocol -- no class hierarchy needed.

The Iterator Protocol

An object is an iterator if it has a next() method returning { value, done }:

const A_CHAR_CODE = 65
const Z_CHAR_CODE = 90

function createAlphabetIterator() {
  let currCode = A_CHAR_CODE
  return {
    next() {
      const currChar = String.fromCodePoint(currCode)
      if (currCode > Z_CHAR_CODE) {
        return { done: true }
      }
      currCode++
      return { value: currChar, done: false }
    }
  }
}

The Iterable Protocol

An object is iterable if it has a [Symbol.iterator]() method that returns an iterator. This enables for...of, spread, destructuring:

class Range {
  constructor(start, end) {
    this.start = start
    this.end = end
  }

  [Symbol.iterator]() {
    let current = this.start
    const end = this.end
    return {
      next() {
        if (current <= end) {
          return { value: current++, done: false }
        }
        return { value: undefined, done: true }
      }
    }
  }
}

for (const n of new Range(1, 5)) { console.log(n) }
const arr = [...new Range(1, 3)] // [1, 2, 3]
ℹ️Info

Native interfaces using iterators for...of, Array.from(), spread (...), destructuring, Map, Set, Promise.all(), yield* -- all consume iterables.

Generators

Generator functions (function*) return generator objects that are both iterators and iterables:

function* range(start, end) {
  for (let i = start; i <= end; i++) {
    yield i
  }
}

for (const n of range(1, 5)) { console.log(n) }

Two-way communication via next(value):

function* twoWay() {
  const input = yield 'first output'
  yield `received: ${input}`
}

const g = twoWay()
g.next()          // { value: 'first output', done: false }
g.next('hello')   // { value: 'received: hello', done: false }

yield* delegates to another iterable:

function* concat(...iterables) {
  for (const it of iterables) {
    yield* it
  }
}

Async Iterators and Generators

async function* fetchPages(urls) {
  for (const url of urls) {
    const res = await fetch(url)
    yield await res.json()
  }
}

for await (const page of fetchPages(urls)) {
  console.log(page)
}

Async iterators use Symbol.asyncIterator and next() returns a Promise<{ value, done }>. Node.js readable streams implement Symbol.asyncIterator, so they can be consumed with for await...of.

Middleware Pattern

ℹ️Info

Intent Define a processing pipeline where each function (middleware) can pre-process input, post-process output, or delegate to the next middleware in the chain.

The book notes that Middleware tightly resembles the GoF Chain of Responsibility pattern, but its implementation in Node.js has become such a standard that it can be considered a pattern of its own.

Request --> [MW1] --> [MW2] --> [MW3] --> Handler
              ^                           |
              +-- Response flows back ----+

ZeroMQ Middleware Framework

The book demonstrates building a middleware framework around ZeroMQ messaging, showing that middleware is a general pattern -- not tied to HTTP:

class MiddlewareManager {
  constructor() {
    this.middlewares = []
  }

  use(fn) {
    this.middlewares.push(fn)
    return this
  }

  async execute(context) {
    let index = 0
    const next = async () => {
      if (index < this.middlewares.length) {
        const middleware = this.middlewares[index++]
        await middleware(context, next)
      }
    }
    await next()
    return context
  }
}

The middleware intercepts inbound/outbound ZeroMQ messages, handling concerns like JSON serialization, compression, or routing.

Command Pattern

ℹ️Info

Intent Encapsulate (materialize/reify) a method invocation as an object, containing all the information needed to execute the action: the target, the method, and the arguments. This allows the action to be executed later, queued, logged, serialized, or undone.

In Node.js, the Command pattern is often called the Task pattern:

function createTask(target, action, ...args) {
  return {
    target,
    action,
    args,
    execute() {
      return target[action](...args)
    },
    serialize() {
      return JSON.stringify({ action, args })
    }
  }
}
💡Tip

Real-world uses The Command/Task pattern enables: undo/redo stacks, job queues (Bull, Agenda), transaction logs, macro recording, and distributed command execution (serialize --> send --> deserialize --> execute on another machine).

Patterns at a Glance

PatternKey MechanismVaries ByNode.js Idiom
StrategyComposition -- delegate to strategy objectAlgorithmPassport strategies, winston transports
StateComposition -- delegate to state objectInternal state transitionsTCP socket states, connection pools
TemplateInheritance -- override abstract stepsSubclass implementationStream._read(), _write(), _transform()
IteratorProtocol -- next() -> {value, done}Data sourceGenerators, for...of, streams
MiddlewarePipeline -- next() chains functionsProcessing stepsExpress, Koa, fastify hooks
CommandReification -- action as objectDeferred executionJob queues, undo stacks

Mind Map

Connections

  • Previous: Chapter 8 -- Structural Design Patterns
  • Next: Chapter 10 -- Universal JavaScript
  • Related: Chapter 6 -- Streams (streams use Template pattern, implement async iterable)
  • Related: Chapter 3 -- Callbacks and Events (Observer pattern vs State pattern, middleware builds on callback chains)
  • Middleware is Node.js's evolution of the GoF Chain of Responsibility

22 quiz · 28 cards · 2 exercises · Ch 9 of 13