Siddhant Deval
Siddhant Deval
frontend13 min read

Architectural Design Patterns in JavaScript: Factory, Observer, Proxy & Module Internals

Design patterns are not academic — they are the vocabulary of code that survives team turnover. Factory, Singleton, Observer, and Proxy map directly to engine-level primitives, and understanding circular ESM dependency resolution turns a frustrating build error into a mechanical, solvable problem.

Architectural Design Patterns in JavaScript: Factory, Observer, Proxy & Module Internals

The runtime is not a black box — and neither are design patterns. When a colleague copies a Factory pattern from a blog post, they often don't know why it exists at the engine level. When they struggle with circular ESM imports, they don't know why one file sees undefined where a class should be. This article treats design patterns not as decorative labels but as mechanical consequences of how JavaScript's object system, module cache, and event-dispatch model work. Understanding the engine makes the patterns obvious.

Architectural Note

§4.3.1 — ESM vs CJS module execution semantics — is fully covered in JavaScript Modules, Chunks & Dynamic Import. This article cross-references that foundation and focuses on new ground: circular dependency resolution, dynamic import() as an architectural tool, and the Observer and Proxy patterns as engine-level primitives — not framework abstractions.


1. Creational Patterns

1.1 Factory Method — Abstracting Object Instantiation

The new keyword is a protocol — it creates a new object, sets its prototype, calls the constructor, and returns it. But new hardcodes the concrete type at the call site. When the type to create is a runtime decision, new is the wrong tool.

TYPESCRIPT
// ❌ Hardcoded type — caller knows too much
interface Logger {
  log(message: string): void
}

class ConsoleLogger implements Logger {
  log(message: string) { console.log(`[CONSOLE] ${message}`) }
}

class FileLogger implements Logger {
  constructor(private path: string) {}
  log(message: string) { fs.appendFileSync(this.path, `${message}\n`) }
}

// Caller must know which logger to instantiate
const logger = process.env.LOG_TO_FILE
  ? new FileLogger('./app.log')  // ← Caller imports FileLogger
  : new ConsoleLogger()          // ← Caller imports ConsoleLogger
// Every call site carries this decision — change it in one place and you miss others

// ✅ Factory function — caller knows the interface, not the class
function createLogger(config: { destination: 'console' | 'file'; path?: string }): Logger {
  if (config.destination === 'file') {
    if (!config.path) throw new Error('File logger requires a path')
    return new FileLogger(config.path)
  }
  return new ConsoleLogger()
}

// Caller only needs the Logger interface
const logger = createLogger({ destination: process.env.LOG_DEST as 'console' | 'file' })
logger.log('Server started')
// The concrete class is an implementation detail — callers are never exposed to it

When to use: When the concrete type depends on runtime configuration, environment variables, or feature flags. When construction has validation logic that should not be the caller's responsibility.

1.2 Singleton for Shared Configuration State

The ES Module system is a natural singleton: a module's top-level code runs exactly once, the first time it is imported. Subsequent imports in any file return the same module object from the cache — no additional evaluation.

TYPESCRIPT
// ❌ Unnecessary class Singleton pattern — JavaScript already provides this
class Config {
  private static instance: Config | null = null
  private values: Record<string, string>

  private constructor() {
    this.values = { ...process.env }
  }

  static getInstance(): Config {
    if (!Config.instance) Config.instance = new Config()
    return Config.instance
  }

  get(key: string): string | undefined {
    return this.values[key]
  }
}

// ✅ Module-level singleton — the module cache IS the singleton guarantee
// config.ts
const config = {
  apiKey:   process.env.API_KEY ?? '',
  baseUrl:  process.env.BASE_URL ?? 'https://api.example.com',
  timeout:  parseInt(process.env.TIMEOUT ?? '5000'),
}
export default config
// This object is evaluated once and cached.
// Every import in every file gets the SAME object reference.

// consumer-a.ts
import config from './config'  // Gets cached module object

// consumer-b.ts
import config from './config'  // Gets the SAME cached module object
// config in consumer-a === config in consumer-b → true
Mental Model Check

Node.js's require() cache and the ESM module registry both implement the singleton pattern at the runtime level. A class-based Singleton in JavaScript is solving a problem the module system already solves — it adds complexity without adding a guarantee.


2. Structural and Behavioral Patterns

2.1 Observer Pattern for Custom Event Buses

The Observer pattern decouples producers (components that emit events) from consumers (components that react to events). The alternative — direct method calls — creates coupling: the producer must know which consumers exist and their method signatures.

TYPESCRIPT
// ❌ Direct coupling — AuthService must know about every dependent
class AuthService {
  private analyticsService: AnalyticsService
  private sessionService: SessionService

  constructor(analytics: AnalyticsService, session: SessionService) {
    this.analyticsService = analytics  // AuthService now depends on both
    this.sessionService   = session
  }

  async login(credentials: Credentials): Promise<void> {
    const user = await this.validateCredentials(credentials)
    this.analyticsService.trackLogin(user.id)  // Direct call — tight coupling
    this.sessionService.createSession(user.id)  // Direct call — tight coupling
  }
}

