Intermediate

Node.js Microservices Communication Patterns

As we design and develop microservices and move away from monolithic architectures, it's not always obvious how best to approach inter-service communication.

Table of Contents

  1. Course Overview
  2. Introduction to communication patterns
  1. Setting up the environment
  1. RESTful services and event driven communication
  1. Webhooks in Node.js — Real-time event handling
  1. Integration of message brokers in an event driven architecture
  1. Implementation of Remote Procedure Calls (RPC) in Node.js
  1. Service Discovery and Load Balancing

1. Course Overview

Welcome to this Node.js Microservices: Communication Patterns course. As we design and develop microservices and move away from monolithic architectures, it’s not always obvious how best to approach inter-service communication.

In this course, we will explore:

  • REST endpoints and webhooks for communication to and beyond our systems
  • message brokers and RPC for communication within our systems

Major topics covered include:

  • Decoupling our microservices
  • Choosing the right data consistency approach
  • The development and use of message brokers and RPC
  • Best practices for developing and using webhooks in your applications

At the end of this course, you will be able to select, evaluate and create the most appropriate communication pattern for each stage of your microservices architecture. Before you begin, you should be familiar with Node.js and know how to build a basic microservice.


2. Introduction to communication patterns

2.1 Introduction — Globomantics

Throughout this course, we will accompany the Globomantics team on their transformation journey from a traditional monolithic platform to a dynamic microservices-oriented business model. This change isn’t just a technical upgrade — it’s a strategic move that reflects the evolving digital landscape.

The Globomantics team recognized that in today’s fast-paced digital world, agility and flexibility are not benefits but necessities. The microservices architecture, with its modular approach, offers incomparable adaptability. It allows companies:

  • Respond quickly to market changes
  • Scale specific features as needed
  • Accelerate development cycles

Yes, the transition to microservices introduces complexities and challenges, but the team believes the benefits far outweigh these obstacles.

The first step in this ambitious project is to overhaul their e-commerce platform, which includes various features such as order, inventory, shipping and customer management. Each of these functions is vital, and transforming them into separate but interconnected microservices will open doors to innovation and efficiency that were previously closed in a monolithic setup.

Monolithic vs Microservices

CharacteristicMonolithic architectureMicroservices architecture
StructureApplication built as a single unitIndependent services, each with a responsibility
PairingAll processes are tightly integratedLoosely coupled services
ScalabilityThe whole application must be scaledEach service can be scaled independently
DeploymentUpdate of the entire applicationIndependent deployment of each service
Fault ToleranceA breakdown can affect the entire systemA fault is isolated to the service concerned

Microservices also present challenges: managing communication between services, the consistency of distributed data, and increased operational complexity.

2.2 What do communication patterns mean?

This course focuses on communication patterns. In software engineering, a communication pattern is not simply a technical transmission of data — it represents a fundamental strategy for how the components of a system interact and collaborate effectively.

In software engineering, a pattern is more than a solution: it is a blueprint that has evolved through decades of trial, error, and improvement. Patterns encapsulate best practices, providing a roadmap for navigating complex problems.

Example: the Singleton pattern — In object-oriented programming, the Singleton pattern allows a class to be responsible for creating and returning a single instance of an expensive resource (such as a database connection or a print service). Whenever a user needs it, they call the Singleton class. If it already exists, the Singleton returns the existing instance; if it does not exist, it creates it and then returns it. This way, each user does not create their own version of an expensive resource.

When we discuss communication patterns, especially in microservices architecture, we look at how these discrete services can exchange information efficiently and reliably. The choice of a communication pattern can have a profound impact on:

  • The scalability of the system
  • The resilience of the system
  • The maintainability of the system

For example, the Publisher‑Subscriber pattern (widely used in message-oriented middleware) allows services to publish messages without knowing who will consume them, while subscribers can listen to messages of interest — which favors a decoupled and scalable architecture.

Creating our own protocols or communication patterns can lead us down a path of unnecessary complexity. It’s like building a private language when a common one already exists. Not only does this make integration with external systems more difficult, but it also increases technical debt and maintenance burden.

2.3 Synchronous vs asynchronous communication

In this section, we’ll dive into the two primary forms of communication in a microservices architecture: synchronous and asynchronous.

Synchronous communication

Synchronous communication resembles a phone call. When one service calls another, it waits for a response before moving on. This direct and immediate form of communication is simple and intuitive.

Example: In an e-commerce application, when a user places an order, the orders service could communicate synchronously with the inventory service to check if an item is available.

Advantages:

  • Simple and direct information flow
  • Immediate results

Disadvantages:

  • Introducing dependencies — if the inventory service is down, the order service cannot proceed
  • Potential creation of bottlenecks
  • Availability and fault tolerance can be significant factors

Asynchronous communication

Asynchronous communication looks like leaving a voicemail. A service sends a message and then continues, without waiting for an immediate response.

Example: Once an order is placed, the order service could asynchronously notify the shipping service to arrange delivery without requiring immediate confirmation.

Advantages:

  • Service decoupling
  • Improved fault tolerance and scalability
  • If the shipping service is temporarily unavailable, the message may be queued and processed later

Disadvantages:

  • Increased complexity — managing message queues and ensuring reliable delivery becomes essential

When to choose?

CriterionSynchronousAsynchronous
Real-time interactions✅ Ideal❌ Less suitable
Strong decoupling❌ Less suitable✅ Ideal
Fault Tolerance❌ Limited✅ Best
Simplicity✅ Simpler❌ More complex
Long operations❌ Blocking✅ Non-blocking

In microservices, the choice between synchronous and asynchronous communication depends on the specific requirements of interactions between services. Synchronous communication is useful for immediate, real-time interactions where rapid responses are needed.

2.4 Pattern overview

In this section, we will have an overview of each of the patterns that we will explore throughout the course. Each serves a unique purpose in the microservices landscape.

Pattern 1: Event-driven communication

Imagine a scenario in our e-commerce platform where a customer places an order. This action triggers an event. The orders service broadcasts this event, which is captured by other services (stocks, shipping). They react independently. This decouples our services, allowing them to operate and scale autonomously.

Pattern 2: Webhooks

Webhooks are essentially user-defined HTTP callbacks. For example, when a new product is added to inventory, the inventory department can send a webhook to the marketing department, triggering an automatic update on the website.

Webhooks:

  • Are simple and powerful for real-time data synchronization
  • Use web request-response cycle for fast response
  • Work is done separately, reducing bottlenecks experienced by our users

Pattern 3: Message-based communication (Message Brokers)

Tools like RabbitMQ with the Publish-Subscribe pattern play a crucial role here. The order service publishes a message to a “new order” queue. Various services subscribe to this queue (billing, shipping), receive this message and process it independently. This pattern ensures a high level of decoupling and scalability.

Because the queue organizes messages, the order service does not need to wait for feedback from billing or shipping before moving on, trusting the queue to send messages as needed.

Pattern 4: Remote Procedure Calls (RPC) — gRPC

RPC offers a different approach. Here, a service directly calls a procedure in another service, almost as if it were a local procedure. Implemented with gRPC, it offers:

  • High-performance communication with Protocol Buffers (binary serialization)
  • strong type consistency thanks to Protobuf contracts
  • An HTTP/2 transport with bidirectional streaming

Pattern 5: Service Discovery and Load Balancing

Finally, we will examine how services are dynamically discovered in the network (service discovery) and how traffic is distributed efficiently between instances (load balancing).

2.5 Considerations for choosing a pattern

To make a decision on which communication pattern is appropriate for a given scenario, several factors must be considered:

1. Latency

In an e-commerce platform, time is of the essence. Any delay in communication between the order department and the inventory department can lead to a poor user experience. High latency in synchronous communication could be detrimental, while an asynchronous method could alleviate the problem.

2. Reliability

It is critical that messages and requests between services are delivered and processed reliably. If a shipping update does not reach the customer, it can lead to dissatisfaction and confusion. Reliable communication patterns, possibly with queuing and acknowledgment mechanisms, are vital here.

3. Coupling

We want to aim for weak coupling. If the payment service is tightly coupled to the orders service, a failure in one could cripple the other. Using patterns like event-driven communication could help reduce coupling, improving system resilience.

4. Complexity

Although a complex, feature-rich communication pattern might provide more functionality, it may also increase the burden of managing and understanding the system. Opting for simpler and more straightforward patterns could be more beneficial, allowing for easy maintenance and faster onboarding of new developers.

5. Data consistency

Data consistency is essential, especially in systems that manage transactions and user data. Ensuring that all departments (inventory, billing, shipping) have a consistent view of data is essential for a properly functioning system.


3. Setting up the environment

3.1 Technologies used

For this course, here are the necessary technologies:

Node.js

This course focuses on Node.js. To confirm your installation, run:

node --version

At the time of registration, the current LTS version is version 20, which is the version used for this course. The Node installation also includes npm (Node Package Manager) and npx (Node Package Execute).

Docker

Docker allows us to containerize our microservices and isolate them from our system code, ensuring that our local development environment matches our production environment as closely as possible. Check the installation:

docker --version

Docker Compose

While Docker allows you to define and start a single container, Docker Compose allows you to do the same for multiple containers, allowing us to begin the process of orchestrating our larger microservices ecosystem and manage it in a single file. Check:

docker compose version

3.2 Code samples and testing tools

Almost all modules in this course are very rich in demos. For each module there is a code ZIP file with a before and after folder for each code clip.

REST Client Extension (VS Code)

To test HTTP endpoints, the course uses the REST Client extension from Huachao Mao. Once installed and activated, any file ending in .http can be used to send HTTP and HTTPS API calls. Three hash marks ### separate the queries. Above each request, the “Send Request” link appears to send it.

### Envoyer un webhook de test
POST http://localhost:3000/webhook
Content-Type: application/json

{
  "type": "order_placed",
  "id": "123",
  "customer": {
    "id": "456",
    "name": "John Doe",
    "email": "john@doe.com"
  }
}

4. RESTful services and event driven communication

Module duration: 45m 54s

4.1 RESTful services in an event-driven architecture

In this module, we focus on using RESTful services in our microservices infrastructure. This is where most services begin, and it may be the only communication pattern you will use in your system.

What is REST?

