API Gateway Patterns for Microservices
How API gateways handle routing, authentication, rate limiting and response aggregation — and how to build or choose the right one.

Microservices create a routing problem. Ten services means ten different endpoints, ten authentication checks, ten rate limiters. Clients should not manage this complexity. An API gateway sits between clients and services, providing a single entry point that handles cross-cutting concerns — routing, authentication, rate limiting, and response transformation.
The gateway pattern comes in several flavors. Choosing the right one depends on whether you need simple routing or complex response aggregation.
The Reverse Proxy Gateway
The simplest gateway is a reverse proxy. It routes requests to the correct service based on the URL path and forwards the response unchanged.
# Nginx as a basic API gateway
upstream user-service {
server user-service:3001;
server user-service:3002;
}
upstream order-service {
server order-service:3003;
}
upstream product-service {
server product-service:3004;
}
server {
listen 80;
# Route based on path prefix
location /api/users {
proxy_pass http://user-service;
proxy_set_header Host $host;
proxy_set_header X-Request-ID $request_id;
}
location /api/orders {
proxy_pass http://order-service;
proxy_set_header Host $host;
proxy_set_header X-Request-ID $request_id;
}
location /api/products {
proxy_pass http://product-service;
proxy_set_header Host $host;
proxy_set_header X-Request-ID $request_id;
}
# Health check endpoint
location /health {
return 200 '{"status": "ok"}';
add_header Content-Type application/json;
}
}This works for simple architectures. But it cannot aggregate responses from multiple services, transform payloads, or run custom authentication logic. For that, you need an application-level gateway.
Application-Level Gateway
A custom gateway written in your application language handles complex routing, response aggregation, and middleware chains.
// src/gateway.ts — Express-based API gateway
import express from 'express';
import { createProxyMiddleware } from 'http-proxy-middleware';
const app = express();
// Middleware chain: logging → auth → rate limit → proxy
app.use(requestLogger);
app.use(authMiddleware);
app.use(rateLimitMiddleware);
// Service routing
const services: Record<string, string> = {
'/api/users': 'http://user-service:3001',
'/api/orders': 'http://order-service:3003',
'/api/products': 'http://product-service:3004',
'/api/notifications': 'http://notification-service:3005',
};
for (const [path, target] of Object.entries(services)) {
app.use(
path,
createProxyMiddleware({
target,
changeOrigin: true,
pathRewrite: { [`^${path}`]: '' },
onError: (err, req, res) => {
console.error(`Proxy error for ${path}:`, err.message);
res.status(502).json({ error: 'Service unavailable' });
},
})
);
}// Authentication middleware at the gateway level
// Services behind the gateway trust the X-User-Id header
async function authMiddleware(
req: Request,
res: Response,
next: NextFunction
) {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({ error: 'Missing authentication token' });
}
try {
const payload = await verifyJwt(token);
req.headers['x-user-id'] = payload.sub;
req.headers['x-user-role'] = payload.role;
next();
} catch {
return res.status(401).json({ error: 'Invalid token' });
}
}With auth handled at the gateway, individual services skip token verification entirely. They trust the X-User-Id header because only the gateway can set it.
Backend for Frontend (BFF)
Different clients need different API shapes. A mobile app needs lightweight payloads. A web dashboard needs rich, aggregated data. The BFF pattern creates a separate gateway for each client type.
// web-bff/src/routes/dashboard.ts — Web BFF aggregates multiple services
app.get('/api/dashboard', async (req, res) => {
const userId = req.headers['x-user-id'] as string;
// Fetch from multiple services in parallel
const [user, orders, notifications, analytics] = await Promise.all([
userService.getProfile(userId),
orderService.getRecent(userId, { limit: 5 }),
notificationService.getUnread(userId),
analyticsService.getSummary(userId),
]);
// Aggregate into a single response optimized for the web dashboard
res.json({
user: {
name: user.name,
avatar: user.avatarUrl,
memberSince: user.createdAt,
},
recentOrders: orders.map((o) => ({
id: o.id,
total: o.total,
status: o.status,
date: o.createdAt,
})),
unreadNotifications: notifications.count,
analytics: {
totalSpent: analytics.totalSpent,
ordersThisMonth: analytics.monthlyOrders,
},
});
});// mobile-bff/src/routes/dashboard.ts — Mobile BFF returns minimal data
app.get('/api/dashboard', async (req, res) => {
const userId = req.headers['x-user-id'] as string;
// Mobile only needs user name and notification count
const [user, notifications] = await Promise.all([
userService.getProfile(userId),
notificationService.getUnread(userId),
]);
res.json({
name: user.name,
avatar: user.avatarUrl,
unread: notifications.count,
});
});Two BFFs, same backend services. The web BFF makes four service calls and returns a rich object. The mobile BFF makes two calls and returns a minimal payload. Neither client makes multiple API requests.
Rate Limiting at the Gateway
The gateway is the natural place for rate limiting because it sees all incoming traffic before it reaches any service.
// src/middleware/rate-limit.ts
import { RateLimiterRedis } from 'rate-limiter-flexible';
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
// Different limits for different endpoint tiers
const limiters = {
default: new RateLimiterRedis({
storeClient: redis,
keyPrefix: 'rl:default',
points: 100, // 100 requests
duration: 60, // per 60 seconds
}),
auth: new RateLimiterRedis({
storeClient: redis,
keyPrefix: 'rl:auth',
points: 5, // 5 attempts
duration: 900, // per 15 minutes
}),
heavy: new RateLimiterRedis({
storeClient: redis,
keyPrefix: 'rl:heavy',
points: 10, // 10 requests
duration: 60, // per 60 seconds
}),
};
function getRateLimiter(path: string): RateLimiterRedis {
if (path.startsWith('/api/auth')) return limiters.auth;
if (path.startsWith('/api/reports')) return limiters.heavy;
return limiters.default;
}
export async function rateLimitMiddleware(
req: Request,
res: Response,
next: NextFunction
) {
const limiter = getRateLimiter(req.path);
const key = req.headers['x-user-id'] as string || req.ip;
try {
const result = await limiter.consume(key);
res.set('X-RateLimit-Remaining', String(result.remainingPoints));
res.set('X-RateLimit-Reset', String(
Math.ceil(result.msBeforeNext / 1000)
));
next();
} catch (rateLimitRes: any) {
res.set('Retry-After', String(
Math.ceil(rateLimitRes.msBeforeNext / 1000)
));
res.status(429).json({
error: 'Too many requests',
retryAfter: Math.ceil(rateLimitRes.msBeforeNext / 1000),
});
}
}Auth endpoints get strict limits (5 attempts per 15 minutes) to prevent brute force. Report generation endpoints get modest limits (10/min) to protect expensive queries. Everything else gets a generous default.
Request and Response Transformation
Gateways can transform requests and responses to maintain backward compatibility or normalize across services.
// Version transformation — old clients send v1 format, service expects v2
app.post('/api/v1/orders', async (req, res) => {
// Transform v1 request to v2 format
const v2Body = {
items: req.body.products.map((p: any) => ({
productId: p.id,
quantity: p.qty, // v1 used 'qty', v2 uses 'quantity'
unitPrice: p.price, // v1 used 'price', v2 uses 'unitPrice'
})),
shippingAddress: {
...req.body.address,
country: req.body.address.countryCode, // field rename
},
};
const response = await orderService.create(v2Body);
// Transform v2 response back to v1 format
res.json({
orderId: response.id,
total: response.totalAmount,
status: response.orderStatus,
});
});// Response filtering — remove internal fields before sending to clients
function stripInternalFields(data: Record<string, unknown>): Record<string, unknown> {
const internal = ['internalId', 'tenantId', 'auditLog', 'debugInfo'];
const filtered = { ...data };
for (const field of internal) {
delete filtered[field];
}
return filtered;
}
app.use('/api', (req, res, next) => {
const originalJson = res.json.bind(res);
res.json = (body: any) => {
const cleaned = Array.isArray(body)
? body.map(stripInternalFields)
: stripInternalFields(body);
return originalJson(cleaned);
};
next();
});Health Checks and Circuit Breaking
The gateway should not route traffic to unhealthy services. Health checks and circuit breakers prevent cascading failures.
// src/health/service-registry.ts
interface ServiceHealth {
url: string;
healthy: boolean;
lastCheck: Date;
consecutiveFailures: number;
}
const services = new Map<string, ServiceHealth>();
async function checkServiceHealth(name: string, url: string): Promise<void> {
const service = services.get(name) || {
url,
healthy: true,
lastCheck: new Date(),
consecutiveFailures: 0,
};
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
await fetch(`${url}/health`, { signal: controller.signal });
clearTimeout(timeout);
service.healthy = true;
service.consecutiveFailures = 0;
} catch {
service.consecutiveFailures++;
// Mark unhealthy after 3 consecutive failures
if (service.consecutiveFailures >= 3) {
service.healthy = false;
}
}
service.lastCheck = new Date();
services.set(name, service);
}
// Check all services every 10 seconds
setInterval(() => {
for (const [name, svc] of services) {
checkServiceHealth(name, svc.url);
}
}, 10000);When a service is unhealthy, the gateway returns a 503 immediately instead of waiting for a timeout. Clients get a fast error and can retry or show a degraded experience.
Key Takeaways
- Start with a reverse proxy — Nginx handles simple path-based routing without custom code
- Use an application gateway for aggregation — when clients need data from multiple services in one call
- BFF pattern serves different clients — mobile and web get optimized API shapes from their own gateway
- Centralize auth and rate limiting — handle once at the gateway, not in every service
- Transform for backward compatibility — gateway can translate between API versions without changing services
- Health check backend services — stop routing to unhealthy services before clients experience errors


