Back to Blog
Design PatternsJavaScriptArchitectureBest Practices

Design Patterns in Modern JavaScript

Apply classic design patterns with modern JavaScript. From factories to observers to strategies, write more maintainable code.

B
Bootspring Team
Engineering
December 5, 2024
6 min read

Design patterns are proven solutions to common problems. Here's how to implement classic patterns using modern JavaScript features.

Creational Patterns#

Factory Pattern#

1// Create objects without specifying exact class 2class UserFactory { 3 static create(type, data) { 4 switch (type) { 5 case 'admin': 6 return new AdminUser(data); 7 case 'member': 8 return new MemberUser(data); 9 case 'guest': 10 return new GuestUser(data); 11 default: 12 throw new Error(`Unknown user type: ${type}`); 13 } 14 } 15} 16 17class AdminUser { 18 constructor({ name, email }) { 19 this.name = name; 20 this.email = email; 21 this.permissions = ['read', 'write', 'delete', 'admin']; 22 } 23} 24 25class MemberUser { 26 constructor({ name, email }) { 27 this.name = name; 28 this.email = email; 29 this.permissions = ['read', 'write']; 30 } 31} 32 33// Usage 34const admin = UserFactory.create('admin', { name: 'John', email: 'john@example.com' }); 35const member = UserFactory.create('member', { name: 'Jane', email: 'jane@example.com' });

Builder Pattern#

1// Construct complex objects step by step 2class QueryBuilder { 3 constructor() { 4 this.query = { 5 select: [], 6 from: null, 7 where: [], 8 orderBy: [], 9 limit: null, 10 }; 11 } 12 13 select(...fields) { 14 this.query.select.push(...fields); 15 return this; 16 } 17 18 from(table) { 19 this.query.from = table; 20 return this; 21 } 22 23 where(condition) { 24 this.query.where.push(condition); 25 return this; 26 } 27 28 orderBy(field, direction = 'ASC') { 29 this.query.orderBy.push({ field, direction }); 30 return this; 31 } 32 33 limit(count) { 34 this.query.limit = count; 35 return this; 36 } 37 38 build() { 39 let sql = `SELECT ${this.query.select.join(', ')} FROM ${this.query.from}`; 40 41 if (this.query.where.length) { 42 sql += ` WHERE ${this.query.where.join(' AND ')}`; 43 } 44 45 if (this.query.orderBy.length) { 46 const orders = this.query.orderBy.map(o => `${o.field} ${o.direction}`); 47 sql += ` ORDER BY ${orders.join(', ')}`; 48 } 49 50 if (this.query.limit) { 51 sql += ` LIMIT ${this.query.limit}`; 52 } 53 54 return sql; 55 } 56} 57 58// Usage 59const query = new QueryBuilder() 60 .select('id', 'name', 'email') 61 .from('users') 62 .where('status = "active"') 63 .where('age > 18') 64 .orderBy('created_at', 'DESC') 65 .limit(10) 66 .build();

Singleton Pattern#

1// Ensure only one instance exists 2class Database { 3 static #instance = null; 4 5 constructor() { 6 if (Database.#instance) { 7 return Database.#instance; 8 } 9 10 this.connection = this.connect(); 11 Database.#instance = this; 12 } 13 14 static getInstance() { 15 if (!Database.#instance) { 16 Database.#instance = new Database(); 17 } 18 return Database.#instance; 19 } 20 21 connect() { 22 console.log('Connecting to database...'); 23 return { connected: true }; 24 } 25 26 query(sql) { 27 return `Executing: ${sql}`; 28 } 29} 30 31// Or using module pattern (simpler) 32// db.js 33let connection = null; 34 35export function getConnection() { 36 if (!connection) { 37 connection = createConnection(); 38 } 39 return connection; 40}

Structural Patterns#

Adapter Pattern#