REST (Representational State Transfer) was proposed in 2001 by Roy Fielding. It is built on a number of principles that are particularly useful for microservices:

  • Request/response cycle — A client makes a request and the server provides a response. The client can be a mobile application, a web browser, or another service.
  • Stateless — Between requests, a server does not have to keep track of the client. Each service can process requests in isolation, free from the burden of shared state.
  • Caching (cacheable) — Since the same query generates the same response, we can cache responses for static data.
  • Uniform interface — REST uses standardized HTTP methods (GET, POST, PUT, DELETE, PATCH).
  • Layered architecture — REST can use intermediate systems (proxies, gateways) transparently.

REST in an event-driven architecture

REST can operate in an event-driven architecture playing two roles:

  1. Event trigger — A REST endpoint receives a request and emits an event to notify other services
  2. Event Receiver — A service listens for and responds to external events through its own endpoints

How REST fits into an event-driven architecture:

Client → [POST /order] → Order Service → émet événement "order_placed"
                                              ↓
                                    Inventory Service (listener)
                                    Shipping Service (listener)
                                    Billing Service (listener)

4.2 RESTful services in practice

Here are code examples illustrating the integration of REST with an event-driven model.

Order service with event emission

import express from 'express';
import { EventEmitter } from 'events';

const app = express();
const eventEmitter = new EventEmitter();

// Endpoint REST — déclencheur d'événements
app.post('/order', express.json(), (req, res) => {
  const order = req.body;
  
  // Traitement de la commande...
  const processedOrder = processOrder(order);
  
  // Emission d'un événement "order_placed"
  eventEmitter.emit('order_placed', processedOrder);
  
  res.status(201).json({ orderId: processedOrder.id, status: 'created' });
});

// Listener d'événements dans le service d'inventaire
eventEmitter.on('order_placed', (order) => {
  checkInventory(order);
  updateStock(order);
  console.log(`Inventory updated for order: ${order.id}`);
});

Inventory Service — Event Response

This pattern illustrates the flexibility of the approach: the inventory service operates independently, reacting to the order_placed event without being directly coupled to the orders service.

// Vérification de l'inventaire déclenchée par un événement
async function checkInventory(order) {
  for (const item of order.items) {
    const stock = await getStockLevel(item.productId);
    if (stock < item.quantity) {
      // Déclencher une alerte de stock faible
      eventEmitter.emit('low_stock', { productId: item.productId, currentStock: stock });
    }
  }
}

The beauty of this setup is the flexibility. The inventory service operates independently, reacting to the order_placed event without being directly coupled to the orders service.

4.3 Building scalable RESTful services

Scalability isn’t just a feature — it’s a fundamental requirement. RESTful services, by their nature, lend themselves well to scalability. Their stateless protocol and ability to process requests independently make them ideal candidates for building systems that can grow and adapt.

Stateless vs. Stateful

Stateful Architecture:

  • Server retains information about each client between requests
  • Leads to complexity and scalability challenges
  • Server must manage and synchronize state between multiple sessions

Stateless Architecture (REST):

  • Each client request contains all the information the server needs to process it
  • No need to maintain server-side sessions — simpler and more scalable model
  • Services can operate independently without relying on shared state or context
  • Services can be distributed across multiple servers or even geographic regions

Horizontal scaling with stateless services

Stateless services allow simple horizontal scaling: you can add instances without worrying about state synchronization. A load balancer distributes requests between instances.

        [Load Balancer]
       /       |        \
[Instance 1] [Instance 2] [Instance 3]

Key principles for scalability

  1. Statelessness — All necessary data is in the query
  2. Caching — Cache frequent responses (Redis, CDN)
  3. Pagination — Never return unlimited datasets
  4. Connection pooling — Reuse database connections
  5. Circuit Breaker — Protect against cascading failures

4.4 RESTful API performance optimization

Pagination

Pagination is essential for managing and presenting large data sets. Imagine trying to retrieve thousands of records in a single API call. Pagination solves this problem by dividing the data into smaller pages.

app.get('/products', (req, res) => {
  const page = parseInt(req.query.page) || 1;
  const limit = parseInt(req.query.limit) || 10;
  
  const startIndex = (page - 1) * limit;
  const endIndex = page * limit;
  
  const result = {};
  
  if (endIndex < products.length) {
    result.next = { page: page + 1, limit };
  }
  
  if (startIndex > 0) {
    result.previous = { page: page - 1, limit };
  }
  
  result.data = products.slice(startIndex, endIndex);
  
  res.json(result);
});

Paging:

  • Reduces load on server and network
  • Improves API response times and overall user experience
  • Makes data navigation easier

Compression

Data compression, particularly with gzip, plays a pivotal role in minimizing the size of HTTP responses:

import compression from 'compression';

app.use(compression({
  level: 6, // niveau de compression (0-9)
  threshold: 1024 // compresser les réponses > 1Ko
}));

Compression significantly reduces the size of responses transmitted over the network.

Caching

Caching allows you to avoid repeated calculations or database accesses for the same data:

import NodeCache from 'node-cache';
const cache = new NodeCache({ stdTTL: 100 }); // TTL de 100 secondes

app.get('/products/:id', async (req, res) => {
  const cacheKey = `product_${req.params.id}`;
  const cachedProduct = cache.get(cacheKey);
  
  if (cachedProduct) {
    return res.json(cachedProduct);
  }
  
  const product = await getProductFromDb(req.params.id);
  cache.set(cacheKey, product);
  res.json(product);
});

Rate Limiting

Rate limiting protects our APIs against abuse and guarantees fair use:

import rateLimit from 'express-rate-limit';

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // limite de 100 requêtes par fenêtre
  message: 'Trop de requêtes, veuillez réessayer plus tard.'
});

app.use('/api/', limiter);

4.5 RESTful services and data consistency

In distributed systems, data is stored across multiple locations or nodes. This structure, while providing scalability and resilience, creates significant challenges in ensuring a consistent view of the data.

Main challenges

  • Network latency — Data transit time can lead to scenarios where different parts of the system have different versions of the same data
  • Partition Tolerance — How the system continues to operate despite partial outages or network failures without compromising data integrity
  • Synchronization — Ensure all updates are reflected in all nodes without conflicts or data loss

ACID

ACID (Atomicity, Consistency, Isolation, Durability) guarantees data integrity during transactions:

PropertyDescription
AtomicityAll parties in a transaction succeed completely or none
ConsistencyEach transaction takes the database from one valid state to another
InsulationConcurrent transactions occur without interfering with each other
SustainabilityOnce a transaction is validated, it persists even in the event of a system crash

In a single database system, the ACID principles provide a solid foundation. However, in a microservices architecture with multiple databases, maintaining ACID throughout the system can be difficult.

BASE

In opposition to ACID, the BASE model offers an alternative for distributed systems:

AcronymMeaningDescription
BABasically AvailableThe system guarantees availability even in partially faulty conditions
SSoft stateSystem state can change even without new entries
EEventually consistentThe system will become consistent over time, although it may not be consistent immediately

The BASE model recognizes that in distributed systems, immediate consistency is not always possible. Instead, it aims for eventual consistency, which may be acceptable for many applications where a slight temporary inconsistency is tolerable.

Saga Pattern

The Saga pattern helps manage long and complex transactions across multiple services. A Saga is a sequence of local transactions, where each transaction updates data in its own service and triggers the next step via a message or event.

Order Service → [create_order] → 
Inventory Service → [reserve_stock] → 
Payment Service → [process_payment] → 
Shipping Service → [arrange_shipment]

If a step fails, the Saga executes compensatory transactions to undo the effects of previous steps, maintaining consistency without a global lock.

CAP theorem

The CAP theorem states that a distributed system can simultaneously guarantee only two of the following three properties:

  • Consistency — Each read gets the most recent write
  • **Availability — Each request receives a response
  • Partition tolerance — System continues to operate despite network outages

In microservices, we usually trade off between **Consistency and **Availability based on business needs.

4.6 Error handling in RESTful microservices

Robust error handling is crucial to maintaining both the reliability and usability of our RESTful services. In a microservices architecture, the importance of proper error handling is amplified with many services interacting in complex workflows.

Error Categories

Errors in RESTful services fall into three broad categories:

CategoryHTTP CodesExamples
Client side errors4xx400 Bad Request, 404 Not Found, 401 Unauthorized
Server side errors5xx500 Internal Server Error, 503 Service Unavailable
Network issuesTimeouts, connections refused

Consistent error responses

A consistent error response format helps API consumers process errors programmatically:

class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
    this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
    this.isOperational = true;
    Error.captureStackTrace(this, this.constructor);
  }
}

// Middleware de gestion des erreurs globales
app.use((err, req, res, next) => {
  err.statusCode = err.statusCode || 500;
  err.status = err.status || 'error';
  
  res.status(err.statusCode).json({
    status: err.status,
    message: err.message,
    ...(process.env.NODE_ENV === 'development' && { stack: err.stack })
  });
});

Pattern Circuit Breaker

The Circuit Breaker allows our system to protect itself when a dependent service is not available:

class CircuitBreaker {
  constructor(threshold = 5, timeout = 60000) {
    this.state = 'CLOSED'; // CLOSED, OPEN, HALF_OPEN
    this.failureCount = 0;
    this.threshold = threshold;
    this.timeout = timeout;
    this.lastFailureTime = null;
  }
  
  async call(fn) {
    if (this.state === 'OPEN') {
      if (Date.now() - this.lastFailureTime > this.timeout) {
        this.state = 'HALF_OPEN';
      } else {
        throw new Error('Circuit is OPEN — service unavailable');
      }
    }
    
    try {
      const result = await fn();
      this.onSuccess();
      return result;
    } catch (error) {
      this.onFailure();
      throw error;
    }
  }
  
  onSuccess() {
    this.failureCount = 0;
    this.state = 'CLOSED';
  }
  
  onFailure() {
    this.failureCount++;
    this.lastFailureTime = Date.now();
    if (this.failureCount >= this.threshold) {
      this.state = 'OPEN';
    }
  }
}
  • CLOSED — Normal operation, all requests pass
  • OPEN — Too many outages, queries fail immediately
  • HALF_OPEN — Periodic test to see if service is restored

Retry with exponential backoff

async function retryWithBackoff(fn, maxRetries = 3, baseDelay = 1000) {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      if (attempt === maxRetries) throw error;
      const delay = baseDelay * Math.pow(2, attempt - 1);
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
}

