Practical Design Patterns in JavaScript
Created at
||Updated at
Lesson watched on Pluralsight (link).
This is not a deep dive into the topic, rather a short introduction to the design patterns mentioned in the course. A deep dive may follow in a future piece.
Design patterns have emerged as solutions to common programming challenges and best practices for structuring and organising code. The concept of design patterns originated from Christopher Alexander’s architectural work (“A pattern language”) and was later adapted to software development by the Gang of Four in their book “Design Patterns: Elements of Reusable Object-Oriented Software”. JavaScript design patterns gained popularity as developers sought ways to address the language’s unique features and limitations, ultimately leading to the creation of patterns like constructor, module, factory, singleton, and more. These patterns have since become valuable tools for developers, promoting code reuse, maintainability and flexibility.
To be more accurate, a design pattern
- Solves a problem
- Is a proven concept
- Does not describe an obvious solution (e.g. it is not a for loop)
- Describes a relationship, how things interact
- Has a significant human component, we have to make it work for our scenario
We are going to shortly look over the following patterns, each with a short code sample.
- Creational: constructor, module, factory, singleton
- Structural: decorator, facade, flyweight
- Behavioural: command, mediator, observer
Creational design patterns
These patterns provide solutions for creating objects in a flexible and reusable manner. They address various aspects of object creation, such as creating objects based on specific conditions and providing a centralised interface for object creation. They aim to increase flexibility, promote code reuse, and simplify object creation processes.
Constructor pattern
Create new objects with their own object scope. Usually, when using this pattern, we want to create more than one objects.
The new
keyword:
- Creates a new object
- Links to an object prototype
- Binds
this
to the new object scope - Returns
this
implicitly
function Job(title) {
this.title = title;
this.published = false;
this.publish = function () {
this.published = true;
};
}
const jsEngineerJob = new Job('JS Engineer');
jsEngineerJob.publish();
We can do the same with the class
keyword.
Note: Javascript does not have actual classes, they are transpiled to functions and prototypes under the hood.
class Job {
constructor(title) {
this.title = title;
this.published = false;
}
publish() {
this.published = true;
}
}
const jsEngineerJob = new Job('JS Engineer');
jsEngineerJob.publish();
Pros:
- Straightforward to use.
- Encapsulates the creation and initialisation logic.
Cons:
- Lack of privacy, all properties and methods are public. Though this can change when using private class fields.
- Can be memory inefficient, since methods are duplicated for each instance.
Prototype
This is a way to create objects by defining a prototype object that serves as a blueprint. Properties and methods can be added to the prototype, which are then inherited by all instances created from it through the prototype chain. This promotes code reuse and avoids duplicating common properties and methods for each instance. The pattern emphasises inheritance and shared behavior among objects.
const job = {
title: '',
published: false,
publish() {
this.published = true;
},
};
const jsEngineerJob = Object.create(job);
jsEngineerJob.title = 'JS Engineer';
jsEngineerJob.publish();
Pros:
- Code reuse and memory efficiency, all objects share the same methods.
- Dynamic runtime modification, properties can be modified at runtime. Depending on the use case though, this can be also be a con.
Cons:
- Potential modification risks at runtime.
- Complexity and potential confusion, since it involves a prototype chain.
Module pattern
This pattern is a way to encapsulate related functions and variables into a single, self-contained module. It allows for logical grouping and organisation of code, promoting modularity and reusability. The module pattern helps to avoid polluting the global namespace and provides a level of privacy by creating a closure around the module’s implementation. It allows for the selective exposure of public methods and properties, while keeping other internal elements inaccessible from the outside. Using this pattern, we do not want multiple instances of an object, but it is not enforced in some way.
Examples
// A function
const DatabaseService = (function () {
let aPrivateVariable = 42;
return {
find() {},
save() {},
};
})();
// A module
let aPrivateVariable = 42;
module.exports.DatabaseService = {
find() {},
save() {},
};
Revealing module pattern
A slight variation on the module pattern. The main concept here, is that all functionality and variables should be hidden unless deliberately exposed. By looking at the return statement we can easily see what is exposed.
Example
const DatabaseService = (function () {
let aPrivateVariable = 42;
function find() {}
function save() {}
return {
find,
save,
};
})();
Pros:
- Encapsulation and privacy, only the exposed properties and methods are accessible from the outside.
- Organisation and structure.
Cons:
- Limited access to private methods and properties, which can make testing more challenging.
Factory pattern
This pattern acts as a central factory that handles the creation of objects based on specific conditions or parameters. It provides a way to create objects without specifying their class or type explicitly.
class HybridJob {
constructor(title) {
this.title = title;
this.type = 'hybrid';
}
}
class RemoteJob {
constructor(title) {
this.title = title;
this.type = 'remote';
}
}
class JobFactory {
getJob(type, title) {
switch (type) {
case 'hybrid':
return new HybridJob(title);
case 'remote':
return new RemoteJob(title);
default:
throw new Error(`Unknown job type: ${type}`);
}
}
}
const jobFactory = new JobFactory();
const jsEngineerJob = jobFactory.getJob('remote', 'JS Engineer');
Pros:
- Abstraction and flexibility. It provides a centralised place for object creation.
Cons:
- Complexity, since a single factory is responsible for creating multiple objects.
- Limited customisation, since all objects are created through the same factory.
Singleton pattern
This is a pattern that lets us ensure that a class has only one instance, while providing a global access point to this instance.
class JobFactory {
instance = null;
constructor() {
if (JobFactory.instance) {
return JobFactory.instance;
}
JobFactory.instance = this;
}
}
const instance1 = new JobFactory();
const instance2 = new JobFactory();
instance1 === instance2; // true
Pros:
- Single instance, which can be useful for managing shared resources.
- Efficient resource utilisation.
Cons:
- May result in tight coupling.
- Difficult to test.
Structural design patterns
These patterns are concerned with the composition and structure of objects. They aim to define relationships between objects to form larger, more flexible structures. They provide solutions for organising and combining objects to achieve the desired functionality, such as simplifying complex interactions, facilitating communication between objects, or adapting interfaces between incompatible objects. Structural design patterns focus on the arrangement and composition of objects rather than solely extending or simplifying their functionality.
Decorator pattern
With this pattern we can add functionality to an object without being obtrusive. It lets us attach new behaviour, by wrapping the original. This pattern allows us to have extended functionality and it can be used to protect the existing functionality.
class Job {
constructor(title) {
this.title = title;
}
publish() {
console.log('Publishing job: ', this.title);
}
}
class HypedJobDecorator {
constructor(job) {
this.job = job;
}
publish() {
this.job.publish();
console.log('Hyped job: Also posting on Twitter.');
}
}
const jsEngineerJob = new Job('JS Engineer');
const hypedJsEngineerJob = new HypedJobDecorator(jsEngineerJob);
hypedJsEngineerJob.publish();
Pros:
- Flexible and dynamic, since we can add functionality at runtime.
- Preserves the object’s interface.
Cons:
- Can be complex, since each decorator adds a new layer of functionality.
Facade pattern
Provides a simpler interface to a more complicated system.
class Job {
constructor(title) {
this.title = title;
}
}
const jsEngineerJob = new Job('JS Engineer');
const JobService = {
publish(job) {},
setPublishDate(job) {},
notifyPublish(job) {},
save(job) {},
};
const JobServiceFacade = {
publishAndNotify(job) {
JobService.publish(job);
if (job.published) {
JobService.setPublishDate(job);
JobService.notifyPublish(job);
JobService.save(job);
}
},
};
JobServiceFacade.publishAndNotify(jsEngineerJob);
Pros:
- Simplifies the interface of a complex system.
- Improved maintainability and modularity.
Cons:
- Limited customisation to the underlying system.
- Potential performance impact, since it adds an additional layer of method calls.
Flyweight pattern
This pattern helps conserve memory by sharing common data between multiple objects instead of keeping all of the data in each object.
Note: Only useful when we have a lot of objects.
Needs to be run in node since it uses process.memoryUsage()
class Job {
constructor({ id, company, type, companyBenefits }) {
this.id = id;
this.company = company;
this.type = type;
this.companyBenefits = companyBenefits;
}
}
class FlyweightJob {
constructor({ id, ...rest }) {
this.id = id;
this.flyweight = FlyweightFactory.getFlyweight(rest);
}
}
class Flyweight {
constructor({ company, type, companyBenefits }) {
this.company = company;
this.type = type;
this.companyBenefits = companyBenefits;
}
}
const FlyweightFactory = {
flyweights: {},
getFlyweight({ company, type, companyBenefits }) {
const key = `${company}-${type}`;
if (!this.flyweights[key]) {
this.flyweights[key] = new Flyweight({
company,
type,
companyBenefits,
});
}
return this.flyweights[key];
},
countFlyweights() {
return Object.keys(this.flyweights).length;
},
};
const getJobService = ({ type }) => ({
jobs: {},
createJob({ id, ...data }) {
if (type === 'job') {
this.jobs[id] = new Job({ id, ...data });
} else {
this.jobs[id] = new FlyweightJob({ id, ...data });
}
},
countJobs() {
return Object.keys(this.jobs).length;
},
});
const JobService = getJobService({ type: 'job' });
const FlyweightJobService = getJobService({ type: 'flyweight' });
const NUM_OF_JOBS = 1000000;
const COMPANIES = ['company 1', 'company 2', 'company 3'];
const TYPES = ['remote', 'hybrid', 'office'];
const initialMemory = process.memoryUsage().heapUsed;
for (let i = 0; i < NUM_OF_JOBS; i++) {
JobService.createJob({
id: i,
company: COMPANIES[Math.floor(Math.random() * COMPANIES.length)],
type: TYPES[Math.floor(Math.random() * TYPES.length)],
companyBenefits: Array(10).fill('benefit'),
});
}
const finalMemory = process.memoryUsage().heapUsed;
const usedMemory = (finalMemory - initialMemory) / 1000000;
console.log('JobService');
console.log(`${JobService.countJobs()} jobs created`);
console.log(`Used memory: ${usedMemory} MB`);
console.log('-----------------------------------');
const initialMemoryFlyweight = process.memoryUsage().heapUsed;
for (let i = 0; i < NUM_OF_JOBS; i++) {
FlyweightJobService.createJob({
id: i,
company: COMPANIES[Math.floor(Math.random() * COMPANIES.length)],
type: TYPES[Math.floor(Math.random() * TYPES.length)],
companyBenefits: Array(10).fill('benefit'),
});
}
const finalMemoryFlyweight = process.memoryUsage().heapUsed;
const usedMemoryFlyweight =
(finalMemoryFlyweight - initialMemoryFlyweight) / 1000000;
console.log('FlyweightJobService');
console.log(`${FlyweightJobService.countJobs()} jobs created`);
console.log(`${FlyweightFactory.countFlyweights()} flyweights created`);
console.log(`Used memory: ${usedMemoryFlyweight} MB`);
Pros:
- Memory efficiency and performance improvement.
Cons:
- Added complexity.
- Potential loss of encapsulation, since it involves sharing state between objects.
Behavioural design patterns
These patterns are concerned with defining how objects communicate and collaborate to achieve specific behaviors and functionalities. They focus on the assignment of responsibilities between objects and controlling the flow of communication to ensure effective cooperation towards a common goal.
Observer pattern
With this pattern objects can watch and be notified of changes on an object.
class Job {
constructor(title) {
this.title = title;
}
publish() {
console.log(`Publishing job: ${this.title}`);
}
}
class NotificationService {
next(job) {
console.log(`Job changed - ${job.title}`);
}
}
class ObserverList {
observers = [];
subscribe(observer) {
this.observers.push(observer);
return () => {
this.observers = this.observers.filter((obs) => {
return obs !== observer;
});
};
}
notifyAll(data) {
this.observers.forEach((observer) => {
observer.next(data);
});
}
}
class ObservableJob extends Job {
constructor(title) {
super(title);
this.observers = new ObserverList();
}
publish() {
super.publish();
this.signalChange();
}
signalChange() {
this.observers.notifyAll(this);
}
subscribeObserver(observer) {
return this.observers.subscribe(observer);
}
}
const notificationService = new NotificationService();
const jsEngineerJob = new ObservableJob('JS Engineer');
const unsubscribe = jsEngineerJob.subscribeObserver(notificationService);
jsEngineerJob.publish();
// When done with observing we can call unsubscribe
unsubscribe();
Pros:
- Loose coupling and flexibility.
- Event-driven architecture.
Cons:
- Potential performance and order of execution issues.
Mediator pattern
With this pattern we can control the communication between objects, so neither has to be coupled with others.
class JobMediator {
applicants = [];
registerApplicant(applicant) {
this.applicants.push(applicant);
}
notify(applicant, message) {
this.applicants.forEach((otherApplicant) => {
if (otherApplicant !== applicant) {
otherApplicant.receive(message);
}
});
}
}
class JobApplicant {
constructor(name, mediator) {
this.name = name;
this.mediator = mediator;
this.mediator.registerApplicant(this);
}
apply(job) {
const message = `${this.name} has applied for the ${job} position.`;
this.mediator.notify(this, message);
}
receive(message) {
console.log(`${this.name} received a message: ${message}`);
}
}
const jobMediator = new JobMediator();
const john = new JobApplicant('John', jobMediator);
const alex = new JobApplicant('Alex', jobMediator);
alex.apply('JS Engineer');
Pros:
- Decoupling and simplified communication.
Cons:
- Complexity and potential performance issues.
Command pattern
This pattern turns a request into a stand-alone object that contains all information about the request. This transformation lets us pass requests as method arguments, delay or queue a request’s execution, and support undoable operations. It fully decouples the execution from the implementation, thus allowing less fragile implementations.
const JobsService = {
jobs: {},
commands: [],
publish(job) {
console.log(`Publishing job ${job.id} ...`);
this.jobs[job.id] = job;
},
clear() {
console.log('Clearing all jobs ...');
this.jobs = {};
},
replay() {
this.commands.forEach(({ name, args }) => {
this.executeNoLog(name, args);
});
},
execute(name, ...args) {
this.commands.push({
name,
args,
});
return this[name]?.apply(this, args);
},
executeNoLog(name, args) {
return this[name]?.apply(this, args);
},
};
JobsService.execute('publish', { id: 1, title: 'Job 1' });
JobsService.execute('publish', { id: 2, title: 'Job 2' });
console.log('Jobs after publishing', JobsService.jobs);
JobsService.clear();
console.log('Jobs after clearing', JobsService.jobs);
JobsService.replay();
console.log('Jobs after replaying', JobsService.jobs);
Pros:
- Decoupling of caller and receiver.
- Undoable operations.
- Support for queuing operations.
Cons:
- Can be challenging to handle more complex scenarios.
One last thing
Some resources I have found helpful regarding design patterns are:
- Design Patterns - Refactoring Guru
- Design patterns in TS
- Awesome Design Patterns
- Learning JS Design Patterns
Thank you for reading.