Observer

Part of the free Low-Level Design course on LogicWiz, in the chapter: Behavioural patterns I. The class-diagram exercises run in the app.

Week 7: One status change, four phone calls

"Why does OrderService import SmsSender, EmailSender and PushSender? Its job is to change a status."

Priya, reviewing OrderService


One method, four jobs

When a rider picks up an order, its status becomes OUT_FOR_DELIVERY. The customer gets an SMS, an email and a push message. Here is how Arjun wrote it:

public class OrderService {
    private final SmsSender sms = new SmsSender();
    private final EmailSender email = new EmailSender();
    private final PushSender push = new PushSender();

    public void markOutForDelivery(Order order) {
        order.setStatus(OrderStatus.OUT_FOR_DELIVERY);
        sms.send(order.phone(), "Your order is on the way");
        email.send(order.email(), "Out for delivery");
        push.send(order.userId(), "Your rider is on the way");
    }
}

It works. Then the requests start:

  • Marketing wants a WhatsApp message.
  • The analytics team wants an event for every status change.
  • The rider app wants to know when an order is cancelled.

Each request edits OrderService, a class whose real job is the order's status. It now depends on every channel. If email.send throws an exception, the push message is never sent. Testing a status change needs fake SMS, email and push senders.

Let the listeners sign up

State the problem first. One object changes. Many other objects need to react. The object that changes should not need to know who they are.

The fix: OrderService keeps a list of listeners behind one small interface. When the status changes, it walks the list and tells each one. A new listener signs up from outside; OrderService does not change.

public interface OrderStatusListener {
    void onStatusChanged(Order order, OrderStatus newStatus);
}

public class OrderService {
    private final List<OrderStatusListener> listeners = new ArrayList<>();

    public void subscribe(OrderStatusListener listener) {
        listeners.add(listener);
    }

    public void changeStatus(Order order, OrderStatus status) {
        order.setStatus(status);
        for (OrderStatusListener l : listeners) {
            l.onStatusChanged(order, status);
        }
    }
}

Each channel becomes a listener. It decides for itself which statuses it cares about:

public class SmsNotifier implements OrderStatusListener {
    private final SmsSender sender;

    public SmsNotifier(SmsSender sender) {
        this.sender = sender;
    }

    public void onStatusChanged(Order order, OrderStatus status) {
        if (status == OrderStatus.OUT_FOR_DELIVERY) {
            sender.send(order.phone(), "Your order is on the way");
        }
    }
}

At startup, main wires them: orderService.subscribe(new SmsNotifier(smsSender));. Adding WhatsApp is one new class and one line in main.

This is the Observer pattern. The object that changes is the subject (here OrderService). The objects that react are observers (the listeners). The subject depends only on the observer interface.

sequenceDiagram
  participant OS as OrderService
  participant S as SmsNotifier
  participant E as EmailNotifier
  participant P as PushNotifier
  OS->>OS: setStatus(OUT_FOR_DELIVERY)
  OS->>S: onStatusChanged(order, status)
  OS->>E: onStatusChanged(order, status)
  OS->>P: onStatusChanged(order, status)

Two small design choices come up in interviews:

  • Push or pull. Here the subject pushes the order and the new status in the call. In the pull style it sends only "something changed", and each observer asks the subject for what it needs. Push is simpler; pull suits observers that need different data.
  • Unsubscribe. Give the subject an unsubscribe method too. A listener that is never removed keeps its objects alive in memory.

What the observer does not fix

The loop above runs every listener one after another, in the same thread, inside changeStatus. That has three effects you should say out loud in a design discussion:

  • A slow listener slows the status change. If the email server takes two seconds, the rider's app waits two seconds.
  • A failing listener can stop the others. If EmailNotifier throws, the loop ends and the push message never goes. Wrap each call in its own try/catch and log the failure.
  • Nothing is stored. If the process crashes after setStatus and before the SMS, that SMS is lost for good.

You can fix the first problem by handing each call to a thread pool, so changeStatus returns at once. That helps speed, but the other two problems remain. A crash still loses the message, and now errors happen on another thread where the caller never sees them.

⚠️ Warning: Do not rely on the order in which listeners run. If the email must go after the invoice is created, that is one step calling another, not two independent observers.

Observer or a message queue?

In a mock interview, a candidate said: "Observer is the same as Kafka. Both are publish and subscribe." The idea is related, but the mechanics are different, and interviewers check that you know how.

In the observer pattern, the subject holds direct references to its observers. It calls their methods itself, in the same process, at the moment of the change.

In publish-subscribe through a broker, a separate system sits in the middle. A broker is a server such as Kafka or RabbitMQ that receives and stores messages. The publisher sends an event to the broker and moves on. It does not know who reads it. Subscribers, often in other services, read the event later at their own speed.

Observer Pub/sub via a broker
Who calls the receiver The subject, directly The broker delivers; the receiver reads
Where Same process Across services and machines
Timing At once, same thread by default Later, at the reader's pace
Crash safety Lost if the process dies Stored by the broker, can be retried

So which one? In a 90-minute machine-coding round, everything runs in one program, so the observer is the right tool. In SwiftKart's real system, the order service would publish an OrderStatusChanged event to a queue, and a separate notification service would send the SMS. That is the HLD course's territory. You can mention it in one sentence to show you know the difference.

Do not use an observer when there is one receiver that will never change. A direct method call is clearer. Also avoid it when the caller needs an answer back, such as "did the payment succeed?". Observers are for announcing, not for asking.

Practice: let the channels subscribe

The starter shows OrderService calling three senders directly. Add a listener interface, make each channel implement it, and let the service hold only a list of listeners.

📌 Summary: Observer lets a subject announce a change to a list of listeners it knows only by an interface. It runs in-process and at once. A message broker adds storage, retries and separate services.

What Arjun learns next

Listeners now hear about status changes. But who decides whether a change is even allowed? Next week, Priya finds an order that went from cancelled to delivered, and Arjun meets the State and Command patterns.

Check your understanding

  1. Analytics wants an event on every order status change. With the observer design in place, what changes?
  2. A candidate says: "Observer and Kafka are the same thing, both are publish and subscribe." What is the key difference?
  3. EmailNotifier throws an exception inside the listener loop. What happens with the simple loop in this lesson?
  4. Checkout must know whether the payment succeeded before it shows the confirmation screen. Should payment be an observer of checkout?