The ticket says “send SMS when payment succeeds.” You open OrderProcessor.process(), scroll past the charge, and add a third call: email was already there, audit was last quarter, SMS is this one. Receipts still send. The audit line still writes. You also just re-ran every paid-order test for a change that had nothing to do with charging a card, and you picked up a merge conflict from whoever is adding a Slack ops ping on another branch.

That is Observer’s entire complaint. From the Design Patterns Roadmap: dependents subscribe to a subject; the subject notifies without naming who is listening.

This post is only about what happens after the charge. Strategy swapped the discount. Factory Method moved new. The shared lab and the three families stay on that hub. Here we care about one growing tail of side effects inside process().

The tail that grows every channel

Here is the success path after email and audit have already landed. Nobody wrote this badly on purpose:

public class OrderProcessor {

    private final PaymentGateway gateway;
    private final DiscountPolicy discount;
    private final OrderRepository orders;
    private final Mailer mailer;
    private final AuditWriter audit;

    public OrderProcessor(
            PaymentGateway gateway,
            DiscountPolicy discount,
            OrderRepository orders,
            Mailer mailer,
            AuditWriter audit) {
        this.gateway = gateway;
        this.discount = discount;
        this.orders = orders;
        this.mailer = mailer;
        this.audit = audit;
    }

    public void process(Order order) {
        BigDecimal payable = discount.payable(order);
        PaymentResult result = gateway.charge(order, payable);
        if (!result.approved()) {
            throw new PaymentDeclinedException(order.id(), result.failureReason());
        }
        orders.markPaid(order.id(), result.reference());

        mailer.sendPaid(order.customerEmail(), order.id(), result.reference());
        audit.record("order.paid", order.id(), result.reference());
    }
}

Now price the SMS ticket — and the next two:

Add SMS on paid           -> edit process(), re-test charge and persist
Add Slack ops ping        -> another collaborator, another constructor argument
Change the email template -> same file as the paid-order state machine
Test FLAT50 in isolation  -> construct a mailer, an audit writer, and soon an SMS client

Four costs, and none of them are about the payable amount. The processor has become the owner of every reaction product will ever attach to “order paid.”

Note: The problem is not that side effects exist. Someone has to send the mail. The problem is a list of dependents that grows on someone else’s calendar, sitting inside the method that also owns money. That is the same axis Open-Closed names; Observer is one common shape for that seam.

What Observer actually is

Two parts, one promise:

PartJob
SubjectOwns the event. Notifies. Does not import Mailer or SmsClient.
ObserverOne interface, one implementation per reaction.

The subject depends on the listener interface. The listeners do not know the subject exists. That direction is the whole pattern. If EmailNotifier imports OrderProcessor, you have not decoupled a reaction; you have created a cycle.

You do not need a class named Observer. You need a verb the publisher already understands — here, “this order was paid”:

public interface OrderEvents {

    void orderPaid(Order order, PaymentResult result);
}

That is the seam. Everything else is an implementation. orderFailed can wait until a ticket asks for it. An interface with one method you never call is ceremony.

One class per reaction

Each call at the bottom of the old process() becomes a type that can be tested without a gateway or a repository.

public final class EmailNotifier implements OrderEvents {

    private final Mailer mailer;

    public EmailNotifier(Mailer mailer) {
        this.mailer = mailer;
    }

    @Override
    public void orderPaid(Order order, PaymentResult result) {
        mailer.sendPaid(order.customerEmail(), order.id(), result.reference());
    }
}

public final class AuditLog implements OrderEvents {

    private final AuditWriter audit;

    public AuditLog(AuditWriter audit) {
        this.audit = audit;
    }

    @Override
    public void orderPaid(Order order, PaymentResult result) {
        audit.record("order.paid", order.id(), result.reference());
    }
}

SMS is not a new kind of payment. It is another OrderEvents. The ticket that used to edit process() is a new class:

public final class SmsNotifier implements OrderEvents {

    private final SmsClient sms;

    public SmsNotifier(SmsClient sms) {
        this.sms = sms;
    }

    @Override
    public void orderPaid(Order order, PaymentResult result) {
        sms.sendPaid(order.customerEmail(), order.id());
    }
}

A subject still has to hold the listeners. A tiny publisher is enough. It is allowed to know there is a list. It is not allowed to know that the list contains email:

public final class OrderEventPublisher implements OrderEvents {

    private final List<OrderEvents> listeners;

    public OrderEventPublisher(List<OrderEvents> listeners) {
        this.listeners = List.copyOf(listeners);
    }

    @Override
    public void orderPaid(Order order, PaymentResult result) {
        for (OrderEvents listener : listeners) {
            listener.orderPaid(order, result);
        }
    }
}

Do not invent a publisher per event name until a second event exists. OrderEvents with orderPaid is the whole API. A FailedEventBus and a RefundEventBus for a codebase that only fires paid is three types that never vary.

Note: Decide what happens if a listener throws. Fan-out that swallows errors will hide a missed receipt. Fan-out that fails the whole process() will roll back a charge that already succeeded. Most checkout systems record “paid” first, then notify, and retry notifications out of band. Pick that policy on purpose; do not let a for loop invent it.

The processor stops naming the channels

OrderProcessor takes OrderEvents the same way it already takes a PaymentGateway — as a constructor argument, not as a list of SDKs it constructs:

public class OrderProcessor {

    private final PaymentGateway gateway;
    private final DiscountPolicy discount;
    private final OrderRepository orders;
    private final OrderEvents events;

    public OrderProcessor(
            PaymentGateway gateway,
            DiscountPolicy discount,
            OrderRepository orders,
            OrderEvents events) {
        this.gateway = gateway;
        this.discount = discount;
        this.orders = orders;
        this.events = events;
    }

    public void process(Order order) {
        BigDecimal payable = discount.payable(order);
        PaymentResult result = gateway.charge(order, payable);
        if (!result.approved()) {
            throw new PaymentDeclinedException(order.id(), result.failureReason());
        }
        orders.markPaid(order.id(), result.reference());
        events.orderPaid(order, result);
    }
}

Registration lives at the edge — the same composition root that already builds gateways and discount creators:

OrderEvents events = new OrderEventPublisher(List.of(
        new EmailNotifier(mailer),
        new AuditLog(audit),
        new SmsNotifier(sms)));

In Spring you often do not write this list by hand. That is the same pattern with a different bus, and it is worth one paragraph, not a rewrite. ApplicationEventPublisher.publishEvent(new OrderPaidEvent(...)) plus @EventListener methods is Observer. If the application already has that bus, use it. Do not add OrderEventPublisher beside ApplicationEventPublisher so the code “has the Gang of Four name.” This post uses a four-type sketch so the shape is visible without a container. Production code should not maintain two buses.

“You just moved the loop” is the fair objection. Yes. The difference is which file owns it. OrderEventPublisher (or Spring) is allowed to gain a listener every quarter. OrderProcessor is not. Adding Slack becomes a new class plus one line of registration — and zero edits to the charge-and-persist path.

What the diff looks like now

Same feature request, both designs:

Before — add SMS
  M OrderProcessor.java      new collaborator inside charge-and-persist
  M OrderProcessorTest.java  every test constructs an SmsClient

After — add SMS
  A SmsNotifier.java         new class, existing callers untouched
  A SmsNotifierTest.java     new test, no gateway, no repository
  M CheckoutConfig.java      one line in the listener list

One added file plus one line of subscription. OrderProcessor is closed against “a new channel appears” and still fully open to editing when the workflow changes — a refund step, a fraud check, a second charge attempt. Those belong in process, because they are not reactions to “paid.”

Notify after markPaid. If orderPaid runs first and then persist fails, listeners will announce a sale that did not stick. The subject should publish facts that already happened.

Proving it with a recording listener

The seam pays a second dividend immediately: the processor becomes testable without a mail server, an audit table, or an SMS vendor.

class RecordingEvents implements OrderEvents {

    Order lastOrder;
    PaymentResult lastResult;

    @Override
    public void orderPaid(Order order, PaymentResult result) {
        this.lastOrder = order;
        this.lastResult = result;
    }
}

A unit test can now assert that a declined charge does not notify anyone, using a fake gateway and a recording listener:

@Test
void declinedChargeDoesNotNotify() {
    FakePaymentGateway gateway = FakePaymentGateway.alwaysDeclined("card_declined");
    RecordingRepository orders = new RecordingRepository();
    RecordingEvents events = new RecordingEvents();
    OrderProcessor processor = new OrderProcessor(
            gateway, new NoDiscount(), orders, events);

    assertThrows(PaymentDeclinedException.class, () -> processor.process(order()));
    assertNull(events.lastOrder);
    assertTrue(orders.markPaidCalls().isEmpty());
}

And EmailNotifier can be tested with a fake Mailer — no processor, no network:

@Test
void paidSendsReceiptToCustomer() {
    FakeMailer mailer = new FakeMailer();
    OrderEvents notifier = new EmailNotifier(mailer);
    Order order = new Order("o-1", "a@b.com", List.of(), new BigDecimal("19.99"));

    notifier.orderPaid(order, PaymentResult.approved("ref-9"));

    assertEquals("a@b.com", mailer.lastTo());
    assertTrue(mailer.lastBody().contains("o-1"));
}

If a test for charging needs a real SMTP session, you have mixed two reasons to change. Split the tests the same way you split the types.

When Observer is the wrong move

Skip the interface when:

  • There is one listener and no second on the roadmap. mailer.sendPaid(...) at the bottom of process() is a method call. Wrapping it in EmailNotifier plus a publisher “for consistency” is two files and a constructor argument that never varies.
  • You already have a framework event bus. Spring ApplicationEvent, Jakarta CDI events, a queue you already publish to after commit — those are Observer. Re-teaching the pattern as a hand-rolled list next to them duplicates delivery, retry, and transaction rules you will get wrong.
  • The reaction is part of the workflow, not a dependent. If the email must succeed before you mark paid (some B2B contracts), that is a step in process(), not a listener you might forget to register. Do not hide a required step on a bus.
  • Listeners must run in a strict, named order that is the business. “Audit, then email, then SMS, and skip SMS if email bounced” is an orchestration. A for loop over subscribers will not stay honest. Keep that sequence visible, or use a workflow object that is allowed to know the steps.

The healthy trigger is a second channel you can paste from a ticket. Email vs audit vs SMS are three reactions. “Log a line and also log it in JSON” is a parameter on one logger — not two observers.

Observer also is not Strategy. Strategy swaps which algorithm computes the payable. Observer fans reactions out after something already happened. If process starts choosing a notifier with if (channel.equals("SMS")), you wanted Strategy (or a simple map) for sending, not a subject. If it names every sender in sequence, you wanted Observer.

Synchronous in-process listeners are enough for this lab. A message broker is the same subscription idea at a different scale: the processor publishes order.paid; consumers you do not deploy with it subscribe. Reach for the broker when you need retry, another service, or a listener that cannot share the processor’s JVM. Do not start there because the pattern’s Wikipedia page shows a cloud diagram.

Cheat sheet

Subject     OrderProcessor / OrderEventPublisher   calls events.orderPaid, does not name Mailer
Observer    OrderEvents                            one method, one reaction
Variants    EmailNotifier, AuditLog, SmsNotifier   one class per channel
Subscribe   composition root (or Spring events)    allowed to change every quarter

Trigger to apply: a second dependent is on a ticket or already at the bottom of process()
Trigger to stop:  one listener, or a bus the framework already gave you
Scoreboard:       new channel = new file; OrderProcessor tests do not construct SmsClient

Do:

  • Name the interface after the event the subject already understands (orderPaid), not IObserver.
  • Persist first, then notify. Publish facts that already happened.
  • Test each listener without the processor; test the processor with a recording listener.
  • Prefer the framework bus you already have over a second list of callbacks.

Don’t:

  • Extract Observer for a single mailer.sendPaid that has not gained a neighbor.
  • Import OrderProcessor from a notifier — the listener should not know the subject.
  • Put charge, discount math, or markPaid inside a listener. Those are the workflow.
  • Stand up a hand-rolled bus next to Spring ApplicationEventPublisher so the design “matches the book.”

Wrap-up

Observer is a small event interface whose implementations are reactions, and a subject that refuses to name them. The success tail in OrderProcessor was expensive because every new channel edited the charge-and-persist path. Moving orderPaid behind OrderEvents makes the next channel an added class, keeps email and audit unit-testable, and leaves process free to change when the workflow changes.

If the next pain is AuditedRetryingCachedGateway — a subclass per combination of extra behaviors — that is Decorator.

Next optional step in the series Add logging and retry by wrapping PaymentGateway, not by subclassing every vendor. Decorator: Add Behavior Without Subclassing the Core