The exponential backoff gradually increases the interval between retries: 1s, 2s, 4s, etc. This avoids immediate retries which would lead to the same failure.

Logging and monitoring

Effective error handling also includes:

  • Structured logging — log each error with context, user ID, timestamp
  • Alerts — trigger alerts when error rates exceed a threshold
  • Dashboards — view error metrics over time

4.7 Demo: Implementing a RESTful service

In this demo, we identify part of the monolith to be migrated, refactor the controller to use the principles discussed, and look at future improvements.

Original Monolithic Controller

// Contrôleur monolithique — tout dans une seule fonction
async function placeOrder(req, res) {
  // 1. Vérifier l'inventaire
  const inventoryOk = checkInventory(req.body.items);
  
  // 2. Traiter le paiement
  const paymentResult = processPayment(req.body.paymentInfo);
  
  // 3. Créer la commande
  const order = createOrder(req.body);
  
  // 4. Mettre à jour l'inventaire
  updateInventory(req.body.items);
  
  // 5. Envoyer la confirmation
  sendOrderConfirmation(order);
  
  res.json({ success: true, orderId: order.id });
}

Problem: Everything is coupled — cannot scale inventory, payment or confirmations independently.

Refactored controller with microservices

File .env:

INVENTORY_SERVICE_URL=http://localhost:3001
PAYMENT_SERVICE_URL=http://localhost:3002
ORDER_CONFIRMATION_SERVICE_URL=http://localhost:3003

config.js file:

import dotenv from 'dotenv';
dotenv.config();

export const config = {
  inventoryServiceUrl: process.env.INVENTORY_SERVICE_URL,
  paymentServiceUrl: process.env.PAYMENT_SERVICE_URL,
  orderConfirmationServiceUrl: process.env.ORDER_CONFIRMATION_SERVICE_URL
};

Refactored controller:

import { config } from './config.js';
import axios from 'axios';

export async function placeOrder(req, res) {
  try {
    // Appel HTTP vers le service d'inventaire
    const inventoryResponse = await axios.post(
      `${config.inventoryServiceUrl}/check`,
      { items: req.body.items }
    );
    
    if (!inventoryResponse.data.available) {
      return res.status(409).json({ error: 'Items not in stock' });
    }
    
    // Appel HTTP vers le service de paiement
    const paymentResponse = await axios.post(
      `${config.paymentServiceUrl}/process`,
      { paymentInfo: req.body.paymentInfo }
    );
    
    // Créer et retourner la commande
    const order = { id: generateOrderId(), ...req.body };
    
    // Notification asynchrone — ne pas attendre la réponse
    axios.post(`${config.orderConfirmationServiceUrl}/send`, { order })
      .catch(err => console.error('Failed to send confirmation:', err));
    
    res.status(201).json({ success: true, orderId: order.id });
  } catch (error) {
    res.status(500).json({ error: 'Failed to process order' });
  }
}

Each service is now independent and can be:

  • Individually scaled as needed
  • Developed and deployed by separate teams
  • Replaced or upgraded without impacting other services

5. Webhooks in Node.js — Real time event handling

Module duration: 53m 10s

5.1 Introduction to webhooks

To better understand what webhooks are, let’s imagine ordering takeaway food from a restaurant:

  1. We call the restaurant and place our order
  2. The restaurant confirms that the order has been received
  3. Rather than waiting on the line until the order is ready, they will send us a call, text or message to let us know the food is ready for collection

What’s important: We don’t have to stay online or stare at the app while our food is prepared. We receive an immediate response that the order has been received. Then, when the food is ready, another event is sent.

A webhook is therefore an exposed endpoint that listens for incoming events and allows you to respond to them.

We can call a webhook to place an order, receive an immediate response knowing that work continues after the connection is closed. And after the job is finished, the store can call back with a message. This time, our endpoint receives it, acknowledges it, and can do work in response.

Webhooks vs Polling

ApproachDescriptionEfficiency
PollingThe client regularly requests updates❌ Consumes resources even without news
WebhooksThe server notifies the client when something happens✅ Efficient, responsive, event-driven

Webhook vs traditional REST

AppearanceTraditional RESTWebhook
InitiatorCustomerServer/event
MomentOn requestWhen the event occurs
LoginMaintained during treatmentClosed after acknowledgment of receipt
Use casesData QueriesEvent notifications

5.2 Webhooks in a microservices architecture

We now know what webhooks are — powerful tools for real-time communication that allow us to share events as they happen, rather than making callers wait or check in regularly.

In a microservices architecture, webhooks play a crucial role. They act as messengers who instantly relay events between services. For example, when a customer places an order, a webhook can immediately notify the inventory department to update stock levels.

Benefits of webhooks in microservices

  1. Real-time data feed — Systems are always in sync and up to date
  2. Event-driven communication — Actions are triggered by events, not planned or manually initiated
  3. Decoupling — Services do not need to know the implementation details of other services
  4. Scalability — Can handle a large number of events asynchronously

Worked example: A payment gateway could use a webhook to notify your accounting department as soon as a transaction is processed. This speed ensures that your financial records are always accurate and up to date.

5.3 Configuring webhooks in Node.js

A webhook receiver is basically an endpoint in your application configured to receive POST requests. These requests are triggered by other services, which form a JSON object describing what happened and send it to our endpoint.

Basic webhook server with Express

// clip3/after/index.js
import express from "express";

const app = express();
const PORT = process.env.PORT || 3000;

// Middleware pour parser le JSON
app.use(express.json());

// Endpoint webhook
app.post("/webhook", (req, res) => {
  console.log(req.body);
  res.status(200).send({ message: "Webhook received" });
});

app.listen(PORT, () => {
  console.log(`Server is running on port ${PORT}`);
});

This code creates a small Express server, adds a webhook receiver and tests it. We use app.use(express.json()) — a built-in body parser that allows us to handle any JSON body and we add it at this point in the application so that any route can use it.

package.json with ESM

{
  "type": "module",
  "dependencies": {
    "express": "^4.18.0"
  }
}

The "type": "module" flag allows us to use ESM imports (import/export) rather than require.

5.4 Securing webhooks

When integrating webhooks into your application, security is crucial to prevent unauthorized access and data breaches.

Payload validation with Zod

We always want to validate the payload we receive to ensure that it contains the expected data and conforms to the expected format. This helps prevent injection attacks.

// clip4/after/middleware/webhook-validator.js
import { z } from "zod";

// Enum des types d'événements acceptés
const eventTypeEnum = z.enum([
  "order_placed",
  "order_cancelled",
  "order_dispatched",
]);

// Schéma de validation du webhook
const webhookSchema = z.object({
  type: eventTypeEnum,
  id: z.string(),
  customer: z.object({
    id: z.string(),
    name: z.string(),
    email: z.string().email()
  })
});

// Middleware de validation
export default function validator(req, res, next) {
  try {
    webhookSchema.parse(req.body);
    next(); // Continuer si valide
  } catch (error) {
    return res.status(400).json({ error: true, message: "Event doesn't match schema" });
  }
}

Expected JSON payload:

{
  "type": "order_placed",
  "id": "123",
  "customer": {
    "id": "456",
    "name": "John Doe",
    "email": "john@doe.com"
  }
}

Server with middleware validation

// clip4/after/index.js
import express from "express";
import validator from "./middleware/webhook-validator.js";

const app = express();
const PORT = process.env.PORT || 3000;

app.use(express.json());

app.post("/webhook", validator, (req, res) => {
  console.log(req.body);
  res.status(200).send({ message: "Webhook received" });
});

app.listen(PORT, () => {
  console.log(`Server is running on port ${PORT}`);
});

Other security practices

  • HTTPS — Always use HTTPS for webhook endpoints
  • HMAC Signature — Check payload signatures to authenticate the sender
  • IP Whitelisting — Restrict requests to IPs known to senders
  • Rate Limiting — Protect against abuse
  • Authentication tokens — Include a secret token in headers

5.5 Handling webhook events

We now dive into how to manage and process events received via webhooks efficiently. We particularly focus on a real-world scenario: updating an inventory system in real time when a sale occurs.

Processing flow

The process involves:

  1. Receive notification of an event
  2. Validate incoming data (security + resource efficiency)
  3. Parse data — understand event content to determine necessary action
  4. Execute action — update systems or trigger other processes

What is important to remember: only the first two steps require the user to be logged in. Parsing and execution can occur after acknowledging the webhook.

Event handler with switch

app.post("/webhook", validator, async (req, res) => {
  // Accusé de réception immédiat
  res.status(200).send({ message: "Webhook received" });
  
  // Traitement asynchrone après la réponse
  try {
    switch (req.body.type) {
      case "order_placed":
        console.log("Traitement d'une nouvelle commande...");
        // Appel REST, émission d'événement, mise à jour de base de données...
        break;
      
      case "order_cancelled":
        console.log("Traitement d'une annulation...");
        break;
      
      case "order_dispatched":
        console.log("Traitement d'une expédition...");
        break;
      
      default:
        console.log("Type d'événement non géré:", req.body.type);
    }
  } catch (error) {
    console.error("Erreur de traitement:", error);
  }
});

The response is sent immediately before processing. This follows the principle of “acknowledge early, process async”.

5.6 Monitoring and logging of webhooks

Monitoring and logging form the backbone of any robust IT infrastructure.

Why monitor webhooks?

  • Visibility — A clear view into your system’s operation at all times
  • Diagnostics — Quickly identify the source of problems and diagnose issues
  • Proactive Management — Manage systems proactively rather than reacting to issues after significant impacts
  • Traceability — Track each event as it occurs
  • Optimization — Data for tuning and optimizing performance

Logging Considerations

  • What to log — Every request and response? Status codes? Execution time?
  • Frequency — For high traffic systems, adjust the verbosity of the logs. Consider sampling or focus on errors and unusual events
  • Privacy and Security — Be careful with personally identifiable information and sensitive data. Ensure that logs do not expose information that could be exploited

Implementation with Pino

import pino from "pino";
import expressPino from "express-pino-logger";

// Configuration du logger Pino
const dest = pino.destination({ dest: "./tmp/webhook.log" });
const logger = pino(dest);

app.use(expressPino({ logger }));

