REST API Design for Microservices

API gateway pattern, service-to-service authentication, versioning strategies, circuit breakers, the BFF pattern, and when to use REST vs events in distributed systems.

Last Updated:

Monolith vs Microservices

A monolith is a single deployable unit containing all functionality. Microservices split that functionality into independently deployable services, each owning its domain and data.

AspectMonolithMicroservices
DeploymentSingle unitIndependent per service
ScalingScale everythingScale only what's under load
DevelopmentSimpler early onParallel team development
Failure isolationOne bug can crash everythingFailures contained per service
DataShared databaseEach service owns its data store
API complexitySingle internal interfaceNetwork calls between services
Operational overheadLowHigh (service mesh, tracing, etc.)
When to useEarly stage, small teamScaling teams, independent release cycles

Recommendation: Start with a well-structured monolith. Migrate to microservices when you have clear bounded contexts, multiple teams, and measurable scaling problems — not before.

API Gateway Pattern

An API gateway is a single entry point for all client traffic. It handles cross-cutting concerns so individual services don't have to:

Client (browser/mobile/partner)
        │
        ▼
┌───────────────────────────────────────┐
│              API Gateway              │
│  • SSL termination                    │
│  • Authentication (JWT validation)    │
│  • Rate limiting                      │
│  • Request routing                    │
│  • Request/response transformation    │
│  • Logging & tracing injection        │
└────┬──────────┬──────────┬────────────┘
     │          │          │
     ▼          ▼          ▼
 User Svc   Order Svc  Payment Svc
(port 3001) (port 3002) (port 3003)

Popular API Gateway Options

GatewayTypeBest For
KongOSS / EnterprisePlugin ecosystem, Kubernetes-native
AWS API GatewayManagedAWS-native workloads, serverless
Nginx / OpenRestyOSSHigh performance, custom Lua scripting
TraefikOSSDocker/Kubernetes automatic service discovery
Envoy ProxyOSS (CNCF)Service mesh, gRPC + REST, advanced load balancing

Simple Gateway with Express (Node.js)

const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');
const jwt = require('jsonwebtoken');

const gateway = express();

// Auth middleware — runs on every request
gateway.use((req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) return res.status(401).json({ error: 'No token' });
  try {
    req.user = jwt.verify(token, process.env.JWT_SECRET);
    next();
  } catch {
    res.status(401).json({ error: 'Invalid token' });
  }
});

// Route to microservices
gateway.use('/users',    createProxyMiddleware({ target: 'http://user-service:3001' }));
gateway.use('/orders',   createProxyMiddleware({ target: 'http://order-service:3002' }));
gateway.use('/payments', createProxyMiddleware({ target: 'http://payment-service:3003' }));

gateway.listen(80);

Service-to-Service Authentication

When microservices call each other internally, they still need to authenticate. Three main approaches:

1. JWT Service Tokens

// API gateway mints a service token after validating the user token
const serviceToken = jwt.sign(
  {
    sub:     req.user.id,
    service: 'api-gateway',
    roles:   req.user.roles,
    iat:     Math.floor(Date.now() / 1000)
  },
  process.env.INTERNAL_JWT_SECRET,
  { expiresIn: '30s' }   // short-lived: limits blast radius if intercepted
);

// Forward to downstream service
req.headers['x-service-token'] = serviceToken;

2. Mutual TLS (mTLS)

Each service has its own TLS certificate issued by an internal CA. When Service A calls Service B, both sides present and verify certificates — proving identity at the network layer. This is automated by service meshes like Istio or Linkerd.

// Node.js mTLS client
const https = require('https');
const fs = require('fs');

const agent = new https.Agent({
  cert: fs.readFileSync('./certs/order-service.crt'),
  key:  fs.readFileSync('./certs/order-service.key'),
  ca:   fs.readFileSync('./certs/internal-ca.crt')   // trust only internal CA
});

fetch('https://payment-service/charge', { agent, ... });

3. Service Mesh (Istio / Linkerd)

A service mesh injects a sidecar proxy (Envoy) alongside each service container. The mesh handles mTLS, retries, circuit breaking, and tracing automatically — services don't need any auth code at all. This is the preferred approach in large Kubernetes deployments.

API Versioning in Microservices

In a microservices architecture, each service may evolve at a different pace. A breaking change in one service must not break consumers in other services.

Contract-First Development

Define the API contract in OpenAPI 3.1 before writing any code. Consumers generate their client code from the spec — changes to the spec automatically surface breaking changes at compile time.

Consumer-Driven Contract Testing

Pact is the industry-standard tool for consumer-driven contract testing. Consumers define the interactions they expect; providers verify they still fulfil those contracts on every CI run.

// Consumer (Order Service) defines what it expects from User Service
const { Pact } = require('@pact-foundation/pact');

const provider = new Pact({ consumer: 'OrderService', provider: 'UserService' });

await provider.addInteraction({
  state: 'user 42 exists',
  uponReceiving: 'a request for user 42',
  withRequest:   { method: 'GET', path: '/users/42' },
  willRespondWith: {
    status: 200,
    body: { id: 42, name: like('Alice'), email: like('alice@example.com') }
  }
});

Tolerant Reader Pattern

Services should only read the fields they need and ignore unknown fields. This allows producers to add new fields without breaking consumers — the most pragmatic versioning strategy for internal service communication.

Circuit Breaker Pattern

