Skip to content
ExponaBLOG
DESIGN PATTERNS IN PRACTICE · DAY 028 / 100

Observer pattern: a Design Patterns guide for developers

Notify interested parties when something happens.

Expona Admin9 min read

Observer pattern: a Design Patterns guide for developers

Most of the code we write reacts to events — a user clicks a button, a database record updates, an API response arrives. The Observer pattern is the architectural answer to "how do we let multiple parts of our system respond to the same event without tangling them together?" This design pattern sits at the heart of modern frameworks, event-driven systems, and asynchronous architectures we build every day.

Why the Observer pattern matters

Before exploring the mechanics, let's ground this in reality. You're building an e-commerce platform. When an order completes, several things need to happen: inventory decreases, an email fires, a webhook notifies the shipping provider, analytics records the conversion, and the customer's loyalty points update. If all that logic lives in one function, you've created a maintenance nightmare. The Observer pattern untangles this.

The pattern defines a one-to-many relationship: one object (the subject) notifies many objects (observers) when something happens. Each observer watches for that event independently and acts on it. The subject never needs to know what observers exist or what they do.

Subjects and observers — Observers subscribe; the subject notifies them

Let's build a practical example. Imagine an Order class that needs to notify interested parties when it's completed.

typescript
interface OrderObserver {
  onOrderCompleted(order: Order): void;
}

class Order {
  private observers: OrderObserver[] = [];
  private status: string = 'pending';

  subscribe(observer: OrderObserver): void {
    this.observers.push(observer);
  }

  unsubscribe(observer: OrderObserver): void {
    this.observers = this.observers.filter(obs => obs !== observer);
  }

  complete(): void {
    this.status = 'completed';
    this.notifyObservers();
  }

  private notifyObservers(): void {
    this.observers.forEach(observer => {
      observer.onOrderCompleted(this);
    });
  }
}

class InventoryService implements OrderObserver {
  onOrderCompleted(order: Order): void {
    console.log('Decreasing inventory...');
    // Update stock
  }
}

class EmailService implements OrderObserver {
  onOrderCompleted(order: Order): void {
    console.log('Sending confirmation email...');
    // Send email
  }
}

class AnalyticsService implements OrderObserver {
  onOrderCompleted(order: Order): void {
    console.log('Logging purchase event...');
    // Track event
  }
}

// Usage
const order = new Order();
order.subscribe(new InventoryService());
order.subscribe(new EmailService());
order.subscribe(new AnalyticsService());

order.complete(); // All observers are notified

The pattern is straightforward: the subject maintains a list of observers, offers methods to add and remove them, and calls each observer's method when an event occurs. Each observer implements the same interface, so the subject treats them uniformly. The order doesn't care if there are three observers or thirty — the notification logic stays the same.

Everywhere in modern code — DOM events, EventEmitter, RxJS and domain events

The Observer pattern isn't theoretical. It's baked into every platform we use.

DOM events in the browser: When you attach a click handler to a button, you're using the Observer pattern. The button is the subject; your callback is the observer.

typescript
const button = document.querySelector('button');
button.addEventListener('click', (event) => {
  console.log('Button clicked');
});
// The button notifies all listeners when clicked

Node.js EventEmitter: Backend Node applications rely on EventEmitter constantly. Kafka consumers, HTTP servers, file watchers — all use this pattern.

typescript
import { EventEmitter } from 'events';

class UserService extends EventEmitter {
  createUser(name: string): void {
    const user = { id: 1, name };
    this.emit('userCreated', user);
  }
}

const userService = new UserService();

userService.on('userCreated', (user) => {
  console.log(`Sending welcome email to ${user.name}`);
});

userService.on('userCreated', (user) => {
  console.log(`Recording signup in analytics`);
});

userService.createUser('Alice'); // Both listeners fire

RxJS Observables: Frontend frameworks use reactive programming extensively. RxJS Observables are a sophisticated take on the pattern, adding operators for transformation and filtering.

typescript
import { Subject } from 'rxjs';
import { debounceTime } from 'rxjs/operators';

class SearchInput {
  private querySubject = new Subject();

  constructor() {
    this.querySubject.pipe(
      debounceTime(300)
    ).subscribe(query => {
      console.log(`Searching for: ${query}`);
    });
  }

  handleInput(query: string): void {
    this.querySubject.next(query);
  }
}

Domain events in event-driven architecture: In services like ride-hailing or SaaS platforms, domain events are how services communicate. A PaymentProcessed event might trigger billing, notifications, and reconciliation simultaneously.

typescript
class PaymentService {
  private eventBus: EventBus;

  processPayment(paymentId: string): void {
    // Process payment logic
    const event = new PaymentProcessed(paymentId, 99.99);
    this.eventBus.publish(event);
  }
}

class NotificationSubscriber {
  subscribe(eventBus: EventBus): void {
    eventBus.subscribe(PaymentProcessed, (event) => {
      console.log(`Notifying customer of payment $${event.amount}`);
    });
  }
}

class ReconciliationSubscriber {
  subscribe(eventBus: EventBus): void {
    eventBus.subscribe(PaymentProcessed, (event) => {
      console.log(`Recording payment in accounting system`);
    });
  }
}

Whether it's a browser click, a Node stream, or a domain event in your trading system, the Observer pattern is there. It's so common we often don't think of it by name — we just call it "event handling."

