Skip to content
wannacode

Design patterns

Problem

The function that grows with every promotion

The price in the shop depends on the promotion:

function finalPrice(price, promo) {
if (promo === "student") {
return price * 0.9;
} else if (promo === "black friday") {
return Math.max(price - 20, 0);
} else if (promo === "loyal customer") {
return price * 0.95;
}
return price;
}
console.log(`€${finalPrice(120, "student").toFixed(2)}`);
console.log(`€${finalPrice(120, "black friday").toFixed(2)}`);
console.log(`€${finalPrice(120, "none").toFixed(2)}`);
Output
€108.00
€100.00
€120.00
It works. But marketing comes up with a new promotion every month. Each one means a new else if in this function, and often in two more: the one for the invoice and the one for the confirmation email. The function knows about all the promotions at once, and every change touches it. This problem is so common that its solution has a name.

The idea

Design patterns

A design pattern is a proven solution to a problem that comes up in many programs. It is not a library and not code to copy. It is a way to arrange the classes and functions that has already been through thousands of projects.

Patterns fall into three groups:

  • Creational: how objects are made. For example Singleton (only one object of the class) and Factory Method (something else decides which class to create).
  • Structural: how classes and objects are put together into bigger ones. For example Adapter (connects incompatible interfaces) and Composite (a tree of parts that you work with as one whole).
  • Behavioral: how objects split the work and talk to each other. For example Strategy (a replaceable rule) and Observer (subscribers to an event).

Why they are worth knowing:

  • you do not reinvent a solution that others have already tested;
  • the code changes with less risk, because the part that changes is kept separate;
  • the names are a shared language. "Put an adapter here" tells a colleague a whole structure in one word.
More precisely, if you are curious
The classic catalog is the 1994 book "Design Patterns" by four authors known as the "Gang of Four" (GoF). It has 23 patterns, written for languages like C++ and Java. Here are the five you will run into most often. In JavaScript some of them get shorter, because a function can be passed as a value: where Java needs an interface and a class, a function is enough here. A pattern is not a goal: if one if does the job, you do not need a pattern.

See it

Strategy: a replaceable rule

The discount becomes a function that the checkout keeps in a field. The checkout does not know which promotion it is, it only calls the function:
class Checkout {
constructor(discount) {
this.discount = discount;
}
total(price) {
return Math.max(this.discount(price), 0);
}
}
Each discount is an arrow function:
const checkout = new Checkout((price) => price);
console.log(`No discount: €${checkout.total(120).toFixed(2)}`);
checkout.discount = (price) => price * 0.9;
console.log(`Student: €${checkout.total(120).toFixed(2)}`);
checkout.discount = (price) => price - 20;
console.log(`Black Friday: €${checkout.total(120).toFixed(2)}`);
Output
No discount: €120.00
Student: €108.00
Black Friday: €100.00
A new promotion is a new arrow function. Checkout does not change. The rule can also be changed while the program is running: you store a new function in checkout.discount.This is Strategy: a family of rules, each with the same parameters and result, so they are interchangeable. The compare function you pass to sort is also a strategy: sort knows how to sort, and you say what to sort by.
More precisely, if you are curious

In the books, the class that uses the strategy is called the "context" (here Checkout). If the strategy has its own data, like the percentage, it is a function that makes a function. The returned arrow function remembers percent even after percentDiscount has finished:

function percentDiscount(percent) {
return (price) => price * (1 - percent / 100);
}
const checkout = new Checkout(percentDiscount(10));
console.log(`Student: €${checkout.total(120).toFixed(2)}`);
Output
Student: €108.00

See it

Observer: subscribers to an event

When the stock of a product changes, the website has to show the new count, and the warehouse has to reorder if there are only a few left. Tomorrow it may be an email to customers too. The product should not know about any of them.

