Skip to content
wannacode

Observer

In short: Observer lets one object notify its subscribers when something happens, without knowing who they are; in Node.js this is an EventEmitter.

Problem

The payment that calls everyone

A customer pays order #1042 for €80. After that, a receipt has to go out by email and SMS, the warehouse has to reserve the goods, and accounting has to issue an invoice. The payments class does it all itself:

class Payments {
constructor() {
this.email = new EmailService();
this.sms = new SmsService();
this.warehouse = new Warehouse();
this.invoices = new InvoiceService();
}
markPaid(order) {
this.email.sendReceipt(order);
this.sms.notifyPaid(order);
this.warehouse.reserve(order);
this.invoices.issue(order);
}
}

Each service is a small class that prints what it did:

const payments = new Payments();
payments.markPaid({ id: 1042, total: 80 });
Output
Email: receipt for #1042
SMS: order #1042 is paid
Warehouse: reserve #1042
Invoice: €80.00 for #1042

It works. But marketing wants loyalty points on every payment. That is one more field and one more line in markPaid. Tomorrow comes a push notification, the day after, statistics. The code every payment goes through is touched for things that have nothing to do with the payment.

Worse, today the SMS gateway is not responding:

class SmsService {
notifyPaid(order) {
throw new Error("SMS gateway is not responding");
}
}
const payments = new Payments();
try {
payments.markPaid({ id: 1042, total: 80 });
} catch (e) {
console.log(`Payment failed: ${e.message}`);
}
Output
Email: receipt for #1042
Payment failed: SMS gateway is not responding

The money is taken, and the page tells the customer the payment failed. The warehouse reserved nothing, there is no invoice. One service nobody thinks is important stopped the whole payment.

The idea

Observer: whoever wants to know subscribes

The payments shouldn't know who cares about them. They should only know that someone wants to find out when an order is paid.

So the payments are an EventEmitter from the built-in module node:events: an object that keeps listeners by event name. Every listener is a function that takes the order. Whoever wants to know listens to the "paid" event from outside with on. On a payment, the object emits the event with emit, and it calls every listener with the order.

This is the Observer pattern. It has three roles:

  • the subject is where something happens. Here it is the payments. Books also call it the publisher;
  • the subscribers (observers) want to find out. They are email, SMS, the warehouse;
  • the notification is the walk through the list: every subscriber is given what happened, here the paid order.

A new subscriber is new code outside. The subject is not touched. A subscriber can also unsubscribe, and then it doesn't hear any more.

See it

The subscribers find out on their own

The payments extend EventEmitter. emit("paid", order) calls every listener of "paid":
import { EventEmitter } from "node:events";
class Payments extends EventEmitter {
markPaid(order) {
this.emit("paid", order);
}
}
Every listener is an arrow function that takes the order:
const email = (order) => console.log(`Email: receipt for #${order.id}`);
const sms = (order) => console.log(`SMS: order #${order.id} is paid`);
const warehouse = (order) => console.log(`Warehouse: reserve #${order.id}`);
const loyalty = (order) => console.log(`Points: +${Math.floor(order.total)} for #${order.id}`);

Three subscribers, a payment. Then loyalty points arrive, and the customer turns SMS off:

const payments = new Payments();
payments.on("paid", email);
payments.on("paid", sms);
payments.on("paid", warehouse);
payments.markPaid({ id: 1042, total: 80 });
payments.on("paid", loyalty);
payments.off("paid", sms);
payments.markPaid({ id: 1043, total: 25 });
Output
Email: receipt for #1042
SMS: order #1042 is paid
Warehouse: reserve #1042
Email: receipt for #1043
Warehouse: reserve #1043
Points: +25 for #1043

const payments = new Payments();
payments.on("paid", email);
payments.on("paid", sms);
payments.on("paid", warehouse);
payments.markPaid({ id: 1042, total: 80 });
payments.on("paid", loyalty);
payments.off("paid", sms);
payments.markPaid({ id: 1043, total: 25 });
Step by step, in words
  1. Nobody listens to the "paid" event yet.
  2. email listens with on: it goes into the listeners of "paid".
  3. sms and warehouse too. Payments doesn't know who they are, only that they take an order.
  4. Order #1042 is paid. The notification goes out to every subscriber, in the order they subscribed.
  5. One after another, in the same call: receipt, SMS, reservation. Each subscriber does its own job.
  6. Loyalty points arrive: loyalty subscribes from outside. Not a single line of Payments changes.
  7. The customer turns SMS off: sms unsubscribes and leaves the list.
  8. Order #1043: the notification reaches email, warehouse and loyalty. sms is not on the list any more and hears nothing.

Payments is the same code from start to end. The points appeared without a single line of it changing, and the SMS stopped without the payments noticing.