Decoupling — The publisher doesn't know who listens

The real power of the Observer pattern is decoupling. The subject publishes an event; observers subscribe independently. Neither side needs to know about the other.

Compare two approaches. First, without the Observer pattern:

typescript
class OrderService {
  completeOrder(orderId: string): void {
    // Complete order logic
    
    // Now we're tightly coupled to these specific services
    const inventoryService = new InventoryService();
    inventoryService.decreaseStock(orderId);
    
    const emailService = new EmailService();
    emailService.sendConfirmation(orderId);
    
    const analyticsService = new AnalyticsService();
    analyticsService.logPurchase(orderId);
  }
}

Adding a new observer? You modify OrderService. Remove an observer? More changes. This couples too many services together.

With the Observer pattern, the subject just publishes; observers handle themselves:

typescript
class OrderService {
  private observers: OrderObserver[] = [];

  registerObserver(observer: OrderObserver): void {
    this.observers.push(observer);
  }

  completeOrder(orderId: string): void {
    // Complete order logic
    const order = this.getOrder(orderId);
    this.notifyObservers(order);
  }

  private notifyObservers(order: Order): void {
    this.observers.forEach(obs => obs.onOrderCompleted(order));
  }
}

// New observer? Just register it. No changes to OrderService.
const orderService = new OrderService();
orderService.registerObserver(new InventoryService());
orderService.registerObserver(new EmailService());
orderService.registerObserver(new LoyaltyPointsService()); // Easy to add

This decoupling is critical in large systems. Teams can work independently. The order service team doesn't need to know that the loyalty team added a new observer. Services can be added, removed, or replaced without touching the publisher. In event-driven architectures processing millions of transactions — think e-commerce checkouts or ride-hailing dispatches — this flexibility is invaluable.

Watch for leaks — Always unsubscribe when observers go away

Here's where many developers trip up. If observers aren't cleaned up, they linger in memory.

Imagine a UI component that subscribes to a subject:

typescript
class UserProfile {
  private unsubscribe: (() => void) | null = null;

  ngOnInit(): void {
    // Component subscribes to user updates
    this.userService.userUpdated$.subscribe(user => {
      console.log(`User updated: ${user.name}`);
    });
    // BUG: No unsubscribe
  }

  ngOnDestroy(): void {
    // When component is destroyed, the subscription remains
    // The subject still holds a reference to the callback
    // Memory leak!
  }
}

If that component is mounted and destroyed repeatedly, the subject accumulates dead callbacks. In a single-page application where users navigate between pages, this adds up.

The fix is always unsubscribe when the observer is done:

typescript
class UserProfile {
  private unsubscribe$ = new Subject();

  ngOnInit(): void {
    this.userService.userUpdated$
      .pipe(takeUntil(this.unsubscribe$))
      .subscribe(user => {
        console.log(`User updated: ${user.name}`);
      });
  }

  ngOnDestroy(): void {
    this.unsubscribe$.next();
    this.unsubscribe$.complete();
  }
}

Or, with manual subscription tracking:

typescript
class Order {
  private observers: OrderObserver[] = [];

  subscribe(observer: OrderObserver): () => void {
    this.observers.push(observer);
    // Return an unsubscribe function
    return () => {
      this.observers = this.observers.filter(obs => obs !== observer);
    };
  }
}

// Usage
const unsubscribe = order.subscribe(new EmailService());
// Later, when done:
unsubscribe();

In production systems handling millions of events — Kafka consumers in a SaaS, WebSocket subscriptions in a real-time trading platform — unsubscribe discipline is non-negotiable. A leaking subscription can silently bloat memory until the service restarts.

Common mistakes

Forgetting to unsubscribe: We covered this above. Always clean up when an observer is destroyed or no longer needed. Use weak references where possible, or return unsubscribe functions.

Synchronous notification blocking: If observers take a long time, they block other observers. Use async/await or background tasks for heavy operations.

typescript
// BAD: Blocking
private notifyObservers(order: Order): void {
  this.observers.forEach(observer => {
    observer.onOrderCompleted(order); // Waits for each one
  });
}

// BETTER: Non-blocking
private async notifyObservers(order: Order): Promise {
  await Promise.all(
    this.observers.map(observer =>
      observer.onOrderCompleted(order).catch(err => {
        console.error('Observer failed:', err);
        // Handle gracefully
      })
    )
  );
}

Silent observer failures: If an observer throws an exception, it might break notification for others. Wrap observer calls in try-catch or use a message queue for resilience.

Observer ordering assumptions: Don't assume observers execute in registration order — or at all. Design systems where observer execution order doesn't matter.

Key takeaways

  • The Observer pattern connects subjects to multiple observers without tight coupling. Subjects publish events; observers subscribe independently.
  • It's everywhere: DOM events, Node EventEmitter, RxJS Observables, and domain events in event-driven services all use this pattern.
  • Decoupling is the payoff. New observers can be added without modifying the subject. Services stay independent.
  • Memory leaks happen when observers aren't unsubscribed. Always clean up when observers are destroyed or no longer relevant.
  • In production, handle observer failures gracefully, avoid blocking notification chains, and don't rely on observer execution order.
  • The Observer pattern scales from simple event handlers to complex event-driven architectures processing millions of transactions.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles