7 Essential Software Design Patterns
Software design patterns are “reusable solutions to common problems” in software development. They systematize and catalog design know‑how discovered by experienced developers. By leveraging them, you can write efficient and flexible code, improve communication across teams, and raise software quality and maintainability.
In 1994, the so‑called Gang of Four (Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides) systematized 23 major patterns in the seminal book “Design Patterns.” These remain relevant today and are grouped into three categories:
- Creational patterns: how objects are created
- Structural patterns: how objects are composed and related
- Behavioral patterns: how objects interact and share responsibilities
This article highlights seven patterns you can put to use right away. For each pattern you’ll find when to use it, benefits, trade‑offs, and code examples.
Creational patterns
Singleton
A design pattern that restricts a class to a single instance across the application and allows global access to that instance. It is used to centrally manage high‑cost shared resources and registries to prevent duplicate creation.
When to use
- Centralizing heavy shared resources
- Logging, metrics, tracing, event bus
- Connection pools, thread pools, caches, ID generators
- Registries that should be single across the app
- Plugin/handler registration, format/locale settings registry
- Platform-level shared context
- Mobile Application context, configuration loader (mostly read‑only)
- The DI container itself (often managed by the framework)
Benefits
- Uniqueness guarantee: prevents duplicate creation and race conditions; provides a single source of truth
- Performance: heavy initialization cost paid once and reused
- Convenience: easy access from anywhere for quick implementations
- Lifecycle consolidation: clear place for init and teardown
Trade‑offs
- Global state increases coupling
- Dependencies become implicit; tests get harder (mock substitution/reset is painful)
- Can introduce order dependence and init‑timing bugs
- Thread safety complexity
- Lazy init and double‑checked locking mistakes can cause race/visibility issues
- Lifecycle and cleanup
- Missing shutdown causes leaks; long‑lived state can bloat caches or go stale
- Misunderstanding in scaling/distributed settings
- “One per process” only; multiple processes/containers mean multiple instances
- If you need shared state, push it to an external store (DB, Redis, etc.)
- Readability/maintainability can suffer
- Easy to become a “God object” with bloated responsibilities
- Used as a Service Locator it becomes an anti‑pattern
Code example
// Singleton logger example (IIFE + closure)
const Logger = (() => {
let instance;
function create() {
const logs = [];
return {
log(message) {
const entry = `${new Date().toISOString()} ${message}`;
logs.push(entry);
console.log(entry);
},
count() {
return logs.length;
},
};
}
return {
getInstance() {
if (!instance) {
instance = create();
}
return instance;
},
};
})();
// Usage: always returns the same instance
const a = Logger.getInstance();
const b = Logger.getInstance();
a.log("App started");
b.log("User logged in");
console.log(a === b); // true (same instance)
console.log(a.count()); // 2
Builder
Extracts the construction steps for complex objects into a dedicated builder. You configure it step by step and finally produce the finished product. Useful when there are many optional parameters or initialization has ordering/validation needs. Often paired with an immutable product and a fluent API.
When to use
- Constructors or config objects with many arguments/options
- Mixed required/optional fields, with validation or derived values
- You want an immutable product, but need stepwise assembly
- Building complex structures (trees, queries, HTTP requests)
- Test data builders for readable, flexible test fixtures
Benefits
- Avoids telescoping constructors; improves readability
- Centralize validation in build() and ensure consistency
- Make defaults and staged construction explicit
- Immutable products reduce side effects
- Fluent, chainable APIs reduce misuse
Trade‑offs
- Extra classes/functions add boilerplate
- Overengineering for simple objects
- Forgetting build() or missing fields leads to runtime errors
- Reusing a stateful builder can introduce side effects
- Small performance overhead
- In JS/TS, object literals and default params can often suffice
Code example
// Example of a builder that incrementally constructs a User
class User {
constructor({ name, email, roles, active }) {
this.name = name;
this.email = email;
this.roles = roles;
this.active = active;
Object.freeze(this); // The finished product is immutable
}
}
class UserBuilder {
constructor() {
this._name = null;
this._email = null;
this._roles = [];
this._active = true;
}
name(v) {
this._name = v;
return this;
}
email(v) {
this._email = v;
return this;
}
addRole(role) {
this._roles.push(role);
return this;
}
active(v) {
this._active = Boolean(v);
return this;
}
build() {
if (!this._name) throw new Error("name is required");
// email is optional (add a check if you need it)
const roles = Array.from(new Set(this._roles));
return new User({
name: this._name,
email: this._email ?? null,
roles,
active: this._active,
});
}
}
// Usage: configure via method chaining and finish with build()
const user = new UserBuilder()
.name("Taro Yamada")
.email("taro@example.com")
.addRole("admin")
.addRole("viewer")
.build();
console.log(user); // Completed, immutable User
console.log(Object.isFrozen(user)); // true
Factory
Instead of directly using new, delegate object creation to a dedicated factory so the “which concrete type and how to initialize it” is hidden. Includes variants like Factory Method and Abstract Factory. Facilitates implementation swapping and configuration‑driven selection.
When to use
- Choose a concrete class based on environment/config/input
- Creation requires pre‑processing, validation, defaults, or dependency injection
- Build consistent product families (UI/DB/network) via Abstract Factory
- Provide a substitution point for plugins or strategies
- Centralize cross‑cutting concerns (caching, pooling, instrumentation)
- Tests need an injection point to supply mocks/stubs
Benefits
- Decouple callers from concrete types; promotes inversion of dependencies
- Centralize construction logic; easy to switch/extend
- Validate and set defaults to prevent invalid instances
- Improves testability (swap/stub the factory)
- Helps maintain consistency among product families
- Easy to add logging/metrics/caching at creation time
Trade‑offs
- More indirection and boilerplate than a simple new
- Can bloat into a “God factory” with giant if/switch/maps
- Over‑abstraction can hide concrete‑specific capabilities
- In dynamic languages, registration typos cause runtime errors
- Overuse harms debuggability and control‑flow clarity
Code example
// Simple factory that chooses a notification channel
class EmailNotifier {
constructor({ from = "noreply@example.com" } = {}) {
this.from = from;
}
send(to, message) {
console.log(`[EMAIL] from=${this.from} to=${to} msg=${message}`);
}
}
class SmsNotifier {
constructor({ senderId = "APP" } = {}) {
this.senderId = senderId;
}
send(to, message) {
console.log(`[SMS] from=${this.senderId} to=${to} msg=${message}`);
}
}
class PushNotifier {
constructor({ app = "MyApp" } = {}) {
this.app = app;
}
send(to, message) {
console.log(`[PUSH] app=${this.app} to=${to} msg=${message}`);
}
}
// Kind → constructor registry
const NotifierRegistry = {
email: EmailNotifier,
sms: SmsNotifier,
push: PushNotifier,
};
// Factory function: return the appropriate instance by kind and options
function createNotifier(kind, options = {}) {
const Ctor = NotifierRegistry[kind];
if (!Ctor) throw new Error(`Unknown notifier: ${kind}`);
return new Ctor(options);
}
// Usage
const sms = createNotifier("sms", { senderId: "MYAPP" });
sms.send("+818012345678", "Your code is 123456");
const mail = createNotifier("email", { from: "support@example.com" });
mail.send("taro@example.com", "Welcome!");
Structural patterns
Facade
Provide a unified, simplified interface (a high‑level API) to a set of complex subsystems. Callers can perform a series of operations through a simple interface while the facade hides internal dependencies and procedural complexity.
When to use
- Offer a standard flow that composes multiple libraries/modules
- Simplify layer boundaries (application services/use cases)
- Wrap external services or legacy APIs and hide their complexity
- Centralize common pre‑processing, error handling, retries, transaction‑like sequences
- Keep a stable public API even if internal details change
- Make it easy to mock entire subsystems in tests
Benefits
- Lowers learning cost and cognitive load; reduces misuse
- Lowers coupling for callers; localizes the impact of changes
- Centralizes error handling, logging, and metrics
- Turns repeated procedures into a reusable high‑level API
- Easier to maintain a stable public contract
Trade‑offs
- Over‑abstraction can hide features and reduce expressiveness
- Facades can bloat into “God objects”
- Leaky abstractions may still require internal knowledge
- Slight additional overhead/latency
- Internal changes may require updating the facade contract
Code example
// Facade that provides a simple API for order processing
// Subsystems (in real life, separate libraries/modules)
class Inventory {
async reserve(sku, qty) {
console.log(`[INV] reserve ${sku} x ${qty}`);
return `resv-${Date.now()}`;
}
async release(reservationId) {
console.log(`[INV] release ${reservationId}`);
}
}
class PaymentGateway {
async charge(amount, card) {
console.log(`[PAY] charge ${amount} JPY`);
return `tx-${Date.now()}`;
}
async refund(txId) {
console.log(`[PAY] refund ${txId}`);
}
}
class Shipping {
async create(address, items) {
console.log(`[SHP] create shipment to ${address.city}`);
return `shp-${Date.now()}`;
}
async cancel(shipmentId) {
console.log(`[SHP] cancel ${shipmentId}`);
}
}
class Notifier {
async email(to, subject, body) {
console.log(`[MAIL] to=${to} subject="${subject}"`);
}
}
// Facade: high-level API that orchestrates multiple subsystems
class OrderFacade {
constructor({ inventory, payment, shipping, notifier, priceList }) {
this.inv = inventory;
this.pay = payment;
this.ship = shipping;
this.note = notifier;
this.price = priceList;
}
async placeOrder({ sku, qty, card, address, email }) {
const amount = (this.price[sku] ?? 0) * qty;
let reservationId = null;
let txId = null;
let shipmentId = null;
try {
reservationId = await this.inv.reserve(sku, qty);
txId = await this.pay.charge(amount, card);
shipmentId = await this.ship.create(address, [{ sku, qty }]);
await this.note.email(
email,
"Order confirmed",
`We have received your order: ${sku} x ${qty}.`
);
return { reservationId, txId, shipmentId, amount };
} catch (err) {
// Simple compensation (rollback)
if (shipmentId) await this.ship.cancel(shipmentId).catch(() => {});
if (txId) await this.pay.refund(txId).catch(() => {});
if (reservationId) await this.inv.release(reservationId).catch(() => {});
throw err;
}
}
}
// Usage: callers only use the simple API
(async () => {
const facade = new OrderFacade({
inventory: new Inventory(),
payment: new PaymentGateway(),
shipping: new Shipping(),
notifier: new Notifier(),
priceList: { "SKU-001": 1200 },
});
const result = await facade.placeOrder({
sku: "SKU-001",
qty: 2,
card: { number: "4242-4242-4242-4242" },
address: { city: "Tokyo" },
email: "taro@example.com",
});
console.log("Result:", result);
})();
Adapter
Converts the interface of an existing class or external API that’s incompatible with your client into the shape the client expects. It absorbs differences in method names, argument shapes, sync/async behavior, etc., enabling reuse and incremental migration.
When to use
- Align a legacy SDK/external service with your app’s common interface (Port)
- Allow switching between vendors while keeping callers vendor‑agnostic
- Convert callbacks to Promise/async, events to method calls
- Normalize data shapes/units/naming differences (snake vs. camel, etc.)
- Safely run old and new APIs in parallel during phased migration
- Replace with in‑memory adapters in tests
Benefits
- Reuse existing code while keeping callers decoupled
- Easy vendor/implementation switching and migration
- Centralize error mapping and validation at the boundary
- Improves testability (mock/stub the adapter)
Trade‑offs
- Slight overhead and more code
- Features that don’t map 1:1 can produce leaky abstractions
- Overstuffed adapters become “God adapters” and hard to maintain
- Must track upstream changes; maintenance cost exists
Code example
// Goal: app expects a Promise-based Storage port
// - get(key): Promise<any|null>
// - set(key, value): Promise<void>
// - delete(key): Promise<void>
// Reality: browser localStorage is sync and string-only
// → Use an adapter to handle async shape and serialization
class LocalStorageAdapter {
constructor(namespace = "app:") {
this.ns = namespace;
}
_k(key) {
return `${this.ns}${key}`;
}
async get(key) {
const raw = localStorage.getItem(this._k(key));
if (raw === null) return null;
try {
return JSON.parse(raw);
} catch {
// In case non-JSON strings were stored or JSON is broken, return as-is
return raw;
}
}
async set(key, value) {
const serialized =
typeof value === "string" ? value : JSON.stringify(value);
localStorage.setItem(this._k(key), serialized);
}
async delete(key) {
localStorage.removeItem(this._k(key));
}
}
// Usage: callers only see the Promise-based interface
(async () => {
const store = new LocalStorageAdapter("user:");
await store.set("profile", { name: "Taro Yamada", age: 28 });
const p = await store.get("profile");
console.log(p); // { name: 'Taro Yamada', age: 28 }
await store.delete("profile");
console.log(await store.get("profile")); // null
})();
Behavioral patterns
Strategy
Define interchangeable algorithms (strategies) behind a common interface and select one at runtime. Callers can switch to the best behavior for the situation/config without knowing internal details.
When to use
- Switching logic for pricing/discount/tax, routing, compression/encryption, sorting
- Replace behavior at runtime based on conditions, settings, or A/B tests
- Specs differ across vendors/regions/regulations
- Inject dummy or deterministic strategies in tests
Benefits
- Prevents sprawling if‑else/switch chains; easy to extend (OCP)
- Adding/swapping strategies has minimal impact on callers
- Test at the strategy level; easy to mock
- Runtime flexibility for experiments and gradual rollouts
Trade‑offs
- More small classes/functions; interface design takes effort
- Over‑abstraction can hide implementation‑specific optimizations
- Strategy selection can bloat into a “God factory”
- Reusing stateful strategies can cause side effects
Code example
// Strategy pattern: swap discount strategies for cart totals
// Strategy interface: calc(subtotal, cart) => non-negative discount amount
class NoDiscount {
calc() {
return 0;
}
}
class PercentageDiscount {
constructor(rate) {
this.rate = rate; // e.g., 0.1 means 10% off
}
calc(subtotal) {
return Math.floor(subtotal * this.rate);
}
}
class BulkItemDiscount {
constructor(sku, minQty, unitOff) {
this.sku = sku;
this.minQty = minQty;
this.unitOff = unitOff; // fixed amount off per order
}
calc(subtotal, cart) {
const item = cart.items.find((i) => i.sku === this.sku) || { qty: 0 };
return item.qty >= this.minQty ? this.unitOff : 0;
}
}
// Context: estimate using the selected strategy
class Pricing {
constructor(strategy = new NoDiscount()) {
this.strategy = strategy;
}
setStrategy(strategy) {
this.strategy = strategy;
}
quote(cart) {
const subtotal = cart.items.reduce(
(sum, i) => sum + i.price * i.qty,
0
);
const discount = Math.max(
0,
Math.min(subtotal, this.strategy.calc(subtotal, cart))
);
const total = subtotal - discount;
return { subtotal, discount, total };
}
}
// Example of choosing a strategy
function selectStrategy(coupon) {
if (!coupon) return new NoDiscount();
if (coupon.type === "percent") return new PercentageDiscount(coupon.rate);
if (coupon.type === "bulk") {
return new BulkItemDiscount(
coupon.sku,
coupon.minQty,
coupon.unitOff
);
}
return new NoDiscount();
}
// Usage
const cart = {
items: [
{ sku: "A-001", price: 1200, qty: 1 },
{ sku: "B-002", price: 800, qty: 2 },
],
};
const coupon1 = { type: "percent", rate: 0.1 }; // 10% off
const coupon2 = { type: "bulk", sku: "B-002", minQty: 2, unitOff: 200 };
const pricing = new Pricing(selectStrategy(coupon1));
console.log(pricing.quote(cart)); // 10% off
pricing.setStrategy(selectStrategy(coupon2));
console.log(pricing.quote(cart)); // 200 JPY off when buying 2+ of B-002
Observer
A pattern for notifying multiple observers about state changes or events. It keeps the publisher (Subject) and subscribers (Observers) loosely coupled so you can add/remove subscriptions and extend behavior at runtime.
When to use
- Subscribing to and handling UI/DOM events
- Application state management (store change notifications, reactive UI updates)
- Distributing domain/application events (module‑to‑module coordination)
- Real‑time updates (WebSocket/Push fan‑out to features)
- Hooks for config changes, cache invalidation, file change watching
- Logging/metrics subscriptions for cross‑cutting concerns
Benefits
- One‑to‑many notifications with loose coupling; easy to extend
- Add behavior without modifying the publisher (Subject)
- Separation of concerns; module‑level testing becomes easier
- Works well for both sync and async implementations
Trade‑offs
- Notification ordering and reentrancy are tricky and bug‑prone
- Forgetting to unsubscribe leads to memory leaks and duplicate subscriptions
- An observer’s exception can stop others (need isolation and catching)
- No built‑in delivery guarantees or backpressure
- Event names and payload contracts can sprawl and hurt maintainability
Code example
// Simple Subject/Observer implementation and usage
class Subject {
constructor() {
this._observers = new Set();
}
// Subscribe; returns an unsubscribe function
subscribe(fn) {
this._observers.add(fn);
return () => this._observers.delete(fn);
}
// Fire once and auto-unsubscribe
once(fn) {
const off = this.subscribe((payload) => {
try {
fn(payload);
} finally {
off();
}
});
return off;
}
// Notify; catch per observer so others still receive events
notify(payload) {
// Iterate a snapshot so unsubscribe during notify is safe
const snapshot = [...this._observers];
for (const fn of snapshot) {
try {
fn(payload);
} catch (e) {
console.error("Observer error:", e);
}
}
}
}
// Concrete example: price ticker (Subject)
class PriceTicker extends Subject {
constructor(symbol) {
super();
this.symbol = symbol;
this.price = null;
}
updatePrice(newPrice) {
if (this.price === newPrice) return;
this.price = newPrice;
this.notify({ symbol: this.symbol, price: this.price });
}
}
// Usage
const ticker = new PriceTicker("BTC/JPY");
// Logging observer
const offLog = ticker.subscribe((e) => {
console.log(`[TICK] ${e.symbol} -> ${e.price.toLocaleString()} JPY`);
});
// Observer that runs only once
ticker.once((e) => {
console.log("First tick received:", e);
});
ticker.updatePrice(7_100_000);
ticker.updatePrice(7_150_000);
// Unsubscribe (no logs after this)
offLog();
ticker.updatePrice(7_200_000);
How to choose patterns
Guidelines
- Replace long if‑else chains with Strategy
- If constructors are unwieldy, use Builder
- For centralizing creation and injecting cross‑cutting concerns, use Factory
- Use Facade to hide the orchestration of multiple subsystems
- Use Adapter to absorb external API differences
- Consider Observer for event‑driven designs
- Singleton is powerful but don’t overuse it; only when true uniqueness is required
Common pitfalls
- Pattern overload: don’t add indirection without a purpose
- God objects: beware of bloated facades and singletons
- Hidden coupling: factories and singletons can obscure dependencies
- Event storms: uncontrolled Observer chains are hard to reason about
Summary
Design patterns are a vocabulary for thinking clearly about software structure. Understand each pattern’s intent, match it to the problem at hand, and apply it no more than necessary. For deeper study, pick up the original “Design Patterns” by the Gang of Four. The examples are classic, but the ideas remain timeless.