Choreography-Based Saga in Node.js

In a Choreography-Based Saga, each service is autonomous and communicates through events. There is no central orchestrator. Services listen for specific events, perform their operations, and emit subsequent events to continue the workflow. This decentralized approach works well for simpler workflows and reduces bottlenecks caused by a central orchestrator.

Choreography-Based Saga Diagram
Choreography-Based Saga Diagram

Scenario: E-commerce Order Management System

We will use the same example as before, where a customer places an order, and multiple services handle the transaction:

  1. Order Service: Creates an order and emits an OrderCreated event.
  2. Payment Service: Listens to the OrderCreated event, processes the payment, and emits a PaymentProcessed or PaymentFailed event.
  3. Inventory Service: Listens to PaymentProcessed, reserves stock, and emits a StockReserved or StockUnavailable event.
  4. Notification Service: Listens to StockReserved to send a confirmation email.

Implementation

Prerequisites

We need to setup docker, and run RabbitMQ as docker container

docker pull rabbitmq
docker run -d --hostname rabbitmq --name rabbitmq -p 5672:5672 rabbitmq

Step 1: Setup Messaging System

Use a message broker like RabbitMQ for event-driven communication. Install the amqplib library for RabbitMQ:

npm install amqplib

Step 2: Order Service

The Order Service creates an order and emits the OrderCreated event.

// file order_service.js
const amqp = require('amqplib');

async function orderService() {
  const connection = await amqp.connect('amqp://localhost:5672');
  const channel = await connection.createChannel();
  await channel.assertExchange('ecommerce', 'fanout');

  // Simulate creating an order
  console.log('Creating order...');
  const order = { orderId: 1, customer: 'John Doe', amount: 100 };
  console.log('Order created:', order);

  // Emit the OrderCreated event
  channel.publish('ecommerce', '', Buffer.from(JSON.stringify({ event: 'OrderCreated', data: order })));
}

orderService();

Step 3: Payment Service

The Payment Service listens to the OrderCreated event, processes the payment, and emits either PaymentProcessed or PaymentFailed.

// file payment_service.js
const amqp = require('amqplib');

async function paymentService() {
  const connection = await amqp.connect('amqp://localhost:5672');
  const channel = await connection.createChannel();
  await channel.assertExchange('ecommerce', 'fanout');
  const queue = await channel.assertQueue('', { exclusive: true });
  channel.bindQueue(queue.queue, 'ecommerce', '');

  channel.consume(queue.queue, (msg) => {
    const { event, data } = JSON.parse(msg.content.toString());
    if (event === 'OrderCreated') {
      console.log(`Processing payment for order ${data.orderId}...`);
      // Simulate payment processing
      const paymentSuccess = true;

      if (paymentSuccess) {
        console.log('Payment processed.');
        channel.publish('ecommerce', '', Buffer.from(JSON.stringify({ event: 'PaymentProcessed', data })));
      } else {
        console.log('Payment failed.');
        channel.publish('ecommerce', '', Buffer.from(JSON.stringify({ event: 'PaymentFailed', data })));
      }
    }
  });
}

paymentService();

Step 4: Inventory Service

The Inventory Service listens to PaymentProcessed, reserves stock, and emits either StockReserved or StockUnavailable.

// file inventory_service.js
const amqp = require('amqplib');

async function inventoryService() {
  const connection = await amqp.connect('amqp://localhost:5672');
  const channel = await connection.createChannel();
  await channel.assertExchange('ecommerce', 'fanout');
  const queue = await channel.assertQueue('', { exclusive: true });
  channel.bindQueue(queue.queue, 'ecommerce', '');

  channel.consume(queue.queue, (msg) => {
    const { event, data } = JSON.parse(msg.content.toString());
    if (event === 'PaymentProcessed') {
      console.log(`Reserving stock for order ${data.orderId}...`);
      // Simulate stock reservation
      const stockAvailable = true;

      if (stockAvailable) {
        console.log('Stock reserved.');
        channel.publish('ecommerce', '', Buffer.from(JSON.stringify({ event: 'StockReserved', data })));
      } else {
        console.log('Stock unavailable.');
        channel.publish('ecommerce', '', Buffer.from(JSON.stringify({ event: 'StockUnavailable', data })));
      }
    }
  });
}

inventoryService();

Step 5: Notification Service

The Notification Service listens to StockReserved and sends a confirmation email.

// file notification_service.js
const amqp = require('amqplib');

async function notificationService() {
  const connection = await amqp.connect('amqp://localhost:5672');
  const channel = await connection.createChannel();
  await channel.assertExchange('ecommerce', 'fanout');
  const queue = await channel.assertQueue('', { exclusive: true });
  channel.bindQueue(queue.queue, 'ecommerce', '');

  channel.consume(queue.queue, (msg) => {
    const { event, data } = JSON.parse(msg.content.toString());
    if (event === 'StockReserved') {
      console.log(`Sending confirmation email for order ${data.orderId}...`);
      console.log('Email sent.');
    }
  });
}

notificationService();

Step 6: Start Services

node inventory_service.js
node payment_service.js
node notification_service.js
node order_service.js

Source demo: https://github.com/VuiLenDi/nodejs_choreography-based-saga

Workflow

  1. Order Service emits OrderCreated:
    • Example: { event: "OrderCreated", data: { orderId: 1, customer: "John Doe", amount: 100 } }
  2. Payment Service listens to OrderCreated:
    • Emits PaymentProcessed if successful.
    • Example: { event: "PaymentProcessed", data: { orderId: 1 } }
  3. Inventory Service listens to PaymentProcessed:
    • Emits StockReserved if stock is available.
    • Example: { event: "StockReserved", data: { orderId: 1 } }
  4. Notification Service listens to StockReserved:
    • Sends confirmation email.

Key Advantages of Choreography-Based Saga

  1. Decentralization:
    • Each service manages its own logic and transitions independently, avoiding a single point of failure.
  2. Scalability:
    • The event-driven approach allows services to scale independently.
  3. Loose Coupling:
    • Services interact through events, reducing tight dependencies.

Key Considerations

  1. Event Management:
    • With many services, managing and tracing events can become complex. Use tools like AWS CloudWatch or Jaeger for monitoring.
  2. Error Handling:
    • Ensure compensating actions are implemented for failure scenarios (e.g., refund payment if stock is unavailable).
  3. Idempotency:
    • Services must handle duplicate events gracefully to avoid inconsistent states.

Conclusion

The Choreography-Based Saga is a decentralized approach for managing distributed transactions in microservices. It allows services to operate autonomously while maintaining consistency through event-driven communication. In Node.js, this pattern can be efficiently implemented using tools like RabbitMQ for event messaging. While it simplifies workflows, proper monitoring and error handling are essential to ensure system reliability.

References

Leave a comment

I’m Tran Minh

Hi, I’m Trần Minh, a Solution Architect passionate about crafting innovative and efficient solutions that make technology work seamlessly for you. Whether you’re here to explore the latest in tech or just to get inspired, I hope you find something that sparks joy and curiosity. Let’s embark on this exciting journey together!

Let’s connect