JavaScript Design Patterns: Strategy, Factory, Adapter, Singleton, and CQRS
Complete guide to essential design patterns in JavaScript — practical implementations with real-world examples
Introduction
Design patterns are proven solutions to common software design problems. While many patterns originated in classical OOP languages, they adapt beautifully to JavaScript's flexible nature. This guide covers five essential patterns with practical JavaScript implementations.
Strategy Pattern
Purpose: Define a family of algorithms, encapsulate each one, and make them interchangeable.
The Strategy pattern lets you switch algorithms at runtime without modifying the client code.
// ❌ Without Strategy - conditional logic everywhere
class PaymentProcessor {
process(method, amount) {
if (method === 'credit') {
console.log(`Processing $${amount} via credit card`);
} else if (method === 'paypal') {
console.log(`Processing $${amount} via PayPal`);
} else if (method === 'crypto') {
console.log(`Processing $${amount} via cryptocurrency`);
}
}
}
// ✅ With Strategy - each algorithm is a separate strategy
const paymentStrategies = {
credit: (amount) => console.log(`Processing $${amount} via credit card`),
paypal: (amount) => console.log(`Processing $${amount} via PayPal`),
crypto: (amount) => console.log(`Processing $${amount} via cryptocurrency`)
};
class PaymentProcessor {
process(strategy, amount) {
strategy(amount);
}
}
// Add new strategies without modifying existing code
paymentStrategies.applePay = (amount) => console.log(`Processing $${amount} via Apple Pay`);
Real-world example: Form validation
const validationStrategies = {
required: (value) => value ? null : 'This field is required',
email: (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? null : 'Invalid email',
minLength: (min) => (value) => value.length >= min ? null : `Minimum ${min} characters`,
maxLength: (max) => (value) => value.length <= max ? null : `Maximum ${max} characters`
};
function validateField(value, rules) {
for (const rule of rules) {
const error = rule(value);
if (error) return error;
}
return null;
}
// Usage
const emailRules = [validationStrategies.required, validationStrategies.email];
console.log(validateField('', emailRules)); // 'This field is required'
console.log(validateField('invalid', emailRules)); // 'Invalid email'
console.log(validateField('user@example.com', emailRules)); // null
Factory Pattern
Purpose: Create objects without specifying the exact class of object to be created.
The Factory pattern centralizes object creation logic, making it easy to add new types.
// ❌ Without Factory - scattered object creation
function createReport(type, data) {
if (type === 'pdf') {
return new PDFReport(data);
} else if (type === 'csv') {
return new CSVReport(data);
} else if (type === 'json') {
return new JSONReport(data);
}
}
// ✅ With Factory - centralized, extensible
class ReportFactory {
constructor() {
this.reportTypes = {};
}
register(type, reportClass) {
this.reportTypes[type] = reportClass;
}
create(type, data) {
const ReportClass = this.reportTypes[type];
if (!ReportClass) throw new Error(`Unknown report type: ${type}`);
return new ReportClass(data);
}
}
// Register report types
const factory = new ReportFactory();
factory.register('pdf', PDFReport);
factory.register('csv', CSVReport);
factory.register('json', JSONReport);
// Create reports
const pdfReport = factory.create('pdf', data);
const csvReport = factory.create('csv', data);
Real-world example: API client factory
class APIClientFactory {
static createClient(type, config) {
switch (type) {
case 'rest':
return new RESTClient(config);
case 'graphql':
return new GraphQLClient(config);
case 'grpc':
return new GRPCClient(config);
default:
throw new Error(`Unknown client type: ${type}`);
}
}
}
// Usage
const restClient = APIClientFactory.createClient('rest', { baseURL: '/api' });
const gqlClient = APIClientFactory.createClient('graphql', { endpoint: '/graphql' });
Adapter Pattern
Purpose: Allow incompatible interfaces to work together.
The Adapter pattern wraps an existing class with a new interface, making it compatible with your code.
// ❌ Without Adapter - incompatible interfaces
class OldLogger {
logMessage(msg) { console.log(msg); }
}
class NewLogger {
info(msg) { console.log(`[INFO] ${msg}`); }
error(msg) { console.error(`[ERROR] ${msg}`); }
}
// Code expects NewLogger interface
function processData(logger) {
logger.info('Processing started');
// ...
logger.info('Processing complete');
}
// OldLogger doesn't have info() method!
// ✅ With Adapter - wrap OldLogger to match NewLogger interface
class LoggerAdapter {
constructor(oldLogger) {
this.oldLogger = oldLogger;
}
info(msg) {
this.oldLogger.logMessage(`[INFO] ${msg}`);
}
error(msg) {
this.oldLogger.logMessage(`[ERROR] ${msg}`);
}
}
// Now OldLogger works with code expecting NewLogger
const oldLogger = new OldLogger();
const adapted = new LoggerAdapter(oldLogger);
processData(adapted); // Works!
Real-world example: Third-party library adapter
// Third-party analytics library
class ThirdPartyAnalytics {
trackEvent(name, properties) {
// Different API than what our app uses
}
}
// Our app's analytics interface
class AnalyticsAdapter {
constructor(thirdParty) {
this.thirdParty = thirdParty;
}
track(eventName, metadata = {}) {
this.thirdParty.trackEvent(eventName, {
...metadata,
timestamp: new Date().toISOString()
});
}
identify(userId, traits = {}) {
this.thirdParty.identifyUser(userId, traits);
}
}
Singleton Pattern
Purpose: Ensure a class has only one instance and provide a global access point.
// ✅ Singleton using ES modules (preferred in modern JS)
// database.js
class Database {
constructor() {
if (Database.instance) {
return Database.instance;
}
this.connection = this.connect();
Database.instance = this;
}
connect() {
console.log('Connecting to database...');
return { /* connection object */ };
}
query(sql) {
return this.connection.execute(sql);
}
}
// Every import gets the same instance
import db from './database.js';
// db is always the same instance
// ✅ Singleton using closures
const createLogger = (() => {
let instance;
return () => {
if (instance) return instance;
instance = {
logs: [],
log(message) {
const entry = { message, timestamp: new Date() };
this.logs.push(entry);
console.log(message);
},
getLogs() {
return [...this.logs];
}
};
return instance;
};
})();
const logger1 = createLogger();
const logger2 = createLogger();
console.log(logger1 === logger2); // true
When to use Singleton:
- ✅ Database connections
- ✅ Configuration management
- ✅ Logging services
- ✅ Cache stores
When to avoid Singleton:
- ❌ When you need testability (hard to mock)
- ❌ When state isolation is needed
- ❌ When you might need multiple instances later
CQRS (Command Query Responsibility Segregation)
Purpose: Separate read operations (queries) from write operations (commands).
CQRS splits your data model into two parts: one for reading and one for writing.
// ❌ Without CQRS - mixed reads and writes
class UserService {
getUser(id) {
return db.users.findById(id);
}
updateUser(id, data) {
const user = db.users.findById(id);
user.name = data.name;
user.email = data.email;
db.users.save(user);
return user;
}
listUsers() {
return db.users.findAll();
}
}
// ✅ With CQRS - separated commands and queries
// Commands (write side)
class UserCommands {
createUser(data) {
const user = { id: generateId(), ...data, createdAt: new Date() };
db.users.insert(user);
eventBus.emit('UserCreated', user);
return user;
}
updateUser(id, data) {
const user = db.users.findById(id);
if (!user) throw new Error('User not found');
Object.assign(user, data, { updatedAt: new Date() });
db.users.save(user);
eventBus.emit('UserUpdated', user);
return user;
}
deleteUser(id) {
db.users.delete(id);
eventBus.emit('UserDeleted', { id });
}
}
// Queries (read side)
class UserQueries {
getUser(id) {
return db.users.findById(id);
}
listUsers(filters = {}) {
let query = db.users.createQuery();
if (filters.status) query = query.where('status', filters.status);
if (filters.role) query = query.where('role', filters.role);
return query.execute();
}
searchUsers(term) {
return db.users.search(term);
}
}
Benefits of CQRS:
- ✅ Optimize reads and writes independently
- ✅ Scale read and write sides separately
- ✅ Simpler queries (no write concerns)
- ✅ Better security (separate permissions)
Pattern Selection Guide
| Pattern | Use When | Example |
|---|---|---|
| Strategy | Multiple algorithms for same task | Payment methods, sorting, validation |
| Factory | Complex object creation | Report generation, API clients |
| Adapter | Incompatible interfaces | Third-party integrations, legacy code |
| Singleton | Single instance needed | Database, config, logger |
| CQRS | Separate reads from writes | Complex domains, high-scale apps |
Best Practices
Do's
// ✅ Use ES modules for singletons
export default new Database();
// ✅ Register strategies/factories dynamically
factory.register('newType', NewTypeClass);
// ✅ Keep adapters thin - just translate interfaces
class Adapter {
newMethod(args) { return this.old.method(args); }
}
// ✅ Start simple, add CQRS when needed
// Don't over-engineer early
Don'ts
// ❌ Don't use Singleton when testability matters
// Use dependency injection instead
// ❌ Don't create factories for simple objects
// new User({ name: 'Alice' }) is fine
// ❌ Don't over-abstract with patterns
// Patterns solve problems, don't create them
// ❌ Don't apply CQRS to simple CRUD apps
// It adds complexity without benefit
Conclusion
Design patterns are tools, not rules:
- Strategy — Swap algorithms at runtime
- Factory — Centralize object creation
- Adapter — Make incompatible interfaces work together
- Singleton — Ensure single instance (use sparingly)
- CQRS — Separate reads from writes (for complex domains)
The best pattern is the simplest solution that works. Don't force patterns where they don't add value.
#javascript #design-patterns #software-architecture #clean-code #web-development