REST API Design Patterns

CQRS, Saga, Transactional Outbox, Retry, Bulkhead, and Strangler Fig — patterns for distributed REST APIs

Last Updated:

As REST APIs grow into distributed systems, individual endpoint design gives way to higher-level architectural patterns. These patterns solve recurring problems in microservices: distributed transactions, fault tolerance, and safe legacy migration.

CQRS — Command Query Responsibility Segregation

CQRS separates read (query) and write (command) APIs into distinct models. Reads can use a denormalized, optimized read store (Redis, Elasticsearch) while writes go to the authoritative store (Postgres).

// Express router: separate read and write sub-applications
const express = require('express');

// Write API (Command side) — strict validation, synchronous consistency
const writeRouter = express.Router();
writeRouter.post('/orders', validateBody(createOrderSchema), async (req, res) => {
  const order = await db.orders.create(req.body); // write to Postgres
  await eventBus.publish('OrderCreated', order);  // trigger read model update
  res.status(201).json({ order_id: order.id, status: 'created' });
});

// Read API (Query side) — optimized read model, eventual consistency
const readRouter = express.Router();
readRouter.get('/orders', async (req, res) => {
  // Read from denormalized Redis/ES read model — fast, pre-joined
  const orders = await readStore.findOrders(req.query);
  res.json({ data: orders, total: orders.length });
});

readRouter.get('/orders/:id', async (req, res) => {
  const order = await readStore.getOrder(req.params.id);
  if (!order) return res.status(404).json({ error: 'Not found' });
  res.json(order);
});

app.use('/v1', writeRouter);
app.use('/v1', readRouter);

When to use: write-heavy APIs where reads have different shape/performance requirements than writes. Common in analytics dashboards and e-commerce. Pairs naturally with event sourcing.

Saga — Distributed Transactions

When a business operation spans multiple microservices (order → payment → inventory → fulfillment), you cannot use a traditional database transaction. A Saga coordinates the operation as a sequence of local transactions, with compensating transactions for rollback.

// Orchestration Saga (Order Service orchestrates the flow)
class OrderSaga {
  async execute(orderData) {
    let orderId, paymentId;

    try {
      // Step 1: Create order
      orderId = await orderService.createOrder(orderData);

      // Step 2: Reserve inventory
      await inventoryService.reserve(orderData.items, orderId);

      // Step 3: Process payment
      paymentId = await paymentService.charge({
        amount: orderData.total,
        customerId: orderData.customerId,
        idempotencyKey: `payment-${orderId}`  // idempotent!
      });

      // Step 4: Confirm fulfillment
      await fulfillmentService.createShipment(orderId);

      return { orderId, paymentId, status: 'confirmed' };

    } catch (error) {
      // Compensate in reverse order
      console.error('Saga failed at:', error.step, 'Compensating...');

      if (paymentId) await paymentService.refund(paymentId);
      if (orderId)   await inventoryService.release(orderId);
      if (orderId)   await orderService.cancel(orderId, error.message);

      throw new SagaError('Order failed — all compensations applied', error);
    }
  }
}

Transactional Outbox Pattern

The most common reliability bug: update the database AND publish an event — but what if the publish fails after the DB write succeeds? The Outbox pattern solves this by writing events to an outbox table in the same transaction as the business data, then publishing from the outbox reliably.

// With PostgreSQL
async function createOrderWithOutbox(db, orderData) {
  return db.transaction(async (trx) => {
    // 1. Insert business data
    const order = await trx('orders').insert(orderData).returning('*');

    // 2. Insert event into outbox (same transaction — atomic!)
    await trx('outbox_events').insert({
      id: crypto.randomUUID(),
      aggregate_id: order[0].id,
      event_type: 'OrderCreated',
      payload: JSON.stringify(order[0]),
      created_at: new Date(),
      published: false
    });

    return order[0];
  });
}

// Background worker: polls outbox and publishes events
async function processOutbox() {
  const events = await db('outbox_events')
    .where({ published: false })
    .orderBy('created_at', 'asc')
    .limit(50)
    .forUpdate().skipLocked();  // distributed-safe locking

  for (const event of events) {
    await kafkaProducer.send({
      topic: event.event_type.toLowerCase().replace(/([A-Z])/g, '.$1').slice(1),
      messages: [{ key: event.aggregate_id, value: event.payload }]
    });

    await db('outbox_events').where({ id: event.id }).update({ published: true });
  }
}

setInterval(processOutbox, 1000); // poll every second

