An Extensive Guide to JavaScript Design Patterns
When building JavaScript applications, you may encounter scenarios where you need to build objects in a certain, predefined fashion, or reuse a common class by modifying or adapting it to multiple use cases.
It is, of course, not convenient to solve these problems again and again.
This is where JavaScript design patterns come to your rescue.
JavaScript design patterns provide you with a structured, repeatable way to tackle commonly occurring problems in JavaScript development.
In this guide, we will take a look at what JavaScript design patterns are and how to use them in your JavaScript apps.
What Is a JavaScript Design Pattern?
JavaScript design patterns are repeatable template solutions for frequently occurring problems in JavaScript app development.
The idea is simple: Programmers all around the world, since the dawn of development, have faced sets of recurring issues when developing apps. Over time, some developers chose to document tried and tested ways to tackle these issues so others could refer back to the solutions with ease.
As more and more developers chose to use these solutions and recognized their efficiency in solving their problems, they became accepted as a standard way of problem-solving and were given the name “design patterns.”
As the importance of design patterns became better understood, these were further developed and standardized. Most modern design patterns have a defined structure now, are organized under multiple categories, and are taught in computer science-related degrees as independent topics.
Types of JavaScript Design Patterns
Here are some of the most popular classifications of JavaScript design patterns.
Creational
Creational design patterns are those that help solve problems around creating and managing new object instances in JavaScript. It can be as simple as limiting a class to having just one object or as complex as defining an intricate method of handpicking and adding each feature in a JavaScript object.
Some examples of creational design patterns include Singleton, Factory, Abstract Factory, and Builder, among others.
Structural
Structural design patterns are those that help solve problems around managing the structure (or schema) of JavaScript objects. These problems could include creating a relationship between two unlike objects or abstracting some features of an object away forspecific users.
A few examples of structural design patterns include Adapter, Bridge, Composite, and Facade.
Behavioral
Behavioral design patterns are those that help solve problems around how control (and responsibility) is passed between various objects. These problems could involve controlling access to a linked list or establishing a single entity that can control access to multiple types of objects.
Some examples of behavioral design patterns include Command, Iterator, Memento, and Observer.
Concurrency
Concurrency design patterns are those that help solve problems around multi-threading and multitasking. These problems could entail maintaining an active object among multiple available objects or handling multiple events supplied to a system by demultiplexing incoming input and handling it piece by piece.
A few examples of concurrency design patterns include active object, nuclear react, and scheduler.
Architectural
Architectural design patterns are those that help solve problems around software design in a broad sense. These generally are related to how to design your system and ensure high availability, mitigate risks, and avoid performance bottlenecks.
Two examples of architectural design patterns are MVC and MVVM.
Elements of a Design Pattern
Almost all design patterns can be broken down into a set of four important components. They are:
- Pattern name: This is used to identify a design pattern while communicating with other users. Examples include “singleton,” “prototype,” and more.
- Problem: This describes the aim of the design pattern. It’s a small description of the issue that the design pattern is trying to solve. It can even include an example scenario to better explain the issue. It can also contain a list of conditions to be met for a design pattern to fully solve the underlying issue.
- Solution: This is the solution to the problem at hand, made up of elements like classes, methods, interfaces, etc. It’s where the bulk of a design pattern lies — it entails relationships, responsibilities, and collaborators of various elements that are clearly defined.
- Results: This is an analysis of how well the pattern was able to solve the problem. Things like space and time usage are discussed, along with alternative approaches to solving the same problem.
If you’re looking to learn more about design patterns and their inception, MSU has some succinct study material that you can refer to.
Why Should You Use Design Patterns?
There are multiple reasons why you would want to use design patterns:
- They’re tried and tested: With a design pattern, you have a tried-and-tested solution to your problem (as long as the design pattern fits the description of your problem). You don’t have to waste time looking for alternate fixes, and you can rest assured that you have a solution that takes care of basic performance optimization for you.
- They’re easy to understand: Design patterns are meant to be small, simple, and easy to understand. You do not need to be a specialized programmer working in a specific industry for decades to understand which design pattern to use. They’re purposefully generic (not limited to any particular programming language) and can be understood by anyone who has sufficient problem-solving skills. This also helps when you have a change of hands in your tech team: A piece of code that relies on a design pattern is easier to understand for any new software developer.
- They’re simple to implement: Most design patterns are very simple, as you’ll see later on in our article. You don’t need to know multiple programming concepts to implement them in your code.
- They propose code architecture that is easily reusable: Code reusability and cleanliness are highly encouraged throughout the tech industry, and design patterns can help you achieve that. Since these patterns are a standard way of solving problems, their designers have taken care to ensure that the encompassing app architecture remains reusable, flexible, and compatible with most forms of writing code.
- They save time and app size: One of the biggest benefits of relying on a standard set of solutions is that they will help you save time when implementing them. There’s a good chance that your entire development team knows design patterns well, so it will be easier for them to plan, communicate, and collaborate when implementing them. Tried and tested solutions mean there’s a good chance you will not end up leaking any resources or taking a detour while building some feature, saving you both time and space. Also, most programming languages provide you with standard template libraries that already implement some common design patterns like Iterator and Observer.
Top 20 JavaScript Design Patterns To Master
Now that you understand what a design pattern is made of and why you need them, let’s take a deeper dive into how some of the most commonly used JavaScript design patterns can be implemented in a JavaScript app.
Creational
Let’s start the discussion with some fundamental, easy-to-learn creational design patterns.
1. Singleton
The Singleton pattern is one of the most commonly used design patterns across the software development industry. The problem that it aims to solve is to maintain only a single instance of a class. This can come in handy when instantiating objects that are resource-intensive, such as database handlers.
Here’s how you can implement it in JavaScript:
function SingletonFoo() {
let fooInstance = null;
// For our reference, let's create a counter that will track the number of active instances
let count = 0;
function printCount() {
console.log("Number of instances: " + count);
}
function init() {
// For our reference, we'll increase the count by one whenever init() is called
count++;
// Do the initialization of the resource-intensive object here and return it
return {}
}
function createInstance() {
if (fooInstance == null) {
fooInstance = init();
}
return fooInstance;
}
function closeInstance() {
count--;
fooInstance = null;
}
return {
initialize: createInstance,
close: closeInstance,
printCount: printCount
}
}
let foo = SingletonFoo();
foo.printCount() // Prints 0
foo.initialize()
foo.printCount() // Prints 1
foo.initialize()
foo.printCount() // Still prints 1
foo.initialize()
foo.printCount() // Still 1
foo.close()
foo.printCount() // Prints 0
While it serves the purpose well, the Singleton pattern is known to make debugging difficult since it masks dependencies and controls the access to initializing or destroying a class’s instances.
2. Factory
The Factory method is also one of the most popular design patterns. The problem that the Factory method aims to solve is creating objects without using the conventional constructor. Instead, it takes in the configuration (or description) of the object that you want and returns the newly created object.
Here’s how you can implement it in JavaScript:
function Factory() {
this.createDog = function (breed) {
let dog;
if (breed === "labrador") {
dog = new Labrador();
} else if (breed === "bulldog") {
dog = new Bulldog();
} else if (breed === "golden retriever") {
dog = new GoldenRetriever();
} else if (breed === "german shepherd") {
dog = new GermanShepherd();
}
dog.breed = breed;
dog.printInfo = function () {
console.log("/n/nBreed: " + dog.breed + "/nShedding Level (out of 5): " + dog.sheddingLevel + "/nCoat Length: " + dog.coatLength + "/nCoat Type: " + dog.coatType)
}
return dog;
}
}
function Labrador() {
this.sheddingLevel = 4
this.coatLength = "short"
this.coatType = "double"
}
function Bulldog() {
this.sheddingLevel = 3
this.coatLength = "short"
this.coatType = "smooth"
}
function GoldenRetriever() {
this.sheddingLevel = 4
this.coatLength = "medium"
this.coatType = "double"
}
function GermanShepherd() {
this.sheddingLevel = 4
this.coatLength = "medium"
this.coatType = "double"
}
function run() {
let dogs = [];
let factory = new Factory();
dogs.push(factory.createDog("labrador"));
dogs.push(factory.createDog("bulldog"));
dogs.push(factory.createDog("golden retriever"));
dogs.push(factory.createDog("german shepherd"));
for (var i = 0, len = dogs.length; i < len; i++) {
dogs[i].printInfo();
}
}
run()
/**
Output:
Breed: labrador
Shedding Level (out of 5): 4
Coat Length: short
Coat Type: double
Breed: bulldog
Shedding Level (out of 5): 3
Coat Length: short
Coat Type: smooth
Breed: golden retriever
Shedding Level (out of 5): 4
Coat Length: medium
Coat Type: double
Breed: german shepherd
Shedding Level (out of 5): 4
Coat Length: medium
Coat Type: double
*/
The Factory design pattern controls how the objects will be created and provides you with a quick way of creating new objects, as well as a uniform interface that defines the properties that your objects will have. You can add as many dog breeds as you want, but as long as the methods and properties exposed by the breed types remain the same, they will work flawlessly.
However, note that the Factory pattern can often lead to a large number of classes that can be difficult to manage.
3. Abstract Factory
The Abstract Factory method takes the Factory method up a level by making factories abstract and thus replaceable without the calling environment knowing the exact factory used or its internal workings. The calling environment only knows that all the factories have a set of common methods that it can call to perform the instantiation action.
This is how it can be implemented using the previous example:
// A factory to create dogs
function DogFactory() {
// Notice that the create function is now createPet instead of createDog, since we need
// it to be uniform across the other factories that will be used with this
this.createPet = function (breed) {
let dog;
if (breed === "labrador") {
dog = new Labrador();
} else if (breed === "pug") {
dog = new Pug();
}
dog.breed = breed;
dog.printInfo = function () {
console.log("/n/nType: " + dog.type + "/nBreed: " + dog.breed + "/nSize: " + dog.size)
}
return dog;
}
}
// A factory to create cats
function CatFactory() {
this.createPet = function (breed) {
let cat;
if (breed === "ragdoll") {
cat = new Ragdoll();
} else if (breed === "singapura") {
cat = new Singapura();
}
cat.breed = breed;
cat.printInfo = function () {
console.log("/n/nType: " + cat.type + "/nBreed: " + cat.breed + "/nSize: " + cat.size)
}
return cat;
}
}
// Dog and cat breed definitions
function Labrador() {
this.type = "dog"
this.size = "large"
}
function Pug() {
this.type = "dog"
this.size = "small"
}
function Ragdoll() {
this.type = "cat"
this.size = "large"
}
function Singapura() {
this.type = "cat"
this.size = "small"
}
function run() {
let pets = [];
// Initialize the two factories
let catFactory = new CatFactory();
let dogFactory = new DogFactory();
// Create a common petFactory that can produce both cats and dogs
// Set it to produce dogs first
let petFactory = dogFactory;
pets.push(petFactory.createPet("labrador"));
pets.push(petFactory.createPet("pug"));
// Set the petFactory to produce cats
petFactory = catFactory;
pets.push(petFactory.createPet("ragdoll"));
pets.push(petFactory.createPet("singapura"));
for (var i = 0, len = pets.length; i < len; i++) {
pets[i].printInfo();
}
}
run()
/**
Output:
Type: dog
Breed: labrador
Size: large
Type: dog
Breed: pug
Size: small
Type: cat
Breed: ragdoll
Size: large
Type: cat
Breed: singapura
Size: small
*/
The Abstract Factory pattern makes it easy for you to exchange concrete factories easily, and it helps promote uniformity between factories and the products created. However, it can become difficult to introduce new kinds of products since you’d have to make changes in multiple classes to accommodate new methods/properties.
4. Builder
The Builder pattern is one of the most complex yet flexible creational JavaScript design patterns. It allows you to build each feature into your product one by one, providing you full control over how your object is built while still abstracting away the internal details.
In the intricate example below, you’ll see the Builder design pattern in action along with Director to help make Pizzas!
// Here's the PizzaBuilder (you can also call it the chef)
function PizzaBuilder() {
let base
let sauce
let cheese
let toppings = []
// The definition of pizza is hidden from the customers
function Pizza(base, sauce, cheese, toppings) {
this.base = base
this.sauce = sauce
this.cheese = cheese
this.toppings = toppings
this.printInfo = function() {
console.log("This pizza has " + this.base + " base with " + this.sauce + " sauce "
+ (this.cheese !== undefined ? "with cheese. " : "without cheese. ")
+ (this.toppings.length !== 0 ? "It has the following toppings: " + toppings.toString() : ""))
}
}
// You can request the PizzaBuilder (/chef) to perform any of the following actions on your pizza
return {
addFlatbreadBase: function() {
base = "flatbread"
return this;
},
addTomatoSauce: function() {
sauce = "tomato"
return this;
},
addAlfredoSauce: function() {
sauce = "alfredo"
return this;
},
addCheese: function() {
cheese = "parmesan"
return this;
},
addOlives: function() {
toppings.push("olives")
return this
},
addJalapeno: function() {
toppings.push("jalapeno")
return this
},
cook: function() {
if (base === null){
console.log("Can't make a pizza without a base")
return
}
return new Pizza(base, sauce, cheese, toppings)
}
}
}
// This is the Director for the PizzaBuilder, aka the PizzaShop.
// It contains a list of preset steps that can be used to prepare common pizzas (aka recipes!)
function PizzaShop() {
return {
makePizzaMargherita: function() {
pizzaBuilder = new PizzaBuilder()
pizzaMargherita = pizzaBuilder.addFlatbreadBase().addTomatoSauce().addCheese().addOlives().cook()
return pizzaMargherita
},
makePizzaAlfredo: function() {
pizzaBuilder = new PizzaBuilder()
pizzaAlfredo = pizzaBuilder.addFlatbreadBase().addAlfredoSauce().addCheese().addJalapeno().cook()
return pizzaAlfredo
},
makePizzaMarinara: function() {
pizzaBuilder = new PizzaBuilder()
pizzaMarinara = pizzaBuilder.addFlatbreadBase().addTomatoSauce().addOlives().cook()
return pizzaMarinara
}
}
}
// Here's where the customer can request pizzas from
function run() {
let pizzaShop = new PizzaShop()
// You can ask for one of the popular pizza recipes...
let pizzaMargherita = pizzaShop.makePizzaMargherita()
pizzaMargherita.printInfo()
// Output: This pizza has flatbread base with tomato sauce with cheese. It has the following toppings: olives
let pizzaAlfredo = pizzaShop.makePizzaAlfredo()
pizzaAlfredo.printInfo()
// Output: This pizza has flatbread base with alfredo sauce with cheese. It has the following toppings: jalapeno
let pizzaMarinara = pizzaShop.makePizzaMarinara()
pizzaMarinara.printInfo()
// Output: This pizza has flatbread base with tomato sauce without cheese. It has the following toppings: olives
// Or send your custom request directly to the chef!
let chef = PizzaBuilder()
let customPizza = chef.addFlatbreadBase().addTomatoSauce().addCheese().addOlives().addJalapeno().cook()
customPizza.printInfo()
// Output: This pizza has flatbread base with tomato sauce with cheese. It has the following toppings: olives,jalapeno
}
run()
You can pair up the Builder with a Director, as shown by the PizzaShop
class in the example above, to predefine a set of steps to follow every time to build a standard variant of your product, i.e., a specific recipe for your pizzas.
The only issue with this design pattern is that it is quite complex to set up and maintain. Adding new features this way is simpler than the Factory method, though.
5. Prototype
The Prototype design pattern is a quick and simple way of creating new objects from existing objects by cloning them.
A prototype object is first created, which can be cloned multiple times to create new objects. It comes in handy when directly instantiating an object is a more resource-intensive operation compared to creating a copy of an existing one.
In the example below, you’ll see how you can use the Prototype pattern to create new documents based on a set template document:
// Defining how a document would look like
function Document() {
this.header = "Acme Co"
this.footer = "For internal use only"
this.pages = 2
this.text = ""
this.addText = function(text) {
this.text += text
}
// Method to help you see the contents of the object
this.printInfo = function() {
console.log("/n/nHeader: " + this.header + "/nFooter: " + this.footer + "/nPages: " + this.pages + "/nText: " + this.text)
}
}
// A protype (or template) for creating new blank documents with boilerplate information
function DocumentPrototype(baseDocument) {
this.baseDocument = baseDocument
// This is where the magic happens. A new document object is created and is assigned the values of the current object
this.clone = function() {
let document = new Document();
document.header = this.baseDocument.header
document.footer = this.baseDocument.footer
document.pages = this.baseDocument.pages
document.text = this.baseDocument.text
return document
}
}
function run() {
// Create a document to use as the base for the prototype
let baseDocument = new Document()
// Make some changes to the prototype
baseDocument.addText("This text was added before cloning and will be common in both documents. ")
let prototype = new DocumentPrototype(baseDocument)
// Create two documents from the prototype
let doc1 = prototype.clone()
let doc2 = prototype.clone()
// Make some changes to both objects
doc1.pages = 3
doc1.addText("This is document 1")
doc2.addText("This is document 2")
// Print their values
doc1.printInfo()
/* Output:
Header: Acme Co
Footer: For internal use only
Pages: 3
Text: This text was added before cloning and will be common in both documents. This is document 1
*/
doc2.printInfo()
/** Output:
Header: Acme Co
Footer: For internal use only
Pages: 2
Text: This text was added before cloning and will be common in both documents. This is document 2
*/
}
run()
The Prototype method works great for cases where a large part of your objects share the same values, or when creating a new object altogether is quite costly. However, it feels like overkill in cases where you don’t need more than a few instances of the class.
Structural
Structural design patterns help you organize your business logic by providing tried and tested ways of structuring your classes. There are a variety of structural design patterns that each cater to unique use cases.
6. Adapter
A common problem when building apps is allowing collaboration between incompatible classes.
A good example to understand this is while maintaining backward compatibility. If you write a new version of a class, you’d naturally want it to be easily usable in all places where the old version worked. However, if you make breaking changes like removing or updating methods that were crucial to the functioning of the old version, you might end up with a class that needs all of its clients to be updated in order to be run.
In such cases, the Adapter design pattern can help.
The Adapter design pattern provides you with an abstraction that bridges the gap between the new class’s methods and properties and the old class’s methods and properties. It has the same interface as the old class, but it contains logic to map old methods to the new methods to execute similar operations. This is similar to how a power plug socket acts as an adapter between a US-style plug and a European-style plug.
Here’s an example:
// Old bot
function Robot() {
this.walk = function(numberOfSteps) {
// code to make the robot walk
console.log("walked " + numberOfSteps + " steps")
}
this.sit = function() {
// code to make the robot sit
console.log("sit")
}
}
// New bot that does not have the walk function anymore
// but instead has functions to control each step independently
function AdvancedRobot(botName) {
// the new bot has a name as well
this.name = botName
this.sit = function() {
// code to make the robot sit
console.log("sit")
}
this.rightStepForward = function() {
// code to take 1 step from right leg forward
console.log("right step forward")
}
this.leftStepForward = function () {
// code to take 1 step from left leg forward
console.log("left step forward")
}
}
function RobotAdapter(botName) {
// No references to the old interfact since that is usually
// phased out of development
const robot = new AdvancedRobot(botName)
// The adapter defines the walk function by using the
// two step controls. You now have room to choose which leg to begin/end with,
// and do something at each step.
this.walk = function(numberOfSteps) {
for (let i=0; i<numberOfSteps; i++) {
if (i % 2 === 0) {
robot.rightStepForward()
} else {
robot.leftStepForward()
}
}
}
this.sit = robot.sit
}
function run() {
let robot = new Robot()
robot.sit()
// Output: sit
robot.walk(5)
// Output: walked 5 steps
robot = new RobotAdapter("my bot")
robot.sit()
// Output: sit
robot.walk(5)
// Output:
// right step forward
// left step forward
// right step forward
// left step forward
// right step forward
}
run()
The main issue with this design pattern is that it adds complexity to your source code. You already needed to maintain two different classes, and now you have another class — the Adapter — to maintain.
7. Bridge
Expanding upon the Adapter pattern, the Bridge design pattern provides both the class and the client with separate interfaces so that they may both work even in cases of incompatible native interfaces.
It helps in developing a very loosely coupled interface between the two types of objects. This also helps in enhancing the extensibility of the interfaces and their implementations for maximum flexibility.
Here’s how you can use it:
// The TV and speaker share the same interface
function TV() {
this.increaseVolume = function() {
// logic to increase TV volume
}
this.decreaseVolume = function() {
// logic to decrease TV volume
}
this.mute = function() {
// logic to mute TV audio
}
}
function Speaker() {
this.increaseVolume = function() {
// logic to increase speaker volume
}
this.decreaseVolume = function() {
// logic to decrease speaker volume
}
this.mute() = function() {
// logic to mute speaker audio
}
}
// The two remotes make use of the same common interface
// that supports volume up and volume down features
function SimpleRemote(device) {
this.pressVolumeDownKey = function() {
device.decreaseVolume()
}
this.pressVolumeUpKey = function() {
device.increaseVolume()
}
}
function AdvancedRemote(device) {
this.pressVolumeDownKey = function() {
device.decreaseVolume()
}
this.pressVolumeUpKey = function() {
device.increaseVolume()
}
this.pressMuteKey = function() {
device.mute()
}
}
function run() {
let tv = new TV()
let speaker = new Speaker()
let tvSimpleRemote = new SimpleRemote(tv)
let tvAdvancedRemote = new AdvancedRemote(tv)
let speakerSimpleRemote = new SimpleRemote(speaker)
let speakerAdvancedRemote = new AdvancedRemote(speaker)
// The methods listed in pair below will have the same effect
// on their target devices
tvSimpleRemote.pressVolumeDownKey()
tvAdvancedRemote.pressVolumeDownKey()
tvSimpleRemote.pressVolumeUpKey()
tvAdvancedRemote.pressVolumeUpKey()
// The advanced remote has additional functionality
tvAdvancedRemote.pressMuteKey()
speakerSimpleRemote.pressVolumeDownKey()
speakerAdvancedRemote.pressVolumeDownKey()
speakerSimpleRemote.pressVolumeUpKey()
speakerAdvancedRemote.pressVolumeUpKey()
speakerAdvancedRemote.pressMuteKey()
}
As you might have already guessed, the Bridge pattern greatly increases the complexity of the codebase. Also, most interfaces usually end up with only one implementation in real-world use cases, so you don’t really benefit from the code reusability much.
8. Composite
The Composite design pattern helps you structure and manage similar objects and entities easily. The basic idea behind the Composite pattern is that the objects and their logical containers can be represented using a single abstract class (that can store data/methods related to the object and references to itself for the container).
It makes the most sense to use the Composite pattern when your data model resembles a tree structure. However, you shouldn’t try to turn a non-tree data model into a tree-like data model just for the sake of using the Composite pattern, as doing so can often take away a lot of flexibility.
In the example below, you’ll see how you can use the Composite design pattern to construct a packaging system for ecommerce products that can also calculate the total order value per package:
// A product class, that acts as a Leaf node
function Product(name, price) {
this.name = name
this.price = price
this.getTotalPrice = function() {
return this.price
}
}
// A box class, that acts as a parent/child node
function Box(name) {
this.contents = []
this.name = name
// Helper function to add an item to the box
this.add = function(content){
this.contents.push(content)
}
// Helper function to remove an item from the box
this.remove = function() {
var length = this.contents.length;
for (var i = 0; i < length; i++) {
if (this.contents[i] === child) {
this.contents.splice(i, 1);
return;
}
}
}
// Helper function to get one item from the box
this.getContent = function(position) {
return this.contents[position]
}
// Helper function to get the total count of the items in the box
this.getTotalCount = function() {
return this.contents.length
}
// Helper function to calculate the total price of all items in the box
this.getTotalPrice = function() {
let totalPrice = 0;
for (let i=0; i < this.getTotalCount(); i++){
totalPrice += this.getContent(i).getTotalPrice()
}
return totalPrice
}
}
function run() {
// Let's create some electronics
const mobilePhone = new Product("mobile phone," 1000)
const phoneCase = new Product("phone case," 30)
const screenProtector = new Product("screen protector," 20)
// and some stationery products
const pen = new Product("pen," 2)
const pencil = new Product("pencil," 0.5)
const eraser = new Product("eraser," 0.5)
const stickyNotes = new Product("sticky notes," 10)
// and put them in separate boxes
const electronicsBox = new Box("electronics")
electronicsBox.add(mobilePhone)
electronicsBox.add(phoneCase)
electronicsBox.add(screenProtector)
const stationeryBox = new Box("stationery")
stationeryBox.add(pen)
stationeryBox.add(pencil)
stationeryBox.add(eraser)
stationeryBox.add(stickyNotes)
// and finally, put them into one big box for convenient shipping
const package = new Box('package')
package.add(electronicsBox)
package.add(stationeryBox)
// Here's an easy way to calculate the total order value
console.log("Total order price: USD " + package.getTotalPrice())
// Output: USD 1063
}
run()
The biggest downside to using the Composite pattern is that changes to the component interfaces can be very challenging in the future. Designing the interfaces takes time and effort, and the tree-like nature of the data model can make it very tough to make changes as you wish.
9. Decorator
The Decorator pattern helps you add new features to existing objects by simply wrapping them up inside a new object. It’s similar to how you can wrap an already-wrapped gift box with new wrapping paper as many times as you want: Each wrap allows you to add as many features as you’d like, so it’s great on the flexibility front.
From a technical perspective, no inheritance is involved, so there’sgreater freedom when designing business logic.
In the example below, you’ll see how the Decorator pattern helps to add more features to a standard Customer
class:
function Customer(name, age) {
this.name = name
this.age = age
this.printInfo = function() {
console.log("Customer:/nName : " + this.name + " | Age: " + this.age)
}
}
function DecoratedCustomer(customer, location) {
this.customer = customer
this.name = customer.name
this.age = customer.age
this.location = location
this.printInfo = function() {
console.log("Decorated Customer:/nName: " + this.name + " | Age: " + this.age + " | Location: " + this.location)
}
}
function run() {
let customer = new Customer("John," 25)
customer.printInfo()
// Output:
// Customer:
// Name : John | Age: 25
let decoratedCustomer = new DecoratedCustomer(customer, "FL")
decoratedCustomer.printInfo()
// Output:
// Customer:
// Name : John | Age: 25 | Location: FL
}
run()
The downsides of this pattern include high code complexity since there is no standard pattern defined for adding new features using decorators. You might end up with a lot of non-uniform and/or similar decorators at the end of your software development lifecycle.
If you’re not careful while designing the decorators, you might end up designing some decorators to be logically dependent on others. If this is not resolved, removing or restructuring decorators later down the line can wreak havoc on your application’s stability.
10. Facade
When building most real-world applications, the business logic usually turns out to be quite complex by the time you are done. You might end up with multiple objects and methods being involved in executing core operations in your app. Maintaining track of their initializations, dependencies, the correct order of method execution, etc., can be quite tricky and error-prone if not done correctly.
The Facade design pattern helps you create an abstraction between the environment that invokes the above-mentioned operations and the objects and methods involved in completing those operations. This abstraction houses the logic for initializing the objects, tracking their dependencies, and other important activities. The calling environment has no information on how an operation is executed. You can freely update the logic without making any breaking changes to the calling client.
Here’s how you can use it in an application:
/**
* Let's say you're trying to build an online store. It will have multiple components and
* complex business logic. In the example below, you will find a tiny segment of an online
* store composed together using the Facade design pattern. The various manager and helper
* classes are defined first of all.
*/
function CartManager() {
this.getItems = function() {
// logic to return items
return []
}
this.clearCart = function() {
// logic to clear cart
}
}
function InvoiceManager() {
this.createInvoice = function(items) {
// logic to create invoice
return {}
}
this.notifyCustomerOfFailure = function(invoice) {
// logic to notify customer
}
this.updateInvoicePaymentDetails = function(paymentResult) {
// logic to update invoice after payment attempt
}
}
function PaymentProcessor() {
this.processPayment = function(invoice) {
// logic to initiate and process payment
return {}
}
}
function WarehouseManager() {
this.prepareForShipping = function(items, invoice) {
// logic to prepare the items to be shipped
}
}
// This is where facade comes in. You create an additional interface on top of your
// existing interfaces to define the business logic clearly. This interface exposes
// very simple, high-level methods for the calling environment.
function OnlineStore() {
this.name = "Online Store"
this.placeOrder = function() {
let cartManager = new CartManager()
let items = cartManager.getItems()
let invoiceManager = new InvoiceManager()
let invoice = invoiceManager.createInvoice(items)
let paymentResult = new PaymentProcessor().processPayment(invoice)
invoiceManager.updateInvoicePaymentDetails(paymentResult)
if (paymentResult.status === 'success') {
new WarehouseManager().prepareForShipping(items, invoice)
cartManager.clearCart()
} else {
invoiceManager.notifyCustomerOfFailure(invoice)
}
}
}
// The calling environment is unaware of what goes on when somebody clicks a button to
// place the order. You can easily change the underlying business logic without breaking
// your calling environment.
function run() {
let onlineStore = new OnlineStore()
onlineStore.placeOrder()
}
A downside to using the Facade pattern is that it adds an additional layer of abstraction between your business logic and client, thereby requiring additional maintenance. More often than not, this increases the overall complexity of the codebase.
On top of that, the Facade
class becomes a mandatory dependency on your app’s functioning — meaning any errors in the Facade
class directly impact the functioning of your app.
11. Flyweight
The Flyweight pattern helps you solve problems that involve objects with repeating components in memory-efficient ways by helping you reuse the common components of your object pool. This helps reduce the load on the memory and results in faster execution times as well.
In the example below, a large sentence is stored in the memory using the Flyweight design pattern. Instead of storing each character as it occurs, the program identifies the set of distinct characters that have been used to write the paragraph and their types (number or alphabet) and builds reusable flyweights for each character that contains details of which character and type are stored.
Then the main array just stores a list of references to these flyweights in the order that they occur in the sentence instead of storing an instance of the character object whenever it occurs.
This reduces the memory taken by the sentence by half. Bear in mind that this is a very basic explanation of how text processors store text.
// A simple Character class that stores the value, type, and position of a character
function Character(value, type, position) {
this.value = value
this.type = type
this.position = position
}
// A Flyweight class that stores character value and type combinations
function CharacterFlyweight(value, type) {
this.value = value
this.type = type
}
// A factory to automatically create the flyweights that are not present in the list,
// and also generate a count of the total flyweights in the list
const CharacterFlyweightFactory = (function () {
const flyweights = {}
return {
get: function (value, type) {
if (flyweights[value + type] === undefined)
flyweights[value + type] = new CharacterFlyweight(value, type)
return flyweights[value + type]
},
count: function () {
let count = 0;
for (var f in flyweights) count++;
return count;
}
}
})()
// An enhanced Character class that uses flyweights to store references
// to recurring value and type combinations
function CharacterWithFlyweight(value, type, position) {
this.flyweight = CharacterFlyweightFactory.get(value, type)
this.position = position
}
// A helper function to define the type of a character
// It identifies numbers as N and everything as A (for alphabets)
function getCharacterType(char) {
switch (char) {
case "0":
case "1":
case "2":
case "3":
case "4":
case "5":
case "6":
case "7":
case "8":
case "9": return "N"
default:
return "A"
}
}
// A list class to create an array of Characters from a given string
function CharactersList(str) {
chars = []
for (let i = 0; i < str.length; i++) {
const char = str[i]
chars.push(new Character(char, getCharacterType(char), i))
}
return chars
}
// A list class to create an array of CharacterWithFlyweights from a given string
function CharactersWithFlyweightsList(str) {
chars = []
for (let i = 0; i " + charactersList.length)
// Output: Character count -> 656
// The number of flyweights created is only 31, since only 31 characters are used to write the
// entire paragraph. This means that to store 656 characters, a total of
// (31 * 2 + 656 * 1 = 718) memory blocks are used instead of (656 * 3 = 1968) which would have
// used by the standard array.
// (We have assumed each variable to take up one memory block for simplicity. This
// may vary in real-life scenarios)
console.log("Flyweights created -> " + CharacterFlyweightFactory.count())
// Output: Flyweights created -> 31
}
run()
As you may have already noticed, the Flyweight pattern adds to the complexity of your software design by not being particularly intuitive. So, if saving memory isn’t a pressing concern for your app, Flyweight’s added complexity can do more bad than good.
Moreover, flyweights trade memory for processing efficiency, so if you’re short on CPU cycles, Flyweight isn’t a good solution for you.
12. Proxy
The Proxy pattern helps you substitute an object for another object. In other terms, proxy objects can take the place of actual objects (that they’re a proxy of) and control access to the object. These proxy objects can be used to perform some actions before or after an invocation request is passed on to the actual object.
In the example below, you’ll see how access to a database instance is controlled via a proxy that performs some basic validation checks on the requests before allowing them through:
function DatabaseHandler() {
const data = {}
this.set = function (key, val) {
data[key] = val;
}
this.get = function (key, val) {
return data[key]
}
this.remove = function (key) {
data[key] = null;
}
}
function DatabaseProxy(databaseInstance) {
this.set = function (key, val) {
if (key === "") {
console.log("Invalid input")
return
}
if (val === undefined) {
console.log("Setting value to undefined not allowed!")
return
}
databaseInstance.set(key, val)
}
this.get = function (key) {
if (databaseInstance.get(key) === null) {
console.log("Element deleted")
}
if (databaseInstance.get(key) === undefined) {
console.log("Element not created")
}
return databaseInstance.get(key)
}
this.remove = function (key) {
if (databaseInstance.get(key) === undefined) {
console.log("Element not added")
return
}
if (databaseInstance.get(key) === null) {
console.log("Element removed already")
return
}
return databaseInstance.remove(key)
}
}
function run() {
let databaseInstance = new DatabaseHandler()
databaseInstance.set("foo," "bar")
databaseInstance.set("foo," undefined)
console.log("#1: " + databaseInstance.get("foo"))
// #1: undefined
console.log("#2: " + databaseInstance.get("baz"))
// #2: undefined
databaseInstance.set("," "something")
databaseInstance.remove("foo")
console.log("#3: " + databaseInstance.get("foo"))
// #3: null
databaseInstance.remove("foo")
databaseInstance.remove("baz")
// Create a fresh database instance to try the same operations
// using the proxy
databaseInstance = new DatabaseHandler()
let proxy = new DatabaseProxy(databaseInstance)
proxy.set("foo," "bar")
proxy.set("foo," undefined)
// Proxy jumps in:
// Output: Setting value to undefined not allowed!
console.log("#1: " + proxy.get("foo"))
// Original value is retained:
// Output: #1: bar
console.log("#2: " + proxy.get("baz"))
// Proxy jumps in again
// Output:
// Element not created
// #2: undefined
proxy.set("," "something")
// Proxy jumps in again
// Output: Invalid input
proxy.remove("foo")
console.log("#3: " + proxy.get("foo"))
// Proxy jumps in again
// Output:
// Element deleted
// #3: null
proxy.remove("foo")
// Proxy output: Element removed already
proxy.remove("baz")
// Proxy output: Element not added
}
run()
This design pattern is commonly used across the industry and helps to implement pre- and post-execution operations easily. However, just like any other design pattern, it also adds complexity to your codebase, so try not to use it if you don’t really need it.
You’ll also want to keep in mind that since an additional object is involved when making calls to your actual object, there might be some latency due to the added processing operations. Optimizing your main object’s performance now also involves optimizing your proxy’s methods for performance.
Behavioral
Behavioral design patterns help you solve problems around how objects interact with one another. This can involve sharing or passing responsibility/control between objects to complete set operations. It can also involve passing/sharing data across multiple objects in the most efficient way possible.
13. Chain of Responsibility
The Chain of Responsibility pattern is one of the simplest behavioral design patterns. It comes in handy when you are designing logic for operations that can be handled by multiple handlers.
Similar to how issue escalation works in support teams, the control is passed through a chain of handlers, and the handler responsible for taking action completes the operation. This design pattern is often used in UI design, where multiple layers of components can handle a user input event, such as a touch or a swipe.
Below you will see an example of a complaint escalation using the Chain of Responsibility pattern. The complaint will be handled by the handlers on the basis of its severity:
// Complaint class that stores title and severity of a complaint
// Higher value of severity indicates a more severe complaint
function Complaint (title, severity) {
this.title = title
this.severity = severity
}
// Base level handler that receives all complaints
function Representative () {
// If this handler can not handle the complaint, it will be forwarded to the next level
this.nextLevel = new Management()
this.handleComplaint = function (complaint) {
if (complaint.severity === 0)
console.log("Representative resolved the following complaint: " + complaint.title)
else
this.nextLevel.handleComplaint(complaint)
}
}
// Second level handler to handle complaints of severity 1
function Management() {
// If this handler can not handle the complaint, it will be forwarded to the next level
this.nextLevel = new Leadership()
this.handleComplaint = function (complaint) {
if (complaint.severity === 1)
console.log("Management resolved the following complaint: " + complaint.title)
else
this.nextLevel.handleComplaint(complaint)
}
}
// Highest level handler that handles all complaints unhandled so far
function Leadership() {
this.handleComplaint = function (complaint) {
console.log("Leadership resolved the following complaint: " + complaint.title)
}
}
function run() {
// Create an instance of the base level handler
let customerSupport = new Representative()
// Create multiple complaints of varying severity and pass them to the base handler
let complaint1 = new Complaint("Submit button doesn't work," 0)
customerSupport.handleComplaint(complaint1)
// Output: Representative resolved the following complaint: Submit button doesn't work
let complaint2 = new Complaint("Payment failed," 1)
customerSupport.handleComplaint(complaint2)
// Output: Management resolved the following complaint: Payment failed
let complaint3 = new Complaint("Employee misdemeanour," 2)
customerSupport.handleComplaint(complaint3)
// Output: Leadership resolved the following complaint: Employee misdemeanour
}
run()
The obvious issue with this design is that it’s linear, so there can be some latency in handling an operation when a large number of handlers are chained to one another.
Keeping track of all handlers can be another pain point, as it can get quite messy after a certain number of handlers. Debugging is yet another nightmare as each request can end on a different handler, making it difficult for you to standardize the logging and debugging process.
14. Iterator
The Iterator pattern is quite simple and is very commonly used in almost all modern object-oriented languages. If you find yourself faced with the task of going through a list of objects that aren’t all the same type, then normal iteration methods, such as for loops, can get quite messy — especially if you’re also writing business logic inside them.
The Iterator pattern can help you isolate the iteration and processing logic for your lists from the main business logic.
Here’s how you can use it on a rather basic list with multiple types of elements:
// Iterator for a complex list with custom methods
function Iterator(list) {
this.list = list
this.index = 0
// Fetch the current element
this.current = function() {
return this.list[this.index]
}
// Fetch the next element in the list
this.next = function() {
return this.list[this.index++]
}
// Check if there is another element in the list
this.hasNext = function() {
return this.index < this.list.length
}
// Reset the index to point to the initial element
this.resetIndex = function() {
this.index = 0
}
// Run a forEach loop over the list
this.forEach = function(callback) {
for (let element = this.next(); this.index <= this.list.length; element = this.next()) {
callback(element)
}
}
}
function run() {
// A complex list with elements of multiple data types
let list = ["Lorem ipsum," 9, ["lorem ipsum dolor," true], false]
// Create an instance of the iterator and pass it the list
let iterator = new Iterator(list)
// Log the first element
console.log(iterator.current())
// Output: Lorem ipsum
// Print all elements of the list using the iterator's methods
while (iterator.hasNext()) {
console.log(iterator.next())
/**
* Output:
* Lorem ipsum
* 9
* [ 'lorem ipsum dolor', true ]
* false
*/
}
// Reset the iterator's index to the first element
iterator.resetIndex()
// Use the custom iterator to pass an effect that will run for each element of the list
iterator.forEach(function (element) {
console.log(element)
})
/**
* Output:
* Lorem ipsum
* 9
* [ 'lorem ipsum dolor', true ]
* false
*/
}
run()
Needless to say, this pattern can be unnecessarily complex for lists without multiple types of elements. Also, if there are too many types of elements in a list, it can become difficult to manage too.
The key is to identify if you really need an iterator based on your list and its future change possibilities. What’s more, the Iterator pattern is only useful in lists, and lists can sometimes limit you to their linear mode of access. Other data structures can sometimes give you greater performance benefits.
15. Mediator
Your application design may sometimes require you to play around with a large number of distinct objects that house various kinds of business logic and often depend on one another. Handling the dependencies can sometimes get tricky as you need to keep track of how these objects exchange data and control between them.
The Mediator design pattern is aimed at helping you solve this problem by isolating the interaction logic for these objects into a separate object by itself.
This separate object is known as the mediator, and it is responsible for getting the work done by your lower-level classes. Your client or the calling environment will also interact with the mediator instead of the lower-level classes.
Here’s an example of the mediator design pattern in action:
// Writer class that receives an assignment, writes it in 2 seconds, and marks it as finished
function Writer(name, manager) {
// Reference to the manager, writer's name, and a busy flag that the manager uses while assigning the article
this.manager = manager
this.name = name
this.busy = false
this.startWriting = function (assignment) {
console.log(this.name + " started writing /"" + assignment + "/"")
this.assignment = assignment
this.busy = true
// 2 s timer to replicate manual action
setTimeout(() => { this.finishWriting() }, 2000)
}
this.finishWriting = function () {
if (this.busy === true) {
console.log(this.name + " finished writing /"" + this.assignment + "/"")
this.busy = false
return this.manager.notifyWritingComplete(this.assignment)
} else {
console.log(this.name + " is not writing any article")
}
}
}
// Editor class that receives an assignment, edits it in 3 seconds, and marks it as finished
function Editor(name, manager) {
// Reference to the manager, writer's name, and a busy flag that the manager uses while assigning the article
this.manager = manager
this.name = name
this.busy = false
this.startEditing = function (assignment) {
console.log(this.name + " started editing /"" + assignment + "/"")
this.assignment = assignment
this.busy = true
// 3 s timer to replicate manual action
setTimeout(() => { this.finishEditing() }, 3000)
}
this.finishEditing = function () {
if (this.busy === true) {
console.log(this.name + " finished editing /"" + this.assignment + "/"")
this.manager.notifyEditingComplete(this.assignment)
this.busy = false
} else {
console.log(this.name + " is not editing any article")
}
}
}
// The mediator class
function Manager() {
// Store arrays of workers
this.editors = []
this.writers = []
this.setEditors = function (editors) {
this.editors = editors
}
this.setWriters = function (writers) {
this.writers = writers
}
// Manager receives new assignments via this method
this.notifyNewAssignment = function (assignment) {
let availableWriter = this.writers.find(function (writer) {
return writer.busy === false
})
availableWriter.startWriting(assignment)
return availableWriter
}
// Writers call this method to notify they're done writing
this.notifyWritingComplete = function (assignment) {
let availableEditor = this.editors.find(function (editor) {
return editor.busy === false
})
availableEditor.startEditing(assignment)
return availableEditor
}
// Editors call this method to notify they're done editing
this.notifyEditingComplete = function (assignment) {
console.log("/"" + assignment + "/" is ready to publish")
}
}
function run() {
// Create a manager
let manager = new Manager()
// Create workers
let editors = [
new Editor("Ed," manager),
new Editor("Phil," manager),
]
let writers = [
new Writer("Michael," manager),
new Writer("Rick," manager),
]
// Attach workers to manager
manager.setEditors(editors)
manager.setWriters(writers)
// Send two assignments to manager
manager.notifyNewAssignment("var vs let in JavaScript")
manager.notifyNewAssignment("JS promises")
/**
* Output:
* Michael started writing "var vs let in JavaScript"
* Rick started writing "JS promises"
*
* After 2s, output:
* Michael finished writing "var vs let in JavaScript"
* Ed started editing "var vs let in JavaScript"
* Rick finished writing "JS promises"
* Phil started editing "JS promises"
*
* After 3s, output:
* Ed finished editing "var vs let in JavaScript"
* "var vs let in JavaScript" is ready to publish
* Phil finished editing "JS promises"
* "JS promises" is ready to publish
*/
}
run()
While the mediator provides your app design with decoupling and a great deal of flexibility, at the end of the day, it’s another class that you need to maintain. You must assess whether your design can really benefit from a mediator before writing one so you don’t end up adding unnecessary complexity to your codebase.
It’s also important to keep in mind that even though the mediator class doesn’t hold any direct business logic, it still contains a lot of code that is crucial to the functioning of your app and can therefore quickly get pretty complex.
16. Memento
Versioning objects is another common problem that you’ll face when developing apps. There are a lot of use cases where you need to maintain the history of an object, support easy rollbacks, and sometimes even support reverting those rollbacks. Writing the logic for such apps can be tough.
The Memento design pattern is meant to solve this problem easily.
A memento is considered to be a snapshot of an object at a certain point in time. The Memento design pattern makes use of these mementos to preserve snapshots of the object as it is changed over time. When you need to roll back to an old version, you can simply pull up the memento for it.
Here’s how you can implement it in a text processing app:
// The memento class that can hold one snapshot of the Originator class - document
function Text(contents) {
// Contents of the document
this.contents = contents
// Accessor function for contents
this.getContents = function () {
return this.contents
}
// Helper function to calculate word count for the current document
this.getWordCount = function () {
return this.contents.length
}
}
// The originator class that holds the latest version of the document
function Document(contents) {
// Holder for the memento, i.e., the text of the document
this.text = new Text(contents)
// Function to save new contents as a memento
this.save = function (contents) {
this.text = new Text(contents)
return this.text
}
// Function to revert to an older version of the text using a memento
this.restore = function (text) {
this.text = new Text(text.getContents())
}
// Helper function to get the current memento
this.getText = function () {
return this.text
}
// Helper function to get the word count of the current document
this.getWordCount = function () {
return this.text.getWordCount()
}
}
// The caretaker class that providers helper functions to modify the document
function DocumentManager(document) {
// Holder for the originator, i.e., the document
this.document = document
// Array to maintain a list of mementos
this.history = []
// Add the initial state of the document as the first version of the document
this.history.push(document.getText())
// Helper function to get the current contents of the documents
this.getContents = function () {
return this.document.getText().getContents()
}
// Helper function to get the total number of versions available for the document
this.getVersionCount = function () {
return this.history.length
}
// Helper function to get the complete history of the document
this.getHistory = function () {
return this.history.map(function (element) {
return element.getContents()
})
}
// Function to overwrite the contents of the document
this.overwrite = function (contents) {
let newVersion = this.document.save(contents)
this.history.push(newVersion)
}
// Function to append new content to the existing contents of the document
this.append = function (contents) {
let currentVersion = this.history[this.history.length - 1]
let newVersion
if (currentVersion === undefined)
newVersion = this.document.save(contents)
else
newVersion = this.document.save(currentVersion.getContents() + contents)
this.history.push(newVersion)
}
// Function to delete all the contents of the document
this.delete = function () {
this.history.push(this.document.save(""))
}
// Function to get a particular version of the document
this.getVersion = function (versionNumber) {
return this.history[versionNumber - 1]
}
// Function to undo the last change
this.undo = function () {
let previousVersion = this.history[this.history.length - 2]
this.document.restore(previousVersion)
this.history.push(previousVersion)
}
// Function to revert the document to a previous version
this.revertToVersion = function (version) {
let previousVersion = this.history[version - 1]
this.document.restore(previousVersion)
this.history.push(previousVersion)
}
// Helper function to get the total word count of the document
this.getWordCount = function () {
return this.document.getWordCount()
}
}
function run() {
// Create a document
let blogPost = new Document("")
// Create a caretaker for the document
let blogPostManager = new DocumentManager(blogPost)
// Change #1: Add some text
blogPostManager.append("Hello World!")
console.log(blogPostManager.getContents())
// Output: Hello World!
// Change #2: Add some more text
blogPostManager.append(" This is the second entry in the document")
console.log(blogPostManager.getContents())
// Output: Hello World! This is the second entry in the document
// Change #3: Overwrite the document with some new text
blogPostManager.overwrite("This entry overwrites everything in the document")
console.log(blogPostManager.getContents())
// Output: This entry overwrites everything in the document
// Change #4: Delete the contents of the document
blogPostManager.delete()
console.log(blogPostManager.getContents())
// Empty output
// Get an old version of the document
console.log(blogPostManager.getVersion(2).getContents())
// Output: Hello World!
// Change #5: Go back to an old version of the document
blogPostManager.revertToVersion(3)
console.log(blogPostManager.getContents())
// Output: Hello World! This is the second entry in the document
// Get the word count of the current document
console.log(blogPostManager.getWordCount())
// Output: 53
// Change #6: Undo the last change
blogPostManager.undo()
console.log(blogPostManager.getContents())
// Empty output
// Get the total number of versions for the document
console.log(blogPostManager.getVersionCount())
// Output: 7
// Get the complete history of the document
console.log(blogPostManager.getHistory())
/**
* Output:
* [
* '',
* 'Hello World!',
* 'Hello World! This is the second entry in the document',
* 'This entry overwrites everything in the document',
* '',
* 'Hello World! This is the second entry in the document',
* ''
* ]
*/
}
run()
While the Memento design pattern is a great solution for managing the history of an object, it can get very resource-intensive. Since each memento is almost a copy of the object, it can bloat your app’s memory very quickly if not used in moderation.
With a large number of objects, their lifecycle management can also be quite a tedious task. On top of all this, the Originator
and the Caretaker
classes are usually very tightly coupled, adding to the complexity of your codebase.
17. Observer
The Observer pattern provides an alternate solution to the multi-object-interaction problem (seen before in the Mediator pattern).
Instead of allowing each object to communicate with one another through a designated mediator, the Observer pattern allows them to observe each other. Objects are designed to emit events when they are trying to send out data or control, and other objects that are “listening” to these events can then receive them and interact based on their contents.
Here’s a simple demonstration of sending out newsletters to multiple people through the Observer pattern:
// The newsletter class that can send out posts to its subscribers
function Newsletter() {
// Maintain a list of subscribers
this.subscribers = []
// Subscribe a reader by adding them to the subscribers' list
this.subscribe = function(subscriber) {
this.subscribers.push(subscriber)
}
// Unsubscribe a reader by removing them from the subscribers' list
this.unsubscribe = function(subscriber) {
this.subscribers = this.subscribers.filter(
function (element) {
if (element !== subscriber) return element
}
)
}
// Publish a post by calling the receive function of all subscribers
this.publish = function(post) {
this.subscribers.forEach(function(element) {
element.receiveNewsletter(post)
})
}
}
// The reader class that can subscribe to and receive updates from newsletters
function Reader(name) {
this.name = name
this.receiveNewsletter = function(post) {
console.log("Newsletter received by " + name + "!: " + post)
}
}
function run() {
// Create two readers
let rick = new Reader("ed")
let morty = new Reader("morty")
// Create your newsletter
let newsletter = new Newsletter()
// Subscribe a reader to the newsletter
newsletter.subscribe(rick)
// Publish the first post
newsletter.publish("This is the first of the many posts in this newsletter")
/**
* Output:
* Newsletter received by ed!: This is the first of the many posts in this newsletter
*/
// Subscribe another reader to the newsletter
newsletter.subscribe(morty)
// Publish the second post
newsletter.publish("This is the second of the many posts in this newsletter")
/**
* Output:
* Newsletter received by ed!: This is the second of the many posts in this newsletter
* Newsletter received by morty!: This is the second of the many posts in this newsletter
*/
// Unsubscribe the first reader
newsletter.unsubscribe(rick)
// Publish the third post
newsletter.publish("This is the third of the many posts in this newsletter")
/**
* Output:
* Newsletter received by morty!: This is the third of the many posts in this newsletter
*/
}
run()
While the Observer pattern is a slick way of passing around control and data, it is better suited to situations where there are a large number of senders and receivers interacting with each other via a limited number of connections. If the objects were to all make one-to-one connections, you would lose the edge you get by publishing and subscribing to events since there will always be only one subscriber for each publisher (when it would have been better handled by a direct line of communication between them).
Additionally, the Observer design pattern can lead to performance problems if the subscription events are not handled properly. If an object continues to subscribe to another object even when it doesn’t need to, it will not be eligible for garbage collection and will add to the memory consumption of the app.
18. State
The State design pattern is one of the most used design patterns across the software development industry. Popular JavaScript frameworks like React and Angular heavily rely on the State pattern to manage data and app behavior based on that data.
Put simply, the State design pattern is helpful in situations where you can define definitive states of an entity (which could be a component, a page, an app, or a machine), and the entity has a predefined reaction to the state change.
Let’s say you’re trying to build a loan application process. Each step in the application process can be defined as a state.
While the customer usually sees a small list of simplified states of their application (pending, in review, accepted, and rejected), there can be other steps involved internally. At each of these steps, the application will be assigned to a distinct person and can have unique requirements.
The system is designed in such a way that at the end of processing in a state, the state is updated to the next one in line, and the next relevant set of steps is started.
Here’s how you can build a task management system using the State design pattern:
// Create titles for all states of a task
const STATE_TODO = "TODO"
const STATE_IN_PROGRESS = "IN_PROGRESS"
const STATE_READY_FOR_REVIEW = "READY_FOR_REVIEW"
const STATE_DONE = "DONE"
// Create the task class with a title, assignee, and duration of the task
function Task(title, assignee) {
this.title = title
this.assignee = assignee
// Helper function to update the assignee of the task
this.setAssignee = function (assignee) {
this.assignee = assignee
}
// Function to update the state of the task
this.updateState = function (state) {
switch (state) {
case STATE_TODO:
this.state = new TODO(this)
break
case STATE_IN_PROGRESS:
this.state = new IN_PROGRESS(this)
break
case STATE_READY_FOR_REVIEW:
this.state = new READY_FOR_REVIEW(this)
break
case STATE_DONE:
this.state = new DONE(this)
break
default:
return
}
// Invoke the callback function for the new state after it is set
this.state.onStateSet()
}
// Set the initial state of the task as TODO
this.updateState(STATE_TODO)
}
// TODO state
function TODO(task) {
this.onStateSet = function () {
console.log(task.assignee + " notified about new task /"" + task.title + "/"")
}
}
// IN_PROGRESS state
function IN_PROGRESS(task) {
this.onStateSet = function () {
console.log(task.assignee + " started working on the task /"" + task.title + "/"")
}
}
// READY_FOR_REVIEW state that updates the assignee of the task to be the manager of the developer
// for the review
function READY_FOR_REVIEW(task) {
this.getAssignee = function () {
return "Manager 1"
}
this.onStateSet = function () {
task.setAssignee(this.getAssignee())
console.log(task.assignee + " notified about completed task /"" + task.title + "/"")
}
}
// DONE state that removes the assignee of the task since it is now completed
function DONE(task) {
this.getAssignee = function () {
return ""
}
this.onStateSet = function () {
task.setAssignee(this.getAssignee())
console.log("Task /"" + task.title + "/" completed")
}
}
function run() {
// Create a task
let task1 = new Task("Create a login page," "Developer 1")
// Output: Developer 1 notified about new task "Create a login page"
// Set it to IN_PROGRESS
task1.updateState(STATE_IN_PROGRESS)
// Output: Developer 1 started working on the task "Create a login page"
// Create another task
let task2 = new Task("Create an auth server," "Developer 2")
// Output: Developer 2 notified about new task "Create an auth server"
// Set it to IN_PROGRESS as well
task2.updateState(STATE_IN_PROGRESS)
// Output: Developer 2 started working on the task "Create an auth server"
// Update the states of the tasks until they are done
task2.updateState(STATE_READY_FOR_REVIEW)
// Output: Manager 1 notified about completed task "Create an auth server"
task1.updateState(STATE_READY_FOR_REVIEW)
// Output: Manager 1 notified about completed task "Create a login page"
task1.updateState(STATE_DONE)
// Output: Task "Create a login page" completed
task2.updateState(STATE_DONE)
// Output: Task "Create an auth server" completed
}
run()
While the State pattern does a great job of segregating steps in a process, it can become extremely difficult to maintain in large applications that have multiple states.
On top of that, if your process design allows more than just linearly moving through all the states, you’re in for writing and maintaining more code, since each state transition needs to be handled separately.
19. Strategy
Also known as the Policy pattern, the Strategy pattern aims to help you encapsulate and freely interchange classes using a common interface. This helps maintain a loose coupling between the client and the classes and allows you to add as many implementations as you’d like.
The Strategy pattern is known to help immensely in situations where the same operation is needed using different methods/algorithms, or where massive switch blocks need to be replaced with more human-friendly code.
Here’s an example of the Strategy pattern:
// The strategy class that can encapsulate all hosting providers
function HostingProvider() {
// store the provider
this.provider = ""
// set the provider
this.setProvider = function(provider) {
this.provider = provider
}
// set the website configuration for which each hosting provider would calculate costs
this.setConfiguration = function(configuration) {
this.configuration = configuration
}
// the generic estimate method that calls the provider's unique methods to calculate the costs
this.estimateMonthlyCost = function() {
return this.provider.estimateMonthlyCost(this.configuration)
}
}
// Foo Hosting charges for each second and KB of hosting usage
function FooHosting (){
this.name = "FooHosting"
this.rate = 0.0000027
this.estimateMonthlyCost = function(configuration){
return configuration.duration * configuration.workloadSize * this.rate
}
}
// Bar Hosting charges per minute instead of seconds
function BarHosting (){
this.name = "BarHosting"
this.rate = 0.00018
this.estimateMonthlyCost = function(configuration){
return configuration.duration / 60 * configuration.workloadSize * this.rate
}
}
// Baz Hosting assumes the average workload to be of 10 MB in size
function BazHosting (){
this.name = "BazHosting"
this.rate = 0.032
this.estimateMonthlyCost = function(configuration){
return configuration.duration * this.rate
}
}
function run() {
// Create a website configuration for a website that is up for 24 hours and takes 10 MB of hosting space
let workloadConfiguration = {
duration: 84700,
workloadSize: 10240
}
// Create the hosting provider instances
let fooHosting = new FooHosting()
let barHosting = new BarHosting()
let bazHosting = new BazHosting()
// Create the instance of the strategy class
let hostingProvider = new HostingProvider()
// Set the configuration against which the rates have to be calculated
hostingProvider.setConfiguration(workloadConfiguration)
// Set each provider one by one and print the rates
hostingProvider.setProvider(fooHosting)
console.log("FooHosting cost: " + hostingProvider.estimateMonthlyCost())
// Output: FooHosting cost: 2341.7856
hostingProvider.setProvider(barHosting)
console.log("BarHosting cost: " + hostingProvider.estimateMonthlyCost())
// Output: BarHosting cost: 2601.9840
hostingProvider.setProvider(bazHosting)
console.log("BarHosting cost: " + hostingProvider.estimateMonthlyCost())
// Output: BarHosting cost: 2710.4000
}
run()
The Strategy pattern is great when it comes to introducing new variations of an entity without changing the clients much. However, it can seem like overkill if you only have a handful of variations to implement.
Also, the encapsulation takes away finer details about each variant’s internal logic, so your client is unaware of how a variant is going to behave.
20. Visitor
The Visitor pattern aims to help you make your code extensible.
The idea is to provide a method in the class that allows objects of other classes to make changes to objects of the current class easily. The other objects visit the current object (also called the place object), or the current class accepts the visitor objects, and the place object handles the visit of each external object appropriately.
Here’s how you can use it:
// Visitor class that defines the methods to be called when visiting each place
function Reader(name, cash) {
this.name = name
this.cash = cash
// The visit methods can access the place object and invoke available functions
this.visitBookstore = function(bookstore) {
console.log(this.name + " visited the bookstore and bought a book")
bookstore.purchaseBook(this)
}
this.visitLibrary = function() {
console.log(this.name + " visited the library and read a book")
}
// Helper function to demonstrate a transaction
this.pay = function(amount) {
this.cash -= amount
}
}
// Place class for a library
function Library () {
this.accept = function(reader) {
reader.visitLibrary()
}
}
// Place class for a bookstore that allows purchasing book
function Bookstore () {
this.accept = function(reader) {
reader.visitBookstore(this)
}
this.purchaseBook = function (visitor) {
console.log(visitor.name + " bought a book")
visitor.pay(8)
}
}
function run() {
// Create a reader (the visitor)
let reader = new Reader("Rick," 30)
// Create the places
let booksInc = new Bookstore()
let publicLibrary = new Library()
// The reader visits the library
publicLibrary.accept(reader)
// Output: Rick visited the library and read a book
console.log(reader.name + " has $" + reader.cash)
// Output: Rick has $30
// The reader visits the bookstore
booksInc.accept(reader)
// Output: Rick visited the bookstore and bought a book
console.log(reader.name + " has $" + reader.cash)
// Output: Rick has $22
}
run()
The only flaw in this design is that each visitor class needs to be updated whenever a new place is added or modified. In cases where multiple visitors and place objects exist together, this can be difficult to maintain.
Other than that, the method works great for enhancing the functionality of classes dynamically.
Best Practices for Implementing Design Patterns
Now that you’ve seen the most common design patterns across JavaScript, here are some tips that you should keep in mind when implementing them.
Take Special Care To Understand if a Pattern Fits the Solution
This tip is to be applied before you implement a design pattern into your source code. While it may look like a design pattern is the end of all of your worries, take a moment to critically analyze if that is true.
There are many patterns that solve the same problem but take different approaches and have different consequences. So your criteria for selecting a design pattern shouldn’t just be whether it solves your problem or not — it should also be how well it solves your problem and whether there is any other pattern that can present a more efficient solution.
Understand the Costs of Implementing a Pattern Before Starting
While design patterns seem to be the best solution for all engineering problems, you shouldn’t jump into implementing them in your source code right away.
While judging the consequences of implementing a solution, you also need to take into consideration your own situation. Do you have a large team of software developers that are well adept at understanding and maintaining design patterns? Or are you an early-stage founder with a minimal development team looking to release a quick MVP of your product? If you answer yes to the last question, design patterns might not be the most optimal way of development for you.
Design patterns do not lead to heavy code reuse unless they are planned in a very early stage of app design. Randomly using design patterns at various stages can lead to an unnecessarily complex app architecture that you’d have to spend weeks simplifying.
The effectiveness of a design pattern cannot be judged by any form of testing. It’s your team’s experience and introspection that will let you know if they work. If you have the time and resources to allocate to these aspects, only then will design patterns truly solve your problems.
Do Not Turn Every Solution Into a Pattern
Another rule of thumb to keep in mind is to refrain from trying to turn every little problem-solution pair into a design pattern and using it wherever you see room for it.
While it’s good to identify standard solutions and keep them in mind when you encounter similar problems, there’s a good chance the new problem you encountered will not fit the exact same description as an older problem. In such a case, you might end up implementing a suboptimal solution and wasting resources.
Design patterns are established today as leading examples of problem-solution pairs because they’ve been tested by hundreds and thousands of programmers over time and have been generalized as much as possible. If you try to replicate that effort by just looking at a bunch of problems and solutions and calling them similar, you might end up doing a lot more damage to your code than you’d ever expected.
When Should You Use Design Patterns?
To sum up, here are a few cues that you should look out for to use design patterns. Not all of them apply to every app’s development, but they should give you a good idea of what to look out for when thinking of using design patterns:
- You have a strong in-house team of developers that understands design patterns well.
- You are following an SDLC model that allows room for in-depth discussions around the architecture of your app, and design patterns have come up in those discussions.
- The same set of problems has come up multiple times in your design discussions, and you know the design pattern that will fit the case.
- You have tried to solve a smaller variation of your problem independently with the design pattern.
- With the design pattern in place, your code does not look overly complex.
If a design pattern solves your problem and helps you write code that’s simple, reusable, modular, loosely coupled, and free of “code smell,” it might be the right way to go.
Another good tip to keep in mind is to avoid making everything about design patterns. Design patterns are meant to help you solve problems. They are not laws to abide by or rules to strictly follow. The ultimate rules and laws are still the same: Keep your code clean, simple, readable, and scalable. If a design pattern helps you do that while solving your problem, you should be good to go with it.
Summary
JavaScript design patterns are a wonderful way of approaching problems that multiple programmers have faced over the course of time. They present tried-and-tested solutions that strive to keep your codebase clean and loosely coupled.
Today, there are hundreds of design patterns available that will solve almost any problem that you encounter while building apps. However, not every design pattern will really solve your problem every time.
Just like any other programming convention, design patterns are meant to be taken as suggestions for solving problems. They are not laws to be followed all the time, and if you treat them like laws, you might end up doing a lot of damage to your apps.
Once your app is finished, you’ll need a place to host it — and Kinsta’s Application Hosting solutions are chief among the fastest, most reliable, and most secure. You just need to sign in to your MyKinsta account (Kinsta’s custom administrative dashboard), connect to your GitHub repository, and launch! Plus, you’re only charged for the resources your app uses.
What are the design patterns that you regularly use in your software programming job? Or is there a pattern that we missed in the list? Let us know in the comments below!
The post An Extensive Guide to JavaScript Design Patterns appeared first on Kinsta®.
共有 0 条评论