class Product {
constructor(name, quantity) {
this.name = name;
this.quantity = quantity;
this.listeners = [];
}
subscribe(listener) {
this.listeners.push(listener);
}
setQuantity(quantity) {
if (quantity === this.quantity) {
return;
}
this.quantity = quantity;
for (const listener of this.listeners) {
listener(this.name, quantity);
}
}
}
const phone = new Product("Phone", 0);
phone.subscribe((name, qty) => console.log(`Website: ${name}, ${qty} in stock`));
phone.subscribe((name, qty) => {
if (qty < 3) {
console.log(`Warehouse: reorder ${name}`);
}
});
phone.setQuantity(10);
phone.setQuantity(2);
Output
Website: Phone, 10 in stock
Website: Phone, 2 in stock
Warehouse: reorder Phone
  • Whoever wants to know subscribes with subscribe and passes a function.
  • On a change, the product goes through the list and calls each function with the name and the count. Who the subscribers are and what they do is none of its business.
  • If the count has not changed, nobody is bothered.

const phone = new Product("Phone", 0);
phone.subscribe((name, qty) => console.log(`Website: ${name}, ${qty} in stock`));
phone.subscribe((name, qty) => {
if (qty < 3) {
console.log(`Warehouse: reorder ${name}`);
}
});
phone.setQuantity(10);
phone.setQuantity(2);
Step by step, in words
  1. Phone with 0 in stock and an empty listeners list.
  2. The website subscribes: the arrow function goes into the list.
  3. So does the warehouse. The product does not know who they are, only that it can call them with a name and a count.
  4. The stock becomes 10. The product goes through the list and calls each subscriber in the order they subscribed. The website shows the new count.
  5. The warehouse checks 10 < 3: no, so it does nothing.
  6. The stock becomes 2. The product goes through the list and calls each subscriber in the order they subscribed. The website shows the new count.
  7. The warehouse checks 2 < 3: yes, so it reorders.

This is Observer. The product is the observed object (the subject), and the listeners are the observers. Buttons in graphical programs work exactly like this: "when clicked, call this".

More precisely, if you are curious
  • You also need an unsubscribe that removes the listener from the list. You remove the same function you subscribed, so you keep it in a variable. A subscriber that nobody removes stays in memory as long as the product does.
const phone = new Product("Phone", 5);
const site = (name, qty) => console.log(`Website: ${qty} in stock`);
phone.subscribe(site);
phone.setQuantity(4);
phone.listeners = phone.listeners.filter((l) => l !== site);
phone.setQuantity(3);
console.log("The website is no longer listening");
Output
Website: 4 in stock
The website is no longer listening
  • In the old lecture the observed class is called Subject, with the methods attach and notifyAllObservers, and the observer is an abstract class with a method update(). The idea is the same, and with functions you do not need a class for each observer.
  • The listeners are called one after another, inside the method that changed the count. If one of them is slow, all of them wait.

Try it

Who will hear it?

Product is the one from the card before this one.

What will the program print?

const coffee = new Product("Coffee", 5);
coffee.subscribe((name, qty) => console.log(`A: ${qty}`));
coffee.setQuantity(4);
coffee.setQuantity(4);
coffee.subscribe((name, qty) => console.log(`B: ${qty}`));
coffee.setQuantity(0);
Answer

See it

Factory Method: which class to create

The customer has chosen how we notify them: email or SMS. The code that sends should not write new EmailNotification() in ten places, each with its own if. JavaScript has no interfaces: a notification is any object with a method send.
// A notification is any object with a method send(to, text).
class EmailNotification {
send(to, text) {
console.log(`Email to ${to}: ${text}`);
}
}
class SmsNotification {
send(to, text) {
console.log(`SMS to ${to}: ${text}`);
}
}
The choice is in one place, in a factory. It is a regular function: in Java it is a static method in a class only because everything there is in a class.
function createNotification(channel) {
switch (channel) {
case "email":
return new EmailNotification();
case "sms":
return new SmsNotification();
default:
throw new Error(`Unknown channel: ${channel}`);
}
}
const customers = [
["email", "maria@example.com"],
["sms", "+359 88 123 4567"],
["pigeon", "5 Shipka St."],
];
for (const [channel, to] of customers) {
try {
const n = createNotification(channel);
n.send(to, "Your order has been shipped.");
} catch (e) {
console.log(e.message);
}
}
Output
Email to maria@example.com: Your order has been shipped.
SMS to +359 88 123 4567: Your order has been shipped.
Unknown channel: pigeon
The caller says what it wants ("email") and gets an object that can send. Only the factory knows exactly which class is behind it. A new channel, for example push, is a new class and a new case in the switch. The sending is not touched.
More precisely, if you are curious
  • This is the most common form in practice. Its more precise name is "simple factory". The Factory Method from the GoF book is something else: the parent has a method that creates the object, and the subclasses decide what kind. JavaScript has no abstract, so the method in the parent throws an error: if the subclass does not write it, the error shows up on the first call:
