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
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.
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.
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
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()
}
})
}
}
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
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) }
}
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.
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]
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
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
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 })
}
}
}
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
| Pattern | Key Mechanism | Varies By | Node.js Idiom |
|---|---|---|---|
| Strategy | Composition -- delegate to strategy object | Algorithm | Passport strategies, winston transports |
| State | Composition -- delegate to state object | Internal state transitions | TCP socket states, connection pools |
| Template | Inheritance -- override abstract steps | Subclass implementation | Stream._read(), _write(), _transform() |
| Iterator | Protocol -- next() -> {value, done} | Data source | Generators, for...of, streams |
| Middleware | Pipeline -- next() chains functions | Processing steps | Express, Koa, fastify hooks |
| Command | Reification -- action as object | Deferred execution | Job 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