Skip to content
· 10 min read · 0 views

API Gateway Patterns: The Front Door to Your Microservices

Master API Gateway patterns for microservices architecture. Learn request routing, authentication, rate limiting, and service mesh integration with TypeScript examples.

// table of contents (17 sections)

A single frontend call often triggers multiple backend services. User profile, recommendations, notifications — each lives in its own microservice. Without an API Gateway, your frontend makes 5+ calls per page load. With one, it makes a single request to a unified entry point that handles routing, authentication, and aggregation.

API Gateways are the unsung heroes of microservices architecture. They centralize cross-cutting concerns so your services can focus on business logic.

The Problem: Frontend-Backend Coupling

Without a gateway, microservices create complexity for clients:

Frontend needs user data:
  ├── GET /api/users/:id          → User Service (port 3001)
  ├── GET /api/users/:id/orders   → Order Service (port 3002)
  ├── GET /api/users/:id/notifications → Notification Service (port 3003)
  └── GET /api/recommendations/:id → Recommendation Service (port 3004)

Result: 4 network calls, 4x latency, 4x failure points

The API Gateway pattern solves this with a single entry point:

Frontend → API Gateway → Parallel calls to services

    Single request, unified response

Core Responsibilities

An API Gateway handles cross-cutting concerns that every service needs:

1. Request Routing

Route requests to the appropriate service based on path, headers, or query parameters:

interface Route {
  path: string;
  service: string;
  methods: string[];
  rewrite?: (path: string) => string;
}

const routes: Route[] = [
  { path: '/api/users', service: 'user-service', methods: ['GET', 'POST', 'PUT'] },
  { path: '/api/orders', service: 'order-service', methods: ['GET', 'POST'] },
  { path: '/api/notifications', service: 'notification-service', methods: ['GET'] },
  { 
    path: '/api/v1/products', 
    service: 'product-service', 
    methods: ['GET'],
    rewrite: (path) => path.replace('/api/v1', '/api')
  },
];

class ApiGateway {
  private serviceRegistry: Map<string, string>;
  
  constructor() {
    this.serviceRegistry = new Map([
      ['user-service', 'http://user-service:3001'],
      ['order-service', 'http://order-service:3002'],
      ['notification-service', 'http://notification-service:3003'],
    ]);
  }

  async route(req: Request): Promise<Response> {
    const url = new URL(req.url);
    const route = this.matchRoute(url.pathname, req.method);
    
    if (!route) {
      return new Response('Not Found', { status: 404 });
    }

    const targetUrl = this.serviceRegistry.get(route.service);
    const rewrittenPath = route.rewrite?.(url.pathname) ?? url.pathname;
    
    return fetch(`${targetUrl}${rewrittenPath}${url.search}`, {
      method: req.method,
      headers: req.headers,
      body: req.body,
    });
  }

  private matchRoute(path: string, method: string): Route | null {
    return routes.find(r => 
      path.startsWith(r.path) && r.methods.includes(method)
    ) ?? null;
  }
}

2. Authentication & Authorization

Centralize auth logic so services don’t repeat it:

import { verify } from 'jsonwebtoken';

interface AuthConfig {
  jwtSecret: string;
  publicPaths: string[];
}

class AuthMiddleware {
  constructor(private config: AuthConfig) {}

  async handle(req: Request): Promise<{ user: any } | null> {
    const url = new URL(req.url);
    
    // Skip auth for public paths
    if (this.config.publicPaths.some(p => url.pathname.startsWith(p))) {
      return null;
    }

    const authHeader = req.headers.get('Authorization');
    if (!authHeader?.startsWith('Bearer ')) {
      throw new AuthError('Missing authorization header', 401);
    }

    const token = authHeader.slice(7);
    
    try {
      const user = verify(token, this.config.jwtSecret);
      
      // Add user info to headers for downstream services
      req.headers.set('X-User-Id', user.id);
      req.headers.set('X-User-Roles', user.roles.join(','));
      
      return { user };
    } catch (err) {
      throw new AuthError('Invalid token', 401);
    }
  }
}

3. Rate Limiting

Protect your services from abuse with centralized rate limiting:

