Observer Pattern

The Observer pattern defines a one-to-many dependency between objects, so that when one object changes state, all its dependents are automatically notified and updated. This pattern is fundamental for building event-driven systems and is particularly useful when modernizing legacy systems to add new features without modifying existing code.

Video Summary

The video presentation covers the Observer pattern comprehensively:

  • Core concepts of publish-subscribe architecture
  • When to use observers vs. direct method calls
  • Implementing observers in TypeScript
  • Real-world applications in legacy modernization
  • Avoiding common pitfalls: memory leaks, notification storms
  • Comparison with related patterns: Event Emitters, Message Queues

Key Concepts

1. Subject and Observers

Subject (Observable): The object being watched Observers: Objects that want to be notified of changes

interface Observer<T> {
  update(data: T): void;
}

class Subject<T> {
  private observers: Observer<T>[] = [];

  subscribe(observer: Observer<T>): void {
    this.observers.push(observer);
  }

  unsubscribe(observer: Observer<T>): void {
    this.observers = this.observers.filter((o) => o !== observer);
  }

  notify(data: T): void {
    this.observers.forEach((observer) => observer.update(data));
  }
}

2. Decoupling

Subject doesn't need to know about concrete observer classes:

// Subject doesn't know what observers do with the data
class UserModel extends Subject<User> {
  updateUser(user: User): void {
    // Update user
    this.notify(user); // All observers automatically notified
  }
}

// Observers can be added without modifying subject
class EmailObserver implements Observer<User> {
  update(user: User): void {
    sendEmail(user.email, 'Profile updated');
  }
}

class AuditObserver implements Observer<User> {
  update(user: User): void {
    logToAudit({ event: 'user_updated', userId: user.id });
  }
}

class CacheObserver implements Observer<User> {
  update(user: User): void {
    cache.invalidate(`user:${user.id}`);
  }
}

Real-World Applications

Example 1: Adding Features to Legacy Systems

// Legacy order processing code we can't modify
class LegacyOrderProcessor {
  private observers: Array<(order: Order) => void> = [];

  subscribe(callback: (order: Order) => void): void {
    this.observers.push(callback);
  }

  processOrder(order: Order): OrderResult {
    // Legacy processing logic
    const result = this.legacyProcess(order);

    // Notify observers (added as seam)
    this.observers.forEach((callback) => callback(order));

    return result;
  }

  private legacyProcess(order: Order): OrderResult {
    // Complex legacy code we don't want to touch
  }
}

// New features added via observers
class InventoryObserver {
  constructor(private inventoryService: InventoryService) {}

  observe(orderProcessor: LegacyOrderProcessor): void {
    orderProcessor.subscribe((order) => {
      order.items.forEach((item) => {
        this.inventoryService.decrementStock(item.productId, item.quantity);
      });
    });
  }
}

class EmailNotificationObserver {
  constructor(private emailService: EmailService) {}

  observe(orderProcessor: LegacyOrderProcessor): void {
    orderProcessor.subscribe((order) => {
      this.emailService.send({
        to: order.customerEmail,
        subject: 'Order Confirmation',
        body: `Your order #${order.id} has been received`,
      });
    });
  }
}

class AnalyticsObserver {
  constructor(private analytics: AnalyticsService) {}

  observe(orderProcessor: LegacyOrderProcessor): void {
    orderProcessor.subscribe((order) => {
      this.analytics.track('order_placed', {
        orderId: order.id,
        total: order.total,
        itemCount: order.items.length,
      });
    });
  }
}

// Wire up observers
const orderProcessor = new LegacyOrderProcessor();
new InventoryObserver(inventoryService).observe(orderProcessor);
new EmailNotificationObserver(emailService).observe(orderProcessor);
new AnalyticsObserver(analyticsService).observe(orderProcessor);

Example 2: Event-Driven Architecture

type EventType = 'USER_CREATED' | 'USER_UPDATED' | 'USER_DELETED';

interface DomainEvent<T = any> {
  type: EventType;
  timestamp: Date;
  data: T;
}

class EventBus {
  private subscribers = new Map<EventType, Array<(event: DomainEvent) => void>>();

  subscribe(eventType: EventType, callback: (event: DomainEvent) => void): void {
    if (!this.subscribers.has(eventType)) {
      this.subscribers.set(eventType, []);
    }

    this.subscribers.get(eventType)!.push(callback);
  }