app.post("/webhook", validator, async (req, res) => {
  res.status(200).send({ message: "Webhook received" });
  
  try {
    req.log.info("Début du traitement");
    
    switch (req.body.type) {
      case "order_placed":
        req.log.info({ orderId: req.body.id }, "Commande placée");
        // Traitement...
        break;
      case "order_cancelled":
        req.log.info({ orderId: req.body.id }, "Commande annulée");
        break;
    }
    
    req.log.info("Fin du traitement");
  } catch (error) {
    req.log.error({ err: error }, "Erreur de traitement");
  }
});

Pino is a lightweight Node.js library that allows us to send structured data in our logs, rather than just a string or error trace without additional details.

5.7 Robust error handling

Robust error handling helps us and our users quickly identify problems and understand the underlying systems. Four key strategies:

Strategy 1: Immediate feedback

We want to respond to every webhook with immediate feedback. If an error occurs while processing the webhook, we want to send an appropriate response code to indicate the error.

// Retourner des erreurs claires avec contexte
export default function validator(req, res, next) {
  try {
    webhookSchema.parse(req.body);
    next();
  } catch (error) {
    // Zod fournit des messages d'erreur détaillés
    // ex: "invalid_type - expected: string, received: undefined - path: id"
    return res.status(400).json({ 
      error: true, 
      message: error.message 
    });
  }
}

Strategy 2: Logging and monitoring

After the webhook passes validation and the initial response is sent, webhook processing should be logged asynchronously. This potentially includes recording the start of a process, key actions during the process, and the end result.

Strategy 3: Retry with exponential backoff

// utils/getWithRetry.js
import axios from "axios";

export default async function getWithRetry(url, attempt = 1) {
  try {
    const response = await axios.get(url);
    return response;
  } catch (error) {
    if (attempt <= 3) {
      setTimeout(() => getWithRetry(url, attempt + 1), Math.pow(2, attempt) * 1000);
    } else {
      throw new Error(`Max retries reached for ${url}.`);
    }
  }
}

The exponential backoff avoids immediate retries that could lead to the same failure. It also spreads the load, improving the reliability and responsiveness of the system.

Strategy 4: Graceful degradation

Design systems that maintain partial functionality even during failures, rather than failing completely.

5.8 Ensuring the reliability of webhook systems

Four ways to ensure the reliability of our webhook systems:

1. Retry mechanisms

Crucial for handling transient errors. The implementation uses exponential backoff — increasing the interval between retries. This not only avoids immediate retries that could lead to the same failure, but also spreads the load, improving the reliability and responsiveness of the system.

2. Idempotence

Ensuring that operations are idempotent means that no matter how many times a webhook is triggered, the operation gives the same result without unwanted side effects.

const processedEvents = new Set();

function checkIfProcessed(eventId) {
  if (processedEvents.has(eventId)) {
    return true; // Déjà traité
  }
  processedEvents.add(eventId);
  return false;
}

app.post("/webhook", validator, async (req, res) => {
  res.status(200).send({ message: "Webhook received" });
  
  try {
    if (checkIfProcessed(req.body.id)) {
      throw new Error(`${req.body.id} already processed.`);
    }
    // Traitement...
  } catch (error) {
    req.log.error(error);
  }
});

Note: In this example, the processedEvents set is in memory and not shared between processes. In a true distributed system, use Redis or a shared database to store processed IDs.

3. High availability

Deploy multiple instances of our services to ensure availability, using load balancers to distribute traffic.

4. Load management

In a microservices architecture, services are typically deployed as Docker services or Kubernetes pods behind a load balancer. Ensuring that our webhooks can be received and processed from any instance without problems.

5.9 Building a scalable webhook system

Node.js Clustering

Node.js is single-threaded, but it can create process clusters to handle more load in parallel:

// clip9/after/index.js (extrait)
import os from "os";
import cluster from "cluster";

const cpus = os.cpus().length;

if (cluster.isPrimary) {
  // Forker un processus par CPU
  for (let i = 0; i < cpus; i++) {
    cluster.fork();
  }
  
  // Redémarrer un worker mort
  cluster.on("exit", (worker, code, signal) => {
    console.log(`worker ${worker.process.pid} died`);
    cluster.fork();
  });
} else {
  // Chaque worker exécute le serveur Express
  console.log(`Worker ${cluster.worker.id} started.`);
  
  const app = express();
  app.use(express.json());
  
  app.post("/webhook", validator, async (req, res) => {
    res.status(200).send({ 
      message: `Webhook received by worker ${cluster.worker.id}` 
    });
    // Traitement asynchrone...
  });
  
  app.listen(PORT);
}

Work is shared between different processes, but all share the same port. This is a form of horizontal scaling on the same machine.

Important: The state in memory (like all events processed) is not shared between workers. For idempotence in this configuration, use an external store like Redis.

Additional scaling strategies

  • Docker containers with Kubernetes orchestration
  • Load balancer (NGINX, HAProxy) in front of several instances
  • Message queues to absorb traffic peaks
  • CDN for static webhooks

5.10 Practical case: Integration with Stripe

One of the biggest advantages of webhooks is their ability to integrate with third-party services. In a microservices architecture, we can use other services as key parts of our architecture.

The Globomantics team has decided to integrate Stripe as a payment processor.

Implementation with ngrok

To test webhooks in local development, use ngrok:

# Créer un tunnel HTTP vers le port local 3000
ngrok http 3000

Ngrok provides a public HTTP URL that tunnels to your local machine — allowing Stripe (or any other external service) to reach your development server.

Configuration in the Stripe dashboard

  1. Go to stripe.com → Dashboard → Developers → Webhooks
  2. Enable test mode
  3. Click on “Create an endpoint”
  4. Add events to listen for:
  • payment_intent.payment_failed
  • payment_intent.succeeded
  1. Paste ngrok URL
  2. Copy the secret signing

Validator suitable for Stripe

// clip10/after/middleware/webhook-validator.js
import { z } from "zod";

const eventTypeEnum = z.enum([
  "payment_intent.succeeded",
  "payment_intent.payment_failed",
]);

const webhookSchema = z.object({
  type: eventTypeEnum,
  id: z.string(),
});

export default function validator(req, res, next) {
  try {
    webhookSchema.parse(req.body);
    next();
  } catch (error) {
    return res.status(400).json({ error: true, message: error.message });
  }
}

Stripe webhook handler with signature verification

// clip10/after/index.js
import express from "express";
import pino from "pino";
import expressPino from "express-pino-logger";
import Stripe from "stripe";

const stripe = Stripe(process.env.STRIPE_API_KEY);
const app = express();
const logger = pino(pino.destination({ dest: "./tmp/webhook.log" }));

app.use(expressPino({ logger }));

app.post(
  "/webhook",
  // Utiliser express.raw() au lieu de express.json() pour la validation de signature
  express.raw({ type: "application/json" }),
  async (req, res) => {
    let event;
    const signature = req.headers["stripe-signature"];
    
    try {
      // Vérification cryptographique de la signature Stripe
      event = stripe.webhooks.constructEvent(
        req.body,
        signature,
        process.env.STRIPE_SIGNING_SECRET
      );
    } catch (error) {
      console.log("Failed validation", error);
      return res.status(400).send({ message: "Failed signing validation." });
    }
    
    // Accusé de réception immédiat
    res.status(200).send({ message: "Webhook received" });
    
    try {
      req.log.info("Starting request");
      
      switch (event.type) {
        case "payment_intent.succeeded":
          console.log("Paiement réussi — traitement de la commande...");
          break;
        case "payment_intent.payment_failed":
          console.log("Paiement échoué — annulation de la commande...");
          break;
        default:
          console.log("Événement inconnu:", event.type);
      }
      
      req.log.info("Finished with request");
    } catch (error) {
      req.log.error(error);
    }
  }
);

app.listen(PORT, () => {
  logger.info(`Server is running on port ${PORT}`);
});

How signature verification works

  1. Stripe signs each payload webhook with your signing secret
  2. The signature is included in the Stripe-Signature header
  3. stripe.webhooks.constructEvent() cryptographically verifies that the payload actually comes from Stripe and has not been altered
  4. If check fails, return 400 immediately

Important: Use express.raw() instead of express.json() for Stripe webhooks — signature verification requires the unparsed raw body.


6. Integration of message brokers in an event driven architecture

Module duration: 56m 29s

6.1 Understanding message brokers

Communication without broker message

In a typical microservices architecture without message broker, each service must communicate directly with several other services. This creates a complex web of direct connections.

Order Service ←→ Inventory Service
     ↓ ↗             ↕
Payment Service ←→ Shipping Service
     ↓                   ↓
Email Service    Notification Service

Problems:

  • If a service goes down or is overloaded, it directly impacts all services that depend on it
  • Strong coupling makes the system difficult to manage and scale

Communication with message broker

With a message broker, the services no longer communicate directly with each other. Instead, they send messages to the broker, which then routes these messages to the appropriate services.

Order Service → [MESSAGE BROKER] → Inventory Service
Payment Service →                → Shipping Service
                                 → Email Service
                                 → Notification Service

Advantages:

  • Services only need to know how to send and receive messages from the broker
  • Significantly reduces the complexity of direct connections
  • Decoupling of services — they operate independently
  • Better scalability, reliability and maintainability

RabbitMQ

RabbitMQ is the message broker we will use. It is one of the most popular message brokers. Alternatives also include Apache Kafka (designed for very high throughput streams) and AWS SQS (AWS managed service).

The RabbitMQ management web interface (rabbitmq:3-management) is accessible on port 15672 and provides a real-time view of:

  • Current messages
  • Active connections
  • Exchanges and queues
  • Throughput statistics

6.2 Principles of event-driven architecture

Event-driven architecture is a design paradigm in which program flow is determined by events. An event can be any significant occurrence or change of state.

Main actors

  • Producers — Generate events (user UI, IoT devices, or any backend service)
  • Channels — The paths by which events are transmitted from producers to consumers (message brokers often serve as event channels)
  • Consumers — Systems or services that respond to events

Key principles

1. Asynchronous communication Events are produced and consumed independently, allowing services to operate without waiting for each other. This asynchronous nature improves the responsiveness and flexibility of the system. Message brokers make this possible.

2. Event Sourcing Involves storing the state of an application as a sequence of events. Instead of persisting the current state, each change is stored as an event, which can be replayed to reconstruct the state. This approach provides a detailed audit log and makes debugging and recovery easier.