1// Make incompatible interfaces work together 2class OldPaymentSystem { 3 constructor() { 4 this.name = 'LegacyPay'; 5 } 6 7 makePayment(amount, currency, cardNumber) { 8 return { 9 status: 'success', 10 transactionId: `LP-${Date.now()}`, 11 amount: amount, 12 }; 13 } 14} 15 16class NewPaymentSystem { 17 constructor() { 18 this.name = 'ModernPay'; 19 } 20 21 processPayment({ amount, currency, paymentMethod }) { 22 return { 23 success: true, 24 id: `MP-${Date.now()}`, 25 chargedAmount: amount, 26 }; 27 } 28} 29 30// Adapter to make old system work with new interface 31class PaymentAdapter { 32 constructor(legacySystem) { 33 this.legacySystem = legacySystem; 34 } 35 36 processPayment({ amount, currency, paymentMethod }) { 37 const result = this.legacySystem.makePayment( 38 amount, 39 currency, 40 paymentMethod.cardNumber 41 ); 42 43 return { 44 success: result.status === 'success', 45 id: result.transactionId, 46 chargedAmount: result.amount, 47 }; 48 } 49} 50 51// Usage 52const paymentProcessor = new PaymentAdapter(new OldPaymentSystem()); 53const result = paymentProcessor.processPayment({ 54 amount: 100, 55 currency: 'USD', 56 paymentMethod: { cardNumber: '4111111111111111' }, 57});

Decorator Pattern#

1// Add behavior to objects dynamically 2class Coffee { 3 cost() { 4 return 5; 5 } 6 7 description() { 8 return 'Coffee'; 9 } 10} 11 12// Decorators 13class MilkDecorator { 14 constructor(coffee) { 15 this.coffee = coffee; 16 } 17 18 cost() { 19 return this.coffee.cost() + 1; 20 } 21 22 description() { 23 return `${this.coffee.description()} + Milk`; 24 } 25} 26 27class SugarDecorator { 28 constructor(coffee) { 29 this.coffee = coffee; 30 } 31 32 cost() { 33 return this.coffee.cost() + 0.5; 34 } 35 36 description() { 37 return `${this.coffee.description()} + Sugar`; 38 } 39} 40 41// Usage 42let coffee = new Coffee(); 43coffee = new MilkDecorator(coffee); 44coffee = new SugarDecorator(coffee); 45 46console.log(coffee.description()); // Coffee + Milk + Sugar 47console.log(coffee.cost()); // 6.5 48 49// Modern alternative: Higher-order functions 50const withLogging = (fn) => (...args) => { 51 console.log(`Calling ${fn.name} with`, args); 52 const result = fn(...args); 53 console.log(`Result:`, result); 54 return result; 55}; 56 57const withTiming = (fn) => (...args) => { 58 const start = performance.now(); 59 const result = fn(...args); 60 console.log(`${fn.name} took ${performance.now() - start}ms`); 61 return result; 62}; 63 64const processData = withLogging(withTiming((data) => { 65 return data.map(x => x * 2); 66}));

Proxy Pattern#

1// Control access to an object 2const userService = { 3 getUser(id) { 4 console.log(`Fetching user ${id}`); 5 return { id, name: 'John' }; 6 }, 7 deleteUser(id) { 8 console.log(`Deleting user ${id}`); 9 }, 10}; 11 12const userServiceProxy = new Proxy(userService, { 13 get(target, prop) { 14 const value = target[prop]; 15 16 if (typeof value === 'function') { 17 return function (...args) { 18 console.log(`[AUDIT] Calling ${prop} with args:`, args); 19 20 // Access control 21 if (prop === 'deleteUser' && !currentUser.isAdmin) { 22 throw new Error('Unauthorized'); 23 } 24 25 return value.apply(target, args); 26 }; 27 } 28 29 return value; 30 }, 31}); 32 33// Caching proxy 34function createCachingProxy(target) { 35 const cache = new Map(); 36 37 return new Proxy(target, { 38 apply(target, thisArg, args) { 39 const key = JSON.stringify(args); 40 41 if (cache.has(key)) { 42 console.log('Cache hit'); 43 return cache.get(key); 44 } 45 46 console.log('Cache miss'); 47 const result = target.apply(thisArg, args); 48 cache.set(key, result); 49 return result; 50 }, 51 }); 52} 53 54const cachedFetch = createCachingProxy(fetchData);

