Behavioral Design Patterns
Behavioral Design Patterns
Behavioral patterns focus on communication between objects—how they interact and distribute responsibilities. These patterns are essential for writing flexible, maintainable Node.js applications.
Strategy Pattern
What is it? A pattern that defines a family of algorithms, encapsulates each one, and makes them interchangeable at runtime.
Why do we need it? When you have multiple ways to do the same thing:
- Multiple payment methods (credit card, PayPal, crypto)
- Multiple sorting algorithms (by name, date, price)
- Multiple compression formats (gzip, brotli, deflate)
- Multiple authentication strategies (JWT, OAuth, API key)
Without Strategy, you end up with giant if-else chains that violate the Open/Closed Principle (code should be open for extension, closed for modification).
How it works:
┌─────────────┐
│ Context │ Strategy Interface
│ │──────────┬──────────┬──────────┐
│ strategy.exec │ │ │ │
└─────────────┘ ▼ ▼ ▼
Strategy A Strategy B Strategy CThe context doesn't know which strategy it's using—it just calls execute(). You can swap strategies at runtime.
The Problem
// Without Strategy: hard to extend, violates open/closed principle
function processPayment(amount, method) {
if (method === "creditCard") {
// Credit card processing logic
} else if (method === "paypal") {
// PayPal processing logic
} else if (method === "crypto") {
// Crypto processing logic
}
// Adding new methods requires modifying this function
}The Solution
// Strategy interface
const paymentStrategies = {
creditCard: {
async process(amount, details) {
console.log(`Processing $${amount} via credit card`);
return { success: true, transactionId: "CC-123" };
},
},
paypal: {
async process(amount, details) {
console.log(`Processing $${amount} via PayPal`);
return { success: true, transactionId: "PP-456" };
},
},
crypto: {
async process(amount, details) {
console.log(`Processing $${amount} in crypto`);
return { success: true, transactionId: "CRYPTO-789" };
},
},
};
class PaymentProcessor {
constructor(strategy) {
this.strategy = strategy;
}
setStrategy(strategy) {
this.strategy = strategy;
}
async processPayment(amount, details) {
if (!this.strategy) {
throw new Error("Payment strategy not set");
}
return this.strategy.process(amount, details);
}
}
// Usage
const processor = new PaymentProcessor(paymentStrategies.creditCard);
await processor.processPayment(100, { cardNumber: "..." });
// Switch strategy at runtime
processor.setStrategy(paymentStrategies.paypal);
await processor.processPayment(50, { email: "..." });Compression Strategy
const zlib = require("zlib");
const { promisify } = require("util");
const compressionStrategies = {
gzip: {
compress: promisify(zlib.gzip),
decompress: promisify(zlib.gunzip),
extension: ".gz",
},
deflate: {
compress: promisify(zlib.deflate),
decompress: promisify(zlib.inflate),
extension: ".deflate",
},
brotli: {
compress: promisify(zlib.brotliCompress),
decompress: promisify(zlib.brotliDecompress),
extension: ".br",
},
none: {
compress: async (data) => data,
decompress: async (data) => data,
extension: "",
},
};
class FileCompressor {
constructor(strategy = compressionStrategies.gzip) {
this.strategy = strategy;
}
async compress(data) {
return this.strategy.compress(Buffer.from(data));
}
async decompress(data) {
const result = await this.strategy.decompress(data);
return result.toString();
}
getExtension() {
return this.strategy.extension;
}
}
// Usage
const compressor = new FileCompressor(compressionStrategies.brotli);
const compressed = await compressor.compress("Hello, World!");
const original = await compressor.decompress(compressed);Functional Strategy
In JavaScript, strategies can simply be functions:
// Sorting strategies
const sortStrategies = {
byName: (a, b) => a.name.localeCompare(b.name),
byDate: (a, b) => new Date(a.date) - new Date(b.date),
byPrice: (a, b) => a.price - b.price,
byPriceDesc: (a, b) => b.price - a.price,
};
function sortProducts(products, strategy = sortStrategies.byName) {
return [...products].sort(strategy);
}
// Usage
const products = [
{ name: "Apple", price: 1.5, date: "2024-01-15" },
{ name: "Banana", price: 0.5, date: "2024-01-10" },
{ name: "Cherry", price: 3.0, date: "2024-01-20" },
];
sortProducts(products, sortStrategies.byPrice);
sortProducts(products, sortStrategies.byDate);State Pattern
What is it? A pattern that allows an object to change its behavior when its internal state changes. The object appears to change its class.
Why do we need it? Many objects have distinct modes:
- Orders: pending → confirmed → shipped → delivered
- TCP connections: listening → established → closed
- UI components: loading → error → success
- Games: menu → playing → paused → game over
Without State pattern, you get methods full of if-else checking current state:
// Without State - messy and error-prone
class Order {
ship() {
if (this.state === "pending") throw new Error("Confirm first");
if (this.state === "shipped") throw new Error("Already shipped");
if (this.state === "cancelled") throw new Error("Order cancelled");
// ... actual logic
}
}With State pattern, each state is an object that handles its own transitions:
// With State - clean and self-documenting
class Order {
ship() {
this.state.ship(this); // State handles it
}
}State Machine
class TrafficLight {
constructor() {
this.states = {
green: {
color: "green",
duration: 30000,
next: "yellow",
},
yellow: {
color: "yellow",
duration: 5000,
next: "red",
},
red: {
color: "red",
duration: 30000,
next: "green",
},
};
this.currentState = this.states.red;
}
getColor() {
return this.currentState.color;
}
change() {
const nextStateName = this.currentState.next;
this.currentState = this.states[nextStateName];
console.log(`Light changed to ${this.currentState.color}`);
return this;
}
async run() {
while (true) {
console.log(
`${this.currentState.color} for ${this.currentState.duration}ms`,
);
await new Promise((r) => setTimeout(r, this.currentState.duration));
this.change();
}
}
}Order State Machine
const orderStates = {
pending: {
name: "pending",
confirm(order) {
order.setState(orderStates.confirmed);
console.log("Order confirmed");
},
cancel(order) {
order.setState(orderStates.cancelled);
console.log("Order cancelled");
},
ship(order) {
throw new Error("Cannot ship pending order");
},
},
confirmed: {
name: "confirmed",
confirm(order) {
throw new Error("Order already confirmed");
},
cancel(order) {
order.setState(orderStates.cancelled);
console.log("Order cancelled");
},
ship(order) {
order.setState(orderStates.shipped);
console.log("Order shipped");
},
},
shipped: {
name: "shipped",
confirm(order) {
throw new Error("Order already shipped");
},
cancel(order) {
throw new Error("Cannot cancel shipped order");
},
ship(order) {
throw new Error("Order already shipped");
},
deliver(order) {
order.setState(orderStates.delivered);
console.log("Order delivered");
},
},
delivered: {
name: "delivered",
// All actions throw errors - terminal state
confirm() {
throw new Error("Order completed");
},
cancel() {
throw new Error("Order completed");
},
ship() {
throw new Error("Order completed");
},
},
cancelled: {
name: "cancelled",
// Terminal state
confirm() {
throw new Error("Order cancelled");
},
cancel() {
throw new Error("Already cancelled");
},
ship() {
throw new Error("Order cancelled");
},
},
};
class Order {
constructor(items) {
this.items = items;
this.state = orderStates.pending;
}
setState(state) {
this.state = state;
}
getState() {
return this.state.name;
}
confirm() {
this.state.confirm(this);
return this;
}
cancel() {
this.state.cancel(this);
return this;
}
ship() {
this.state.ship(this);
return this;
}
deliver() {
this.state.deliver(this);
return this;
}
}
// Usage
const order = new Order(["item1", "item2"]);
console.log(order.getState()); // 'pending'
order.confirm(); // 'Order confirmed'
console.log(order.getState()); // 'confirmed'
order.ship(); // 'Order shipped'
console.log(order.getState()); // 'shipped'
order.cancel(); // Error: Cannot cancel shipped orderXState-style State Machine
function createMachine(config) {
return {
current: config.initial,
states: config.states,
transition(event) {
const currentState = this.states[this.current];
const transition = currentState.on?.[event];
if (!transition) {
console.warn(`No transition for ${event} in state ${this.current}`);
return this;
}
// Run exit action
if (currentState.exit) {
currentState.exit();
}
// Update state
this.current = transition.target || transition;
// Run entry action
const newState = this.states[this.current];
if (newState.entry) {
newState.entry();
}
return this;
},
getState() {
return this.current;
},
};
}
// Usage
const loginMachine = createMachine({
initial: "idle",
states: {
idle: {
on: { SUBMIT: "loading" },
},
loading: {
entry: () => console.log("Starting login..."),
on: {
SUCCESS: "authenticated",
ERROR: "error",
},
},
authenticated: {
entry: () => console.log("Welcome!"),
on: { LOGOUT: "idle" },
},
error: {
entry: () => console.log("Login failed"),
on: { RETRY: "loading" },
},
},
});
loginMachine.transition("SUBMIT"); // 'Starting login...'
loginMachine.transition("SUCCESS"); // 'Welcome!'
console.log(loginMachine.getState()); // 'authenticated'Iterator Pattern
What is it? A pattern that provides a way to access elements of a collection sequentially without exposing its underlying structure.
Why do we need it?
- Traverse trees, graphs, linked lists with simple loops
- Provide a uniform interface for different data structures
- Enable lazy evaluation (generate values on-demand)
- Support infinite sequences without infinite memory
JavaScript has built-in support via the Iterator protocol (Symbol.iterator) and generators (function*).
Built-in Iterators
Built-in Iterators
JavaScript has built-in iteration protocols:
// Arrays are iterable
for (const item of [1, 2, 3]) {
console.log(item);
}
// Maps are iterable
const map = new Map([
["a", 1],
["b", 2],
]);
for (const [key, value] of map) {
console.log(key, value);
}
// Strings are iterable
for (const char of "hello") {
console.log(char);
}Custom Iterators
class Range {
constructor(start, end, step = 1) {
this.start = start;
this.end = end;
this.step = step;
}
[Symbol.iterator]() {
let current = this.start;
const end = this.end;
const step = this.step;
return {
next() {
if (current <= end) {
const value = current;
current += step;
return { value, done: false };
}
return { done: true };
},
};
}
}
// Usage
const range = new Range(1, 10, 2);
for (const num of range) {
console.log(num); // 1, 3, 5, 7, 9
}
// Works with spread
console.log([...range]); // [1, 3, 5, 7, 9]Generators
Generators make creating iterators easy:
function* range(start, end, step = 1) {
for (let i = start; i <= end; i += step) {
yield i;
}
}
for (const num of range(1, 10, 2)) {
console.log(num); // 1, 3, 5, 7, 9
}Paginated API Iterator
async function* fetchAllPages(url, pageSize = 100) {
let page = 1;
let hasMore = true;
while (hasMore) {
const response = await fetch(`${url}?page=${page}&limit=${pageSize}`);
const data = await response.json();
for (const item of data.items) {
yield item;
}
hasMore = data.hasMore;
page++;
}
}
// Usage
for await (const user of fetchAllPages("/api/users")) {
console.log(user);
// Automatically fetches next page when needed
}
// Collect all (be careful with large datasets)
const allUsers = [];
for await (const user of fetchAllPages("/api/users")) {
allUsers.push(user);
}Tree Traversal
class TreeNode {
constructor(value, children = []) {
this.value = value;
this.children = children;
}
// Depth-first traversal
*[Symbol.iterator]() {
yield this.value;
for (const child of this.children) {
yield* child; // Delegate to child's iterator
}
}
// Breadth-first traversal
*bfs() {
const queue = [this];
while (queue.length > 0) {
const node = queue.shift();
yield node.value;
queue.push(...node.children);
}
}
}
// Build tree
const tree = new TreeNode("root", [
new TreeNode("a", [new TreeNode("a1"), new TreeNode("a2")]),
new TreeNode("b", [new TreeNode("b1")]),
]);
// DFS (default iterator)
console.log([...tree]); // ['root', 'a', 'a1', 'a2', 'b', 'b1']
// BFS
console.log([...tree.bfs()]); // ['root', 'a', 'b', 'a1', 'a2', 'b1']Observer Pattern
What is it? A pattern that defines a one-to-many dependency between objects. When one object (the subject) changes state, all its dependents (observers) are notified automatically.
Why do we need it?
- Decoupling: The subject doesn't need to know about its observers
- Dynamic subscriptions: Observers can subscribe/unsubscribe at runtime
- Broadcast communication: One event notifies many listeners
- Event-driven architecture: React to changes instead of polling
Real-world examples:
- DOM events (
element.addEventListener) - Node.js EventEmitter
- Redux store subscriptions
- WebSocket message handlers
- Database change notifications
┌─────────────┐ ┌────────────┐
│ Subject │ ── notify ──► │ Observer 1 │
│ (store) │ └────────────┘
│ │ ┌────────────┐
│ state │ ── notify ──► │ Observer 2 │
│ changed! │ └────────────┘
│ │ ┌────────────┐
│ │ ── notify ──► │ Observer 3 │
└─────────────┘ └────────────┘EventEmitter is Observer
Node.js EventEmitter is the observer pattern:
const EventEmitter = require("events");
class Store extends EventEmitter {
constructor() {
super();
this.state = {};
}
setState(key, value) {
const oldValue = this.state[key];
this.state[key] = value;
this.emit("change", key, value, oldValue);
this.emit(`change:${key}`, value, oldValue);
}
getState(key) {
return this.state[key];
}
}
// Usage
const store = new Store();
// Observers
store.on("change", (key, newVal, oldVal) => {
console.log(`State changed: ${key} = ${newVal} (was ${oldVal})`);
});
store.on("change:user", (user) => {
console.log("User changed:", user);
});
// Trigger observers
store.setState("user", { name: "Alice" });
store.setState("theme", "dark");Pub/Sub Implementation
class PubSub {
constructor() {
this.subscribers = new Map();
}
subscribe(event, callback) {
if (!this.subscribers.has(event)) {
this.subscribers.set(event, new Set());
}
this.subscribers.get(event).add(callback);
// Return unsubscribe function
return () => {
this.subscribers.get(event).delete(callback);
};
}
publish(event, data) {
if (!this.subscribers.has(event)) return;
for (const callback of this.subscribers.get(event)) {
callback(data);
}
}
// Subscribe to event once
once(event, callback) {
const unsubscribe = this.subscribe(event, (data) => {
unsubscribe();
callback(data);
});
return unsubscribe;
}
}
// Usage
const pubsub = new PubSub();
const unsubscribe = pubsub.subscribe("user:login", (user) => {
console.log("User logged in:", user.name);
});
pubsub.once("app:ready", () => {
console.log("App is ready!");
});
pubsub.publish("user:login", { name: "Alice" });
pubsub.publish("app:ready");
pubsub.publish("app:ready"); // Nothing happens - already fired once
unsubscribe(); // Remove subscriptionReactive Store with Observer
class ReactiveStore {
constructor(initialState = {}) {
this.state = initialState;
this.listeners = new Map();
this.computedCache = new Map();
}
subscribe(path, callback) {
if (!this.listeners.has(path)) {
this.listeners.set(path, new Set());
}
this.listeners.get(path).add(callback);
// Immediately call with current value
callback(this.get(path));
return () => this.listeners.get(path).delete(callback);
}
get(path) {
return path.split(".").reduce((obj, key) => obj?.[key], this.state);
}
set(path, value) {
const keys = path.split(".");
const lastKey = keys.pop();
const target = keys.reduce((obj, key) => {
if (!obj[key]) obj[key] = {};
return obj[key];
}, this.state);
target[lastKey] = value;
// Notify all affected listeners
this.notify(path);
}
notify(changedPath) {
for (const [path, callbacks] of this.listeners) {
if (path.startsWith(changedPath) || changedPath.startsWith(path)) {
const value = this.get(path);
for (const callback of callbacks) {
callback(value);
}
}
}
}
computed(name, fn, deps) {
const compute = () => {
const values = deps.map((dep) => this.get(dep));
return fn(...values);
};
// Subscribe to dependencies
for (const dep of deps) {
this.subscribe(dep, () => {
this.computedCache.set(name, compute());
this.notify(name);
});
}
// Initial computation
this.computedCache.set(name, compute());
}
}
// Usage
const store = new ReactiveStore({
user: { firstName: "John", lastName: "Doe" },
settings: { theme: "dark" },
});
store.computed("fullName", (first, last) => `${first} ${last}`, [
"user.firstName",
"user.lastName",
]);
store.subscribe("user.firstName", (name) => {
console.log("First name:", name);
});
store.subscribe("fullName", (name) => {
console.log("Full name:", name);
});
store.set("user.firstName", "Jane");
// Logs: First name: Jane
// Logs: Full name: Jane DoeSummary
Behavioral patterns manage object communication:
| Pattern | Purpose | Use When |
|---|---|---|
| Strategy | Interchangeable algorithms | Multiple ways to do the same thing |
| State | Behavior varies by state | Object has distinct modes |
| Iterator | Sequential access | Custom collection traversal |
| Observer | Event notification | Decoupled communication |
Key takeaways:
- Strategy replaces conditionals with composition
- State machines prevent invalid state transitions
- Generators simplify iterator creation
- EventEmitter is Node.js's built-in observer pattern
- Pub/Sub decouples publishers from subscribers
Note
These patterns are building blocks. Combine them—use State to manage modes, Strategy for behavior within each state, and Observer to notify of changes.