We can replay messages in our message broker and thus reconstruct a real understanding of how the state of our application has changed over time.

3. CQRS (Command Query Responsibility Segregation) A pattern that separates read and write operations to optimize performance:

  • Commands — Responsible for state change
  • Queries — Data Recovery Managers

6.3 Setting up a message broker (RabbitMQ)

Docker Compose for RabbitMQ

# clip-03/after/docker-compose.yml
services:
  rabbitmq:
    image: rabbitmq:3-management
    container_name: rabbitmq
    ports:
      - "5672:5672"   # Port AMQP (lecture/écriture)
      - "15672:15672" # Port interface web de gestion
    environment:
      RABBITMQ_DEFAULT_USER: user
      RABBITMQ_DEFAULT_PASSWORD: password

Ports:

  • 5672 — Standard AMQP port for read/write operations
  • 15672 — Port for web management interface

Starting:

docker-compose up

Access to web interface: http://localhost:15672 (user/password)

The web interface allows you to:

  • Define channels and exchanges
  • View and define queues and streams
  • Add queues manually
  • View statistics (throughput, churn of queues/channels)
  • View used ports and their uses

6.4 Publish/Subscribe model

The Publish/Subscribe (Pub/Sub) pattern is a messaging pattern where messages are published to specific topics and then delivered to all subscribers of those topics. This model decouples producers from consumers, allowing greater flexibility and scalability.

Relationships between concepts

Event-driven Architecture (paradigme de design)
    ↓ pattern de messagerie
Publish/Subscribe Model
    ↓ implémentation
Message Broker (RabbitMQ)
  • Event-driven Architecture — The architectural approach guiding the design of systems that respond to events
  • Pub/Sub — A messaging pattern in EDA that decouples producers and consumers via topics or queues
  • Message Brokers — Implement Pub/Sub and other messaging patterns

Producer with Express and amqplib

// clip-04/after/producer/producer.js
import amqp from "amqplib";
import express from "express";

const app = express();
const PORT = process.env.PORT || 3000;
app.use(express.json());

let channel;
let connection;
const queue = "order";

app.post("/send", (req, res) => {
  const msg = req.body.message;
  
  if (!msg) {
    return res.status(400).send({ error: "Message is required" });
  }
  
  if (!channel) {
    return res.status(500).send({ error: "Channel is not established." });
  }
  
  try {
    channel.sendToQueue(queue, Buffer.from(msg));
    console.log(` [x] Sent to queue: ${msg}`);
    res.send({ success: true, message: "Message sent" });
  } catch (error) {
    console.log(`Failed to send message to queue`, error);
    res.status(500).send({ error: "Failed to send message to queue." });
  }
});

async function connectAndStartUp() {
  try {
    console.log("Trying to connect ...");
    connection = await amqp.connect("amqp://user:password@rabbitmq");
    channel = await connection.createChannel();
    await channel.assertQueue(queue, { durable: true });
    console.log("Connected to RabbitMQ");
    
    app.listen(PORT, () => {
      console.log(`Producer server is running on port ${PORT}`);
    });
  } catch (error) {
    console.log(error.message);
    if (error.message.includes("connect ECONNREFUSED")) {
      // Réessayer après 5 secondes si RabbitMQ n'est pas encore prêt
      setTimeout(() => connectAndStartUp(), 5000);
    }
  }
}

connectAndStartUp();

Consumer with amqplib

// clip-04/after/consumer/consumer.js
import amqp from "amqplib";

const queue = "order";

async function connectAndListen() {
  try {
    const connection = await amqp.connect("amqp://user:password@rabbitmq");
    const channel = await connection.createChannel();
    await channel.assertQueue(queue, { durable: true });
    
    // Traiter un message à la fois
    channel.prefetch(1);
    
    await channel.consume(queue, (message) => {
      const text = message.content.toString();
      console.log(" [x] Received '%s'", text);
      
      const seconds = text.split(".").length - 1;
      setTimeout(() => {
        console.log(" [x] Done");
        channel.ack(message); // Accusé de réception
      }, seconds * 1000);
    }, { noAck: false }); // noAck: false = acknowledgement manuel
    
    console.log(" [*] Waiting for messages. To exit press CTRL+C");
  } catch (error) {
    console.warn(error);
    if (error.message.includes("connect ECONNREFUSED")) {
      setTimeout(() => connectAndListen(), 5000);
    }
  }
}

connectAndListen();

Key points:

  • channel.prefetch(1) — Process one message at a time (fair dispatch)
  • { noAck: false } — Manual acknowledgment (if the consumer crashes, the message is resent)
  • channel.ack(message) — Signals RabbitMQ that the message was processed successfully

6.5 Design of event-driven services

Thoughtful design is crucial to achieving scalability, maintainability, and resilience in your microservices.

Design Principles

1. Single Responsibility Imagine that each department has a single responsibility or focus. For example, the payments department should only handle payments, delivery only.

2. Loose Coupling Services should operate independently without relying too heavily on other services. This makes it easier to update, replace, or scale individual services.

3. High Cohesion Related features should be grouped together. For example, all tasks related to inventory management should be handled by a single inventory department.

Event flow optimization

Three key parts of the event flow to optimize:

Receiving events:

  • Quick acknowledgment
  • Lightweight input validation
  • Queue to smooth traffic spikes

Event processing:

  • Asynchronous processing decoupled from reception
  • Idempotence to manage re-deliveries
  • Timeout and circuit breakers

Acknowledgment:

  • Acknowledgment after successful processing
  • Handling failed messages (dead-letter queues)

6.6 Managing high volume event streams

A high volume event stream is a large number of events generated continuously and quickly from various sources (user interactions, the website, the point of sale, the order service, or business metrics).

Challenges

  • Ensure timely processing
  • Maintain data integrity
  • Scale the system to handle the increased load
  • Without proper management, the system may become overwhelmed, leading to delays and potential loss of data

Buffering and batching

Buffering and batching are effective strategies for handling bursty traffic and ensuring efficient processing:

  • Buffering — Temporarily store incoming events to smooth out peaks
  • Batching — Group multiple events for collective processing, reducing overhead and improving throughput
// clip-07/after/producer/producer.js (extrait clé — buffering)
const eventBuffer = [];
const BATCH_SIZE = 10;
const BATCH_INTERVAL = 1; // secondes

function sendBatch() {
  if (eventBuffer.length > 0) {
    // Prendre un batch depuis le buffer
    const batch = eventBuffer.splice(0, BATCH_SIZE);
    
    batch.forEach(event => {
      const message = JSON.stringify(event);
      channel.sendToQueue(queue, Buffer.from(message), { persistent: true });
      console.log(` [x] Sent message ${message}`);
    });
    
    console.log(` [*] Sent batch of ${batch.length} messages`);
  }
}

app.post("/send", (req, res) => {
  // Ajouter au buffer au lieu d'envoyer directement
  eventBuffer.push(`${++id} ${msg}`);
  console.log(` [x] Buffered ${msg}`);
  res.send({ success: true, message: "Message buffered" });
});

// Envoyer les batches à intervalles réguliers
setInterval(sendBatch, BATCH_INTERVAL * 1000);

This approach smoothes traffic — rather than sending each message immediately, they are accumulated in a buffer and sent in batches at regular intervals.

6.7 Reliability and fault tolerance

Reliability refers to the ability of a system to operate consistently and correctly over time. Fault tolerance is the ability of the system to continue operating in the presence of faults.

Message Durability

// Déclarer la queue comme durable
await channel.assertQueue(queue, { durable: true });

// Envoyer le message avec persistance
channel.sendToQueue(queue, Buffer.from(message), { persistent: true });
  • Durable queue — Survives RabbitMQ restarts
  • Persistent message — Stored on disk (not just in memory)

Acknowledgments

await channel.consume(queue, (message) => {
  try {
    // Traitement...
    channel.ack(message); // Succès → message retiré de la queue
  } catch (error) {
    channel.nack(message, false, true); // Échec → message remis en queue
  }
}, { noAck: false });

If a consumer fails to process a message, the message remains in the queue and can be redelivered.

Dead-Letter Exchange

Messages that cannot be processed successfully are routed to a special queue for analysis and further handling.

// clip-07/after/consumer/consumer.js (extrait — gestion avancée)
const deadLetterQueue = "order_dead";
const MAX_RETRIES = 3;

await channel.consume(queue, (message) => {
  if (message !== null) {
    const text = message.content.toString();
    
    // Simulation: 50% de chance de succès
    const processingSuccess = Math.random() > 0.5;
    
    setTimeout(() => {
      if (processingSuccess) {
        channel.ack(message); // Succès
      } else {
        const retries = getRetryCount(message);
        
        if (retries >= MAX_RETRIES) {
          console.log("Max retries reached → dead-letter queue");
          channel.nack(message, false, false); // Envoyer à la dead-letter queue
        } else {
          // Réessayer avec compteur incrémenté
          const newHeaders = { 
            ...message.properties.headers, 
            "x-retries": retries + 1 
          };
          channel.sendToQueue(
            message.fields.routingKey, 
            message.content, 
            { headers: newHeaders, persistent: true }
          );
          channel.ack(message); // Acknowledger le message original
        }
      }
    }, seconds * 1000);
  }
}, { noAck: false });

// Consumer de la dead-letter queue
await channel.consume(deadLetterQueue, (message) => {
  if (message !== null) {
    console.log(" [x] Dead-letter queue received:", message.content.toString());
    channel.ack(message);
  }
}, { noAck: false });

function getRetryCount(message) {
  const retries = message.properties.headers["x-retries"];
  return retries ? retries : 0;
}

This system provides a sustainable platform — if a consumer or producer is not functioning at any given time, there is space and time for recovery.

6.8 Scalability with message brokers

Scalability Types

  • Horizontal scaling — Add more instances of a service to share the load, use a load balancer to direct traffic
  • Vertical scaling — Increase the computing power and storage of a particular service

Message brokers like RabbitMQ facilitate scalability by decoupling producers and consumers, allowing each to scale independently. They manage the distribution of messages between several consumers, ensuring efficient load balancing.

Scaling strategies with RabbitMQ

1. Horizontal scaling — Scale producers and consumers horizontally to distribute the load.

