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.
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.
| Aspect | Monolith | Microservices |
|---|---|---|
| Deployment | Single unit | Independent per service |
| Scaling | Scale everything | Scale only what's under load |
| Development | Simpler early on | Parallel team development |
| Failure isolation | One bug can crash everything | Failures contained per service |
| Data | Shared database | Each service owns its data store |
| API complexity | Single internal interface | Network calls between services |
| Operational overhead | Low | High (service mesh, tracing, etc.) |
| When to use | Early stage, small team | Scaling 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
| Gateway | Type | Best For |
|---|---|---|
| Kong | OSS / Enterprise | Plugin ecosystem, Kubernetes-native |
| AWS API Gateway | Managed | AWS-native workloads, serverless |
| Nginx / OpenResty | OSS | High performance, custom Lua scripting |
| Traefik | OSS | Docker/Kubernetes automatic service discovery |
| Envoy Proxy | OSS (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
| State | Behaviour | Transition |
|---|---|---|
| Closed (normal) | All requests pass through | Opens 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 through | Success → 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:
| Aspect | REST (synchronous) | Events (async — Kafka, RabbitMQ) |
|---|---|---|
| Coupling | Tight (caller waits for response) | Loose (producer doesn't know consumers) |
| Latency | Real-time response | Near-real-time to delayed |
| Use when | You need the result to continue (e.g., auth check) | Side effects don't block the user (e.g., send email) |
| Failure handling | Circuit breaker, retry | Dead-letter queue, consumer retry |
| Scalability | Each service must handle load spike | Queue absorbs spikes, consumers process at own rate |
| Examples | Get user, validate payment | Order 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-diffflag 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
Related Topics
gRPC vs REST
When to use gRPC for internal microservice communication vs REST.
API Monitoring
OpenTelemetry, distributed tracing, and health checks for microservices.
Authentication
JWT, OAuth 2.0, and mTLS for service-to-service auth.
OpenAPI / Swagger
Contract-first API design with OpenAPI 3.1 for microservices teams.
Idempotency
Safe retries with idempotency keys — essential in distributed systems.
Webhooks
Event-driven push notifications between services and external partners.