10.2 Event Notification, State Behavior, and Centralized Coordination
Observer, State, and Mediator patterns respectively address: how a fact notifies multiple observers, how an object's behavior evolves across its lifecycle, and how a group of peer objects avoids forming a mesh of interdependencies.
Observer: Publish Facts, Not Control Subscribers
Observer establishes a one-to-many notification pattern. The publisher knows the observer contract, not why each observer responds. This Java 21 fragment assumes project-defined MatchId and PlayerId; save each public type separately.
public record MatchFinished(
MatchId matchId,
PlayerId winnerId,
java.time.Instant finishedAt) {}
@FunctionalInterface
public interface MatchFinishedListener {
void on(MatchFinished event);
}
public final class LocalMatchEvents {
private final java.util.List<MatchFinishedListener> listeners;
public LocalMatchEvents(java.util.List<MatchFinishedListener> listeners) {
this.listeners = java.util.List.copyOf(listeners);
}
public void publish(MatchFinished event) {
java.util.Objects.requireNonNull(event, "event");
for (MatchFinishedListener listener : listeners) {
listener.on(event);
}
}
}Event names use facts that have already occurred MatchFinished, rather than imperative statements UpdateLeaderboard. The latter re-couples the publisher to the responsibilities of the subscribers.
Synchronous Observer Failure Semantics
This implementation runs synchronously in registration order on the publishing thread. The first exception propagates and stops later listeners; earlier effects are not rolled back. The listener list is a fixed snapshot with no dynamic registration, and recursive publishing has no guard. If A logs, B throws, and C sends mail, only A and B run. Define and test any different policy:
- If one listener fails, should subsequent listeners continue?
- Do listeners participate in the publisher's transaction?
- Is recursive publishing allowed?
- Can a listener modify the subscription list?
- What are the timeout policies and observation mechanisms?
If "save match" and "update rating" must be atomic, relying solely on an observer pattern without transactional guarantees is insufficient to achieve true decoupling. If eventual consistency is acceptable, we can write to an Outbox within a transaction and then asynchronously deliver the message via a messaging system, this shifts the problem into message reliability, requiring idempotent consumers, retries, message ordering, and dead-letter queue strategies.
In-process observers are not the same as message brokers: the former typically shares the same process and failure domain, while the latter operates across processes and introduces delivery semantics.
Java Flow adds a request(n) demand protocol and cancellation. This simple Observer has no backpressure; renaming the interface would not provide it.
Subscription Lifecycle
When listeners register dynamically, the subject holds strong references to them, which may extend the listener's lifecycle. A more reliable approach than "replacing all references with weak ones" is to have the subscription return an explicit handle:
interface Subscription extends AutoCloseable {
@Override void close();
}This interface only sketches a dynamic subscription contract; the fixed-list publisher above does not implement registration or removal. Define whether repeated close() is idempotent and whether a callback already running may finish after concurrent removal. The owner closes the subscription at the end of its lifecycle. Weak references may allow listeners to vanish silently when no strong references remain, and they do not substitute for explicit ownership.
State: Grouping State-Specific Behaviors and Transitions Together
When allowed operations differ significantly by state, scattered if (status == ...) checks obscure transitions. These four complete states use an exclusive deadline and a trusted payment-completion timestamp. Payment received after cancellation always enters refund-pending state. The minimal PaymentReceipt is specific to this example; the caller must first verify payment identity, amount, currency, and registration ownership.
public record PaymentReceipt(String paymentId, java.time.Instant paidAt) {
public PaymentReceipt {
if (paymentId == null || paymentId.isBlank()) {
throw new IllegalArgumentException("blank payment id");
}
java.util.Objects.requireNonNull(paidAt, "paidAt");
}
}
public sealed interface RegistrationState permits PendingRegistration,
ConfirmedRegistration, CancelledRegistration, RefundPendingRegistration {
RegistrationState paymentConfirmed(PaymentReceipt receipt);
RegistrationState cancel(java.time.Instant now);
}
public record PendingRegistration(java.time.Instant deadline)
implements RegistrationState {
public PendingRegistration {
java.util.Objects.requireNonNull(deadline, "deadline");
}
@Override
public RegistrationState paymentConfirmed(PaymentReceipt receipt) {
java.util.Objects.requireNonNull(receipt, "receipt");
return receipt.paidAt().isBefore(deadline)
? new ConfirmedRegistration(receipt)
: new RefundPendingRegistration(receipt);
}
@Override
public RegistrationState cancel(java.time.Instant now) {
return new CancelledRegistration(now);
}
}
public record ConfirmedRegistration(PaymentReceipt receipt)
implements RegistrationState {
public ConfirmedRegistration {
java.util.Objects.requireNonNull(receipt, "receipt");
}
@Override
public RegistrationState paymentConfirmed(PaymentReceipt repeated) {
if (!receipt.equals(repeated)) {
throw new IllegalArgumentException("conflicting payment");
}
return this;
}
@Override
public RegistrationState cancel(java.time.Instant now) {
java.util.Objects.requireNonNull(now, "now");
return new RefundPendingRegistration(receipt);
}
}
public record CancelledRegistration(java.time.Instant cancelledAt)
implements RegistrationState {
public CancelledRegistration {
java.util.Objects.requireNonNull(cancelledAt, "cancelledAt");
}
@Override
public RegistrationState paymentConfirmed(PaymentReceipt receipt) {
return new RefundPendingRegistration(receipt);
}
@Override
public RegistrationState cancel(java.time.Instant now) {
java.util.Objects.requireNonNull(now, "now");
return this;
}
}
public record RefundPendingRegistration(PaymentReceipt receipt)
implements RegistrationState {
public RefundPendingRegistration {
java.util.Objects.requireNonNull(receipt, "receipt");
}
@Override
public RegistrationState paymentConfirmed(PaymentReceipt repeated) {
if (!receipt.equals(repeated)) {
throw new IllegalArgumentException("conflicting payment");
}
return this;
}
@Override
public RegistrationState cancel(java.time.Instant now) {
java.util.Objects.requireNonNull(now, "now");
return this;
}
}Each method returns a next state without modifying the old object; the context must retain and persist that return value. Only an identical receipt counts as a replay. A different payment ID or timestamp raises a conflict instead of hiding a second charge behind “idempotency.” Payment after cancellation becomes refund-pending. That state records intent; it neither calls a refund gateway nor models refund completion.
Transitions Must Be Consistent with Concurrency
When two processes simultaneously handle "payment successful" and "cancellation," in-memory state patterns cannot prevent lost updates. The database layer still requires versioning/OCC, conditional updates, or locking:
UPDATE registration
SET state = :next_state, version = version + 1
WHERE id = :id AND version = :expected_version;The :name placeholders belong to the data-access layer; this is not SQL to paste directly into psql. Zero updated rows means no row was updated: its version may have changed, or it may be absent or filtered out. It does not prove the command already succeeded. Re-read to distinguish absence, conflict, and replay, then recompute the transition from current state. Persist state and any required outgoing event in the same transaction.
When state count is small and transitions are closed, an enum with a single centralized switch may be clearer than a class hierarchy. State is well-suited when state-specific behaviors are numerous and transitions are duplicated in multiple places.
Mediator: Centralizing a Group of Objects' Collaboration Protocol
In a game room, if the countdown, player connections, judge panel, and live streaming status directly call each other, it creates a web of interdependencies. The Mediator pattern allows them to communicate only through a central coordinator:
public final class MatchRoomCoordinator {
private final PlayerConnections players;
private final MatchClock clock;
private final BroadcastPort broadcast;
private boolean started;
public MatchRoomCoordinator(PlayerConnections players, MatchClock clock,
BroadcastPort broadcast) {
this.players = java.util.Objects.requireNonNull(players);
this.clock = java.util.Objects.requireNonNull(clock);
this.broadcast = java.util.Objects.requireNonNull(broadcast);
}
public void playerReady(PlayerId playerId) {
if (started) return;
players.markReady(playerId);
if (players.allReady()) {
started = true;
clock.start();
broadcast.publish(MatchRoomEvent.started());
}
}
}This project fragment assumes a single-thread room event loop, with players checking room membership. Setting started before external calls blocks duplicate and synchronous reentrant ready notifications from starting twice. It does not handle concurrent threads. If starting the clock or broadcasting fails, the flag remains set. Production code needs recoverable state and reliable event publication; resetting the flag would assume the external operation never happened.
Centralizing the protocol makes participants simpler and event order easier to test. The trade-off is that the Mediator might grow into a new God Object. It should be split by use case or collaboration context, never consolidate all system communications into a single SystemMediator.
Mediator differs from Facade in direction: Facade provides a simple entry point for external callers; Mediator coordinates internal peers so they don’t directly depend on each other. A service component might play both roles, but it must clearly define which protocol it maintains.
The Choice of Three Patterns
| Problem | Pattern |
|---|---|
| A fact needs to notify an unknown number of observers | Observer |
| The behavior of an operation changes based on the object's state | State |
| Multiple peer objects collaborate to form a meshed dependency | Mediator |