After spending a couple of years in JavaScript development, I’ve realized how incredibly important design patterns are, in modern JavaScript (ES6). And I’d love to share my experience and knowledge on the subject, hoping you’d make this a critical part of your development process as well.
Note: All the examples covered in this post are implemented with ES6 features, but you can also integrate the design patterns with ES5.
At Velotio, we always follow best practices to achieve highly maintainable and more robust code. And we are strong believers of using design patterns as one of the best ways to write clean code.
In the post below, I’ve listed the most useful design patterns I’ve implemented so far and how you can implement them too:
1. Module
The module pattern simply allows you to keep units of code cleanly separated and organized.
Modules promote encapsulation, which means the variables and functions are kept private inside the module body and can’t be overwritten.
Creating a module in ES6 is quite simple.
// Addition module
export const sum = (num1, num2) => num1 + num2;// usage
import { sum } from 'modules/sum';
const result = sum(20, 30); // 50ES6 also allows us to export the module as default. The following example gives you a better understanding of this.
// All the variables and functions which are not exported are private within the module and cannot be used outside. Only the exported members are public and can be used by importing them.
// Here the businessList is private member to city module
const businessList = new WeakMap();
// Here City uses the businessList member as it’s in same module
class City {
constructor() {
businessList.set(this, ['Pizza Hut', 'Dominos', 'Street Pizza']);
}
// public method to access the private ‘businessList’
getBusinessList() {
return businessList.get(this);
}
// public method to add business to ‘businessList’
addBusiness(business) {
businessList.get(this).push(business);
}
}
// export the City class as default module
export default City;// usage
import City from 'modules/city';
const city = new City();
city.getBusinessList();There is a great article written on the features of ES6 modules here.
2. Factory
Imagine creating a Notification Management application where your application currently only allows for a notification through Email, so most of the code lives inside the EmailNotification class. And now there is a new requirement for PushNotifications. So, to implement the PushNotifications, you have to do a lot of work as your application is mostly coupled with the EmailNotification. You will repeat the same thing for future implementations.
To solve this complexity, we will delegate the object creation to another object called factory.
class PushNotification {
constructor(sendTo, message) {
this.sendTo = sendTo;
this.message = message;
}
}
class EmailNotification {
constructor(sendTo, cc, emailContent) {
this.sendTo = sendTo;
this.cc = cc;
this.emailContent = emailContent;
}
}
// Notification Factory
class NotificationFactory {
createNotification(type, props) {
switch (type) {
case 'email':
return new EmailNotification(props.sendTo, props.cc, props.emailContent);
case 'push':
return new PushNotification(props.sendTo, props.message);
}
}
}
// usage
const factory = new NotificationFactory();
// create email notification
const emailNotification = factory.createNotification('email', {
sendTo: 'receiver@domain.com',
cc: 'test@domain.com',
emailContent: 'This is the email content to be delivered.!',
});
// create push notification
const pushNotification = factory.createNotification('push', {
sendTo: 'receiver-device-id',
message: 'The push notification message',
});3. Observer
(Also known as the publish/subscribe pattern.)
An observer pattern maintains the list of subscribers so that whenever an event occurs, it will notify them. An observer can also remove the subscriber if the subscriber no longer wishes to be notified.
On YouTube, many times, the channels we’re subscribed to will notify us whenever a new video is uploaded.
// Publisher
class Video {
constructor(observable, name, content) {
this.observable = observable;
this.name = name;
this.content = content;
// publish the ‘video-uploaded’ event
this.observable.publish('video-uploaded', {
name,
content,
});
}
}
// Subscriber
class User {
constructor(observable) {
this.observable = observable;
this.intrestedVideos = [];
// subscribe with the event naame and the call back function
this.observable.subscribe('video-uploaded', this.addVideo.bind(this));
}
addVideo(video) {
this.intrestedVideos.push(video);
}
}
// Observer
class Observable {
constructor() {
this.handlers = [];
}
subscribe(event, handler) {
this.handlers[event] = this.handlers[event] || [];
this.handlers[event].push(handler);
}
publish(event, eventData) {
const eventHandlers = this.handlers[event];
if (eventHandlers) {
for (var i = 0, l = eventHandlers.length; i < l; ++i) {
eventHandlers[i].call({}, eventData);
}
}
}
}
// usage
const observable = new Observable();
const user = new User(observable);
const video = new Video(observable, 'ES6 Design Patterns', videoFile);4. Mediator
The mediator pattern provides a unified interface through which different components of an application can communicate with each other.
If a system appears to have too many direct relationships between components, it may be time to have a central point of control that components communicate through instead.
The mediator promotes loose coupling.
A real-time analogy could be a traffic light signal that handles which vehicles can go and stop, as all the communications are controlled from a traffic light.
Let’s create a chatroom (mediator) through which the participants can register themselves. The chatroom is responsible for handling the routing when the participants chat with each other.
// each participant represented by Participant object
class Participant {
constructor(name) {
this.name = name;
}
getParticiantDetails() {
return this.name;
}
}
// Mediator
class Chatroom {
constructor() {
this.participants = {};
}
register(participant) {
this.participants[participant.name] = participant;
participant.chatroom = this;
}
send(message, from, to) {
if (to) {
// single message
to.receive(message, from);
} else {
// broadcast message to everyone
for (key in this.participants) {
if (this.participants[key] !== from) {
this.participants[key].receive(message, from);
}
}
}
}
}
// usage
// Create two participants
const john = new Participant('John');
const snow = new Participant('Snow');
// Register the participants to Chatroom
var chatroom = new Chatroom();
chatroom.register(john);
chatroom.register(snow);
// Participants now chat with each other
john.send('Hey, Snow!');
john.send('Are you there?');
snow.send('Hey man', yoko);
snow.send('Yes, I heard that!');5. Command
In the command pattern, an operation is wrapped as a command object and passed to the invoker object. The invoker object passes the command to the corresponding object, which executes the command.
The command pattern decouples the objects executing the commands from objects issuing the commands. The command pattern encapsulates actions as objects. It maintains a stack of commands whenever a command is executed, and pushed to stack. To undo a command, it will pop the action from stack and perform reverse action.
You can consider a calculator as a command that performs addition, subtraction, division and multiplication, and each operation is encapsulated by a command object.
// The list of operations can be performed
const addNumbers = (num1, num2) => num1 + num2;
const subNumbers = (num1, num2) => num1 - num2;
const multiplyNumbers = (num1, num2) => num1 * num2;
const divideNumbers = (num1, num2) => num1 / num2;
// CalculatorCommand class initialize with execute function, undo function // and the value
class CalculatorCommand {
constructor(execute, undo, value) {
this.execute = execute;
this.undo = undo;
this.value = value;
}
}
// Here we are creating the command objects
const DoAddition = value => new CalculatorCommand(addNumbers, subNumbers, value);
const DoSubtraction = value => new CalculatorCommand(subNumbers, addNumbers, value);
const DoMultiplication = value => new CalculatorCommand(multiplyNumbers, divideNumbers, value);
const DoDivision = value => new CalculatorCommand(divideNumbers, multiplyNumbers, value);
// AdvancedCalculator which maintains the list of commands to execute and // undo the executed command
class AdvancedCalculator {
constructor() {
this.current = 0;
this.commands = [];
}
execute(command) {
this.current = command.execute(this.current, command.value);
this.commands.push(command);
}
undo() {
let command = this.commands.pop();
this.current = command.undo(this.current, command.value);
}
getCurrentValue() {
return this.current;
}
}
// usage
const advCal = new AdvancedCalculator();
// invoke commands
advCal.execute(new DoAddition(50)); //50
advCal.execute(new DoSubtraction(25)); //25
advCal.execute(new DoMultiplication(4)); //100
advCal.execute(new DoDivision(2)); //50
// undo commands
advCal.undo();
advCal.getCurrentValue(); //1006. Facade
The facade pattern is used when we want to show the higher level of abstraction and hide the complexity behind the large codebase.
A great example of this pattern is used in the common DOM manipulation libraries like jQuery, which simplifies the selection and events adding mechanism of the elements.
// JavaScript:
/* handle click event */
document.getElementById('counter').addEventListener('click', () => {
counter++;
});
// jQuery:
/* handle click event */
$('#counter').on('click', () => {
counter++;
});Though it seems simple on the surface, there is an entire complex logic implemented when performing the operation.
The following Account Creation example gives you clarity about the facade pattern:
// Here AccountManager is responsible to create new account of type
// Savings or Current with the unique account number
let currentAccountNumber = 0;
class AccountManager {
createAccount(type, details) {
const accountNumber = AccountManager.getUniqueAccountNumber();
let account;
if (type === 'current') {
account = new CurrentAccount();
} else {
account = new SavingsAccount();
}
return account.addAccount({ accountNumber, details });
}
static getUniqueAccountNumber() {
return ++currentAccountNumber;
}
}
// class Accounts maintains the list of all accounts created
class Accounts {
constructor() {
this.accounts = [];
}
addAccount(account) {
this.accounts.push(account);
return this.successMessage(complaint);
}
getAccount(accountNumber) {
return this.accounts.find(account => account.accountNumber === accountNumber);
}
successMessage(account) {}
}
// CurrentAccounts extends the implementation of Accounts for providing more specific success messages on successful account creation
class CurrentAccounts extends Accounts {
constructor() {
super();
if (CurrentAccounts.exists) {
return CurrentAccounts.instance;
}
CurrentAccounts.instance = this;
CurrentAccounts.exists = true;
return this;
}
successMessage({ accountNumber, details }) {
return `Current Account created with ${details}. ${accountNumber} is your account number.`;
}
}
// Same here, SavingsAccount extends the implementation of Accounts for providing more specific success messages on successful account creation
class SavingsAccount extends Accounts {
constructor() {
super();
if (SavingsAccount.exists) {
return SavingsAccount.instance;
}
SavingsAccount.instance = this;
SavingsAccount.exists = true;
return this;
}
successMessage({ accountNumber, details }) {
return `Savings Account created with ${details}. ${accountNumber} is your account number.`;
}
}
// usage
// Here we are hiding the complexities of creating account
const accountManager = new AccountManager();
const currentAccount = accountManager.createAccount('current', { name: 'John Snow', address: 'pune' });
const savingsAccount = accountManager.createAccount('savings', { name: 'Petter Kim', address: 'mumbai' });7. Adapter
The adapter pattern converts the interface of a class to another expected interface, making two incompatible interfaces work together.
With the adapter pattern, you might need to show the data from a 3rd party library with the bar chart representation, but the data formats of the 3rd party library API and the display bar chart are different. Below, you’ll find an adapter that converts the 3rd party library API response to Highcharts’ bar representation:
// API Response
[{
symbol: 'SIC DIVISION',
exchange: 'Agricultural services',
volume: 42232,
}]
// Required format
[{
category: 'Agricultural services',
name: 'SIC DIVISION',
y: 42232,
}]
const mapping = {
symbol: 'category',
exchange: 'name',
volume: 'y',
};
const highchartsAdapter = (response, mapping) => {
return response.map(item => {
const normalized = {};
// Normalize each response's item key, according to the mapping
Object.keys(item).forEach(key => (normalized[mapping[key]] = item[key]));
return normalized;
});
};
highchartsAdapter(response, mapping);Conclusion
This has been a brief introduction to the design patterns in modern JavaScript (ES6). This subject is massive, but hopefully this article has shown you the benefits of using it when writing code.
Related Articles
1. Cleaner, Efficient Code with Hooks and Functional Programming
2. Building a Progressive Web Application in React [With Live Code Examples]
Leave a Reply