2. Multiple queues and exchanges — Allows you to manage different message types and routing patterns.

3. RabbitMQ Clusters — Distribute load across multiple nodes for high availability and scalability.

Docker Compose with multiple consumers

services:
  rabbitmq:
    image: rabbitmq:3-management
    
  producer:
    build: ./producer
    depends_on:
      - rabbitmq
    
  consumer1:
    build: ./consumer
    depends_on:
      - rabbitmq
  
  consumer2:
    build: ./consumer
    depends_on:
      - rabbitmq
  
  consumer3:
    build: ./consumer
    depends_on:
      - rabbitmq

With channel.prefetch(1), RabbitMQ distributes messages in a round-robin manner among available consumers — each consumer processes one message at a time and RabbitMQ sends the next one to the free consumer.

6.9 Monitoring and maintenance of message broker systems

Key Metrics to Monitor

  1. Throughput and latency — Measure the processing rate of messages and the time taken to process them
  2. Queue Depth — Monitor the number of messages in queues and the rate at which messages are added and processed
  3. Resource Usage — Track CPU, memory, and disk I/O usage to ensure efficient utilization
  4. Error rates and retry counters — Identify potential problems in message processing

Monitoring tools

  • Prometheus + Grafana — Collection and visualization of RabbitMQ metrics
  • PM2 — Detailed insights into Node.js application performance
  • New Relic — Application performance monitoring
  • RabbitMQ Management Plugin — Integrated web interface with visual representation of metrics

Regular maintenance tasks

  • Purge tails — Clean unused or stagnant tails regularly
  • RabbitMQ Update — Keep RabbitMQ up to date for security patches and performance improvements
  • Configuration backup — Regularly back up RabbitMQ configurations
  • Review of dead-letter queues — Regularly analyze messages in dead-letter queues to identify error patterns

7. Implementing Remote Procedure Calls (RPC) in Node.js

Module duration: 42m 56s

7.1 Introduction to RPC in Microservices

What is CPP?

RPC (Remote Procedure Protocol) allows a service to invoke a method in another service as if it were a local function.

Service 1 ←→ [Réseau] ←→ Service 2
    ↓                         ↓
invoque méthode()      exécute méthode()
reçoit résultat    ←   retourne résultat

RPC does the work of:

  1. Answer the call in the calling department
  2. Serialize the data
  3. Send over network
  4. Have the method invoked by the receiving service
  5. Serialize the result
  6. Return and deserialize for the calling service

All this while allowing the calling service to act as if it had invoked the function locally without having to consider network calls, serialization, deserialization, or any other complexity.

RPC vs. REST

CriterionRESTRPC (gRPC)
Main useExternal communicationInternal communication
ProtocolHTTP/1.1 (text)HTTP/2 (binary)
FormatJSON (verbose)Protocol Buffers (compact)
PerformanceMediumHigh
ContractImplicit (OpenAPI)Explicit (Protobuf)
StreamingLimitedNative bidirectional

RPC is more efficient and effective than REST for internal communication between services. REST and webhooks manage external interactions.

7.2 RPC mechanisms and protocols

The RPC flow

Client Function → Client Stub → RPC Runtime → [Réseau] → RPC Runtime → Server Stub → Server Function
    ← reçoit résultat ← désérialise ←                        → sérialise ← exécute
  • Client Stub — Knows what arguments to pass and what data to receive
  • RPC Runtime — Encode, serialize, and send over the network

gRPC

gRPC (developed by Google) uses:

  • Protocol Buffers — For encoding (binary, efficient)
  • HTTP/2 — For transport
  • Supports two-way streaming and flow control
  • Ideal for high performance, low latency internal communications

XML-RPC

  • Uses XML to encode calls and HTTP as transport mechanism
  • Simplicity and language-agnostic nature
  • XML verbosity can lead to higher latencies and larger message sizes
  • Often used in legacy systems

JSON-RPC

  • Lightweight protocol using JSON to encode messages
  • Can be transported over HTTP or WebSockets
  • Easy to implement and understand
  • Suitable for web applications where human readability of messages is beneficial

Protocol Buffers

Protocol Buffers are a binary serialization system:

// inventory.proto
syntax = "proto3";

package inventory.v1;

service InventoryService {
  rpc UpdateInventory (InventoryRequest) returns (InventoryResponse);
}

message InventoryRequest {
  string product_id = 1;
  int32 quantity = 2;
}

message InventoryResponse {
  string message = 1;
}

Advantages of Protocol Buffers:

  • Compactness — Much smaller binary format than JSON
  • Speed — Fast serialization/deserialization
  • Strong typing — Explicit contract between services
  • Compatibility — Compatible forward and backward versions (via field tags)

field tags (the numbers = 1, = 2) are unique identifiers used to map fields in the wire-format representation. They are unique within a message but can be repeated between different messages.

7.3 Implementing gRPC in Node.js

Definition of Protobuf

// protos/inventory.proto
syntax = "proto3";

package inventory.v1;

service InventoryService {
  rpc UpdateInventory (InventoryRequest) returns (InventoryResponse);
}

message InventoryRequest {
  string product_id = 1;
  int32 quantity = 2;
}

message InventoryResponse {
  string message = 1;
}

gRPC Server (Inventory Service)

// clip-03/after/inventory-service/server.js
import grpc from "@grpc/grpc-js";
import protoLoader from "@grpc/proto-loader";
import path from "node:path";

const __dirname = path.dirname(new URL(import.meta.url).pathname);

// Charger la définition Protobuf
const packageDefinition = protoLoader.loadSync(
  path.join(__dirname, "../protos/inventory.proto"),
  {
    keepCase: true,    // Conserver la casse (éviter CamelCase auto)
    longs: String,
    enums: String,
    defaults: true,
    oneofs: true
  }
);

// Obtenir le namespace inventory.v1
const inventoryProto = grpc.loadPackageDefinition(packageDefinition).inventory.v1;

const inventory = {};

// Créer le serveur gRPC
const server = new grpc.Server();

// Implémenter le service
server.addService(inventoryProto.InventoryService.service, {
  UpdateInventory: (call, callback) => {
    const { product_id, quantity } = call.request;
    
    if (!inventory[product_id]) {
      inventory[product_id] = 0;
    }
    
    inventory[product_id] += quantity;
    
    callback(null, {
      message: `Inventory updated for product ${product_id}`
    });
  }
});

const PORT = process.env.PORT || 50051;

// Bind et démarrage
server.bindAsync(
  `0.0.0.0:${PORT}`,
  grpc.ServerCredentials.createInsecure(),
  () => {
    console.log(`Inventory service is running at http://127.0.0.1:${PORT}`);
  }
);

Dependencies:

npm install @grpc/grpc-js @grpc/proto-loader

keepCase: true — By default, the protoLoader converts to camelCase JavaScript. With keepCase: true, the case is preserved exactly as in the .proto — UpdateInventory remains UpdateInventory (not updateInventory).

7.4 Designing gRPC and Protobuf services

Best practices for designing gRPC services and messages:

1. Clear and consistent naming conventions

Use clear and consistent names in services, methods and messages. These names are used across client- and server-side services — consistency minimizes errors.

// ✅ Bon — noms clairs et cohérents
service InventoryService {
  rpc UpdateInventory (InventoryRequest) returns (InventoryResponse);
}

// ❌ Mauvais — noms ambigus
service Svc {
  rpc Update (Req) returns (Resp);
}

2. Versioning

Version the Proto Buffers to better manage backward compatibility:

// Version 1
package inventory.v1;

// Version 2 (avec nouveaux champs)
package inventory.v2;

When loading from package definition:

// Pour v1
const inventoryProto = grpc.loadPackageDefinition(packageDef).inventory.v1;

// Pour v2
const inventoryProto = grpc.loadPackageDefinition(packageDef).inventory.v2;

3. Granular methods

Define methods granularly, ensuring that they do one thing well — avoid methods that try to handle multiple responsibilities.

4. Robust response error handling

message InventoryResponse {
  string message = 1;
  bool success = 2;
  string error_code = 3;
}

5. Documentation

Document Protobuf services and definitions thoroughly to ensure they are easy to use and understand:

// Service gérant les opérations d'inventaire
// Version: v1
// Owner: inventory-team
service InventoryService {
  // Met à jour le niveau de stock pour un produit donné.
  // Retourne le message de confirmation ou un code d'erreur.
  rpc UpdateInventory (InventoryRequest) returns (InventoryResponse);
}

7.5 Implementing RPC calls between services

Now that we have a single service up and running, let’s implement RPC calls between services.

Protobuf definition for order service

// protos/order.proto
syntax = "proto3";

package order.v1;

service OrderService {
  rpc CreateOrder (OrderRequest) returns (OrderResponse);
}

message OrderRequest {
  string product_id = 1;
  int32 quantity = 2;
}

message OrderResponse {
  string order_id = 1;
  string message = 2;
}

Order Service calling the Inventory Service

// clip-05/after/order-service/server.js
import grpc from "@grpc/grpc-js";
import protoLoader from "@grpc/proto-loader";
import path from "node:path";
import { v4 as uuid } from "uuid";

const __dirname = path.dirname(new URL(import.meta.url).pathname);

// Charger les deux protobufs
const orderPackageDef = protoLoader.loadSync(
  path.join(__dirname, "../protos/order.proto"),
  { keepCase: true, longs: String, enums: String, defaults: true, oneofs: true }
);

const inventoryPackageDef = protoLoader.loadSync(
  path.join(__dirname, "../protos/inventory.proto"),
  { keepCase: true, longs: String, enums: String, defaults: true, oneofs: true }
);

const orderProto = grpc.loadPackageDefinition(orderPackageDef).order.v1;
const inventoryProto = grpc.loadPackageDefinition(inventoryPackageDef).inventory.v1;

const server = new grpc.Server();

server.addService(orderProto.OrderService.service, {
  CreateOrder: (call, callback) => {
    const { product_id, quantity } = call.request;
    const order_id = uuid();
    
    // Créer un client pour appeler l'Inventory Service
    const inventoryClient = new inventoryProto.InventoryService(
      "localhost:50051",
      grpc.credentials.createInsecure()
    );
    
    // Appel RPC vers l'Inventory Service
    inventoryClient.UpdateInventory(
      { product_id, quantity },
      (error, response) => {
        if (error) {
          callback(error);
        } else {
          callback(null, {
            order_id,
            message: `Order created successfully: ${response.message}`
          });
        }
      }
    );
  }
});