interface RateLimitConfig {
  windowMs: number;
  maxRequests: number;
  keyGenerator?: (req: Request) => string;
}

class RateLimiter {
  private requests: Map<string, number[]> = new Map();

  constructor(private config: RateLimitConfig) {}

  async check(req: Request): Promise<void> {
    const key = this.config.keyGenerator?.(req) ?? this.defaultKeyGenerator(req);
    const now = Date.now();
    
    const timestamps = this.requests.get(key) ?? [];
    const validTimestamps = timestamps.filter(t => now - t < this.config.windowMs);
    
    if (validTimestamps.length >= this.config.maxRequests) {
      const retryAfter = Math.ceil(
        (validTimestamps[0] + this.config.windowMs - now) / 1000
      );
      throw new RateLimitError('Too many requests', 429, retryAfter);
    }

    validTimestamps.push(now);
    this.requests.set(key, validTimestamps);
  }

  private defaultKeyGenerator(req: Request): string {
    const ip = req.headers.get('X-Forwarded-For') ?? 'unknown';
    const userId = req.headers.get('X-User-Id') ?? ip;
    return `${userId}:${new URL(req.url).pathname}`;
  }
}

For production, use Redis-backed rate limiting:

import { Redis } from 'ioredis';

class RedisRateLimiter {
  constructor(
    private redis: Redis,
    private config: RateLimitConfig
  ) {}

  async check(req: Request): Promise<RateLimitInfo> {
    const key = `ratelimit:${this.getKey(req)}`;
    const now = Date.now();
    const windowStart = now - this.config.windowMs;

    // Use Redis pipeline for atomic operations
    const result = await this.redis
      .pipeline()
      .zremrangebyscore(key, 0, windowStart)
      .zcard(key)
      .zadd(key, now, `${now}:${Math.random()}`)
      .expire(key, Math.ceil(this.config.windowMs / 1000))
      .exec();

    const currentCount = result[1][1] as number;
    const remaining = Math.max(0, this.config.maxRequests - currentCount - 1);

    return {
      allowed: currentCount < this.config.maxRequests,
      remaining,
      resetAt: now + this.config.windowMs,
    };
  }
}

4. Request Aggregation

Combine multiple service calls into a single response:

interface AggregationSpec {
  services: {
    name: string;
    url: string;
    path: string;
    method?: string;
    body?: any;
  }[];
  combine: (results: Record<string, any>) => any;
}

class RequestAggregator {
  async aggregate(spec: AggregationSpec): Promise<any> {
    // Execute all requests in parallel
    const promises = spec.services.map(async (service) => {
      const response = await fetch(service.url + service.path, {
        method: service.method ?? 'GET',
        body: service.body ? JSON.stringify(service.body) : undefined,
        headers: { 'Content-Type': 'application/json' },
      });
      
      return {
        name: service.name,
        data: await response.json(),
      };
    });

    const results = await Promise.all(promises);
    
    // Convert to object for combine function
    const resultMap = results.reduce((acc, r) => {
      acc[r.name] = r.data;
      return acc;
    }, {} as Record<string, any>);

    return spec.combine(resultMap);
  }
}

// Example: User dashboard aggregation
const dashboardSpec: AggregationSpec = {
  services: [
    { name: 'user', url: 'http://user-service', path: '/api/users/123' },
    { name: 'orders', url: 'http://order-service', path: '/api/users/123/orders' },
    { name: 'notifications', url: 'http://notification-service', path: '/api/users/123/notifications' },
  ],
  combine: (results) => ({
    user: results.user,
    recentOrders: results.orders.slice(0, 5),
    unreadNotifications: results.notifications.filter((n: any) => !n.read),
  }),
};

Gateway Implementation Options

Build or buy? Here are your options:

Option 1: Off-the-Shelf Gateways

GatewayBest ForKey Features
KongTeams wanting GUI managementPlugin ecosystem, declarative config, admin API
EnvoyService mesh integrationDynamic config, L7 load balancing, observability
TraefikContainer/K8s environmentsAuto-discovery, Let’s Encrypt, middleware
AWS API GatewayAWS-native deploymentsLambda integration, authorizers, usage plans
NGINX PlusHigh-performance routingCaching, rate limiting, active health checks