// ✅ Observer pattern — AuthService emits; dependents subscribe
type EventMap = {
  'user:login':   { userId: string; timestamp: number }
  'user:logout':  { userId: string }
  'error:auth':   { reason: string }
}

class TypedEventBus<T extends Record<string, unknown>> {
  private listeners: { [K in keyof T]?: Array<(payload: T[K]) => void> } = {}

  on<K extends keyof T>(event: K, handler: (payload: T[K]) => void): () => void {
    if (!this.listeners[event]) this.listeners[event] = []
    this.listeners[event]!.push(handler)
    return () => this.off(event, handler)  // Returns unsubscribe fn
  }

  off<K extends keyof T>(event: K, handler: (payload: T[K]) => void): void {
    this.listeners[event] = this.listeners[event]?.filter(h => h !== handler)
  }

  emit<K extends keyof T>(event: K, payload: T[K]): void {
    this.listeners[event]?.forEach(h => h(payload))
  }
}

const authBus = new TypedEventBus<EventMap>()

// AuthService emits — knows nothing about consumers
class AuthService {
  async login(credentials: Credentials): Promise<void> {
    const user = await this.validateCredentials(credentials)
    authBus.emit('user:login', { userId: user.id, timestamp: Date.now() })
    // That's it — AuthService is done. Consumers subscribe independently.
  }
}

// Consumers subscribe — know nothing about each other
authBus.on('user:login', ({ userId }) => analytics.trackLogin(userId))
authBus.on('user:login', ({ userId }) => session.create(userId))
authBus.on('user:login', ({ userId }) => welcomeEmailQueue.push(userId))
Mental model diagram of the Observer pattern for a typed event bus. Center box labeled 'TypedEventBus<EventMap>' with cyan border containing three method labels: 'on(event, handler) → unsubscribe fn', 'off(event, handler)', 'emit(event, payload)'. Three arrows point inward from the left labeled 'subscribe' from three component boxes: 'AnalyticsService', 'SessionService', 'EmailQueueService'. One arrow points to the bus from the right labeled 'emit — user:login payload' from 'AuthService'. An outward fan of three arrows from the bus to each subscriber labeled 'notify all handlers'. A red X-crossed bidirectional arrow between AuthService and each subscriber labeled 'AuthService does not reference subscribers — zero import coupling'. Footer annotation in green: 'Adding a new subscriber (e.g., AuditLogger) requires zero changes to AuthService or existing subscribers'.
Mental model diagram of the Observer pattern for a typed event bus. Center box labeled 'TypedEventBus<EventMap>' with cyan border containing three method lab…
Pro Tip & Optimization

The on() method returning an unsubscribe function is the correct Node.js-compatible pattern. Always store the return value and call it in cleanup (React's useEffect return, Svelte's onDestroy, etc.) to prevent EventEmitter memory leaks.

2.2 Proxy Pattern for Object Reactivity and Validation

The Proxy API is covered in depth in Part 5. In the context of design patterns, the key application is validation at the boundary:

TYPESCRIPT
// ✅ Proxy for validation — intercept writes to enforce invariants
function createValidatedConfig<T extends object>(
  target: T,
  validators: { [K in keyof T]?: (value: T[K]) => string | null }
): T {
  return new Proxy(target, {
    set(obj, prop: string | symbol, value) {
      const key = prop as keyof T
      const validate = validators[key]

      if (validate) {
        const error = validate(value)
        if (error) throw new TypeError(`Config validation failed for '${String(prop)}': ${error}`)
      }

      obj[key] = value
      return true
    }
  })
}

const serverConfig = createValidatedConfig(
  { port: 3000, host: 'localhost', timeout: 5000 },
  {
    port:    v => (v < 1 || v > 65535) ? 'Port must be 1–65535' : null,
    timeout: v => v < 100 ? 'Timeout must be at least 100ms' : null,
  }
)

serverConfig.port = 8080   // ✅ Valid
serverConfig.port = -1     // ❌ TypeError: Config validation failed for 'port': Port must be 1–65535
serverConfig.timeout = 50  // ❌ TypeError: Config validation failed for 'timeout': ...

3. Module Federation — Circular Dependencies & Dynamic Imports

3.1 ESM vs CJS — Execution Behavior

ESM modules are statically linked and live bindings: the import is a live reference to the exporting module's namespace object. CJS modules are synchronously evaluated and cached: require() executes the file and returns a snapshot of module.exports.

For the full treatment of this difference, see JavaScript Modules, Chunks & Dynamic Import. What matters here: this behavioral difference determines how circular dependencies behave.

3.2 Resolving Circular Dependencies

A circular dependency occurs when module A imports from module B, and module B imports from module A. In ESM, this is handled via live bindings — but there is a window during module evaluation where a binding exists but its value has not been assigned yet.

JAVASCRIPT
// ❌ Circular ESM — partially initialized binding
// a.mjs
import { b } from './b.mjs'
export const a = 'Module A'
console.log('a.mjs: b =', b)  // ← b may be undefined here!