class Shop {
createNotification() {
throw new Error("createNotification() must be written in the subclass");
}
confirmOrder(to) {
this.createNotification().send(to, "The order has been received.");
}
}
class OnlineShop extends Shop {
createNotification() {
return new EmailNotification();
}
}
class PhoneShop extends Shop {
createNotification() {
return new SmsNotification();
}
}
new OnlineShop().confirmOrder("maria@example.com");
new PhoneShop().confirmOrder("+359 88 123 4567");
Output
Email to maria@example.com: The order has been received.
SMS to +359 88 123 4567: The order has been received.

confirmOrder is written once, in Shop, and does not know what notification it sends.

  • If the factory does not reach a return, it returns undefined. Then the error comes only at send, far from the real cause:
function createNotification(channel) {
if (channel === "email") {
return new EmailNotification();
}
if (channel === "sms") {
return new SmsNotification();
}
}
const n = createNotification("pigeon");
try {
n.send("5 Shipka St.", "Your order has been shipped.");
} catch (e) {
console.log(`${e.name}: ${e.message}`);
}
Output
TypeError: Cannot read properties of undefined (reading 'send')

An exception with a clear message in the factory is better.

See it

Singleton: only one object

The application's settings, like the currency, are read once. If every part of the program makes its own settings object, they are read many times and can drift apart.

class AppConfig {
static instance = null;
constructor() {
if (AppConfig.instance !== null) {
return AppConfig.instance;
}
console.log("Reading the settings...");
this.currency = "EUR";
AppConfig.instance = this;
}
}
const a = new AppConfig();
const b = new AppConfig();
console.log(a === b);
console.log(b.currency);
Output
Reading the settings...
true
EUR
  • JavaScript has no private constructor: anyone can write new AppConfig(). So the guard is in the constructor itself.
  • The only object is in the static field instance. If there is already an object, the constructor returns it with return. When a constructor returns an object, new gives you that one, not the new one.
  • "Reading the settings..." is printed once, and a === b is true: there is one object.

This is Singleton.

More precisely, if you are curious
  • The simplest Singleton in JavaScript is a plain object. There is no class to make a second one from, and Object.freeze protects its fields:
const settings = Object.freeze({
currency: "EUR",
});
console.log(settings.currency);
console.log(Object.isFrozen(settings));
Output
EUR
true
  • In JavaScript this is exactly what is done more often: the object is created once, in a module, and the other parts bring it in with import.
  • A Singleton is a global variable for the whole program with a nice name. Anyone can use it from anywhere, so it is hard to test and hard to replace. It is often better to create the object once at the start of the program and pass it as a parameter to the ones that use it.

Try it

Find the bug

Order numbers have to be unique, so the counter is a Singleton. But the program gave out the number 1001 twice:

const numbers = new OrderNumbers();
console.log(numbers.nextNumber());
console.log(numbers.nextNumber());
console.log(new OrderNumbers().nextNumber());
Output
1001
1002
1001

Which line in the class is the bug on?

Pick the line with the bug.

See it

Adapter: connects incompatible interfaces

The shop pays through an interface with an amount in euros and a "yes or no" answer. The bank gives you an old library that wants cents and returns an error code. The library is not yours, and you cannot change it.

// The bank's old library. You cannot change it.
class LegacyBankClient {
// The amount is in cents. Returns 0 on success and an error code otherwise.
charge(cents, reference) {
console.log(`Bank: ${cents} cents for ${reference}`);
return cents <= 50000 ? 0 : 51;
}
}

You write a class that looks like what the shop expects, and inside it calls the bank:

// This is what the shop expects: an object with a method pay(orderId, amountEur) that returns true or false.
class BankAdapter {
constructor(bank) {
this.bank = bank;
}
pay(orderId, amountEur) {
const cents = Math.round(amountEur * 100);
return this.bank.charge(cents, `ORDER-${orderId}`) === 0;
}
}
const gateway = new BankAdapter(new LegacyBankClient());
console.log(gateway.pay("1001", 49.9));
console.log(gateway.pay("1002", 1299));
Output
Bank: 4990 cents for ORDER-1001
true
Bank: 129900 cents for ORDER-1002
false
The adapter translates in both directions: euros into cents and a number into text on the way there, code 0 into true on the way back. The shop does not know there is an old library behind it: all that matters to it is that the object has pay. If tomorrow you change banks, you write a new adapter, and the shop is not touched.

This is Adapter: like a plug adapter that changes neither the plug nor the wall.

More precisely, if you are curious

The classic example in the books, and in the old lecture, is a media player. AudioPlayer can play only mp3, and an existing class AdvancedMediaPlayer has the methods playVlc and playMp4. MediaAdapter implements the player's interface and forwards to the right method. The structure is the same as here.

Exercise

Patterns in the shop

Solve the tasks in your editor.

1. Discounts as strategies. Three functions that return an arrow function: none(), percent(p) and fixed(amount) (never below €0). A class Cart with an array of prices and a replaceable discount in the field discount. Add coffee beans (€18.50), honey (€12) and tea (€4.20), and print the total with no discount, with −10%, with −€5 and with −€50.

Output
No discount: €34.70
-10%: €31.23
-€5: €29.70
-€50: €0.00

2. "Notify me when it is in stock". The product keeps a list of customers who are waiting. When the stock goes from 0 to more than 0, it notifies all of them and clears the list, because the notification is one-time. The console has 0 in stock, Maria and Georgi subscribe, then the stock becomes 5, 0 and 3.

Output
Email to Maria: Console is in stock (5 units)
SMS to Georgi: Console is in stock
End

3. A notification factory. Add PushNotification and a factory for the three channels that ignores uppercase and lowercase. Read a number of lines and then lines "channel;address". Send "Your reservation is confirmed." to each address, and for an unknown channel print the exception's message.

Input
4
Email;maria@example.com
sms;+359 88 123 4567
push;Elena's phone
fax;02 123 456
Output
Email to maria@example.com: Your reservation is confirmed.
SMS to +359 88 123 4567: Your reservation is confirmed.
Push to Elena's phone: Your reservation is confirmed.
Unknown channel: fax
4. An old thermometer. A class OldThermometer returns the temperature in Fahrenheit with readFahrenheit(). The new system works with sensors: objects with a method celsius(). Write ThermometerAdapter (°C = (°F − 32) × 5 / 9). In an array of sensors, put two old thermometers (71.6 °F and 39.2 °F) through the adapter, and one new sensor as the object { celsius: () => 21.5 }.
Output
Office: 22.0 °C
Warehouse: 4.0 °C
Server room: 21.5 °C

More tasks

  • Shipping as a strategy. Make functions for the shipping price to an office, to an address and from the store, and put the chosen one in the checkout together with the discount.
  • A log as a Singleton. A class Logger whose constructor returns the only object, and a method log(text) that numbers the lines. Check that the numbers continue when you call it from two different functions.
  • A price observer. Add subscribers for price changes to the product, and send a notification only when the price drops by at least 10%.

Test

Check yourself

1. What is a design pattern?
2. Every new promotion in the shop adds a new else if to the price function. Which pattern helps?
3. The product tells the website, the warehouse and the email system when the stock changes, without knowing who they are. Which pattern is it?
4. The Singleton constructor has if (OrderNumbers.instance !== null) return OrderNumbers.instance;, and at the end this.instance = this;. What happens on the second new OrderNumbers()?
5. You have an old class that returns the temperature in Fahrenheit, and the program expects a sensor with a method celsius(). What do you do?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·