Kong Example (declarative config):

# kong.yml
_format_version: "3.0"

services:
  - name: user-service
    url: http://user-service:3001
    routes:
      - name: users-route
        paths:
          - /api/users
    plugins:
      - name: rate-limiting
        config:
          minute: 100
          policy: local
      - name: jwt
        config:
          secret_is_base64: false

  - name: order-service
    url: http://order-service:3002
    routes:
      - name: orders-route
        paths:
          - /api/orders

Option 2: Custom Gateway

When you need fine-grained control or want to avoid vendor lock-in:

import { Hono } from 'hono';

const gateway = new Hono();

// Global middleware
gateway.use('*', logger());
gateway.use('*', cors());
gateway.use('*', rateLimitMiddleware);

// Health check (no auth required)
gateway.get('/health', (c) => c.json({ status: 'ok' }));

// Auth middleware for protected routes
gateway.use('/api/*', authMiddleware);

// Route to services
gateway.all('/api/users/*', proxy('http://user-service:3001'));
gateway.all('/api/orders/*', proxy('http://order-service:3002'));
gateway.all('/api/notifications/*', proxy('http://notification-service:3003'));

// Aggregated endpoint
gateway.get('/api/dashboard/:userId', async (c) => {
  const userId = c.req.param('userId');
  const aggregator = new RequestAggregator();
  
  const dashboard = await aggregator.aggregate({
    services: [
      { name: 'user', url: 'http://user-service:3001', path: `/api/users/${userId}` },
      { name: 'orders', url: 'http://order-service:3002', path: `/api/users/${userId}/orders` },
    ],
    combine: (results) => ({
      user: results.user,
      orders: results.orders,
    }),
  });

  return c.json(dashboard);
});

export default gateway;

Advanced Patterns

Circuit Breaker at Gateway Level

Combine with resilient API patterns to fail fast:

import CircuitBreaker from 'opossum';

class GatewayWithCircuitBreaker {
  private breakers: Map<string, CircuitBreaker> = new Map();

  async proxy(serviceName: string, request: Request): Promise<Response> {
    if (!this.breakers.has(serviceName)) {
      this.breakers.set(serviceName, this.createBreaker(serviceName));
    }

    const breaker = this.breakers.get(serviceName)!;
    
    try {
      return await breaker.fire(request);
    } catch (err) {
      if (breaker.opened) {
        // Return cached response or fallback
        return this.getFallback(serviceName, request);
      }
      throw err;
    }
  }

  private createBreaker(serviceName: string): CircuitBreaker {
    return new CircuitBreaker(
      (req: Request) => this.executeProxy(serviceName, req),
      {
        timeout: 3000,
        errorThresholdPercentage: 50,
        resetTimeout: 30000,
      }
    );
  }

  private async getFallback(serviceName: string, request: Request): Promise<Response> {
    // Return stale cache data or graceful degradation response
    return new Response(
      JSON.stringify({ 
        error: 'Service temporarily unavailable',
        fallback: true 
      }),
      { status: 503, headers: { 'Content-Type': 'application/json' } }
    );
  }
}

GraphQL Federation Gateway

For GraphQL services, use federation instead of REST aggregation:

import { ApolloGateway } from '@apollo/gateway';
import { ApolloServer } from '@apollo/server';

const gateway = new ApolloGateway({
  serviceList: [
    { name: 'users', url: 'http://user-service:3001/graphql' },
    { name: 'orders', url: 'http://order-service:3002/graphql' },
    { name: 'products', url: 'http://product-service:3003/graphql' },
  ],
});

const server = new ApolloServer({
  gateway,
  subscriptions: false,
});

// Single GraphQL endpoint handles all services
// Clients query what they need, gateway resolves across services

Response Caching

Cache expensive operations at the edge:

interface CacheEntry {
  data: any;
  etag: string;
  cachedAt: number;
  ttl: number;
}

class ResponseCache {
  private cache: Map<string, CacheEntry> = new Map();

