Skip to content
wannacode

The shop: which pattern where

In short: A design patterns exercise: a shop with payment, notifications and storage in one method is reorganised with Strategy, Observer and Repository, while its output stays the same.

Problem

A shop in one method

Here is a small online shop. The order method works out the sum from the payment method, stores the order, sends an email and writes to the warehouse:

class Shop {
orders = [];
placeOrder(id, customer, amount, method) {
let total;
if (method === "card") {
total = amount;
} else if (method === "cash on delivery") {
total = amount + 2.5;
} else {
throw new Error("Unknown payment method: " + method);
}
this.orders.push(`${id};${customer};${total}`);
console.log(`Email to ${customer}: order ${id}, €${total.toFixed(2)}`);
console.log("Warehouse: prepare " + id);
}
count() {
return this.orders.length;
}
}
const shop = new Shop();
shop.placeOrder("A-1", "ana@example.com", 80, "card");
shop.placeOrder("A-2", "ivan@example.com", 45, "cash on delivery");
console.log("Orders: " + shop.count());
Output
Email to ana@example.com: order A-1, €80.00
Warehouse: prepare A-1
Email to ivan@example.com: order A-2, €47.50
Warehouse: prepare A-2
Orders: 2

It works. But next month three changes are coming: bank transfer with a 2% discount, an SMS after every order and a database instead of a list. All three are in the same method. Each of them touches it, and a test for the sum without an email going out can't be written.

There is no new pattern in this lesson. It is an exercise: you recognise which of the patterns in the module organises each of these things, and which ones are not needed here.

The idea

The symptom names the pattern

You don't pick a pattern from a list. You pick it from what hurts:

Symptom in the code Pattern
a chain of if on a kind that grows with every new variant Strategy, or Factory if the chain picks what to create
after one event the code itself calls everyone who must find out Observer
storage sits inside the rules and a test without a database is impossible Repository and passing it in from outside
a switch on the state text in every method State
a constructor with many optional parameters Builder
a third-party library with an awkward interface Adapter
five subsystems that must be called in an exact order Facade
behaviour "around" a method: logging, cache, add-ons to the price Decorator
undoing the last action Command
a tree where a part and the whole are used the same way Composite

If you can't find the symptom, you most likely don't need a pattern.

Try it

Recognise it

Look at the shop from the first card again.

1. A chain of ifs on the payment method text that grows with every new method. Which pattern organises it?
2. After an order the method itself sends an email and writes to the warehouse. Tomorrow SMS comes too. Which pattern?
3. Orders are kept in a list inside the shop. Tomorrow you need a database, and for the test you want something in memory. Which pattern?
4. The order has three fields. Does it need a Builder?

See it

One possible way to organise it

Three of the symptoms are in the shop, so there are three patterns as well.

The payment methods are strategies: functions in a Map. The order is a plain object with three fields that the shop creates. Storage is a class with two methods, save and count:
class InMemoryOrders {
#orders = [];
save(order) {
this.#orders.push(order);
}
count() {
return this.#orders.length;
}
}
The shop gets the storage and the table of methods from outside. It keeps a list of subscribers and tells each one when an order is placed:
class Shop {
#listeners = [];
constructor(orders, methods) {
this.orders = orders;
this.methods = methods;
}
subscribe(listener) {
this.#listeners.push(listener);
}
placeOrder(id, customer, amount, method) {
const payment = this.methods.get(method);
if (payment === undefined) {
throw new Error("Unknown payment method: " + method);
}
const order = { id, customer, total: payment(amount) };
this.orders.save(order);
for (const listener of this.#listeners) {
listener(order);
}
}
count() {
return this.orders.count();
}
}

Which storage, which methods and which subscribers is decided in one place, where the shop is put together:

const methods = new Map([
["card", (amount) => amount],
["cash on delivery", (amount) => amount + 2.5],
]);
const shop = new Shop(new InMemoryOrders(), methods);
shop.subscribe((o) => console.log(`Email to ${o.customer}: order ${o.id}, €${o.total.toFixed(2)}`));
shop.subscribe((o) => console.log("Warehouse: prepare " + o.id));
shop.placeOrder("A-1", "ana@example.com", 80, "card");
shop.placeOrder("A-2", "ivan@example.com", 45, "cash on delivery");
console.log("Orders: " + shop.count());
Output
Email to ana@example.com: order A-1, €80.00
Warehouse: prepare A-1
Email to ivan@example.com: order A-2, €47.50
Warehouse: prepare A-2
Orders: 2

The output is the same, line by line. Organising didn't change the behaviour, and that is the first thing you check. Here both programs run at every check of the site and give the same output.

See it

The three changes now

Bank transfer is a new row in the table. SMS is a new subscriber. The database will be a new implementation of the storage (methods save and count). Not a single line of the shop class changes:

const methods = new Map([
["card", (amount) => amount],
["cash on delivery", (amount) => amount + 2.5],
["bank transfer", (amount) => amount * 0.98],
]);
const shop = new Shop(new InMemoryOrders(), methods);
shop.subscribe((o) => console.log("SMS to " + o.customer + ": " + o.id));
shop.placeOrder("A-3", "ana@example.com", 100, "bank transfer");
Output
SMS to ana@example.com: A-3

Now a test for the sum is easy too: you pass storage in memory and don't subscribe the email.

The idea

Where to stop

The module has more than ten patterns, and three are used here. The others aren't forgotten, they just don't help here:

  • Builder: the order has three required fields. The constructor (record) is enough;
  • Singleton: nothing has to be the only one. The storage is passed in from outside, so the test and the shop can each have their own;
  • Factory: the payment methods are functions in a table, and the table is already the place where the choice is made;
  • State: the order has no states yet. When "paid" and "shipped" arrive, it will come too (see the tasks).

You stop when the change that is coming becomes easy. More patterns doesn't mean better code.

Exercise

Keep organising the shop

Solve the tasks in your editor, starting from the organised shop. After each step run the program from the card "One possible way to organise it": its output must stay the same.

1. The order's states. The order goes new → paid → shipped and can be cancelled, but not after it has been shipped. Do it with State (or with an enum and a transition table, if the states have no behaviour of their own) and show the error when cancelling a shipped order.

2. A discount over €100. An order over €100 gets a 5% discount, whatever the payment. Think about where it belongs: in a strategy, in a Decorator around the strategy or in the shop. Why?

3. Storage that remembers. Make a second storage that writes the orders to a file (one per line), and pass it to the shop instead of the one in memory. The shop class doesn't change.

4. A third-party courier. The warehouse is now called through a courier's library with a method createShipment(reference, weightGrams). The warehouse subscriber becomes an Adapter to it.

Test

Check yourself

1. You organise the code with patterns. How do you know you didn't break anything?
2. Which one is NOT a good reason to add a pattern?
3. After organising, you add bank transfer and SMS. What do you touch in the shop class?

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