DEV Community

Manoj Khatri
Manoj Khatri

Posted on

The Everyday Backend Engineer: Step 10 — The Observer Pattern

Welcome back to The Everyday Backend Engineer: Practical Design Patterns. In our last post, we made our core algorithms interchangeable using the Strategy Pattern. Today, we close out our design patterns roadmap with arguably the most native pattern in the entire Node.js ecosystem: The Observer Pattern.

Let’s look at how to master event-driven decoupling to trigger secondary workflows seamlessly without bloat.


🔴 The Problem: Direct Inline Side-Effects

Imagine you are writing a video processing engine or a simple order fulfillment system. When a specific event happens—such as an order being finalized—multiple unrelated departments want a piece of the action:

  1. The Notification Service needs to send an SMS and Email receipt.
  2. The Logistics Service needs to generate a warehouse fulfillment ticket.
  3. The Analytics Service needs to update marketing tracking boards.

If you don't decouple these events, your primary execution service ends up managing a giant web of secondary micro-services:

// ❌ Bad Practice: The primary service is drowning in secondary dependencies
const EmailService = require('../services/email');
const WarehouseService = require('../services/warehouse');
const AnalyticsTracker = require('../services/analytics');

class OrderProcessor {
    async finalizeOrder(order) {
        console.log("Saving primary order to the database...");
        // Core business logic ends here

        // The codebase smell: Procedural cascading dependencies
        await EmailService.sendReceipt(order.userEmail);
        await WarehouseService.createShipment(order.id);
        await AnalyticsTracker.trackSale(order.totalAmount);
    }
}

module.exports = OrderProcessor;

Enter fullscreen mode Exit fullscreen mode

Why does this slow your system down?

Your core OrderProcessor is now structurally dependent on three separate systems. If the AnalyticsTracker throws a network timeout error or if the warehouse API changes its interface, your core transaction fails or hangs. Furthermore, adding a fourth side-effect (like an auditing logger) means breaking open this core file yet again.


🟢 The Solution: Pub/Sub Broadcast Architecture

The Observer Pattern solves this by establishing a one-to-many relationship. A central object (called the Subject or Publisher) maintains a list of dependents (called Observers or Subscribers).

When a major event occurs inside the Subject, it simply screams into the ether: "Hey, event X just happened!" and attaches the payload data. It does not know or care who is listening. The individual Observers intercept that broadcast independently and run their respective workflows concurrently outside the core logic flow.

📻 The Real-World Analogy: "The Radio Broadcast"

Think of a live Radio Station broadcasting a sports commentary. The host behind the microphone simply speaks into the transmission desk. They do not visit the homes of 10,000 listeners individually to tell them the score. Anyone with a radio tuner tuned into that specific station frequency (The Subscriber) hears the audio feed and reacts to it independently—whether they are driving, sitting in a cafe, or cooking at home.


💻 The Clean Node.js Implementation (EventEmitter)

In JavaScript and Node.js, the Observer Pattern is baked directly into the core library via the EventEmitter class from the native events module.

Let’s build a completely decoupled, event-driven architecture.

First, we set up our core execution context (The Publisher):

// services/orderService.js
const EventEmitter = require('events');

// Extend EventEmitter to give our class publishing powers
class OrderService extends EventEmitter {
    constructor() {
        super();
    }

    async placeOrder(orderData) {
        console.log("📦 Core Operation: Processing inventory checks and database locks...");

        const finalizedOrder = { ...orderData, id: "ORD-55419", status: "PAID" };

        console.log("✅ Core Transaction Complete. Broadcasting event into the system stream...");

        // 🚀 Emit the event asynchronously. No hardcoded tracking or email dependencies!
        this.emit('orderPlaced', finalizedOrder);

        return finalizedOrder;
    }
}

module.exports = OrderService;

Enter fullscreen mode Exit fullscreen mode

Next, we establish our completely isolated Observer listeners:

// sub-systems/listeners.js

// Observer 1: Notification Handler
function setupNotificationListener(orderEmitter) {
    orderEmitter.on('orderPlaced', (order) => {
        console.log(`✉️ [Notification Service] Dispatched digital receipt to customer.`);
    });
}

// Observer 2: Logistics Fulfillment Handler
function setupLogisticsListener(orderEmitter) {
    orderEmitter.on('orderPlaced', (order) => {
        console.log(`🏭 [Logistics Service] Warehouse ticket created for ID: ${order.id}.`);
    });
}

module.exports = { setupNotificationListener, setupLogisticsListener };

Enter fullscreen mode Exit fullscreen mode

🎯 Wiring the Pipeline Together at the Root

Look at how modular and non-intrusive our system boot file becomes. We pass our event router instance down into the subsystem hooks to attach the listeners dynamically:

// app.js (The Composition Root)
const OrderService = require('./services/orderService');
const { setupNotificationListener, setupLogisticsListener } = require('./sub-systems/listeners');

const orderService = new OrderService();

// 🔌 Wire up the isolated system observers cleanly at application launch
setupNotificationListener(orderService);
setupLogisticsListener(orderService);

// Simulated API Request Trigger
async function handleUserCheckoutRequest() {
    const payload = { item: 'MacBook Pro', totalAmount: 2499, userEmail: 'dev@test.com' };

    // Trigger the workflow
    await orderService.placeOrder(payload);
}

handleUserCheckoutRequest();
/* Output:
 📦 Core Operation: Processing inventory checks and database locks...
 ✅ Core Transaction Complete. Broadcasting event into the system stream...
 ✉️ [Notification Service] Dispatched digital receipt to customer.
 🏭 [Logistics Service] Warehouse ticket created for ID: ORD-55419.
*/

Enter fullscreen mode Exit fullscreen mode

🧠 Code Smell Test: When should you use an Observer?

Keep this architectural signal in mind during review sessions:

  • The Smell: Your main service methods execute a core business task successfully, but then contain trailing blocks of code whose sole purpose is to call secondary logging, emailing, metrics-tracking, or clear-cache routines.
  • The Fix: Replace those secondary method calls with a single uniform .emit('eventName', data) statement, and move the execution code blocks out into isolated listener files.

🎓 The Roadmap Wrap-up: Your Structural Toolkit

Congratulations! You have completed the entire 10-step software design patterns blog series. Let's look back at the ultimate backend system design matrix you have mastered:

  1. Singleton: Share resource connections globally (e.g., Database pools).
  2. Factory: Encapsulate and abstract messy runtime object instantiations.
  3. Builder: Assemble complex, multi-parameter objects sequentially via method chaining.
  4. Dependency Injection: Inject external tools into constructors to decouple testing layers.
  5. Decorator: Stack optional business features onto baseline entities on the fly.
  6. Facade: Bundle chaotic multi-module sequencing behind a single, clean API doorway.
  7. Adapter: Translate rigid third-party vendor SDK signatures into your application language.
  8. Repository: Isolate query statements to build database-agnostic services.
  9. Strategy: Make algorithms interchangeable at runtime depending on dynamic system conditions.
  10. Observer: Deconstruct background side-effects into asynchronous event-driven triggers.

Do you utilize Node's native EventEmitter for local memory streams, or do you transition straight to network brokers like Kafka, RabbitMQ, or Redis Pub/Sub for distributed cloud scale? Let me know in the comments section below! Thank you for reading the series!

Top comments (0)