// b.mjs
import { a } from './a.mjs'
export const b = 'Module B'
console.log('b.mjs: a =', a)  // ← a may be undefined here!

// Execution order:
// 1. Node starts evaluating a.mjs
// 2. Sees import from b.mjs → evaluates b.mjs first
// 3. b.mjs imports a.mjs → a.mjs is already being evaluated (cycle detected)
//    ESM gives b.mjs access to a.mjs's namespace, but 'a' is not yet initialized
// 4. console.log in b.mjs: a = undefined ← live binding, not yet assigned
// 5. b.mjs finishes: b = 'Module B'
// 6. a.mjs continues: b = 'Module B' ← correct, b is now initialized

The solution is lazy resolution with dynamic import():

JAVASCRIPT
// ✅ Break the cycle with dynamic import inside a function
// a.mjs
export const a = 'Module A'

export async function callB() {
  const { b } = await import('./b.mjs')  // ← Lazy: resolved at call time, not module load
  return b
}

// b.mjs
export const b = 'Module B'

export async function callA() {
  const { a } = await import('./a.mjs')  // ← Lazy: resolved at call time
  return a
}

// No circular evaluation problem:
// a.mjs exports 'a' immediately — b.mjs does not need it at load time
// b.mjs exports 'b' immediately — a.mjs does not need it at load time
// Cross-calls happen lazily, after both modules are fully initialized
Before/After split diagram on circular ESM dependency resolution. Left panel labeled 'Static import — Circular binding hazard' with red border. Two file boxes 'a.mjs' and 'b.mjs' each with arrows pointing at each other labeled 'import'. Between them: a timeline showing evaluation order with a highlighted red zone labeled 'b.mjs reads a before a is initialized → undefined'. Code snippet in left panel: 'import { a } from ./a.mjs' with a red label underneath: 'Live binding exists, value = undefined at read time'. Right panel labeled 'Dynamic import() — Cycle broken' with green border. Same two file boxes, but arrows are now curved dotted arrows inside function bodies labeled 'async function callB() { await import(./b.mjs) }'. A green annotation: 'import() is deferred until the function is called — both modules fully initialized by then'. Footer: 'Dynamic import() evaluates the module on first call, not on module load — breaking circular initialization order dependencies'.
Before/After split diagram on circular ESM dependency resolution. Left panel labeled 'Static import — Circular binding hazard' with red border. Two file boxe…

3.3 Dynamic Imports and Code Splitting Optimization

Dynamic import() is not just a circular-dependency fix — it is the primary mechanism for code splitting: deferring the loading of a module until the moment it is needed.

TYPESCRIPT
// ✅ Dynamic import for feature-flagged code — never loaded if flag is off
async function initFeature(featureId: string): Promise<void> {
  const features: Record<string, () => Promise<{ init: () => void }>> = {
    'video-editor':  () => import('./features/VideoEditor'),
    'data-export':   () => import('./features/DataExport'),
    'ai-assistant':  () => import('./features/AIAssistant'),
  }

  const loader = features[featureId]
  if (!loader) throw new Error(`Unknown feature: ${featureId}`)

  const { init } = await loader()
  init()
}

// Only 'features/VideoEditor' is fetched if the user accesses the video editor feature
// The other modules are never downloaded for users who don't use them

// ✅ Preload hint — start loading before the user navigates
function prefetchRoute(path: string) {
  const routes: Record<string, () => Promise<unknown>> = {
    '/checkout': () => import('./pages/Checkout'),
    '/profile':  () => import('./pages/Profile'),
  }
  routes[path]?.()  // Fire but don't await — preloads the chunk in the background
}
Crucial Requirement

Dynamic import() is a network request at runtime. It has latency, it can fail, and the browser caches it based on HTTP headers — treat it with the same discipline as fetch(). Failing to handle the rejected Promise from a import() call is as dangerous as failing to handle a failed fetch().


Summary

Pattern Rule
Factory function Use when the concrete type is a runtime decision — callers know the interface, not the class
Module-level singleton ES Modules are cached after first evaluation — exported objects are singletons by default
Observer / Event Bus Decouple producers from consumers — emitters know the event name and payload, not the subscribers
Proxy for validation Intercept set traps to enforce invariants at the boundary — fail fast at the write site
Circular ESM Static imports in circular dependencies see undefined during initialization — dynamic import() inside a function defers evaluation and breaks the cycle
Dynamic import() A runtime network request — handle failures, leverage HTTP caching, prefetch on hover/intent

What's Next

In Part 5, we go deeper into the Proxy API: all 13 trap types, the Reflect API's role in forwarding intercepted operations correctly, building a reactive state manager from scratch, and the ToPrimitive coercion algorithm that governs every == comparison in JavaScript. Part 5 → Metaprogramming: Proxy, Reflect & Coercion


References

  1. MDN — Proxy API
  2. ECMAScript Specification — Cyclic Module Records
  3. Node.js Documentation — ES Modules: Cycles
  4. MDN — Dynamic import()
  5. Addy Osmani — JavaScript Design Patterns
Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#JavaScript#Design Patterns#Observer Pattern#Factory Pattern#ESM#Module System#Architecture
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.