  publish(event: DomainEvent): void {
    const callbacks = this.subscribers.get(event.type) || [];

    callbacks.forEach((callback) => {
      try {
        callback(event);
      } catch (error) {
        console.error('Observer error:', error);
      }
    });
  }
}

// Domain service publishes events
class UserService {
  constructor(private eventBus: EventBus) {}

  async createUser(user: User): Promise<User> {
    const createdUser = await database.insert('users', user);

    this.eventBus.publish({
      type: 'USER_CREATED',
      timestamp: new Date(),
      data: createdUser,
    });

    return createdUser;
  }
}

// Multiple systems react to events
class WelcomeEmailHandler {
  constructor(eventBus: EventBus) {
    eventBus.subscribe('USER_CREATED', (event) => {
      const user = event.data;
      emailService.send({
        to: user.email,
        template: 'welcome',
        data: { name: user.name },
      });
    });
  }
}

class UserAnalyticsHandler {
  constructor(eventBus: EventBus) {
    eventBus.subscribe('USER_CREATED', (event) => {
      analytics.track('user_signup', { userId: event.data.id });
    });
  }
}

class CRMSyncHandler {
  constructor(eventBus: EventBus) {
    eventBus.subscribe('USER_CREATED', (event) => {
      crmAPI.createContact(event.data);
    });
  }
}

Example 3: Reactive State Management

class Observable<T> {
  private value: T;
  private observers: Array<(value: T) => void> = [];

  constructor(initialValue: T) {
    this.value = initialValue;
  }

  get(): T {
    return this.value;
  }

  set(newValue: T): void {
    if (this.value !== newValue) {
      this.value = newValue;
      this.notify();
    }
  }

  subscribe(callback: (value: T) => void): () => void {
    this.observers.push(callback);

    // Return unsubscribe function
    return () => {
      this.observers = this.observers.filter((cb) => cb !== callback);
    };
  }

  private notify(): void {
    this.observers.forEach((callback) => callback(this.value));
  }
}

// Usage
const userCount = new Observable(0);

// UI subscribes to updates
const unsubscribe = userCount.subscribe((count) => {
  document.getElementById('user-count').textContent = String(count);
});

// Update triggers UI refresh
userCount.set(42); // UI automatically updates

// Clean up
unsubscribe();

Common Pitfalls

1. Memory Leaks from Forgotten Unsubscribe

Problem: Observers never unsubscribe, preventing garbage collection.

// Bad: Creates memory leak
function setupObserver() {
  const observer = new MyObserver();
  subject.subscribe(observer);
  // observer never unsubscribed!
}

Solution: Always clean up subscriptions:

// Good: Unsubscribe when done
class Component {
  private unsubscribe: () => void;

  mount() {
    this.unsubscribe = subject.subscribe(this.handleUpdate);
  }

  unmount() {
    this.unsubscribe();
  }

  handleUpdate = (data: Data) => {
    // Handle update
  };
}

2. Notification Storms

Problem: Updates trigger observers that trigger more updates.

Solution: Batch updates or prevent recursion:

class Subject<T> {
  private isNotifying = false;

  notify(data: T): void {
    if (this.isNotifying) {
      console.warn('Recursive notification prevented');
      return;
    }

    this.isNotifying = true;

    try {
      this.observers.forEach((observer) => observer.update(data));
    } finally {
      this.isNotifying = false;
    }
  }
}

3. Observer Execution Order Dependency

Problem: Observers depend on execution order.

Solution: Make observers independent or use priority system:

interface PrioritizedObserver<T> extends Observer<T> {
  priority: number;
}

class Subject<T> {
  private observers: PrioritizedObserver<T>[] = [];

  subscribe(observer: PrioritizedObserver<T>): void {
    this.observers.push(observer);
    this.observers.sort((a, b) => b.priority - a.priority);
  }
}

Key Takeaways

  • Observer pattern enables loose coupling between subjects and observers
  • Perfect for adding new functionality to legacy systems without modification
  • Forms the basis of event-driven architectures
  • Watch for memory leaks—always unsubscribe observers
  • Prevent notification storms with guards against recursion
  • Observers should be independent, not rely on execution order
  • Consider async observers for long-running operations

Further Reading

Sunsetting - Observer Pattern | Sunsetting Learn