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:
| Part | Job |
|---|---|
| Subject | Owns the event. Notifies. Does not import Mailer or SmsClient. |
| Observer | One 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 ofprocess()is a method call. Wrapping it inEmailNotifierplus 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
forloop 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), notIObserver. - 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.sendPaidthat has not gained a neighbor. - Import
OrderProcessorfrom a notifier — the listener should not know the subject. - Put charge, discount math, or
markPaidinside a listener. Those are the workflow. - Stand up a hand-rolled bus next to Spring
ApplicationEventPublisherso 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.