Combining State (order lifecycle) + Observer (customer/restaurant notification) — a smaller-scope companion to the Ride Booking multi-pattern exercise, with a direct focus on testability.
Published September 23, 2026
A deliberately smaller two-pattern combination (State + Observer, no Strategy this time) — worth doing specifically to practice the same justification discipline as the Ride Booking exercise, but without pricing/matching complexity distracting from the core two-pattern interaction.
interface OrderState { OrderState advance(FoodOrder order); }
class PlacedState implements OrderState { public OrderState advance(FoodOrder order) { return new ConfirmedState(); } }
class ConfirmedState implements OrderState { public OrderState advance(FoodOrder order) { return new PreparingState(); } }
class PreparingState implements OrderState { public OrderState advance(FoodOrder order) { return new OutForDeliveryState(); } }
class OutForDeliveryState implements OrderState { public OrderState advance(FoodOrder order) { return new DeliveredState(); } }
class DeliveredState implements OrderState { public OrderState advance(FoodOrder order) { return this; } }
interface OrderObserver { void onStateChanged(FoodOrder order, OrderState newState); }
class CustomerNotificationObserver implements OrderObserver { public void onStateChanged(FoodOrder order, OrderState state) { /* push notification */ } }
class RestaurantDashboardObserver implements OrderObserver { public void onStateChanged(FoodOrder order, OrderState state) { /* update kitchen display */ } }
class FoodOrder {
private OrderState state = new PlacedState();
private final List<OrderObserver> observers = new CopyOnWriteArrayList<>();
void advanceState() {
state = state.advance(this);
observers.forEach(o -> o.onStateChanged(this, state));
}
}
This is a near-identical shape to Ride Booking's State+Observer half (and to Restaurant Management System's own order-state flow) — worth explicitly recognizing the repetition rather than re-deriving it as if novel, which is itself a strong signal: pattern recognition includes noticing "I've solved this exact combination before," not just recalling individual pattern definitions.
@Test
void placedStateAdvancesTo Confirmed() {
OrderState state = new PlacedState();
OrderState next = state.advance(new FoodOrder()); // no mocking needed — advance() only depends on its own logic
assertInstanceOf(ConfirmedState.class, next);
}
@Test
void advancingOrderNotifiesAllObservers() {
FoodOrder order = new FoodOrder();
OrderObserver mockObserver = mock(OrderObserver.class);
order.addObserver(mockObserver);
order.advanceState();
verify(mockObserver).onStateChanged(eq(order), any(ConfirmedState.class));
}
Each OrderState implementation is testable in complete isolation — PlacedState.advance() needs nothing beyond a FoodOrder reference (which could be a bare, minimally-constructed instance, not a fully wired-up object graph) to verify it transitions correctly. Separately, FoodOrder.advanceState()'s notification behavior is testable with a mock OrderObserver, verifying the fact that observers get called on every transition without needing a real CustomerNotificationObserver (which would require mocking a push-notification service) at all. This isolation — testing state-transition logic and notification-firing behavior as two entirely separate concerns — is a direct, concrete payoff of State+Observer's decomposition, not just an abstract design-quality claim.
The linear advance() chain can't express two things every real ordering system needs: cancellation (allowed early, refused late) and payment failure (the order never progresses). Instead of adding if checks around the state classes, give the state interface events, each with a sensible default:
interface OrderState {
String name();
default OrderState onPaymentConfirmed(FoodOrder o) { throw illegal("payment confirmed"); }
default OrderState onPaymentFailed(FoodOrder o) { throw illegal("payment failed"); }
default OrderState onRestaurantAccepted(FoodOrder o) { throw illegal("restaurant accepted"); }
default OrderState onPickedUp(FoodOrder o) { throw illegal("picked up"); }
default OrderState onDelivered(FoodOrder o) { throw illegal("delivered"); }
default OrderState onCancelRequested(FoodOrder o) { throw illegal("cancel"); }
private IllegalStateException illegal(String event) {
return new IllegalStateException("Event '" + event + "' not allowed in state " + name());
}
}
final class AwaitingPayment implements OrderState {
public String name() { return "AWAITING_PAYMENT"; }
public OrderState onPaymentConfirmed(FoodOrder o) { return new Placed(); }
public OrderState onPaymentFailed(FoodOrder o) { return new Cancelled("payment failed"); }
public OrderState onCancelRequested(FoodOrder o) { return new Cancelled("customer cancelled"); }
}
final class Placed implements OrderState {
public String name() { return "PLACED"; }
public OrderState onRestaurantAccepted(FoodOrder o) { return new Preparing(); }
public OrderState onCancelRequested(FoodOrder o) {
o.refundInFull(); // nothing has been cooked yet
return new Cancelled("customer cancelled");
}
}
final class Preparing implements OrderState {
public String name() { return "PREPARING"; }
public OrderState onPickedUp(FoodOrder o) { return new OutForDelivery(); }
public OrderState onCancelRequested(FoodOrder o) {
throw new IllegalStateException("Order is being prepared; contact support to cancel");
}
}
record Cancelled(String reason) implements OrderState {
public String name() { return "CANCELLED"; } // terminal: every event is illegal
}
And FoodOrder applies events in one place, notifying observers only after a successful transition:
final class FoodOrder {
private OrderState state = new AwaitingPayment();
private final List<OrderObserver> observers = new CopyOnWriteArrayList<>();
synchronized void apply(Function<OrderState, OrderState> event) {
OrderState next = event.apply(state); // throws if the event isn't allowed here
OrderState previous = state;
state = next;
observers.forEach(obs -> obs.onStateChanged(this, previous, next));
}
}
order.apply(s -> s.onCancelRequested(order));
What this buys you:
Cancelled, Delivered) override nothing, so they accept no events.Each pattern gets its own focused test, which was the point of the exercise:
@Test void cancellingWhilePreparingIsRefused() {
FoodOrder order = orderIn(new Preparing());
assertThrows(IllegalStateException.class, () -> order.apply(s -> s.onCancelRequested(order)));
}
@Test void observersAreNotifiedOnlyForSuccessfulTransitions() {
FoodOrder order = orderIn(new Preparing());
RecordingObserver rec = new RecordingObserver();
order.addObserver(rec);
assertThrows(IllegalStateException.class, () -> order.apply(s -> s.onCancelRequested(order)));
assertTrue(rec.events().isEmpty());
}
@Test void pricingStrategyIsIndependentOfState() {
assertEquals(Money.of(450), new SurgePricing(1.5).price(orderWithSubtotal(300)));
}
State tests don't need observers, observer tests use a trivial recording observer, and pricing tests need no orders in any particular state. When a bug appears, the failing test points to one pattern.
Q: What would testing look like WITHOUT this decomposition — say, if all logic lived in one FoodOrder.advance() method with a switch statement? A: Every test would need to construct a fully-wired FoodOrder AND stub/mock whatever notification mechanism was hard-coded inside that one method — testing 'does PLACED correctly transition to CONFIRMED' would be entangled with testing 'does a notification get sent,' making it impossible to verify one without the machinery for the other, exactly the coupling this design avoids.
Q: Why do CustomerNotificationObserver and RestaurantDashboardObserver both exist as separate classes rather than one CombinedObserver handling both concerns? A: Single Responsibility again — customer-facing push notifications and an internal kitchen dashboard update are genuinely different concerns with different failure modes and different owners; combining them would mean a kitchen-dashboard-update bug risking breaking customer notifications too, an unnecessary coupling this separation avoids.
Q: Is CopyOnWriteArrayList the right choice for FoodOrder's observers list here, same as elsewhere in this course? A: Yes, for the same reason as Design a Notification/Observer-Based Pub-Sub — observers are registered rarely (once, typically at order creation) and iterated frequently (on every state change), exactly CopyOnWriteArrayList's target profile.
Q: How would you extend this to support order cancellation, which doesn't fit the linear advance() progression? A: A cancellation likely needs to be modeled as a transition available from MULTIPLE states (Placed, Confirmed, Preparing can all potentially be cancelled; OutForDelivery/Delivered likely can't) — this would mean adding a separate cancel() method to the OrderState interface (not just advance()), with each state implementation deciding whether cancellation is even valid from that state, a natural extension of the same interface-per-state structure.
Q: Why give the state interface one method per event instead of a single advance()?
A: Real workflows branch: payment can succeed or fail, and cancellation is sometimes allowed. Event methods let each state accept only the events valid for it, with default implementations rejecting the rest. That makes illegal transitions explicit errors, rather than hidden bugs.
Q: Why notify observers only after the state has changed successfully? A: If observers ran first, and the transition then failed, customers could be told about changes that never happened. Computing the next state first, assigning it, then notifying, keeps notifications consistent with the actual order state.
Q: Where should refund logic live when an order is cancelled? A: The decision belongs to the state transition (only certain states refund, and possibly partially). The actual refund is best delegated to a payment service, triggered through the order or through an observer reacting to the cancellation, so the state classes stay free of payment-provider details.