const PORT = process.env.PORT || 50052;
server.bindAsync(
  `0.0.0.0:${PORT}`,
  grpc.ServerCredentials.createInsecure(),
  (error) => {
    if (error) {
      console.error(error);
      return;
    }
    console.log(`Order Service running at http://127.0.0.1:${PORT}`);
  }
);

gRPC client

// clip-05/after/client-service/client.js
import grpc from "@grpc/grpc-js";
import protoLoader from "@grpc/proto-loader";
import path from "node:path";

const __dirname = path.dirname(new URL(import.meta.url).pathname);

const packageDefinition = protoLoader.loadSync(
  path.join(__dirname, "../protos/order.proto"),
  { keepCase: true, longs: String, enums: String, defaults: true, oneofs: true }
);

const orderProto = grpc.loadPackageDefinition(packageDefinition).order.v1;

// Créer un client pour l'Order Service
const client = new orderProto.OrderService(
  "localhost:50052",
  grpc.credentials.createInsecure()
);

// Appel RPC
client.CreateOrder({ product_id: "12345", quantity: 2 }, (error, response) => {
  if (error) {
    console.error("Error:", error);
  } else {
    console.log(response);
  }
});

7.6 Handling errors and exceptions in RPC

gRPC Status Codes

gRPC uses a set of status codes (self-describing enums) to indicate the result of an RPC call:

CodeMeaning
OKSuccess
CANCELLEDOperation canceled
UNKNOWNUnknown error
INVALID_ARGUMENTInvalid argument provided by client
NOT_FOUNDResource not found
ALREADY_EXISTSAlready existing resource
PERMISSION_DENIEDPermission denied
RESOURCE_EXHAUSTEDResources exhausted (quota exceeded)
UNIMPLEMENTEDOperation not implemented
INTERNALInternal error
UNAVAILABLEService unavailable
UNAUTHENTICATEDUnauthenticated request

Note: If your service is accessible by an external client, remember to pass the minimum amount of information that is meaningful. Saying “resources exhausted” could let a malicious user know information about your system. For internal services only, use the most explicit and significant error.

Implementation with callbacks

// Côté serveur — avec gestion d'erreur
server.addService(inventoryProto.InventoryService.service, {
  UpdateInventory: (call, callback) => {
    const { product_id, quantity } = call.request;
    
    // Validation des arguments
    if (!product_id || quantity === null) {
      const error = {
        code: grpc.status.INVALID_ARGUMENT,
        message: "Product ID and quantity must be provided"
      };
      callback(error);
      return;
    }
    
    // Recherche de la ressource
    const order = orders.find(o => o.id === call.request.order_id);
    if (!order) {
      callback({
        code: grpc.status.NOT_FOUND,
        details: `Order ${call.request.order_id} not found`
      });
      return;
    }
    
    // Traitement normal
    callback(null, { message: "Success" });
  }
});

// Côté client — gestion de l'erreur (error-first callbacks)
client.UpdateInventory({ product_id, quantity }, (error, response) => {
  if (error) {
    // Toujours vérifier l'erreur en premier
    console.error("gRPC Error:", error.code, error.message);
    return; // Important: retourner pour ne pas continuer
  }
  // Traitement normal
  console.log(response);
});

Resilience Patterns

In addition to error handling at the service and customer level, it is important to design our system to be resilient:

  • Retries — Retry failed operations with exponential backoff
  • Circuit Breaker — Prevent repeated calls to a failing service
  • Timeouts — Set timeouts to avoid indefinite waits
  • Deadlines — Use gRPC deadlines for distributed timeouts

7.7 PRC Security Considerations

TLS (Transport Layer Security)

gRPC supports TLS to encrypt communication between client and server. TLS provides three things:

  1. Encryption — Protects data in transit from eavesdropping
  2. Authentication — Uses certificates to authenticate the identity of communicating parties
  3. Integrity — Ensures data is not altered in transit

Generating certificates with OpenSSL

# Générer une clé privée RSA (aes256 pour la protection)
openssl genrsa -aes256 -out server-key.pem 4096

# Créer une demande de signature de certificat (CSR)
openssl req -new -key server-key.pem -out server-csr.pem

# Signer le certificat (auto-signé pour dev/test)
openssl x509 -req -days 365 -in server-csr.pem -signkey server-key.pem -out server-cert.pem

Important: The private key must never leave the service where it was generated. If you have multiple services, they will need individual keys or an accessible secure store.

gRPC server with TLS

import fs from "node:fs";

// Charger les certificats
const credentials = grpc.ServerCredentials.createSsl(
  fs.readFileSync("server-cert.pem"), // Certificat CA
  [
    {
      cert_chain: fs.readFileSync("server-cert.pem"),
      private_key: fs.readFileSync("server-key.pem")
    }
  ],
  false // Pas de mutual TLS requis côté client
);

server.bindAsync(`0.0.0.0:${PORT}`, credentials, () => {
  console.log(`Secure gRPC server running on port ${PORT}`);
});

gRPC client with TLS

const credentials = grpc.credentials.createSsl(
  fs.readFileSync("server-cert.pem") // Certificat du serveur
);

const client = new orderProto.OrderService(
  `localhost:${PORT}`,
  credentials
);

Authentication and authorization

  • mTLS (mutual TLS) — Client and server authenticate each other
  • JWT Tokens — Via gRPC metadata for authorization tokens
  • API Keys — Via gRPC metadata headers

7.8 Performance and scalability in RPC

RPC communication performance depends on three main factors:

PostmanObjective
LatencyMinimize request-response travel time
ThroughputMaximize the number of requests processed per period
OverheadReduce computational and memory overhead

Optimization techniques

1. Protocol Buffers (already included with gRPC) Optimizes data serialization and enables low-latency, high-throughput data transfer.

2. Connection Pooling By default, gRPC uses HTTP/2 — connections are already more efficient. But we can make them even more efficient by reusing connections to reduce the overhead of establishing new connections.

3. Message compression

// Côté client — activer la compression gzip
const client = new inventoryProto.InventoryService(
  "localhost:50051",
  grpc.credentials.createInsecure(),
  {
    "grpc.default_compression_algorithm": grpc.compressionAlgorithms.gzip
  }
);

With Protocol Buffers binary encoding and gzip compression, we achieve maximum efficiency for data transmission.

4. gRPC Streaming

service InventoryService {
  // Unaire (standard)
  rpc UpdateInventory (InventoryRequest) returns (InventoryResponse);
  
  // Server streaming — le serveur envoie un flux de réponses
  rpc WatchInventory (InventoryRequest) returns (stream InventoryResponse);
  
  // Client streaming — le client envoie un flux de requêtes
  rpc BatchUpdate (stream InventoryRequest) returns (InventoryResponse);
  
  // Bidirectionnel
  rpc SyncInventory (stream InventoryRequest) returns (stream InventoryResponse);
}

7.9 Monitoring and logging RPC activities

Why monitor gRPC services?

  • Issue Detection — Quickly identify and resolve issues
  • Performance Analysis — Ensure services are running optimally
  • Security — Track access and detect suspicious activity
  • Compliance — Meet regulatory and business requirements
ToolUsage
PrometheusOpen-source metrics collection
GrafanaVisualization and dashboards
JaegerTracing and distributed monitoring of microservices
ELK StackElasticsearch + Logstash + Kibana
PinoStructured Logging for Node.js

Pino integration in gRPC

import pino from "pino";

const logger = pino({
  level: process.env.LOG_LEVEL || "info",
  transport: {
    target: "pino-pretty",
    options: { colorize: true }
  }
});

server.addService(orderProto.OrderService.service, {
  CreateOrder: (call, callback) => {
    logger.info(
      { product_id: call.request.product_id, quantity: call.request.quantity },
      "Received CreateOrder request"
    );
    
    // Traitement...
    
    logger.info(
      { order_id },
      "Order created successfully"
    );
  }
});

Pino is a lightweight Node.js library that sends structured data in our logs (instead of simple strings), allowing for more efficient analysis and alerts.


8. Service Discovery and Load Balancing

Module duration: 35m 21s

8.1 Introduction to service discovery and load balancing

What is Service Discovery?

service discovery is the process by which microservices dynamically locate themselves in a network. It has three components:

  1. Service Provider — The service that actually provides a service
  2. Service Registry — The registry with which the provider registers and deregisters when it stops
  3. Consumer — When the consumer wants to use a provider, he asks the registry where the provider is located, obtains a service address/location, and connects directly to this service
Provider → register → [Service Registry]
Consumer → query for provider → [Service Registry] → returns address → Consumer → connect → Provider

In a microservices architecture, servers often scale up and down, causing their network locations to change frequently — which is why we need this extra step to make our services discoverable.

What is Load Balancing?

If we have a traffic spike (like a conference or sale), all users attempt to connect to our application via the internet. As we move from a monolith to more distributed microservices, we will need a mechanism to distribute traffic fairly between instances.

Internet → [Load Balancer] → Instance 1
                           → Instance 2
                           → Instance 3

Why is this important?

In a microservices architecture, service discovery and load balancing are essential for:

  • High Availability — Redirect traffic if an instance goes down
  • Scalability — Add instances without manual reconfiguration
  • Performance — Distribute the load evenly

8.2 Implementing service discovery

Implementation depends on your cloud architecture and your underlying technology choices. The goal is to give you enough knowledge to make good decisions.

Service discovery mechanisms

1. Client-side Discovery The client directly queries the service registry for available instances. This approach gives the customer full control over the discovery and load balancing logic.

Client → query → [Service Registry] → returns instances
Client → implements load balancing logic → connects to chosen instance

2. Server-side Discovery A load balancer queries the service registry and takes responsibility for redirecting client requests to the appropriate service instances. This approach abstracts the customer discovery process.

Client → [Load Balancer] → query → [Service Registry]
                         → routes → Instance

3. DNS-based Discovery Uses DNS to resolve service addresses. Services register their addresses with the DNS service, and clients use standard DNS queries for discovery.

ToolDescriptionUse cases
ConsulDeveloped by HashiCorp, offers discovery and health checking serviceComplex multi-data center environments
etcdPowered by CoreOS, distributed key-value storageKubernetes (this is its internal registry)
ZookeeperApache project, distributed coordinationHadoop/Kafka environments

