API Gateway Patterns for Microservices
A practical examination of API gateway patterns including request aggregation, circuit breaking, service discovery, and protocol translation for microservices architectures.
An API gateway sits at the intersection of every client request and every backend service. This position gives it unique power to simplify client interactions, enforce cross-cutting policies, and absorb the complexity of a microservices architecture. But with that power comes a design challenge: how do you structure a gateway that is flexible enough to serve diverse clients, resilient enough to handle upstream failures, and simple enough to maintain as your service count grows? This article examines the gateway patterns we have refined while building Dispatch, drawing from real-world microservices deployments in fintech.
The Gateway Aggregation Pattern
The most immediate value an API gateway provides is request aggregation. Mobile clients and single-page applications often need data from multiple backend services to render a single view. Without a gateway, the client makes multiple round trips, each adding latency and complexity. With aggregation, the gateway assembles a composite response from several upstream calls.
import { Hono } from 'hono'
const app = new Hono()
interface UserProfile {
id: string
name: string
email: string
}
interface AccountBalance {
currency: string
available: number
pending: number
}
interface RecentTransactions {
transactions: Array<{
id: string
amount: number
merchant: string
date: string
}>
}
app.get('/api/v1/dashboard/:userId', async (c) => {
const userId = c.req.param('userId')
const timeout = AbortSignal.timeout(3000)
// Fan out to three services in parallel
const [profileRes, balanceRes, transactionsRes] = await Promise.allSettled([
fetch(`${USERS_SERVICE}/users/${userId}`, { signal: timeout }),
fetch(`${ACCOUNTS_SERVICE}/accounts/${userId}/balance`, { signal: timeout }),
fetch(`${TRANSACTIONS_SERVICE}/transactions?userId=${userId}&limit=5`, {
signal: timeout,
}),
])
// Build composite response, gracefully degrading on partial failures
const profile =
profileRes.status === 'fulfilled' && profileRes.value.ok
? ((await profileRes.value.json()) as UserProfile)
: null
const balance =
balanceRes.status === 'fulfilled' && balanceRes.value.ok
? ((await balanceRes.value.json()) as AccountBalance)
: null
const transactions =
transactionsRes.status === 'fulfilled' && transactionsRes.value.ok
? ((await transactionsRes.value.json()) as RecentTransactions)
: null
if (!profile) {
return c.json({ error: 'User profile unavailable' }, 503)
}
return c.json({
profile,
balance: balance || { currency: 'USD', available: 0, pending: 0 },
recentTransactions: transactions?.transactions || [],
_meta: {
balanceAvailable: balance !== null,
transactionsAvailable: transactions !== null,
},
})
})The key design decisions here are parallel execution and graceful degradation. The three upstream calls execute simultaneously via Promise.allSettled, which returns results for all promises regardless of individual failures. The response is assembled with fallback values, and a _meta field tells the client which data sources were unavailable. The profile is treated as essential (its absence returns a 503), while balance and transactions are optional.
In Dispatch, we generalize this pattern into a configurable aggregation engine. Service teams define aggregation endpoints in a YAML configuration, specifying which upstream calls to make, how to merge their responses, and which fields are required versus optional.
The Circuit Breaker Pattern
When an upstream service is unhealthy, continuing to send it requests does two harmful things: it increases the response time for every client (because the gateway waits for a timeout), and it prevents the upstream from recovering (because it is flooded with requests it cannot handle). The circuit breaker pattern addresses both problems.
interface CircuitBreakerState {
status: 'closed' | 'open' | 'half-open'
failureCount: number
lastFailureTime: number
successCount: number
}
class CircuitBreaker {
private state: CircuitBreakerState = {
status: 'closed',
failureCount: 0,
lastFailureTime: 0,
successCount: 0,
}
constructor(
private config: {
failureThreshold: number
recoveryTimeout: number
halfOpenMaxAttempts: number
}
) {}
async execute<T>(fn: () => Promise<T>): Promise<T> {
if (this.state.status === 'open') {
if (Date.now() - this.state.lastFailureTime > this.config.recoveryTimeout) {
this.state.status = 'half-open'
this.state.successCount = 0
} else {
throw new CircuitOpenError('Circuit is open, request rejected')
}
}
try {
const result = await fn()
if (this.state.status === 'half-open') {
this.state.successCount++
if (this.state.successCount >= this.config.halfOpenMaxAttempts) {
this.state.status = 'closed'
this.state.failureCount = 0
}
} else {
this.state.failureCount = 0
}
return result
} catch (error) {
this.state.failureCount++
this.state.lastFailureTime = Date.now()
if (this.state.failureCount >= this.config.failureThreshold) {
this.state.status = 'open'
}
throw error
}
}
getState(): CircuitBreakerState {
return { ...this.state }
}
}
// Usage in Dispatch
const breakers = new Map<string, CircuitBreaker>()
function getBreaker(serviceName: string): CircuitBreaker {
if (!breakers.has(serviceName)) {
breakers.set(
serviceName,
new CircuitBreaker({
failureThreshold: 5,
recoveryTimeout: 30_000,
halfOpenMaxAttempts: 3,
})
)
}
return breakers.get(serviceName)!
}
app.all('/api/v1/payments/*', async (c) => {
const breaker = getBreaker('payments-service')
try {
const response = await breaker.execute(() =>
fetch(`${PAYMENTS_SERVICE}${c.req.path}`, {
method: c.req.method,
headers: c.req.raw.headers,
body: c.req.raw.body,
signal: AbortSignal.timeout(5000),
})
)
return new Response(response.body, {
status: response.status,
headers: response.headers,
})
} catch (error) {
if (error instanceof CircuitOpenError) {
return c.json(
{
error: {
code: 503,
message: 'Payments service temporarily unavailable',
retryAfter: 30,
},
},
503
)
}
throw error
}
})The circuit breaker has three states. Closed is the normal operating mode where all requests pass through. Open means the upstream has exceeded the failure threshold, and the gateway immediately returns 503 without attempting the upstream call. Half-open is the recovery state, where a limited number of probe requests test whether the upstream has recovered.
Dispatch maintains a circuit breaker per upstream service. The breaker states are exposed via a health endpoint, giving operations teams visibility into which services are experiencing issues.
The Backend-for-Frontend (BFF) Pattern
Different clients have different data needs. A mobile app needs compact, pre-aggregated responses. A web dashboard needs richer data with more fields. An internal admin tool needs access to raw service data. The BFF pattern addresses this by creating client-specific API layers within the gateway.
import { Hono } from 'hono'
const mobileApi = new Hono()
const webApi = new Hono()
const adminApi = new Hono()
// Mobile: compact response, minimal fields
mobileApi.get('/transactions', async (c) => {
const data = await fetchTransactions(c)
return c.json({
transactions: data.map((t) => ({
id: t.id,
amount: t.amount,
merchant: t.merchantName,
date: t.createdAt,
})),
})
})
// Web: richer response with additional context
webApi.get('/transactions', async (c) => {
const [transactions, categories, merchants] = await Promise.all([
fetchTransactions(c),
fetchCategories(c),
fetchMerchants(c),
])
return c.json({
transactions: transactions.map((t) => ({
...t,
category: categories.find((cat) => cat.id === t.categoryId),
merchant: merchants.find((m) => m.id === t.merchantId),
})),
filters: {
categories: categories.map((cat) => ({ id: cat.id, name: cat.name })),
},
})
})
// Admin: raw data with internal fields
adminApi.get('/transactions', async (c) => {
const data = await fetchTransactionsAdmin(c)
return c.json({ transactions: data }) // Includes internal fields
})
// Mount BFFs under separate paths
const gateway = new Hono()
gateway.route('/mobile/v1', mobileApi)
gateway.route('/web/v1', webApi)
gateway.route('/admin/v1', adminApi)In Dispatch, BFF routing is driven by the X-Client-Type header or by path prefix. Each BFF layer can have its own authentication requirements, rate limits, and response transformations.
The Request Transformation Pattern
Upstream services evolve their APIs independently. A service might rename a field, change a date format, or restructure its response. The gateway can absorb these changes, transforming requests and responses to maintain backward compatibility for clients.
import { Hono } from 'hono'
const app = new Hono()
// Transform request: client sends v1 format, upstream expects v2
app.post('/api/v1/transfers', async (c) => {
const clientBody = await c.req.json<{
from_account: string
to_account: string
amount: number
}>()
// Transform to upstream v2 format
const upstreamBody = {
sourceAccountId: clientBody.from_account,
destinationAccountId: clientBody.to_account,
transferAmount: {
value: clientBody.amount,
currency: 'USD',
},
initiatedAt: new Date().toISOString(),
}
const response = await fetch(`${TRANSFERS_SERVICE}/v2/transfers`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(upstreamBody),
})
const upstreamResult = await response.json<{
transferId: string
sourceAccountId: string
destinationAccountId: string
transferAmount: { value: number; currency: string }
status: string
completedAt: string | null
}>()
// Transform response back to v1 format
return c.json({
id: upstreamResult.transferId,
from_account: upstreamResult.sourceAccountId,
to_account: upstreamResult.destinationAccountId,
amount: upstreamResult.transferAmount.value,
status: upstreamResult.status,
completed_at: upstreamResult.completedAt,
})
})This pattern is particularly valuable during API version migrations. Dispatch can serve both old and new API formats simultaneously, allowing clients to migrate at their own pace while the upstream service has already moved to the new format.
The Service Discovery Pattern
In a dynamic microservices environment, service locations change as instances scale up and down. Hardcoding upstream URLs in the gateway is fragile. Instead, Dispatch implements a lightweight service discovery mechanism:
interface ServiceEndpoint {
url: string
weight: number
healthy: boolean
lastChecked: number
metadata: Record<string, string>
}
interface ServiceRegistry {
get(serviceName: string): Promise<ServiceEndpoint[]>
register(serviceName: string, endpoint: ServiceEndpoint): Promise<void>
deregister(serviceName: string, url: string): Promise<void>
}
class KVServiceRegistry implements ServiceRegistry {
constructor(private kv: KVNamespace) {}
async get(serviceName: string): Promise<ServiceEndpoint[]> {
const data = await this.kv.get<ServiceEndpoint[]>(
`services:${serviceName}`,
'json'
)
return (data || []).filter((e) => e.healthy)
}
async register(serviceName: string, endpoint: ServiceEndpoint): Promise<void> {
const existing = await this.get(serviceName)
const updated = [...existing.filter((e) => e.url !== endpoint.url), endpoint]
await this.kv.put(`services:${serviceName}`, JSON.stringify(updated))
}
async deregister(serviceName: string, url: string): Promise<void> {
const existing = await this.get(serviceName)
const updated = existing.filter((e) => e.url !== url)
await this.kv.put(`services:${serviceName}`, JSON.stringify(updated))
}
}
// Gateway middleware for service resolution
function serviceResolver(registry: ServiceRegistry): MiddlewareHandler {
return async (c, next) => {
const serviceName = c.req.path.split('/')[3] // e.g., /api/v1/payments/...
const endpoints = await registry.get(serviceName)
if (endpoints.length === 0) {
return c.json({ error: 'Service not found' }, 503)
}
// Weighted random selection
const totalWeight = endpoints.reduce((sum, e) => sum + e.weight, 0)
let random = Math.random() * totalWeight
const selected = endpoints.find((e) => {
random -= e.weight
return random <= 0
}) || endpoints[0]
c.set('upstreamUrl', selected.url)
await next()
}
}Conclusion
API gateway patterns are not academic exercises -- they are the practical solutions to problems that every microservices architecture encounters as it scales. Aggregation reduces client complexity and network overhead. Circuit breakers prevent cascade failures. BFF layers serve diverse clients efficiently. Request transformation decouples client and service evolution. Service discovery adapts to dynamic infrastructure. In Dispatch, these patterns are implemented as composable building blocks that teams can assemble based on their specific needs. The gateway should simplify your architecture, not become another source of complexity. Keeping each pattern focused, well-tested, and independently deployable is the key to a gateway that scales with your organization.
Related Articles
API Monitoring and Alerting Best Practices
A comprehensive guide to monitoring API gateways in production, covering the four golden signals, structured logging, distributed tracing, and actionable alerting strategies.
Edge Computing for Fintech: Latency and Compliance Benefits
How edge computing addresses the unique challenges of fintech platforms, including latency-sensitive transactions, data residency requirements, and regulatory compliance across jurisdictions.
API Performance Optimization: From 200ms to 20ms
A practical guide to optimizing API gateway performance, covering the specific techniques that took Dispatch's p95 latency from 200ms to under 20ms.