Behavioral Patterns#

Observer Pattern#

1// Define a subscription mechanism 2class EventEmitter { 3 constructor() { 4 this.events = new Map(); 5 } 6 7 on(event, listener) { 8 if (!this.events.has(event)) { 9 this.events.set(event, []); 10 } 11 this.events.get(event).push(listener); 12 return () => this.off(event, listener); 13 } 14 15 off(event, listener) { 16 const listeners = this.events.get(event); 17 if (listeners) { 18 const index = listeners.indexOf(listener); 19 if (index > -1) { 20 listeners.splice(index, 1); 21 } 22 } 23 } 24 25 emit(event, data) { 26 const listeners = this.events.get(event); 27 if (listeners) { 28 listeners.forEach(listener => listener(data)); 29 } 30 } 31} 32 33// Usage 34const store = new EventEmitter(); 35 36const unsubscribe = store.on('userLoggedIn', (user) => { 37 console.log(`Welcome, ${user.name}!`); 38}); 39 40store.emit('userLoggedIn', { name: 'John' }); 41 42unsubscribe(); // Clean up

Strategy Pattern#

1// Define a family of algorithms 2const pricingStrategies = { 3 regular: (price) => price, 4 member: (price) => price * 0.9, 5 premium: (price) => price * 0.8, 6 sale: (price) => price * 0.5, 7}; 8 9class ShoppingCart { 10 constructor() { 11 this.items = []; 12 this.pricingStrategy = pricingStrategies.regular; 13 } 14 15 setPricingStrategy(strategy) { 16 this.pricingStrategy = pricingStrategies[strategy] || pricingStrategies.regular; 17 } 18 19 addItem(item) { 20 this.items.push(item); 21 } 22 23 calculateTotal() { 24 const subtotal = this.items.reduce((sum, item) => sum + item.price, 0); 25 return this.pricingStrategy(subtotal); 26 } 27} 28 29// Usage 30const cart = new ShoppingCart(); 31cart.addItem({ name: 'Laptop', price: 1000 }); 32cart.addItem({ name: 'Mouse', price: 50 }); 33 34console.log(cart.calculateTotal()); // 1050 35 36cart.setPricingStrategy('premium'); 37console.log(cart.calculateTotal()); // 840

Command Pattern#

1// Encapsulate requests as objects 2class Command { 3 execute() { 4 throw new Error('Must implement execute'); 5 } 6 7 undo() { 8 throw new Error('Must implement undo'); 9 } 10} 11 12class AddTextCommand extends Command { 13 constructor(document, text, position) { 14 super(); 15 this.document = document; 16 this.text = text; 17 this.position = position; 18 } 19 20 execute() { 21 this.document.insert(this.text, this.position); 22 } 23 24 undo() { 25 this.document.delete(this.position, this.text.length); 26 } 27} 28 29class CommandHistory { 30 constructor() { 31 this.history = []; 32 this.position = -1; 33 } 34 35 execute(command) { 36 command.execute(); 37 this.history = this.history.slice(0, this.position + 1); 38 this.history.push(command); 39 this.position++; 40 } 41 42 undo() { 43 if (this.position >= 0) { 44 this.history[this.position].undo(); 45 this.position--; 46 } 47 } 48 49 redo() { 50 if (this.position < this.history.length - 1) { 51 this.position++; 52 this.history[this.position].execute(); 53 } 54 } 55} 56 57// Usage 58const history = new CommandHistory(); 59history.execute(new AddTextCommand(doc, 'Hello', 0)); 60history.execute(new AddTextCommand(doc, ' World', 5)); 61history.undo(); // Removes ' World' 62history.redo(); // Adds ' World' back

Conclusion#

Design patterns provide vocabulary and proven solutions for common problems. Modern JavaScript features (classes, modules, Proxy, higher-order functions) make many patterns cleaner to implement.

Don't force patterns where they're not needed. Use them when they solve real problems in your codebase.

Share this article

Help spread the word about Bootspring