8.3 Registration and discovery of services

Health Checks

The service registry also performs health checks:

  • Ping an HTTP port every 15 or 20 seconds
  • Ensures a positive response consistently
  • If the health check fails once, the service is not immediately destroyed — a delay is given
  • After a certain number of failed checks, the registry will no longer direct traffic to this service and will trigger the start of a new replacement service

Why service discovery is important

  1. Auto-scaling — Dynamically discovering new service instances as they are added helps scale the system to handle varying loads
  2. Rolling Updates — Service discovery enables seamless updates by allowing new versions of services to be discovered without downtime
  3. Disaster Recovery — Quickly restoring service connections after outages ensures minimal disruption to service availability

Ensure service discovery resilience

  • Redundant Registers — Deploy multiple register instances in a cluster to ensure high availability
  • Gracious Updates — Use rolling update strategies to keep the registry up to date without interruption
  • Fast Propagation — Ensure service state changes propagate quickly through the registry

8.4 Load balancing strategies

Why is load balancing important?

  • Scalability — Handle more traffic by adding more services
  • High availability — Even if some servers go down, the system remains accessible (in partnership with the discovery service)
  • Performance — Reduce response times by efficiently distributing the load

Two types of strategies

TypeDescriptionWhen to use
StaticDistribution based on fixed predefined rulesPredictable environments, consistent capabilities
DynamicDistribution adjusted according to load and real-time performanceFluctuating traffic, heterogeneous capacities

Static:

  • Distribution rules determined by fixed criteria
  • Does not change based on real-time conditions
  • Simple to understand and useful in predictable environments

Dynamic:

  • Distribution adjusts based on real-time data
  • May adapt to server load, response times, or other factors
  • Very useful with fluctuating traffic patterns and variable loads

How to choose?

  1. Server Capabilities and Performance — Are there characteristics of your system that make a strategy more appropriate?
  2. Traffic Patterns — Is your traffic predictable or fluctuating?
  3. Session requirements — Do your applications require sticky sessions?

8.5 Static load balancing algorithms

Round Robin

Distributes incoming requests to servers in sequential, circular order. The main goal is to fairly distribute traffic between all servers.

Request 1 → Server A
Request 2 → Server B
Request 3 → Server C
Request 4 → Server A (recommence)

Advantages:

  • Simple — Easy to implement and understand
  • Fair — Ensures that all servers handle an equal number of requests over time
  • Predictable — Provides a predictable query distribution pattern

Disadvantages:

  • Ignore server load — Does not take into account server load or capacity, which can lead to unbalanced workloads
  • Performance Variability — If servers have different capacities or are under different loads, this method may result in uneven performance
  • Not suitable for all scenarios — Best in environments where server capacities are roughly equal and consistent

IP Hashing

The load balancer applies a hash function to the client IP address, and this maps to one of the available servers, ensuring that the same client IP always routes to the same server.

Advantages:

  • Session persistence — Ensures that clients are consistently routed to the same server, crucial for stateful applications
  • Predictable — Provides a predictable routing pattern
  • Simple to implement

Disadvantages:

  • May lead to uneven distribution if IP addresses are not uniformly distributed
  • Difficult to use behind Shared NATs (multiple users with the same IP)

Weighted Round Robin

Variant of round robin where some servers receive more requests than others depending on their capacity:

Server A (poids 3): reçoit 3 requêtes pour 1 requête de B
Server B (poids 1): reçoit 1 requête pour 3 de A

Useful when servers have different capacities.

8.6 Dynamic load balancing

Dynamic policies adapt to changing server loads, and these policies work very well when integrating with container orchestration platforms like Kubernetes.

Least Connections

Technique that directs traffic to the server with the current fewest active connections.

Server A: 10 connexions actives
Server B: 3 connexions actives  ← nouvelle requête routée ici
Server C: 7 connexions actives

Advantages:

  • Real-time load balancing — Adjusts to current loads
  • Improves resource utilization — No single server becomes a bottleneck
  • Flexible — Works well in environments with varying capacities and traffic patterns

Disadvantages:

  • Monitoring overhead — Requires continuous monitoring of active connections
  • May introduce slight latency due to time taken to check connection counters

Ideal for: Systems with fluctuating traffic and variable loads, real-time applications.

Least Response Time

This policy directs traffic to the server with the lowest response time.

Advantages:

  • Optimizes performance — Ensures users experience the lowest possible latency
  • Adapts to real-time changes in server performance and load

Disadvantages:

  • Overhead monitoring — Requires continuous monitoring of response times
  • May consistently favor a particular server if its response times are consistently better

Ideal for: High performance web applications, latency sensitive APIs.

Adaptive Load Balancing

Monitors server performance across multiple metrics (response time, CPU usage, active connections) and dynamically adjusts traffic distribution.

Advantages:

  • Comprehensive Adaptation — Adjusts to a Variety of Metrics
  • Enhanced Performance — Constantly optimizes overall system performance

Disadvantages:

  • Complexity — More complex to implement and maintain

Ideal for: Complex systems with unpredictable load profiles.

8.7 Service mesh and load balancing

A service mesh is an infrastructure layer dedicated to managing service-to-service communication in a microservices architecture. The goal is to provide reliable, secure, and observable communication between microservices.

Components of a service mesh

[Control Plane] — gère et configure le Data Plane, impose les politiques, monitore
       ↓
[Data Plane] — proxies qui gèrent la communication réelle entre services
   Proxy A ←→ Proxy B ←→ Proxy C
   (Service A)  (Service B)  (Service C)
  • Data Plane — Manages actual communication between services, proxying, service registration, and load balancing
  • Control Plane — Manages and configures the data plane, imposes policies, and monitors communication

When to introduce a service mesh?

  • Advanced traffic management needs — Implement more sophisticated traffic routing algorithms and capabilities
  • Centralized management — Centralize the configuration of load balancing policies, keep them in source control, make them manageable and shareable
  • Enhanced Observability — Detailed metrics and insights into traffic patterns and performance
  • Built-in Security — Built-in security features like mutual TLS and policy enforcement
ToolDescription
IstioPowerful service mesh with advanced traffic management, security policies and observability
LinkerdLightweight service mesh focused on simplicity and performance
Consul ConnectHashiCorp service mesh integrated with Consul for service discovery

8.8 Management of faults and failovers

Fault types

  1. Server crash — Individual servers go down due to hardware or software issues
  2. Network failure — Disruptions in the network preventing communication between services
  3. Service outage — A specific microservice becomes unresponsive or fails
  4. Data center outage — Complete outage of a data center due to a power outage or natural disaster

Management strategies

Redundancy:

  • Deploy multiple instances of our services to ensure availability
  • This applies not only to our microservices, but also to the services maintaining our infrastructure (service mesh, load balancers, service discovery mechanisms)
  • Replicate data and services across multiple locations

Regular Health Checks:

  • Monitor service health and detect outages early

Graceful degradation:

  • Design systems to maintain partial functionality even during outages

Failover mechanisms

  • Automatic Failover — Automatic failover to a backup server in the event of a failure
  • Load Balancer Failover — Load balancers can detect unhealthy servers and automatically redirect traffic
  • Data replication — Ensure data is replicated across regions for failure recovery

8.9 Security considerations

Three main security challenges in this architecture:

1. Risk of unauthorized access

Secure the discovery service:

  • Authentication — Ensure that only authorized services can register and discover other services. Check who is making these requests, what services are doing these things
  • Fine-grained access control — Implement granular access control to manage what each service can discover. Once we know what a service is, we can ask: Is this service allowed to do this thing?
  • Encryption — Use TLS to encrypt communication between services and the service registry
  • Audit logs — Maintain detailed audit logs of all service discovery activities

2. Data integrity

Secure load balancing:

  • TLS between Load Balancer and Services — Terminate TLS on the load balancer but also maintain TLS up to the services (do not send in clear text in the internal network)
  • Security policies in service mesh — Use service mesh to enforce security policies
  • Regular renewal of certificates — To maintain high security standards

3. Secure Scalability and Resilience

  • Secure new instances — When new instances are added (auto-scaling), they must be immediately secured
  • Regular audit — Review security configurations during scaling
  • Automation — Automate security configurations to ensure they are applied consistently

Important Note: Even if everything happens inside a Virtual Private Cloud (VPC) or subnet that you control, still encrypt communications — if someone breaks into your VPC or subnet, they won’t be able to see all of your data in the clear.


9. Summary — Comparison of communication patterns

PatternTypeWhen to useTools
RESTSynchronousExternal interactions, simple CRUD, real timeExpress, Fastify, axios
WebhooksAsynchronous (push)Third-party event notifications, integrationsExpress + Stripe/GitHub/etc.
Message Broker (Pub/Sub)AsynchronousDecoupled internal communication, high loadRabbitMQ, Kafka, AWS SQS
RPC (gRPC)SynchronousInternal communication, high performance, strict contractsgRPC, Protocol Buffers
Discovery ServiceInfrastructureDynamic discovery, auto-scalingConsul, etcd, Kubernetes DNS
Load BalancingInfrastructureTraffic distribution, high availabilityNGINX, HAProxy, AWS ALB, Istio

Decision tree

Question: Qui initie la communication?
├── Un client externe → REST
└── Un événement interne?
    ├── Notification vers l'extérieur → Webhook
    └── Communication interne?
        ├── Réponse immédiate nécessaire → gRPC (RPC)
        └── Découplage souhaité → Message Broker (Pub/Sub)

Key Takeaways

  1. REST is the starting point for most services — simple, universal, stateless
  2. Webhooks reverse polling model — push rather than pull, more efficient for notifications
  3. Message Brokers (RabbitMQ) deeply decouple services — producer and consumer do not need to know each other
  4. gRPC provides the best performance for internal communication with strict type contracts via Protobuf
  5. Service Discovery + Load Balancing are infrastructure concerns that make the previous four patterns viable at scale


Search Terms

node.js · microservices · communication · patterns · apis · backend · full-stack · web · service · grpc · load · services · balancing · rpc · webhooks · discovery · message · pattern · event · restful · handling · monitoring · webhook · architecture

Interested in this course?

Contact us to book it or get a custom training plan for your team.