Which Design Patterns Actually Matter in Modern JavaScript?
The original GoF "Design Patterns" book (1994) described 23 patterns in C++. Many of them are solving problems that JavaScript handles differently — dynamic typing, first-class functions, and prototype-based objects change the calculus significantly.
This guide focuses on the patterns that genuinely improve real codebases in 2026: the ones you'll encounter in popular frameworks, in job interviews, and in the codebases you'll actually maintain.
Creational Patterns
Singleton
Ensure a class has only one instance:
class DatabaseConnection {
private static instance: DatabaseConnection | null = null;
private pool: Pool;
private constructor() {
this.pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20,
});
}
// The only way to get the instance
static getInstance(): DatabaseConnection {
if (!DatabaseConnection.instance) {
DatabaseConnection.instance = new DatabaseConnection();
}
return DatabaseConnection.instance;
}
async query(sql: string, params?: unknown[]) {
return this.pool.query(sql, params);
}
}
// Usage — always gets the same instance
const db1 = DatabaseConnection.getInstance();
const db2 = DatabaseConnection.getInstance();
console.log(db1 === db2); // true
// Modern alternative: module-level singleton (simpler)
// db.ts
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export default pool; // ES modules are singletons by default!
Factory Method
Create objects without specifying the exact class:
interface Notifier {
send(to: string, message: string): Promise<void>;
}
class EmailNotifier implements Notifier {
async send(to: string, message: string) {
await sendEmail({ to, subject: 'Notification', body: message });
}
}
class SMSNotifier implements Notifier {
async send(to: string, message: string) {
await sendSMS({ phone: to, text: message });
}
}
class SlackNotifier implements Notifier {
async send(to: string, message: string) {
await postToSlack({ channel: to, text: message });
}
}
// Factory
class NotifierFactory {
static create(type: 'email' | 'sms' | 'slack'): Notifier {
const map = {
email: EmailNotifier,
sms: SMSNotifier,
slack: SlackNotifier,
};
const NotifierClass = map[type];
if (!NotifierClass) throw new Error(`Unknown notifier type: ${type}`);
return new NotifierClass();
}
}
// Usage — client doesn't know which class was created
const notifier = NotifierFactory.create(process.env.NOTIFIER_TYPE as 'email');
await notifier.send('alice@example.com', 'Your order is ready!');
Builder
Construct complex objects step by step:
class QueryBuilder {
private table = '';
private conditions: string[] = [];
private columns = '*';
private limitVal?: number;
private orderByCol?: string;
private orderByDir = 'ASC';
private params: unknown[] = [];
from(table: string) {
this.table = table;
return this; // Method chaining
}
select(...columns: string[]) {
this.columns = columns.join(', ');
return this;
}
where(condition: string, ...params: unknown[]) {
this.params.push(...params);
this.conditions.push(condition.replace('?', `${this.params.length}`));
return this;
}
limit(n: number) {
this.limitVal = n;
return this;
}
orderBy(column: string, direction: 'ASC' | 'DESC' = 'ASC') {
this.orderByCol = column;
this.orderByDir = direction;
return this;
}
build(): { sql: string; params: unknown[] } {
let sql = `SELECT ${this.columns} FROM ${this.table}`;
if (this.conditions.length > 0) {
sql += ` WHERE ${this.conditions.join(' AND ')}`;
}
if (this.orderByCol) {
sql += ` ORDER BY ${this.orderByCol} ${this.orderByDir}`;
}
if (this.limitVal !== undefined) {
sql += ` LIMIT ${this.limitVal}`;
}
return { sql, params: this.params };
}
}
// Clean, readable query construction
const { sql, params } = new QueryBuilder()
.from('users')
.select('id', 'name', 'email')
.where('country = ?', 'US')
.where('created_at > ?', new Date('2026-01-01'))
.orderBy('name')
.limit(20)
.build();
Structural Patterns
Decorator
Add behavior to objects without modifying them:
interface UserService {
findById(id: string): Promise<User | null>;
create(data: CreateUserDto): Promise<User>;
}
// Base implementation
class UserServiceImpl implements UserService {
async findById(id: string) {
return db.query('SELECT * FROM users WHERE id = $1', [id]);
}
async create(data: CreateUserDto) {
return db.query('INSERT INTO users ...', [data.name, data.email]);
}
}
// Caching decorator (wraps the service)
class CachedUserService implements UserService {
constructor(
private service: UserService,
private cache: Redis
) {}
async findById(id: string) {
const cached = await this.cache.get(`user:${id}`);
if (cached) return JSON.parse(cached);
const user = await this.service.findById(id);
if (user) await this.cache.setEx(`user:${id}`, 3600, JSON.stringify(user));
return user;
}
async create(data: CreateUserDto) {
const user = await this.service.create(data);
await this.cache.setEx(`user:${user.id}`, 3600, JSON.stringify(user));
return user;
}
}
// Logging decorator
class LoggingUserService implements UserService {
constructor(private service: UserService, private logger: Logger) {}
async findById(id: string) {
const start = Date.now();
const user = await this.service.findById(id);
this.logger.info({ id, duration: Date.now() - start }, 'findById');
return user;
}
// ... same for create
}
// Compose: Logging(Caching(Base))
const userService: UserService =
new LoggingUserService(
new CachedUserService(
new UserServiceImpl(),
redis
),
logger
);
Behavioral Patterns
Observer (Event Emitter)
type EventMap = {
'user.created': { userId: string; email: string };
'order.placed': { orderId: string; total: number };
'payment.failed': { orderId: string; reason: string };
};
class TypedEventBus {
private listeners = new Map<string, Set<Function>>();
on<K extends keyof EventMap>(
event: K,
listener: (data: EventMap[K]) => void
): () => void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(listener);
// Return unsubscribe function
return () => this.listeners.get(event)?.delete(listener);
}
emit<K extends keyof EventMap>(event: K, data: EventMap[K]): void {
this.listeners.get(event)?.forEach(listener => {
listener(data);
});
}
}
const bus = new TypedEventBus();
// Subscribe (type-safe!)
const unsubscribe = bus.on('user.created', ({ userId, email }) => {
sendWelcomeEmail(email);
});
// Emit
bus.emit('user.created', { userId: '123', email: 'alice@example.com' });
// Clean up
unsubscribe();
Strategy
Define a family of algorithms, make them interchangeable:
interface SortStrategy<T> {
sort(data: T[]): T[];
}
class QuickSort<T> implements SortStrategy<T> {
sort(data: T[]): T[] {
return [...data].sort(); // Simplified
}
}
class MergeSort<T> implements SortStrategy<T> {
sort(data: T[]): T[] {
// Merge sort implementation
return [...data].sort();
}
}
class DataProcessor<T> {
constructor(private strategy: SortStrategy<T>) {}
setStrategy(strategy: SortStrategy<T>) {
this.strategy = strategy;
}
process(data: T[]): T[] {
return this.strategy.sort(data);
}
}
// Switch algorithms at runtime
const processor = new DataProcessor(new QuickSort<number>());
processor.process([3, 1, 4, 1, 5]);
// For large datasets, switch to merge sort
if (data.length > 10000) {
processor.setStrategy(new MergeSort<number>());
}
Repository Pattern
Abstract data access behind an interface:
interface UserRepository {
findById(id: string): Promise<User | null>;
findByEmail(email: string): Promise<User | null>;
save(user: User): Promise<User>;
delete(id: string): Promise<void>;
}
// PostgreSQL implementation
class PgUserRepository implements UserRepository {
constructor(private pool: Pool) {}
async findById(id: string) {
const result = await this.pool.query(
'SELECT * FROM users WHERE id = $1', [id]
);
return result.rows[0] ?? null;
}
async findByEmail(email: string) {
const result = await this.pool.query(
'SELECT * FROM users WHERE email = $1', [email]
);
return result.rows[0] ?? null;
}
async save(user: User) {
const result = await this.pool.query(
'INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *',
[user.name, user.email]
);
return result.rows[0];
}
async delete(id: string) {
await this.pool.query('DELETE FROM users WHERE id = $1', [id]);
}
}
// In-memory implementation for tests!
class InMemoryUserRepository implements UserRepository {
private users = new Map<string, User>();
async findById(id: string) {
return this.users.get(id) ?? null;
}
async findByEmail(email: string) {
return [...this.users.values()].find(u => u.email === email) ?? null;
}
async save(user: User) {
const saved = { ...user, id: user.id ?? crypto.randomUUID() };
this.users.set(saved.id, saved);
return saved;
}
async delete(id: string) {
this.users.delete(id);
}
}
// Service uses the interface — doesn't know which implementation
class UserService {
constructor(private users: UserRepository) {}
async register(email: string, password: string): Promise<User> {
const existing = await this.users.findByEmail(email);
if (existing) throw new Error('Email already registered');
return this.users.save({ email, passwordHash: hashPassword(password) });
}
}
// Production
const userService = new UserService(new PgUserRepository(pool));
// Tests — fast, no database needed
const userService = new UserService(new InMemoryUserRepository());
Anti-Patterns to Avoid
// ❌ God Object — one class does everything
class Application {
handleLogin() { ... }
sendEmail() { ... }
processPayment() { ... }
generatePDF() { ... }
// 50 more methods...
}
// ✅ Single Responsibility — each class has one job
// ❌ Anemic Domain Model — objects are just data bags
class User {
name: string;
email: string;
// No behavior — all logic in UserService
}
// ✅ Rich Domain Model — behavior lives with data
class User {
private passwordHash: string;
verifyPassword(password: string): boolean {
return bcrypt.compare(password, this.passwordHash);
}
changeEmail(newEmail: string): void {
if (!isValidEmail(newEmail)) throw new Error('Invalid email');
this.email = newEmail;
this.emit('email.changed', { userId: this.id });
}
}
→ Visualize and inspect complex data structures with the JSON Viewer.