Cook Up Your Code: JavaScript Design Patterns
Arum Puri
Posted on August 24, 2024
Picture this: you're standing in your kitchen ready to cook up a tasty meal. You've got all the ingredients laid out, but you're missing a recipe to follow. You start to experiment, but soon you feel overwhelmed. You add too much salt to one dish, burn another. Without a clear plan, cooking becomes a mess of guesswork.
Creating software can feel just like this. You have all the tools and know-how, but adding new features can become a frustrating puzzle without a well-organized approach. Do you understand what your code needs to do, but are you working out the best way to make everything work together? That's where things get complicated. One tiny error, and you find yourself tumbling down a hole filled with bugs and tangled code.
Enter design patterns— the time-tested recipes passed down by coders over the years. These reusable fixes help you handle the tricky parts of making software without breaking a sweat. We'll get into what exactly design patterns are, how they can make your coding life easier, and why they are the key to building robust, easy-to-maintain apps. To make things more interesting, we will be using cooking terminology throughout our explanation—because, let's be honest, who doesn't love a good cooking show?
So, what is a design pattern? How are they going to help us build better apps?
A design pattern is a reusable solution template that one can apply to recurring problems and themes in software design. That will be a good cookbook of tried and tested solutions from experienced developers working on common problems of software design. The guidelines say that with design patterns, we can achieve maintainable and reusable code in our apps.
Design patterns are, in essence, classified according to the problem they solve into three broad categories: creational design patterns, structural design patterns, and behavioral design patterns.
Design patterns are divided into three categories based on the problem they solve. They are creational design patterns, structural design patterns, and behavioral design patterns.
Creational Design Patterns: The Ingredients and Preparation
Creational design patterns provide mechanisms for creating objects. In the context of a cooking show, these patterns are like gathering and preparing ingredients before cooking. Some patterns that fall under this category are Constructor, Factory, Abstract, Prototype, Singleton, and Builder. To gain a better understanding, look at the three examples below.
1. Singleton
Imagine that there is some family's secret sauce that can only be made in some special pot, passed through generations. Of course, the sauce cannot taste the same if the pot is different. This is pretty much what Singleton does: a design pattern where a class is restricted to having a single instance.
class SecretSauce {
constructor() {
if (SecretSauce.instance) {
return SecretSauce.instance;
}
SecretSauce.instance = this;
this.flavor = 'Umami';
}
getFlavor() {
return this.flavor;
}
}
const sauce1 = new SecretSauce();
const sauce2 = new SecretSauce();
console.log(sauce1.getFlavor() === sauce2.getFlavor()); // true
2. Factory Method
The Factory Method provides a generic interface for creating objects, allowing us to specify the kind of object we want. In our cooking show, the Recipe Book is the factory. Depending on the type of dish you want to make, it will give you the recipe—object—that you need.
// Product Classes
class Pizza {
constructor(size, toppings) {
this.size = size;
this.toppings = toppings;
}
prepare() {
console.log(`Preparing a ${this.size} pizza with ${this.toppings.join(', ')} toppings.`);
}
}
class Pasta {
constructor(sauce, noodles) {
this.sauce = sauce;
this.noodles = noodles;
}
prepare() {
console.log(`Preparing pasta with ${this.noodles} noodles and ${this.sauce} sauce.`);
}
}
// Creator Class
class RecipeBook {
createDish(type, options) {
let dish;
if (type === 'Pizza') {
dish = new Pizza(options.size, options.toppings);
} else if (type === 'Pasta') {
dish = new Pasta(options.sauce, options.noodles);
}
return dish;
}
}
// Usage
const recipeBook = new RecipeBook();
const pizzaOptions = {
size: 'large',
toppings: ['cheese', 'pepperoni', 'olives']
};
const pastaOptions = {
sauce: 'alfredo',
noodles: 'fettuccine'
};
const pizza = recipeBook.createDish('Pizza', pizzaOptions);
const pasta = recipeBook.createDish('Pasta', pastaOptions);
pizza.prepare(); // Preparing a large pizza with cheese, pepperoni, olives toppings.
pasta.prepare(); // Preparing pasta with fettuccine noodles and alfredo sauce.
The Factory Method comes in handy in scenarios of complex objects' creation—for example, generating different instances depending on the environment or managing many similar objects.
*3. Abstract Factory *
It encapsulates the implementation details from the general usage of the Objects. The best way to explain this is if you consider a meal kit delivery service: whether you cook Italian, Chinese, or Mexican, this service will deliver everything with ingredients and recipes, only tailored to the cuisine at hand, so everything fits perfectly.
// Abstract Factory Interfaces
class ItalianKitchen {
createPizza(options) {
return new Pizza(options.size, options.toppings);
}
createPasta(options) {
return new Pasta(options.sauce, options.noodles);
}
}
class MexicanKitchen {
createTaco(options) {
return new Taco(options.shellType, options.fillings);
}
createBurrito(options) {
return new Burrito(options.size, options.fillings);
}
}
// Concrete Product Classes
class Pizza {
constructor(size, toppings) {
this.size = size;
this.toppings = toppings;
}
prepare() {
console.log(`Preparing a ${this.size} pizza with ${this.toppings.join(', ')} toppings.`);
}
}
class Pasta {
constructor(sauce, noodles) {
this.sauce = sauce;
this.noodles = noodles;
}
prepare() {
console.log(`Preparing pasta with ${this.noodles} noodles and ${this.sauce} sauce.`);
}
}
class Taco {
constructor(shellType, fillings) {
this.shellType = shellType;
this.fillings = fillings;
}
prepare() {
console.log(`Preparing a taco with a ${this.shellType} shell and ${this.fillings.join(', ')} fillings.`);
}
}
class Burrito {
constructor(size, fillings) {
this.size = size;
this.fillings = fillings;
}
prepare() {
console.log(`Preparing a ${this.size} burrito with ${this.fillings.join(', ')} fillings.`);
}
}
// Client Code
const italianKitchen = new ItalianKitchen();
const mexicanKitchen = new MexicanKitchen();
const italianPizza = italianKitchen.createPizza({
size: 'medium',
toppings: ['mozzarella', 'tomato', 'basil']
});
const mexicanTaco = mexicanKitchen.createTaco({
shellType: 'hard',
fillings: ['beef', 'lettuce', 'cheese']
});
italianPizza.prepare(); // Preparing a medium pizza with mozzarella, tomato, basil toppings.
mexicanTaco.prepare(); // Preparing a taco with a hard shell and beef, lettuce, cheese fillings.
Structural Design Patterns: The Cooking Techniques and Tools
Structural design patterns focus on object composition, identifying straightforward ways to establish relationships between different objects. They help ensure that when one part of a system changes, the overall structure remains stable. In cooking, these patterns represent the techniques and tools we use to combine ingredients into a harmonious and delicious dish.
Patterns that fall under this category include Decorator, Facade, Flyweight, Adapter, and Proxy.
1. The Facade Pattern
The Facade pattern offers a convenient, high-level interface to a more complex body of code, effectively hiding the underlying complexity. Imagine a sous chef simplifying complex tasks for the head chef. The sous-chef gathers ingredients, preps them, and organizes everything so the head chef can focus on the final touches of the dish
// Complex Subsystem
class IngredientPrep {
chop(ingredient) {
console.log(`Chopping ${ingredient}.`);
}
measure(amount, ingredient) {
console.log(`Measuring ${amount} of ${ingredient}.`);
}
}
class CookingProcess {
boil(waterAmount) {
console.log(`Boiling ${waterAmount} of water.`);
}
bake(temp, duration) {
console.log(`Baking at ${temp} degrees for ${duration} minutes.`);
}
}
class Plating {
arrangeDish(dish) {
console.log(`Arranging the ${dish} on the plate.`);
}
garnish(garnish) {
console.log(`Adding ${garnish} as garnish.`);
}
}
// Facade Class
class SousChef {
constructor() {
this.ingredientPrep = new IngredientPrep();
this.cookingProcess = new CookingProcess();
this.plating = new Plating();
}
prepareDish(dishName) {
console.log(`Starting to prepare ${dishName}...`);
this.ingredientPrep.chop('vegetables');
this.ingredientPrep.measure('2 cups', 'flour');
this.cookingProcess.boil('1 liter');
this.cookingProcess.bake(180, 30);
this.plating.arrangeDish(dishName);
this.plating.garnish('parsley');
console.log(`${dishName} is ready!`);
}
}
// Client Code
const sousChef = new SousChef();
sousChef.prepareDish('Lasagna');
// Output:
// Starting to prepare Lasagna...
// Chopping vegetables.
// Measuring 2 cups of flour.
// Boiling 1 liter of water.
// Baking at 180 degrees for 30 minutes.
// Arranging the Lasagna on the plate.
// Adding parsley as garnish.
// Lasagna is ready!
2. Decorator
The Decorator pattern is used to modify existing systems by adding features to objects without significantly altering the underlying code. If our applications require many distinct types of objects, this pattern is ideal. For instance, when making coffee, we start with a basic cup and then dynamically add ingredients like milk, sugar, or whipped cream. The Decorator pattern lets us add the base coffee without changing the core recipe.
// Base Component
class Coffee {
constructor() {
this.description = 'Basic Coffee';
}
getDescription() {
return this.description;
}
cost() {
return 2; // Base cost for a simple coffee
}
}
// Decorator Class
class CoffeeDecorator {
constructor(coffee) {
this.coffee = coffee;
}
getDescription() {
return this.coffee.getDescription();
}
cost() {
return this.coffee.cost();
}
}
// Concrete Decorators
class Milk extends CoffeeDecorator {
constructor(coffee) {
super(coffee);
}
getDescription() {
return `${this.coffee.getDescription()}, Milk`;
}
cost() {
return this.coffee.cost() + 0.5;
}
}
class Sugar extends CoffeeDecorator {
constructor(coffee) {
super(coffee);
}
getDescription() {
return `${this.coffee.getDescription()}, Sugar`;
}
cost() {
return this.coffee.cost() + 0.2;
}
}
class WhippedCream extends CoffeeDecorator {
constructor(coffee) {
super(coffee);
}
getDescription() {
return `${this.coffee.getDescription()}, Whipped Cream`;
}
cost() {
return this.coffee.cost() + 0.7;
}
}
// Client Code
let myCoffee = new Coffee();
console.log(`${myCoffee.getDescription()} costs $${myCoffee.cost()}`); // Basic Coffee costs $2
myCoffee = new Milk(myCoffee);
console.log(`${myCoffee.getDescription()} costs $${myCoffee.cost()}`); // Basic Coffee, Milk costs $2.5
myCoffee = new Sugar(myCoffee);
console.log(`${myCoffee.getDescription()} costs $${myCoffee.cost()}`); // Basic Coffee, Milk, Sugar costs $2.7
myCoffee = new WhippedCream(myCoffee);
console.log(`${myCoffee.getDescription()} costs $${myCoffee.cost()}`); // Basic Coffee, Milk, Sugar, Whipped Cream costs $3.4
3. Flyweight
The Flyweight pattern is a classical structural solution for optimizing code that is repetitive, slow, and inefficiently shares data. It aims to minimize memory in use in an application by sharing as much data as possible with related objects. Think of common ingredients like salt, pepper, and olive oil that are used in many dishes. Instead of having separate instances of these ingredients for each dish, they are shared across dishes to save resources. For example, you put salt on fried chicken and beef stew from the same jar.
// Flyweight Class
class Ingredient {
constructor(name) {
this.name = name;
}
use() {
console.log(`Using ${this.name}.`);
}
}
// Flyweight Factory
class IngredientFactory {
constructor() {
this.ingredients = {};
}
getIngredient(name) {
if (!this.ingredients[name]) {
this.ingredients[name] = new Ingredient(name);
}
return this.ingredients[name];
}
getTotalIngredientsMade() {
return Object.keys(this.ingredients).length;
}
}
// Client Code
const ingredientFactory = new IngredientFactory();
const salt1 = ingredientFactory.getIngredient('Salt');
const salt2 = ingredientFactory.getIngredient('Salt');
const pepper = ingredientFactory.getIngredient('Pepper');
salt1.use(); // Using Salt.
salt2.use(); // Using Salt.
pepper.use(); // Using Pepper.
console.log(ingredientFactory.getTotalIngredientsMade()); // 2, Salt and Pepper were created only once
console.log(salt1 === salt2); // true, Salt is reused
Behavioral Design Patterns: The Cooking Process and Interaction
Behavioral patterns focus on improving or streamlining the communication between disparate objects in a system. They identify common communication patterns among objects and provide solutions that distribute the communication responsibility among different objects, thereby increasing communication flexibility. In a cooking show, behavioral design patterns are the way we cook the dish, the process of cooking, and how various parts of the kitchen interact with each other to create the final dish. Some of the behavioral patterns are Iterator, Mediator, Observer, and Visitor.
1.Observer
The Observer pattern is used to notify components of state changes. When a subject needs to inform observers about a change, it broadcasts a notification. If an observer no longer wishes to receive updates, they can be removed from the list of observers. For example, once the head chef finishes preparing a dish, all the assistant chefs need to be notified to begin their tasks, such as plating or garnishing. The Observer pattern allows multiple chefs (observers) to be notified when the head chef (subject) completes a dish.
// Subject Class
class HeadChef {
constructor() {
this.chefs = [];
this.dishReady = false;
}
addObserver(chef) {
this.chefs.push(chef);
}
removeObserver(chef) {
this.chefs = this.chefs.filter(c => c !== chef);
}
notifyObservers() {
if (this.dishReady) {
this.chefs.forEach(chef => chef.update(this.dishName));
}
}
prepareDish(dishName) {
this.dishName = dishName;
console.log(`HeadChef: Preparing ${dishName}...`);
this.dishReady = true;
this.notifyObservers();
}
}
// Observer Class
class Chef {
constructor(name) {
this.name = name;
}
update(dishName) {
console.log(`${this.name}: Received notification - ${dishName} is ready!`);
}
}
// Client Code
const headChef = new HeadChef();
const chef1 = new Chef('Chef A');
const chef2 = new Chef('Chef B');
headChef.addObserver(chef1);
headChef.addObserver(chef2);
headChef.prepareDish('Beef Wellington');
// Output:
// HeadChef: Preparing Beef Wellington...
// Chef A: Received notification - Beef Wellington is ready!
// Chef B: Received notification - Beef Wellington is ready!
2. Mediator
The Mediator pattern allows one object to be in charge of the communication between several other objects when an event occurs. While it does sound similar to the Observer pattern, the key difference is that the Mediator handles communication between objects rather than just broadcasting changes. For example, let's think of our kitchen with its grill, bakery, and garnish station sections. A kitchen coordinator (mediator) handles the communication so that all the preparations are done on time.
// Mediator Class
class KitchenCoordinator {
notify(sender, event) {
if (event === 'dishPrepared') {
console.log(`Coordinator: Notifying all stations that ${sender.dishName} is ready.`);
} else if (event === 'orderReceived') {
console.log(`Coordinator: Received order for ${sender.dishName}, notifying preparation stations.`);
}
}
}
// Colleague Classes
class GrillStation {
constructor(coordinator) {
this.coordinator = coordinator;
}
prepareDish(dishName) {
this.dishName = dishName;
console.log(`GrillStation: Grilling ${dishName}.`);
this.coordinator.notify(this, 'dishPrepared');
}
}
class BakeryStation {
constructor(coordinator) {
this.coordinator = coordinator;
}
bakeDish(dishName) {
this.dishName = dishName;
console.log(`BakeryStation: Baking ${dishName}.`);
this.coordinator.notify(this, 'dishPrepared');
}
}
// Client Code
const coordinator = new KitchenCoordinator();
const grillStation = new GrillStation(coordinator);
const bakeryStation = new BakeryStation(coordinator);
grillStation.prepareDish('Steak');
// Output:
// GrillStation: Grilling Steak.
// Coordinator: Notifying all stations that Steak is ready.
bakeryStation.bakeDish('Bread');
// Output:
// BakeryStation: Baking Bread.
// Coordinator: Notifying all stations that Bread is ready.
3. Command
The Command design pattern is an Object Behavioral Pattern that encapsulates the invocation of methods, requests, or operations into a single object and allows both parameterization and pass method calls that can be executed at our discretion. For example, look at how the head chef gives the command below.
// Command Interface
class Command {
execute() {}
}
// Concrete Commands
class GrillCommand extends Command {
constructor(grillStation, dishName) {
super();
this.grillStation = grillStation;
this.dishName = dishName;
}
execute() {
this.grillStation.grill(this.dishName);
}
}
class BakeCommand extends Command {
constructor(bakeryStation, dishName) {
super();
this.bakeryStation = bakeryStation;
this.dishName = dishName;
}
execute() {
this.bakeryStation.bake(this.dishName);
}
}
// Receiver Classes
class GrillStation {
grill(dishName) {
console.log(`GrillStation: Grilling ${dishName}.`);
}
}
class BakeryStation {
bake(dishName) {
console.log(`BakeryStation: Baking ${dishName}.`);
}
}
// Invoker Class
class HeadChef {
setCommand(command) {
this.command = command;
}
executeCommand() {
this.command.execute();
}
}
// Client Code
const grillStation = new GrillStation();
const bakeryStation = new BakeryStation();
const grillCommand = new GrillCommand(grillStation, 'Steak');
const bakeCommand = new BakeCommand(bakeryStation, 'Bread');
const headChef = new HeadChef();
headChef.setCommand(grillCommand);
headChef.executeCommand(); // GrillStation: Grilling Steak.
headChef.setCommand(bakeCommand);
headChef.executeCommand(); // BakeryStation: Baking Bread.
Behavioral patterns can feel similar, so let's highlight their differences:
Observer: When a head chef prepares a dish, several other chefs are informed about it.
Mediator: A coordinator works in the kitchen, facilitating communication between various stations in the kitchen.
Command: The head chef issues commands to grill or bake dishes, encapsulating these actions as objects.
Design patterns give a clear way to fix common issues in software development much like a tidy kitchen and smart cooking methods lead to a good meal. When you get these patterns and put them to use, you make your coding easier and help your apps work better and grow more. It doesn't matter if you're new to coding or have done it for a long time - think of design patterns as trusted recipes passed down by many coders over the years. Try them out, play around with them, and soon you'll find that making strong apps becomes as natural as following a recipe you love. Happy coding!
Posted on August 24, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 22, 2024