  async getOrFetch(
    key: string, 
    fetcher: () => Promise<any>,
    ttl: number = 60000
  ): Promise<{ data: any; cached: boolean; etag: string }> {
    const entry = this.cache.get(key);
    const now = Date.now();

    if (entry && now - entry.cachedAt < ttl) {
      return { data: entry.data, cached: true, etag: entry.etag };
    }

    const data = await fetcher();
    const etag = `"${Buffer.from(JSON.stringify(data)).toString('base64').slice(0, 20)}"`;
    
    this.cache.set(key, { data, etag, cachedAt: now, ttl });

    return { data, cached: false, etag };
  }

  invalidate(pattern: string): void {
    for (const key of this.cache.keys()) {
      if (key.includes(pattern)) {
        this.cache.delete(key);
      }
    }
  }
}

Gateway vs Service Mesh

How do gateways relate to service meshes like Istio or Linkerd?

┌─────────────────────────────────────────────────────────────┐
│                        Internet                             │
└─────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│                     API Gateway                             │
│  • Authentication                                           │
│  • Rate Limiting                                            │
│  • Request Aggregation                                      │
│  • Public API Management                                    │
└─────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│                     Service Mesh                            │
│  • Internal service-to-service communication               │
│  • mTLS                                                     │
│  • Traffic splitting                                        │
│  • Observability                                            │
└─────────────────────────────────────────────────────────────┘
        │              │              │              │
        ▼              ▼              ▼              ▼
   ┌─────────┐   ┌─────────┐   ┌─────────┐   ┌─────────┐
   │ Service │   │ Service │   │ Service │   │ Service │
   │    A    │   │    B    │   │    C    │   │    D    │
   └─────────┘   └─────────┘   └─────────┘   └─────────┘

Key distinction:

  • API Gateway: North-south traffic (client → services)
  • Service Mesh: East-west traffic (service ↔ service)

Use both together: Gateway handles external concerns, mesh handles internal.


Production Checklist

Before deploying your gateway:

  • Health checks/health endpoint for load balancer
  • Graceful shutdown — Drain connections before exit
  • Timeout configuration — Don’t let slow services hang requests
  • Circuit breakers — Prevent cascading failures
  • Rate limiting — Per-user and global limits
  • Observability — Logs, metrics, distributed traces
  • Request ID — Correlate requests across services
  • Compression — Gzip/Brotli for large responses
  • Security headers — CORS, CSP, HSTS
  • Blue-green deployment — Zero-downtime gateway updates
// Request ID middleware for tracing
gateway.use('*', async (c, next) => {
  const requestId = c.req.header('X-Request-Id') ?? crypto.randomUUID();
  c.set('requestId', requestId);
  c.header('X-Request-Id', requestId);
  await next();
});

When to Skip the Gateway

Not every application needs an API Gateway:

  • Monoliths — Single service, no routing needed
  • Simple CRUD — Direct database access is simpler
  • Serverless — API Gateway (AWS) handles it for you
  • Small teams — Added complexity may not be worth it

The gateway pattern shines when you have:

  • Multiple services that need unified access
  • Cross-cutting concerns (auth, rate limiting, logging)
  • Client aggregation needs (mobile apps benefit from fewer round trips)
  • Need for API versioning and deprecation

Summary

API Gateways centralize the concerns that every microservice needs:

ConcernWithout GatewayWith Gateway
AuthEach service validates tokensCentralized, user info passed via headers
Rate LimitingScattered, inconsistentUnified, Redis-backed
RoutingClient knows all URLsSingle entry point
AggregationMultiple client callsSingle request, parallel service calls
ObservabilityPer-service loggingCentralized metrics and tracing

Build a custom gateway when you need flexibility. Use Kong, Envoy, or Traefik when you want battle-tested solutions with less maintenance overhead.

Combined with circuit breakers for resilience and workflow orchestration for background jobs, your microservices architecture becomes robust and maintainable.


Alhamdulillah for the patterns that make distributed systems manageable.

You might also like

Enjoyed This Post?

Want to discuss the topic, have questions, or looking to collaborate on something similar? Drop a comment below or reach out directly.

Discussion