Retry with Exponential Backoff + Jitter

All network calls can fail transiently. Retrying immediately creates thundering herd — adding jitter spreads load. This is the most important client-side resilience pattern.

async function fetchWithRetry(url, options = {}, config = {}) {
  const {
    maxRetries = 3,
    baseDelayMs = 100,
    maxDelayMs = 10_000,
    retryOn = [429, 500, 502, 503, 504]
  } = config;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      const res = await fetch(url, options);

      // Don't retry client errors (4xx except 429)
      if (!retryOn.includes(res.status)) return res;

      if (attempt === maxRetries) return res; // last attempt — return as-is

      // Read Retry-After header if present (respect server's guidance)
      const retryAfter = res.headers.get('Retry-After');
      const serverDelay = retryAfter ? parseFloat(retryAfter) * 1000 : null;

      // Exponential backoff with full jitter
      const exponentialDelay = Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs);
      const jitter = Math.random() * exponentialDelay;
      const delay = serverDelay || jitter;

      console.warn(`Request failed (${res.status}), retrying in ${Math.round(delay)}ms (attempt ${attempt + 1}/${maxRetries})`);
      await new Promise(r => setTimeout(r, delay));

    } catch (networkError) {
      if (attempt === maxRetries) throw networkError;
      const delay = Math.random() * Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs);
      await new Promise(r => setTimeout(r, delay));
    }
  }
}

// Usage
const res = await fetchWithRetry('https://api.example.com/orders', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', 'Idempotency-Key': idemKey },
  body: JSON.stringify(orderData)
});

Bulkhead — Isolating Failure Domains

In a ship, bulkheads are walls that prevent one flooded compartment from sinking the whole vessel. In APIs, bulkheads isolate failure domains so a slow downstream service doesn't exhaust your entire thread/connection pool.

// Separate HTTP agent pools per downstream service
const https = require('node:https');

const paymentAgent = new https.Agent({ maxSockets: 10 });   // limit to 10 concurrent connections
const inventoryAgent = new https.Agent({ maxSockets: 20 });
const notificationAgent = new https.Agent({ maxSockets: 5 });

// A slow payment API can only consume 10 sockets — doesn't starve inventory calls
function callPaymentAPI(data) {
  return fetch('https://payment-api/charge', {
    method: 'POST', agent: paymentAgent, body: JSON.stringify(data)
  });
}

// With opossum circuit breaker (see /microservices)
const CircuitBreaker = require('opossum');
const paymentBreaker = new CircuitBreaker(callPaymentAPI, {
  timeout: 3000,           // fail fast after 3s
  errorThresholdPercentage: 50,
  resetTimeout: 30000
});

Strangler Fig Pattern — Migrate Legacy APIs

Named after the strangler fig tree that grows around and eventually replaces its host. Gradually replace a legacy API by routing traffic to the new implementation, one endpoint at a time.

// API gateway routing: new endpoints go to v2, legacy to v1
app.use((req, res, next) => {
  const migratedPaths = ['/users', '/products'];  // migrated so far
  const isLegacyPath = !migratedPaths.some(p => req.path.startsWith(p));

  if (isLegacyPath) {
    return proxy.web(req, res, { target: 'https://legacy-api.internal' });
  }
  next();  // continue to new implementation
});

// Gradually expand migratedPaths as you migrate each endpoint

Request Coalescing

Multiple simultaneous requests for the same resource can be collapsed into a single upstream call — useful for cache stampede prevention and reducing load on slow downstream APIs.

const pending = new Map();

async function coalesce(key, fetchFn) {
  if (pending.has(key)) {
    return pending.get(key);  // join existing in-flight request
  }

  const promise = fetchFn().finally(() => pending.delete(key));
  pending.set(key, promise);
  return promise;
}

// Usage
app.get('/products/:id', async (req, res) => {
  const product = await coalesce(
    `product:${req.params.id}`,
    () => fetchProductFromDB(req.params.id)
  );
  res.json(product);
});

Pattern Decision Guide

ProblemPattern
Reads and writes have different shapes or scale differentlyCQRS
Business operation spans multiple servicesSaga + compensating transactions
Need to reliably publish events after a DB writeTransactional Outbox
Client needs to handle transient failures safelyRetry + exponential backoff + jitter
Slow downstream should not starve other servicesBulkhead + Circuit Breaker
Migrating a legacy API incrementallyStrangler Fig
Cache stampede / duplicate concurrent requestsRequest Coalescing