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.

technical9 min readBy Klivvr Engineering
Share:

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

business

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.

11 min read