Course: JavaScript Advanced
Lesson 15: Design Patterns
Level
Advanced
Estimated Reading Time
10–15 minutes
Goal
By the end of this lesson, the learner will be able to:
- Implement the Observer pattern for decoupled event-driven communication between modules
- Implement the Module pattern to encapsulate state and expose a controlled public API
- Implement the Singleton pattern to ensure a single shared instance of a class or service
Frontend Development Context
Design patterns are reusable solutions to recurring software design problems. They are not specific pieces of code — they are templates for how to structure code to solve particular categories of problems cleanly. In frontend development, the Observer pattern is the foundation of every event system and reactive framework. The Module pattern is how JavaScript code has been organized in browsers since before ES6. The Singleton pattern governs shared services like configuration managers, analytics trackers, and WebSocket connections.
Understanding these patterns at the implementation level — not just recognizing the names — means you can evaluate whether a framework or library is using a pattern correctly, adapt the pattern to unusual requirements, and explain architectural decisions to teammates. React's event system, Vuex's store, and the browser's own EventTarget API are all Observer pattern implementations.
Explanation
The Observer pattern (also called Publish-Subscribe or EventEmitter) decouples the code that produces events from the code that responds to them. A central "emitter" holds a registry of event types and their listener functions. When emit(event, data) is called, all registered listeners for that event are invoked. Neither the emitter nor the listeners need to know about each other directly — they communicate through the event name. This makes it easy to add, remove, or replace listeners without changing the producer of events.
The Module pattern uses a closure or ES6 module to keep internal implementation details private and expose only a deliberate public interface. The internal state (variables, helper functions) is inaccessible from outside. The public API (returned object or exports) is the only way to interact with the module. This creates a clear boundary between implementation details that can change freely and the public contract that callers depend on.
The Singleton pattern ensures that a class or service has only one instance throughout the application's lifetime. The first call creates the instance; all subsequent calls return the same one. This is useful for global shared resources — a configuration object, an analytics service, a WebSocket connection — where multiple instances would be redundant, inconsistent, or costly. In JavaScript, ES6 modules are themselves singletons: a module's exports are created once and reused across all imports.
These patterns share a common thread: they are about managing who knows what and who controls what. Observer decouples event producers from consumers. Module controls access to internal state. Singleton controls how many instances of a resource exist.
Code Example
This example implements all three patterns: a typed EventEmitter (Observer), a configuration Module with private state, and a Logger Singleton.
// --- Observer Pattern: EventEmitter ---
class EventEmitter {
#listeners = new Map();
on(event, listener) {
if (!this.#listeners.has(event)) {
this.#listeners.set(event, new Set());
}
this.#listeners.get(event).add(listener);
return () => this.off(event, listener); // return unsubscribe function
}
off(event, listener) {
this.#listeners.get(event)?.delete(listener);
}
emit(event, ...args) {
this.#listeners.get(event)?.forEach(listener => listener(...args));
}
once(event, listener) {
const wrapper = (...args) => {
listener(...args);
this.off(event, wrapper);
};
this.on(event, wrapper);
}
}
const appEvents = new EventEmitter();
const unsub = appEvents.on("user:login", (user) => {
console.log("User logged in:", user.name);
});
appEvents.once("user:login", (user) => {
console.log("First login ever:", user.name); // fires once then auto-removes
});
appEvents.emit("user:login", { name: "Alice", role: "admin" });
// "User logged in: Alice"
// "First login ever: Alice"
appEvents.emit("user:login", { name: "Bob", role: "viewer" });
// "User logged in: Bob" (once listener is gone)
unsub(); // remove the first listener
appEvents.emit("user:login", { name: "Carol" }); // nothing fires
// --- Module Pattern: Configuration ---
const AppConfig = (function () {
const _config = {
theme: "light",
language: "en",
debugMode: false,
};
const _allowedThemes = new Set(["light", "dark", "auto"]);
return {
get(key) {
return _config[key];
},
set(key, value) {
if (key === "theme" && !_allowedThemes.has(value)) {
throw new Error(`Invalid theme: ${value}`);
}
_config[key] = value;
},
getAll() {
return { ..._config }; // return a copy — callers can't mutate internal state
},
};
})();
AppConfig.set("theme", "dark");
console.log(AppConfig.get("theme")); // "dark"
console.log(AppConfig.getAll()); // { theme: "dark", language: "en", debugMode: false }
// --- Singleton Pattern: Logger ---
class Logger {
static #instance = null;
#logs = [];
constructor(prefix) {
if (Logger.#instance) return Logger.#instance;
this.prefix = prefix;
Logger.#instance = this;
}
log(message) {
const entry = `[${this.prefix}] ${message}`;
this.#logs.push(entry);
console.log(entry);
}
getLogs() {
return [...this.#logs];
}
static getInstance() {
if (!Logger.#instance) new Logger("App");
return Logger.#instance;
}
}
const logger1 = new Logger("App");
const logger2 = new Logger("Other"); // returns existing instance
console.log(logger1 === logger2); // true — same instance
logger1.log("Application started");
logger2.log("This also goes to logger1");
console.log(Logger.getInstance().getLogs());
// ["[App] Application started", "[App] This also goes to logger1"]
Code Explanation
EventEmitter uses a private Map where each key is an event name and each value is a Set of listener functions. Using a Set automatically prevents the same listener from being registered twice for the same event. The on method returns an unsubscribe function — a common modern pattern that lets callers clean up without needing to hold separate references to both the emitter and the listener. once wraps the listener in a function that removes itself after the first invocation.
AppConfig is the Module pattern using an IIFE. _config and _allowedThemes are completely private — there is no way to access them except through the returned public methods. The getAll method returns a shallow copy via spread, so callers cannot mutate the internal configuration object directly. Validation logic in set enforces invariants without exposing the rules publicly.
Logger implements the Singleton with a private static field #instance. The constructor checks if an instance already exists and returns it if so — meaning new Logger("Other") returns the exact same instance as new Logger("App"). The static getInstance method provides a cleaner API for retrieving the singleton without going through new. All logs are stored in the single instance, so any part of the codebase using the logger sees the complete log history.
Pattern Highlights
✅ Positive Pattern: Return an unsubscribe function from
on(Observer) so callers can clean up event listeners without needing to hold a separate reference to both the emitter and the original listener function.
⚠️ Neutral Note: The Singleton pattern makes testing harder because the global instance persists between tests. In test environments, provide a
reset()method or use dependency injection so each test can work with a fresh instance.
❌ Negative Pattern: Do not use the Singleton pattern for objects that represent different things in different contexts — if different parts of the app need independent instances with different configurations, the Singleton creates invisible coupling that leads to hard-to-trace state conflicts.
Common Mistakes
Mistake 1: Forgetting to remove Observer listeners
Adding listeners without removing them causes memory leaks and stale callbacks that run long after they should have stopped.
function setupComponent(emitter) {
emitter.on("data:update", (data) => {
renderComponent(data); // runs even after component is destroyed
});
// Missing: return an unsubscribe function and call it on cleanup
}
Store the unsubscribe function returned by on and call it when the component is destroyed or the subscription is no longer relevant.
Mistake 2: Exposing Singleton internals through getters
A Singleton that exposes mutable references to its internal state can be modified by any caller, defeating encapsulation.
class Config {
static instance = new Config();
settings = { theme: "light" };
getSettings() {
return this.settings; // direct reference — callers can mutate it!
}
}
Config.instance.getSettings().theme = "dark"; // bypasses any validation
Return copies ({ ...this.settings }) or use private fields with controlled accessor methods.
Mistake 3: Using the Module pattern when ES6 modules are available
In modern projects with bundlers, wrapping code in IIFEs is unnecessary — ES6 modules already provide file-level scope isolation.
// unnecessary wrapping in a modern ES module
const MyModule = (function () {
const private = "data";
return { get() { return private; } };
})();
In an ES6 module, private is already module-scoped and invisible to other files. Export only what you want public. Save the IIFE pattern for scripts that run in non-module environments.
Quick Recap
- The Observer pattern decouples event producers from consumers through a central registry — producers emit events by name, consumers subscribe by name; neither needs to know about the other directly.
- The Module pattern uses closures (or ES6 modules) to keep internal state private and expose a deliberate public API — callers can only interact through the public interface, protecting internal invariants.
- The Singleton pattern ensures only one instance of a class exists — implemented by storing the instance in a static private field and returning it on subsequent construction attempts.
- All three patterns are about controlling access: Observer controls communication flow, Module controls state access, and Singleton controls instance count.