Skip to content

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 only knows about the observer contract and has no knowledge of why each observer responds.

java
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) {
        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

The above implementation is synchronous, sequential, and thread-bound. We must decide:

  • 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 game" and "update score" 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.

Subscription Lifecycle

When a Subject is dynamically registered, it holds a strong reference to the listener, 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:

java
interface Subscription extends AutoCloseable {
    @Override void close();
}

The owner closes the subscription when their own lifecycle ends. 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 an object's allowed operations and outcomes vary significantly across states, scattered if (status == ...) make transition rules difficult to maintain.

java
public sealed interface RegistrationState {
    RegistrationState paymentConfirmed(PaymentReceipt receipt);
    RegistrationState cancel(java.time.Instant now);
}

public record PendingRegistration(Deadline deadline)
        implements RegistrationState {
    @Override
    public RegistrationState paymentConfirmed(PaymentReceipt receipt) {
        return new ConfirmedRegistration(receipt);
    }

    @Override
    public RegistrationState cancel(java.time.Instant now) {
        return new CancelledRegistration(now, "cancelled before payment");
    }
}

public record ConfirmedRegistration(PaymentReceipt receipt)
        implements RegistrationState {
    @Override
    public RegistrationState paymentConfirmed(PaymentReceipt ignored) {
        return this; // idempotent duplicate callback
    }

    @Override
    public RegistrationState cancel(java.time.Instant now) {
        return new RefundPendingRegistration(receipt, now);
    }
}

Each state object expresses valid behaviors and the next state; the context object holds the current state and is responsible for persisting transitions.

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:

sql
UPDATE registration
   SET state = :next_state, version = version + 1
 WHERE id = :id AND version = :expected_version;

A zero affected row count indicates the state has already been advanced by another transaction, re-read and process according to idempotent rules.

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:

java
public final class MatchRoomCoordinator {
    private final PlayerConnections players;
    private final MatchClock clock;
    private final BroadcastPort broadcast;

    public void playerReady(PlayerId playerId) {
        players.markReady(playerId);
        if (players.allReady()) {
            clock.start();
            broadcast.publish(MatchRoomEvent.started());
        }
    }
}

By centralizing the coordination protocol, participants become simpler and easier to test for event sequencing. 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

ProblemPattern
A fact needs to notify an unknown number of observersObserver
The behavior of an operation changes based on the object's stateState
Multiple peer objects collaborate to form a meshed dependencyMediator

References

Built with VitePress | Software Systems Atlas