Lesson 12 of 15 6 min

LLD Masterclass: The Observer Pattern in Production Systems

Learn how to implement the Observer pattern for event-driven decoupled systems. From basic Java interfaces to Spring ApplicationEvents and thread-safety considerations.

Reading Mode

Hide the curriculum rail and keep the lesson centered for focused reading.

Introduction to the Observer Pattern

Mental Model

Applying Staff-level engineering principles to build robust, production-grade software.

graph TD
    JVM[Java Virtual Machine]
    JVM --> Heap[Heap Memory]
    JVM --> Stack[Thread Stacks]
    JVM --> Metaspace[Metaspace]
    Heap --> Eden[Young Gen: Eden]
    Heap --> Survivor[Young Gen: Survivor]
    Heap --> Old[Old Generation]

In a modern backend system, you often need to perform multiple actions when a single event occurs. For example, when a user completes a purchase:

  1. Update the Inventory.
  2. Send an Email Receipt.
  3. Notify the Shipping Service.
  4. Update the Loyalty Points.

If you put all this logic into a single OrderService, you violate the Single Responsibility Principle (SRP). The code becomes a "God Class" that is hard to test and maintain.

The Observer Pattern allows a "Subject" to notify a list of "Observers" (listeners) automatically when its state changes, without knowing who they are.

1. The Classic Java Implementation

The basic pattern consists of a Subject interface and an Observer interface.

public interface Observer {
    void update(String event);
}

public class EmailNotificationObserver implements Observer {
    @Override
    public void update(String event) {
        System.out.println("Sending Email: " + event);
    }
}

public class Subject {
    private List<Observer> observers = new ArrayList<>();

    public void addObserver(Observer observer) {
        observers.add(observer);
    }

    public void notifyObservers(String event) {
        for (Observer observer : observers) {
            observer.update(event);
        }
    }
}

The Problem with the Classic Approach

  • Thread Safety: What if multiple threads add/remove observers or trigger notifications simultaneously?
  • Tight Coupling: You still have to manually register observers.
  • Synchronous Execution: If one observer is slow (e.g., sending an email), the entire Subject is blocked.

2. Production Pattern: Spring @EventListener

In a real Spring Boot production system, we rarely implement the interfaces manually. We use Application Events.

Step 1: Define the Event

public class OrderPlacedEvent {
    private final String orderId;
    // Constructor and Getter
}

Step 2: Publish the Event

@Service
public class OrderService {
    @Autowired
    private ApplicationEventPublisher eventPublisher;

    public void placeOrder(String orderId) {
        // Business logic...
        eventPublisher.publishEvent(new OrderPlacedEvent(orderId));
    }
}

Step 3: Listen to the Event

@Component
public class InventoryListener {
    @EventListener
    public void handleOrder(OrderPlacedEvent event) {
        System.out.println("Updating inventory for: " + event.getOrderId());
    }
}

@Component
public class EmailListener {
    @EventListener
    @Async // Make it non-blocking!
    public void handleOrder(OrderPlacedEvent event) {
        // Long running email task
    }
}

3. Senior Level Considerations

A. Asynchronicity (@Async)

By default, Spring events are synchronous. If an observer fails, the original transaction might roll back. Use @Async to ensure that failure in a non-critical observer (like analytics) doesn't crash the main business process.

B. Transactional Bound Events (@TransactionalEventListener)

What if the database transaction fails after you've sent the "Order Successful" email? Use @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) to ensure observers only run after the database has successfully saved the data.

C. Error Handling

Always wrap your observer logic in a try-catch block. An unhandled exception in an observer can block the thread or, if synchronous, prevent the main task from completing.

When to use Observer vs. Message Queues (Kafka)?

  • Use Observer (Local Events): For logic within the same microservice. It's faster, has zero network overhead, and is easier to manage.
  • Use Kafka/RabbitMQ: For communication between different microservices.

Final Takeaways

  • The Observer pattern is the foundation of Decoupled Architecture.
  • Use Spring's built-in event system instead of manual implementations.
  • Always think about Transactional boundaries and Error handling in observers.

Engineering Standard: The "Staff" Perspective

In high-throughput distributed systems, the code we write is often the easiest part. The difficulty lies in how that code interacts with other components in the stack.

1. Data Integrity and The "P" in CAP

Whenever you are dealing with state (Databases, Caches, or In-memory stores), you must account for Network Partitions. In a standard Java microservice, we often choose Availability (AP) by using Eventual Consistency patterns. However, for financial ledgers, we must enforce Strong Consistency (CP), which usually involves distributed locks (Redis Redlock or Zookeeper) or a strictly linearizable sequence.

2. The Observability Pillar

Writing logic without observability is like flying a plane without a dashboard. Every production service must implement:

  • Tracing (OpenTelemetry): Track a single request across 50 microservices.
  • Metrics (Prometheus): Monitor Heap usage, Thread saturation, and P99 latencies.
  • Structured Logging (ELK/Splunk): Never log raw strings; use JSON so you can query logs like a database.

3. Production Incident Prevention

To survive a 3:00 AM incident, we use:

  • Circuit Breakers: Stop the bleeding if a downstream service is down.
  • Bulkheads: Isolate thread pools so one failing endpoint doesn't crash the entire app.
  • Retries with Exponential Backoff: Avoid the "Thundering Herd" problem when a service comes back online.

Critical Interview Nuance

When an interviewer asks you about this topic, don't just explain the code. Explain the Trade-offs. A Staff Engineer is someone who knows that every architectural decision is a choice between two "bad" outcomes. You are picking the one that aligns with the business goal.

Performance Checklist for High-Load Systems:

  1. Minimize Object Creation: Use primitive arrays and reusable buffers.
  2. Batching: Group 1,000 small writes into 1 large batch to save I/O cycles.
  3. Async Processing: If the user doesn't need the result immediately, move it to a Message Queue (Kafka/SQS).

Key Takeaways

  • Thread Safety: What if multiple threads add/remove observers or trigger notifications simultaneously?
  • Tight Coupling: You still have to manually register observers.
  • Synchronous Execution: If one observer is slow (e.g., sending an email), the entire Subject is blocked.

Verbal Interview Script

Interviewer: "How does the JVM handle memory allocation for this implementation, and what are the GC implications?"

Candidate: "In this implementation, the short-lived objects are allocated in the Eden space of the Young Generation. Because they have a very short lifecycle, they will be quickly collected during a Minor GC, which is highly efficient. However, if we were to maintain strong references to these objects—for instance, in a static Map or a long-lived cache—they would survive multiple GC cycles and get promoted to the Old Generation. This would eventually trigger a Major GC (or Full GC), causing a "Stop-the-World" pause that increases our P99 latency. To mitigate this in a high-throughput environment, I would consider using the ZGC or Shenandoah garbage collectors for predictable sub-millisecond pause times, or optimize the data structures to reduce object churn."

Want to track your progress?

Sign in to save your progress, track completed lessons, and pick up where you left off.