JavaScript Design Patterns Explained with Examples
When we say “Design patterns are reusable solutions to commonly occurring problems in software development,” we’re talking about standardized approaches that developers have crafted over time to solve problems that come up frequently when writing software. Think of them as blueprints or templates that can be applied to different situations.
Let me give you some concrete examples:
- Commonly occurring problems might include:
- How to ensure only one instance of an object exists (solved by Singleton pattern)
- How to create objects without specifying the exact class (solved by Factory pattern)
- How to notify multiple objects about changes (solved by Observer pattern)
- How to organize and encapsulate code (solved by Module pattern)
- When we say these patterns lead to more maintainable code, we mean:
- The code is easier to understand for other developers (and yourself in the future)
- It follows established conventions that many developers recognize
- It’s structured in a way that makes it easier to modify or extend
- Scalable code means:
- The application can grow without becoming overly complex
- New features can be added without breaking existing functionality
- The code can handle increased loads or additional use cases
- Robust code refers to:
- Code that can handle errors gracefully
- Code that is less prone to bugs
- Code that behaves predictably in different scenarios
Here’s a simple analogy: Think of design patterns like recipes in cooking. If you’re cooking a specific dish, you don’t have to figure out from scratch how to make it - you can follow a tested recipe. Similarly, when solving common programming problems, you don’t have to reinvent the wheel; you can use established design patterns.
Let’s take a quick example. Suppose you’re building an application that needs to manage user settings. You want to ensure these settings are consistent throughout the app. Instead of creating multiple instances of a settings manager, you could use the Singleton pattern:
class Settings {
constructor() {
if (Settings.instance) {
return Settings.instance;
}
this.theme = 'light';
this.fontSize = 'medium';
Settings.instance = this;
}
changeTheme(newTheme) {
this.theme = newTheme;
}
}
// Usage
const settings1 = new Settings();
const settings2 = new Settings();
console.log(settings1 === settings2); // true
settings1.changeTheme('dark');
console.log(settings2.theme); // 'dark'
In this example:
- Maintainable: Other developers can quickly understand that this is a Singleton
- Scalable: You can easily add more settings without worrying about synchronization
- Robust: You ensure consistency by preventing multiple instances
Let’s explore four essential design patterns with practical, real-world examples.
1. Singleton Pattern
The Singleton pattern ensures that a class has only one instance and provides a global point of access to it. This is particularly useful for managing global state or resources.
Real-World Use Case: Configuration Manager
class ConfigManager {
constructor() {
if (ConfigManager.instance) {
return ConfigManager.instance;
}
this.config = {
apiUrl: 'https://api.example.com',
timeout: 5000,
version: '1.0.0'
};
ConfigManager.instance = this;
}
get(key) {
return this.config[key];
}
set(key, value) {
this.config[key] = value;
}
}
// Usage
const configManager1 = new ConfigManager();
const configManager2 = new ConfigManager();
console.log(configManager1 === configManager2); // true
configManager1.set('apiUrl', 'https://api.newexample.com');
console.log(configManager2.get('apiUrl')); // https://api.newexample.com
In this example, the ConfigManager ensures that application configuration is consistent across different parts of your application.
2. Factory Pattern
The Factory pattern provides an interface for creating objects but allows subclasses to decide which class to instantiate. It’s useful when you need to create objects without exposing the creation logic.
Real-World Use Case: Payment Method Factory
class PaymentMethod {
processPayment(amount) {
throw new Error('processPayment method must be implemented');
}
}
class CreditCardPayment extends PaymentMethod {
processPayment(amount) {
console.log(`Processing credit card payment of $${amount}`);
}
}
class PayPalPayment extends PaymentMethod {
processPayment(amount) {
console.log(`Processing PayPal payment of $${amount}`);
}
}
class PaymentMethodFactory {
createPaymentMethod(type) {
switch (type) {
case 'credit-card':
return new CreditCardPayment();
case 'paypal':
return new PayPalPayment();
default:
throw new Error('Invalid payment method');
}
}
}
// Usage
const factory = new PaymentMethodFactory();
const creditCardPayment = factory.createPaymentMethod('credit-card');
const paypalPayment = factory.createPaymentMethod('paypal');
creditCardPayment.processPayment(100); // Processing credit card payment of $100
paypalPayment.processPayment(50); // Processing PayPal payment of $50
This pattern is excellent for payment processing systems where different payment methods need to be handled uniformly.
3. Observer Pattern
The Observer pattern defines a one-to-many dependency between objects. When one object changes state, all its dependents are notified and updated automatically.
Real-World Use Case: News Feed
class NewsFeed {
constructor() {
this.subscribers = [];
}
subscribe(subscriber) {
this.subscribers.push(subscriber);
}
unsubscribe(subscriber) {
this.subscribers = this.subscribers.filter(sub => sub !== subscriber);
}
notify(news) {
this.subscribers.forEach(subscriber => subscriber.update(news));
}
}
class NewsSubscriber {
constructor(name) {
this.name = name;
}
update(news) {
console.log(`${this.name} received news: ${news}`);
}
}
// Usage
const newsFeed = new NewsFeed();
const subscriber1 = new NewsSubscriber('John');
const subscriber2 = new NewsSubscriber('Jane');
newsFeed.subscribe(subscriber1);
newsFeed.subscribe(subscriber2);
newsFeed.notify('Breaking: JavaScript is awesome!');
// John received news: Breaking: JavaScript is awesome!
// Jane received news: Breaking: JavaScript is awesome!
newsFeed.unsubscribe(subscriber1);
newsFeed.notify('Another news update');
// Jane received news: Another news update
This pattern is perfect for implementing features like notifications, event handling, or any scenario where you need to maintain a list of dependents to notify.
4. Module Pattern
The Module pattern encapsulates ‘privacy’, state and organization using closures. It provides a way to wrap public and private methods and variables in a single object.
Real-World Use Case: User Authentication Module
const UserAuth = (function() {
// Private variables and methods
let currentUser = null;
function validateUsername(username) {
return username.length >= 3;
}
function validatePassword(password) {
return password.length >= 8;
}
// Public API
return {
login(username, password) {
if (validateUsername(username) && validatePassword(password)) {
currentUser = username;
return `${username} successfully logged in`;
}
return 'Invalid username or password';
},
logout() {
const username = currentUser;
currentUser = null;
return `${username} logged out`;
},
getCurrentUser() {
return currentUser;
}
};
})();
// Usage
console.log(UserAuth.login('john', 'password123')); // john successfully logged in
console.log(UserAuth.getCurrentUser()); // john
console.log(UserAuth.logout()); // john logged out
The Module pattern is excellent for creating a public API while keeping certain variables and methods private and inaccessible from the outside.
Conclusion
Design patterns are powerful tools in a developer’s arsenal. By understanding and correctly implementing these patterns, you can write more maintainable and scalable code. Remember, patterns should be used judiciously - not every problem requires a design pattern solution. The key is to understand when and where to apply them effectively.
As you continue your JavaScript journey, you’ll encounter situations where these patterns can significantly improve your code structure. Practice implementing them in your projects, and you’ll become more proficient at recognizing scenarios where each pattern can be beneficial.
Happy coding!