In a microservices architecture, a slow or failing dependency can cascade and bring down your entire system. The circuit breaker pattern stops calls to a failing service and returns a fallback response instead.

// npm install opossum
const CircuitBreaker = require('opossum');

async function callPaymentService(chargeData) {
  const response = await fetch('http://payment-service/charge', {
    method: 'POST',
    body: JSON.stringify(chargeData),
    signal: AbortSignal.timeout(2000)  // 2s timeout
  });
  if (!response.ok) throw new Error('Payment service error: ' + response.status);
  return response.json();
}

const breaker = new CircuitBreaker(callPaymentService, {
  timeout:             3000,  // 3s before considering a request failed
  errorThresholdPercentage: 50, // Open circuit if >50% of requests fail
  resetTimeout:        30000  // Try again after 30s (half-open state)
});

// Fallback when circuit is open
breaker.fallback(() => ({ status: 'queued', message: 'Payment queued for retry' }));

breaker.on('open',     () => logger.warn('Payment circuit OPEN'));
breaker.on('halfOpen', () => logger.info('Payment circuit HALF-OPEN'));
breaker.on('close',    () => logger.info('Payment circuit CLOSED'));

// Use the breaker instead of calling directly
const result = await breaker.fire(chargeData);

Circuit Breaker States

StateBehaviourTransition
Closed (normal)All requests pass throughOpens when error rate exceeds threshold
Open (failing)Requests fail immediately (no network call)After reset timeout → half-open
Half-open (testing)One test request is allowed throughSuccess → closed; Failure → open

Backend for Frontend (BFF) Pattern

A single generic API is always a compromise — what's optimal for the mobile app is rarely optimal for the web app or third-party partners. The BFF pattern solves this by creating a dedicated API layer per client type.

                 Mobile App    Web App    Partner API
                     │            │            │
                     ▼            ▼            ▼
              ┌─────────┐  ┌─────────┐  ┌─────────────┐
              │ Mobile  │  │  Web    │  │  Partner    │
              │   BFF   │  │  BFF    │  │    BFF      │
              └────┬────┘  └────┬────┘  └──────┬──────┘
                   │            │              │
                   └────────────┼──────────────┘
                                │ (internal gRPC or REST)
                      ┌─────────┴────────┐
                      │  Microservices   │
                      │ User / Order /   │
                      │ Payment / etc.   │
                      └──────────────────┘

Each BFF aggregates data from multiple microservices, formats it for the specific client's needs, and handles client-specific concerns (e.g., mobile BFF compresses images, web BFF returns full HTML metadata).

BFF with GraphQL

GraphQL is an excellent fit for the BFF layer — it lets the frontend team specify exactly which fields they need, and the BFF resolves those fields by calling the appropriate internal microservices. This is the architecture used by Netflix, Airbnb, and many other large-scale platforms.

// GraphQL BFF resolvers — aggregate from multiple microservices
const resolvers = {
  Query: {
    dashboard: async (_, __, { dataSources }) => {
      const [user, orders, notifications] = await Promise.all([
        dataSources.userAPI.getUser(),
        dataSources.orderAPI.getRecentOrders({ limit: 5 }),
        dataSources.notificationAPI.getUnread()
      ]);
      return { user, orders, notifications };
    }
  }
};

REST vs Event-Driven Communication

Not all service-to-service communication should be synchronous REST. Choose based on the coupling and latency requirements:

AspectREST (synchronous)Events (async — Kafka, RabbitMQ)
CouplingTight (caller waits for response)Loose (producer doesn't know consumers)
LatencyReal-time responseNear-real-time to delayed
Use whenYou need the result to continue (e.g., auth check)Side effects don't block the user (e.g., send email)
Failure handlingCircuit breaker, retryDead-letter queue, consumer retry
ScalabilityEach service must handle load spikeQueue absorbs spikes, consumers process at own rate
ExamplesGet user, validate paymentOrder created → send confirmation email, update inventory

The Pattern: Sync for Reads, Async for Side Effects

// Order Service — hybrid approach
async function createOrder(orderData) {
  // 1. Sync REST call — need the result to continue
  const user = await fetch('http://user-service/users/' + orderData.userId);
  const payment = await fetch('http://payment-service/charge', { method: 'POST', ... });

  // 2. Save order
  const order = await db.orders.create({ ...orderData, paymentId: payment.id });

  // 3. Async events — fire-and-forget side effects
  await kafka.publish('order.created', {
    orderId:  order.id,
    userId:   order.userId,
    total:    order.total
  });
  // ↑ Email Service, Inventory Service, Analytics Service
  //   all subscribe independently — Order Service doesn't know or care

  return order;
}

API Contracts with OpenAPI

In a microservices world, the API contract is the interface between teams. OpenAPI 3.1 provides a machine-readable contract that enables:

  • Mock servers: Frontend and consumer teams develop against a mock before the real service is built
  • Contract testing: CI validates that the service implementation matches the spec
  • Client SDK generation: Teams auto-generate type-safe API clients from the spec
  • Breaking change detection: Tools like openapi-diff flag breaking changes before deployment
# Detect breaking changes between v1 and v2 of your spec
npx openapi-diff openapi-v1.yaml openapi-v2.yaml

# Breaking changes detected:
# - Removed property 'phone' from User schema
# - Changed response type of GET /users/{id} from object to array
# → Bump major version or maintain backward compatibility