Singleton, Observer, Factory, Strategy, Proxy — some patterns aged perfectly into modern JavaScript, some are obsolete. Here’s an honest audit with ES2026 implementations.
Design patterns were formalized in 1994 in the Gang of Four book — written for C++ and Smalltalk, in a world before closures, modules, or reactive primitives existed. JavaScript developers adopted them wholesale in the 2000s and 2010s, often cargo-culting patterns that solved problems JavaScript simply doesn’t have.
In 2026, with ES modules, native Proxy, optional chaining, class fields, Signals on the horizon, and TypeScript ubiquitous, it’s time for an honest audit. Some patterns are more relevant than ever. Some were always a workaround for missing language features and should be retired. And some have quietly transformed into something unrecognizable from their original form.
Here’s the audit — pattern by pattern, with honest verdicts and modern implementations.
The Patterns That Aged Perfectly
1. Observer / Pub-Sub — Still Essential, Completely Transformed
Original problem: Allow objects to notify other objects about state changes without tight coupling.
Verdict: ✅ More relevant than ever — but you probably use it without calling it that.
The Observer pattern is the backbone of modern JavaScript. Every event listener, every reactive framework, every RxJS stream, every Vue watch, every React useEffect dependency — all Observer pattern. It didn’t die. It became the water you swim in.
The raw implementation has evolved significantly:
// 2010s implementation — manual subscription management
class EventEmitter {
constructor() {
this._events = {}
}
on(event, listener) {
if (!this._events[event]) this._events[event] = []
this._events[event].push(listener)
return () => this.off(event, listener) // return unsubscribe fn
}
off(event, listener) {
this._events[event] = (this._events[event] || [])
.filter(l => l !== listener)
}
emit(event, ...args) {
(this._events[event] || []).forEach(l => l(...args))
}
}
// 2026 — use the platform: native EventTarget
class Store extends EventTarget {
#state
constructor(initialState) {
super()
this.#state = initialState
}
setState(updater) {
const prev = this.#state
this.#state = typeof updater === 'function'
? updater(prev)
: { ...prev, ...updater }
this.dispatchEvent(new CustomEvent('change', {
detail: { prev, next: this.#state }
}))
}
getState() {
return this.#state
}
}
const store = new Store({ count: 0 })
// Subscribe
store.addEventListener('change', ({ detail }) => {
console.log('State changed:', detail.next)
})
// Using AbortController for cleanup — the modern unsubscribe
const controller = new AbortController()
store.addEventListener('change', handler, { signal: controller.signal })
controller.abort() // unsubscribes cleanly
The EventTarget + AbortController pattern is the 2026 way. No libraries needed, works in every environment, and plays nicely with the using keyword for automatic cleanup.
When to use: Any time you need decoupled communication between parts of your application — which is always.
2. Factory — Still Solid, Simplified by Modern JS
Original problem: Create objects without specifying their exact class. Decouple creation logic from usage.
Verdict: ✅ Relevant — but much simpler than the classic OOP version.
The Gang of Four Factory Method required abstract classes and inheritance hierarchies. In modern JavaScript, a factory is often just a function:
// Overcomplicated 2000s version — class hierarchy for no reason
class AnimalFactory {
createAnimal(type) {
switch(type) {
case 'dog': return new Dog()
case 'cat': return new Cat()
}
}
}
// 2026 — just a function
function createAnimal(type, config = {}) {
const base = {
speak() { return `${this.name} makes a sound` },
...config,
}
const animals = {
dog: { ...base, name: 'Dog', speak() { return 'Woof!' } },
cat: { ...base, name: 'Cat', speak() { return 'Meow!' } },
fish: { ...base, name: 'Fish', speak() { return '...' } },
}
if (!animals[type]) throw new Error(`Unknown animal type: ${type}`)
return Object.freeze(animals[type])
}
const dog = createAnimal('dog')
dog.speak() // 'Woof!'
The real power of Factory in 2026 is in async factories and dependency injection:
// Async factory — extremely common pattern for DB connections, API clients
async function createDatabase(config) {
const client = await connectToDatabase(config)
await client.runMigrations()
return {
query: (sql, params) => client.execute(sql, params),
transaction: (fn) => client.transaction(fn),
close: () => client.disconnect(),
}
}
// Factory for environment-specific implementations
function createStorage(env = 'browser') {
if (env === 'browser') {
return {
get: (key) => JSON.parse(localStorage.getItem(key)),
set: (key, val) => localStorage.setItem(key, JSON.stringify(val)),
delete: (key) => localStorage.removeItem(key),
}
}
if (env === 'node') {
const cache = new Map()
return {
get: (key) => cache.get(key),
set: (key, val) => cache.set(key, val),
delete: (key) => cache.delete(key),
}
}
if (env === 'cloudflare') {
return {
get: (key) => KV_NAMESPACE.get(key, { type: 'json' }),
set: (key, val) => KV_NAMESPACE.put(key, JSON.stringify(val)),
delete: (key) => KV_NAMESPACE.delete(key),
}
}
}
// Use the same interface regardless of environment
const storage = createStorage(process.env.RUNTIME)
await storage.set('user', { id: 1, name: 'Alice' })
When to use: Creating objects with complex initialization, when the concrete type depends on configuration or environment, or when you want to return an interface rather than a class instance.
3. Strategy — More Useful Than Ever
Original problem: Define a family of algorithms, encapsulate each one, make them interchangeable.
Verdict: ✅ Excellent pattern — first-class functions make it trivial.
In classical OOP, Strategy required interfaces and concrete implementations. In JavaScript, a strategy is just a function. This makes the pattern dramatically easier to use:
// Classic OOP version — tons of boilerplate
class Sorter {
constructor(strategy) { this.strategy = strategy }
sort(data) { return this.strategy.sort(data) }
}
class BubbleSortStrategy { sort(data) { /* ... */ } }
class QuickSortStrategy { sort(data) { /* ... */ } }
// 2026 — strategies are just functions
const strategies = {
bubble: (arr) => { /* bubble sort */ return arr },
quick: (arr) => { /* quick sort */ return arr },
merge: (arr) => { /* merge sort */ return arr },
native: (arr) => [...arr].sort(),
}
function sort(data, strategy = 'native') {
const fn = strategies[strategy]
if (!fn) throw new Error(`Unknown strategy: ${strategy}`)
return fn(data)
}
Real-world 2026 example — payment processing:
// Each payment method is a strategy
const paymentStrategies = {
stripe: async ({ amount, currency, token }) => {
const charge = await stripe.charges.create({ amount, currency, source: token })
return { success: true, transactionId: charge.id, provider: 'stripe' }
},
paypal: async ({ amount, currency, token }) => {
const order = await paypal.orders.create({
intent: 'CAPTURE',
purchase_units: [{ amount: { value: amount, currency_code: currency } }]
})
return { success: true, transactionId: order.id, provider: 'paypal' }
},
crypto: async ({ amount, currency, walletAddress }) => {
const tx = await cryptoGateway.send({ amount, currency, to: walletAddress })
return { success: true, transactionId: tx.hash, provider: 'crypto' }
},
}
async function processPayment(method, paymentData) {
const strategy = paymentStrategies[method]
if (!strategy) throw new Error(`Payment method not supported: ${method}`)
try {
return await strategy(paymentData)
} catch (err) {
return { success: false, error: err.message, provider: method }
}
}
// Clean call site — strategy selected at runtime
const result = await processPayment(user.preferredPaymentMethod, {
amount: 4999,
currency: 'USD',
token: paymentToken,
})
When to use: Anywhere you have multiple implementations of the same operation that need to be swappable — sorting, validation, payment, formatting, compression, authentication.
4. Proxy Pattern — Native Language Feature Since ES2015
Original problem: Provide a surrogate or placeholder for another object to control access.
Verdict: ✅ Native to the language — use it directly.
The JavaScript Proxy object implements the pattern at the language level. You don’t need to build a Proxy pattern — you just use Proxy:
// Validation proxy — intercept property sets
function createValidatedObject(target, validators) {
return new Proxy(target, {
set(obj, prop, value) {
const validate = validators[prop]
if (validate) {
const error = validate(value)
if (error) throw new TypeError(`${prop}: ${error}`)
}
obj[prop] = value
return true
},
get(obj, prop) {
return obj[prop]
}
})
}
const user = createValidatedObject({}, {
age: (v) => (typeof v !== 'number' || v < 0) ? 'Must be a non-negative number' : null,
email: (v) => !v.includes('@') ? 'Must be a valid email' : null,
name: (v) => v.length < 2 ? 'Must be at least 2 characters' : null,
})
user.name = 'Alice' // ✓
user.age = 30 // ✓
user.age = -5 // ✗ TypeError: age: Must be a non-negative number
user.email = 'invalid' // ✗ TypeError: email: Must be a valid email
// Reactive state — how Vue 3's reactivity system works under the hood
function reactive(target) {
const subscribers = new Map()
return new Proxy(target, {
get(obj, prop) {
// Track who's reading this property (simplified)
if (activeEffect) {
if (!subscribers.has(prop)) subscribers.set(prop, new Set())
subscribers.get(prop).add(activeEffect)
}
return obj[prop]
},
set(obj, prop, value) {
obj[prop] = value
// Notify subscribers (simplified)
subscribers.get(prop)?.forEach(effect => effect())
return true
}
})
}
// Caching proxy — memoize expensive operations transparently
function createCachedAPI(api, ttlMs = 60_000) {
const cache = new Map()
return new Proxy(api, {
get(target, method) {
if (typeof target[method] !== 'function') return target[method]
return async (...args) => {
const key = `${method}:${JSON.stringify(args)}`
const cached = cache.get(key)
if (cached && Date.now() - cached.timestamp < ttlMs) {
return cached.data
}
const data = await target[method](...args)
cache.set(key, { data, timestamp: Date.now() })
return data
}
}
})
}
const cachedAPI = createCachedAPI(myExpensiveAPI, 30_000)
await cachedAPI.getUser(1) // hits network
await cachedAPI.getUser(1) // returns cache, no network call
When to use: Validation, reactive state, caching, logging, access control, lazy initialization. The native Proxy is one of JavaScript’s most powerful and underused features.
The Patterns That Need a Modern Rethink
5. Singleton — Mostly Obsolete, Sometimes Necessary
Original problem: Ensure a class has only one instance and provide a global access point.
Verdict: ⚠️ Usually unnecessary — ES modules ARE singletons.
This is the most misunderstood pattern in JavaScript. Developers build elaborate Singleton classes when the module system already gives you a singleton for free:
// Pointless Singleton class in 2026 — stop writing this
class DatabaseConnection {
static #instance = null
static getInstance() {
if (!DatabaseConnection.#instance) {
DatabaseConnection.#instance = new DatabaseConnection()
}
return DatabaseConnection.#instance
}
private constructor() {
this.connection = createConnection()
}
}
// Just do this instead — modules are singletons
// db.js
const connection = await createConnection(process.env.DATABASE_URL)
export const db = {
query: (sql, params) => connection.execute(sql, params),
transaction: (fn) => connection.transaction(fn),
}
// Anywhere you import db, you get the same instance automatically
import { db } from './db.js'
When Singleton is still valid:
- Managing truly global shared state that must be initialized lazily
- Browser globals that need controlled initialization
- Class instances shared across dynamically imported modules
// Legitimate lazy Singleton — when you need deferred initialization
class Logger {
static #instance = null
static getInstance(config) {
if (!Logger.#instance) {
if (!config) throw new Error('Logger must be initialized with config on first call')
Logger.#instance = new Logger(config)
}
return Logger.#instance
}
#config
constructor(config) {
this.#config = config
}
log(level, message, meta = {}) {
if (level < this.#config.minLevel) return
const entry = JSON.stringify({ level, message, meta, ts: Date.now() })
this.#config.transport(entry)
}
}
// Initialize once at startup with config
Logger.getInstance({ minLevel: 2, transport: console.log })
// Use anywhere without passing config
Logger.getInstance().log(3, 'User logged in', { userId: 1 })
The honest take: If you’re writing a Singleton class just to have one instance, delete it and use a module export. If you need lazy initialization or config-dependent initialization, then a Singleton class is warranted.
6. Decorator Pattern — Now a Language Feature
Original problem: Add behavior to objects dynamically without modifying their class.
Verdict: ✅ Standardized as TC39 Decorators (Stage 3 → shipping in 2026)
JavaScript Decorators are now shipping in major engines. The pattern that developers implemented manually for years is becoming native syntax:
// Manual decorator pattern — pre-2026
function readonly(target, key, descriptor) {
descriptor.writable = false
return descriptor
}
function memoize(fn) {
const cache = new Map()
return function(...args) {
const key = JSON.stringify(args)
if (cache.has(key)) return cache.get(key)
const result = fn.apply(this, args)
cache.set(key, result)
return result
}
}
// 2026 — native decorator syntax (TC39, shipping in V8/SpiderMonkey)
function memoize(fn, context) {
const cache = new Map()
return function(...args) {
const key = JSON.stringify(args)
if (!cache.has(key)) cache.set(key, fn.apply(this, args))
return cache.get(key)
}
}
function readonly(value, context) {
context.addInitializer(function() {
Object.defineProperty(this, context.name, { writable: false })
})
}
class DataService {
@memoize
expensiveCalculation(input) {
// Called once per unique input, cached thereafter
return performHeavyWork(input)
}
@readonly
apiVersion = 'v2'
}
TypeScript has had experimental decorators for years. The TC39 standardized version ships slightly differently but brings the same power natively.
The Patterns That Are Dead (or Should Be)
7. Module Pattern (IIFE) — Killed by ES Modules
Original problem: Create private scope and encapsulation in JavaScript, which had no module system.
Verdict: ❌ Dead. Use ES modules.
// Pre-2015 — IIFE was the only way to get private scope
const UserModule = (function() {
// "private" variables
let users = []
let nextId = 1
// "private" function
function validate(user) {
return user.name && user.email
}
// "public" interface
return {
add(user) {
if (!validate(user)) throw new Error('Invalid user')
users.push({ ...user, id: nextId++ })
},
getAll() { return [...users] },
find(id) { return users.find(u => u.id === id) },
}
})()
// 2026 — ES modules with private class fields
// users.js
let users = [] // genuinely private — not accessible outside module
let nextId = 1
function validate(user) { // genuinely private
return user.name && user.email
}
export function addUser(user) {
if (!validate(user)) throw new Error('Invalid user')
users.push({ ...user, id: nextId++ })
}
export function getUsers() { return [...users] }
export function findUser(id) { return users.find(u => u.id === id) }
The IIFE module pattern was a workaround for the absence of a module system. ES modules provide everything the IIFE pattern provided — private scope, a public interface, lazy loading — and more. There is no reason to write an IIFE module in 2026.
8. Namespace Pattern — Killed by ES Modules
Original problem: Avoid polluting the global scope by grouping related functionality under a single global object.
Verdict: ❌ Dead. Use ES modules.
// 2005–2015 — namespacing was necessary
var MyApp = MyApp || {}
MyApp.utils = MyApp.utils || {}
MyApp.utils.formatDate = function(date) { /* ... */ }
MyApp.utils.formatCurrency = function(amount) { /* ... */ }
MyApp.models = MyApp.models || {}
MyApp.models.User = function(data) { /* ... */ }
// 2026 — just use modules
// utils/format.js
export function formatDate(date) { /* ... */ }
export function formatCurrency(amount) { /* ... */ }
// models/user.js
export class User { /* ... */ }
Every major bundler, runtime, and browser supports ES modules natively. The namespace pattern was a workaround for the absence of modules. It is gone.
9. Revealing Module Pattern — Also Dead
Original problem: A variation of the Module pattern that explicitly maps private functions to the returned public interface.
Verdict: ❌ Dead. Same reason — ES modules supersede it entirely.
// Revealing Module — common in 2010s jQuery-era codebases
const cartModule = (function() {
let items = []
function addItem(item) { items.push(item) }
function removeItem(id) { items = items.filter(i => i.id !== id) }
function getTotal() { return items.reduce((sum, i) => sum + i.price, 0) }
function getItems() { return [...items] }
// "Reveal" only what should be public
return { addItem, removeItem, getTotal, getItems }
})()
// 2026
// cart.js — module system handles this natively
let items = []
export const addItem = (item) => items.push(item)
export const removeItem = (id) => { items = items.filter(i => i.id !== id) }
export const getTotal = () => items.reduce((sum, i) => sum + i.price, 0)
export const getItems = () => [...items]
// items stays private — nothing outside this module can access it
Patterns That Emerged in the JavaScript Era
These weren’t in the Gang of Four book — they emerged from JavaScript’s unique characteristics.
10. Middleware Pattern — Pure JavaScript Innovation
This pattern came from Express.js and has become one of the most important architectural patterns in the ecosystem. Every framework — Hono, Nuxt, Next.js, Koa, Fastify — uses it.
// The core insight: a pipeline of functions, each calling the next
class Pipeline {
#middlewares = []
use(fn) {
this.#middlewares.push(fn)
return this // chainable
}
async execute(context) {
let index = 0
const next = async () => {
if (index >= this.#middlewares.length) return
const middleware = this.#middlewares[index++]
await middleware(context, next)
}
await next()
return context
}
}
// Usage — compose behavior without inheritance
const pipeline = new Pipeline()
.use(async (ctx, next) => {
console.log(`→ ${ctx.method} ${ctx.path}`)
const start = Date.now()
await next()
console.log(`← ${ctx.status} (${Date.now() - start}ms)`)
})
.use(async (ctx, next) => {
if (!ctx.headers.authorization) {
ctx.status = 401
ctx.body = { error: 'Unauthorized' }
return // short-circuit — next() not called
}
ctx.user = await verifyToken(ctx.headers.authorization)
await next()
})
.use(async (ctx, next) => {
ctx.body = await handleRequest(ctx)
ctx.status = 200
await next()
})
await pipeline.execute(requestContext)
When to use: Request/response processing, data transformation pipelines, plugin systems, any sequential processing where each step can modify context and decide whether to continue.
11. Composition Over Inheritance — The Modern Object Model
Not a Gang of Four pattern — it’s the antidote to them. JavaScript’s prototype chain made inheritance attractive, but modern JavaScript increasingly favors object composition:
// Inheritance — brittle, causes the "banana-gorilla-jungle" problem
class Animal {
eat() { return 'nom nom' }
}
class Dog extends Animal {
bark() { return 'woof' }
}
class GuideDog extends Dog {
// Inherits eat() and bark() — what if I want bark() without eat()?
guide() { return 'leads the way' }
}
// Composition — mix in exactly the behaviors you need
const canEat = (state) => ({
eat: () => `${state.name} eats`,
})
const canBark = (state) => ({
bark: () => `${state.name} says woof!`,
})
const canGuide = (state) => ({
guide: () => `${state.name} guides safely`,
})
const canSwim = (state) => ({
swim: () => `${state.name} swims`,
})
// Compose exactly the animal you need
function createDog(name) {
const state = { name }
return Object.freeze({
...canEat(state),
...canBark(state),
name,
})
}
function createGuideDog(name) {
const state = { name }
return Object.freeze({
...canEat(state),
...canBark(state),
...canGuide(state),
name,
})
}
function createDuck(name) {
const state = { name }
return Object.freeze({
...canEat(state),
...canSwim(state), // no bark needed
quack: () => `${name} says quack!`,
name,
})
}
The 2026 Pattern Cheat Sheet
| Pattern | Status | Reason |
|---|---|---|
| Observer / Pub-Sub | ✅ Essential | Foundation of all reactive systems |
| Factory | ✅ Essential | Cleaner with functions, invaluable for DI |
| Strategy | ✅ Essential | First-class functions make it trivial |
| Proxy | ✅ Essential | Now a native language feature |
| Middleware | ✅ Essential | Pure JS innovation, everywhere |
| Composition | ✅ Essential | Better than inheritance in almost every case |
| Decorator | ✅ Standardized | TC39, shipping natively |
| Singleton | ⚠️ Often misused | Modules are singletons — use them |
| Module (IIFE) | ❌ Dead | ES modules supersede entirely |
| Namespace | ❌ Dead | ES modules supersede entirely |
| Revealing Module | ❌ Dead | ES modules supersede entirely |
Final Thoughts
The Gang of Four patterns weren’t wrong — they were solving real problems. The ones that died (IIFE modules, namespaces) died because JavaScript finally got a module system. The ones that thrived (Observer, Strategy, Factory, Proxy) thrived because they represent genuinely good ideas about how to structure code, and JavaScript’s functional nature makes them cleaner than their OOP origins.
The new patterns — Middleware, Composition — emerged from JavaScript’s unique character: first-class functions, dynamic objects, and the absence of a strict type system forcing everything into class hierarchies.
In 2026, the question isn’t “do I know the Gang of Four patterns?” It’s “do I know which problems each pattern solves, and do I reach for the right tool at the right time?” The developers who write the most maintainable JavaScript are the ones who compose small, focused functions, use the module system for encapsulation, reach for Proxy when they need to intercept object access, and stop writing Singleton classes when a module export will do.