More precisely, if you are curious
  • EventEmitter also has once: a listener that removes itself after the first event. In the browser, the same idea is EventTarget: a button has addEventListener("click", ...), and a click calls every listener.
  • You remove the same function you attached, so it is in a variable (sms). off with a new arrow function, even with the same code, removes nothing.
  • Listeners are called one after another, in the same call to markPaid. A slow SMS gateway slows down the payment too. Big shops send the event to a queue (a message broker), and the services pick it up from there when they are ready.

See it

One subscriber fails, the others shouldn't

Observer removed the link between the payment and the SMS, but not the error. The SMS subscriber throws:

const sms = (order) => {
throw new Error("SMS gateway is not responding");
};
const payments = new Payments();
payments.on("paid", email);
payments.on("paid", sms);
payments.on("paid", warehouse);
try {
payments.markPaid({ id: 1042, total: 80 });
} catch (e) {
console.log(`Payment failed: ${e.message}`);
}
Output
Email: receipt for #1042
Payment failed: SMS gateway is not responding

Subscribers are called in a loop, and the exception from the SMS leaves the loop. The warehouse still reserves nothing, and the error still reaches the payment. The subject has to protect every call on its own:

markPaid(order) {
for (const listener of this.listeners("paid")) {
try {
listener(order);
} catch (e) {
console.log(`A subscriber failed: ${e.message}`);
}
}
}

With the same program:

const payments = new Payments();
payments.on("paid", email);
payments.on("paid", sms);
payments.on("paid", warehouse);
try {
payments.markPaid({ id: 1042, total: 80 });
} catch (e) {
console.log(`Payment failed: ${e.message}`);
}
Output
Email: receipt for #1042
A subscriber failed: SMS gateway is not responding
Warehouse: reserve #1042

The payment went through, the warehouse reserved, and the failure is recorded. In a real shop this line goes to the log, and the SMS is retried later.

emit calls the listeners one after another and stops at the first exception. this.listeners("paid") gives you a copy of the list of listeners, so you can catch the error of each.

Try it

Predict the output

The subscribers are the ones from the card "The subscribers find out on their own". What does the program print?

const payments = new Payments();
payments.on("paid", email);
payments.on("paid", warehouse);
payments.on("paid", email);
payments.markPaid({ id: 7, total: 10 });
payments.off("paid", warehouse);
payments.markPaid({ id: 8, total: 10 });
Answer

Try it

Find the bug

The warehouse is subscribed, but it reserves nothing. The program prints only:
Output
Email: receipt for #1042

Which line has the bug?

Pick the line with the bug.

More precisely, if you are curious

A listener that removes itself during the notification is safe with EventEmitter: emit copies the list before calling the listeners. once does exactly that:

const payments = new Payments();
payments.once("paid", (order) => console.log(`Welcome coupon: #${order.id}`));
payments.on("paid", email);
payments.markPaid({ id: 1042, total: 80 });
payments.markPaid({ id: 1043, total: 25 });
Output
Welcome coupon: #1042
Email: receipt for #1042
Email: receipt for #1043

With an array of functions of your own and for...of, the same thing skips the listener after the removed one, and in Java it throws ConcurrentModificationException.

The idea

When you don't need it

Observer has a price. The code doesn't say who will react to the payment: to find out, you search for every place that subscribes. A subscriber nobody unsubscribes lives in memory as long as the subject lives. It's not worth it when:

  • there is one reaction and it is always the same. One call is clearer than a list with one subscriber;
  • the step is part of the action itself. If the payment is invalid without an invoice, the invoice is not a subscriber: it is called directly, and its failure stops the payment;
  • the order matters and the steps depend on each other (first the warehouse, then the payment, then the invoice). That is a sequence, not an event. How to put it in one place is in the lesson "Adapter and Facade".

It's worth it when the reactions are many, they grow, and the subject must not depend on them: notifications, points, statistics, logs.

Exercise

The invoice as a subscriber

Solve the task in your editor.

Invoice. Make the invoice a subscriber: a function invoice that prints the amount with two digits after the point. Subscribe email, the warehouse and the invoice, and pay order #1042 for €80. The payments are not touched.

Output
Email: receipt for #1042
Warehouse: reserve #1042
Invoice: €80.00 for #1042

More tasks

  • A gift over €100. A subscriber that prints "Gift: free delivery for #…" only for orders over €100. Pay one for €80 and one for €120.
  • The coupon that unsubscribes itself. Write the coupon subscriber from "Find the bug" and payments that handle it. Do it with once, then with an array of your own, and see which listener is skipped.
  • Two events. Add refunds too. The "refunded" event and a markRefunded method. Email listens to both, the warehouse only to the payment.

Test

Check yourself

1. The shop adds loyalty points on every payment. What do you write with Observer?
2. The SMS subscriber throws an exception and the notification is not protected. What happens?
3. The same subscriber is subscribed twice. How many times does it hear about one payment?
4. The invoice must be issued, or the payment does not count. Does Observer fit here?

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