Skip to main content

πŸšͺ API Gateway Implementation

Once you have a dozen services, you don't want clients calling each one directly β€” that leaks your architecture, duplicates auth everywhere, and multiplies round trips. An API Gateway is the single front door: it routes, aggregates, secures, throttles, and observes all traffic on your services' behalf. In this lesson you'll build one with Express and see when to reach for a managed product instead.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain what an API Gateway does and the cross-cutting concerns it centralizes
  • Implement routing, request aggregation, and JWT auth in an Express gateway
  • Add rate limiting, caching, and circuit breaking to protect backend services
  • Choose between the single-gateway, gateway-per-service, and Backend-for-Frontend patterns
  • Compare building a gateway vs. using managed products (Kong, AWS API Gateway, NGINX)

Estimated Time: 40–50 minutes  β€’  Difficulty: Advanced

Hands-on: Build a working Express gateway with auth, routing, aggregation, and caching.

In This Lesson

What Is an API Gateway?

An API Gateway is a single entry point that sits between clients and your backend services. Every request comes in through the gateway, which decides where it goes, applies shared policies, and can combine several service responses into one before replying to the client.

πŸ’‘ Hotel concierge analogy: Guests don't call housekeeping, the kitchen, and the box office separately. They ask the concierge, who knows which department handles what (routing), coordinates a multi-part request like "dinner, a show, and a taxi" (aggregation), speaks each department's language (protocol translation), and checks you're actually a guest before acting (security).
graph TD A[Mobile Client] --> G[API Gateway] B[Web Client] --> G C[Third-party Client] --> G G --> D[User Service] G --> E[Product Service] G --> F[Order Service] G --> H[Payment Service] G --> I[Notification Service]

πŸ“– Why not let clients call services directly?

Direct-to-service calls force each client to know your topology, duplicate auth and retry logic, suffer many round trips, and break whenever you restructure services. The gateway hides all of that behind one stable interface.

Core Functions

1. Request routing

The gateway maps incoming paths to backend services. With http-proxy-middleware in Express, each route proxies to a service URL and rewrites the path:

const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');

const app = express();

const services = {
  user:    'http://user-service:3001',
  product: 'http://product-service:3002',
  order:   'http://order-service:3003',
};

// /api/users/42  ->  http://user-service:3001/42
app.use('/api/users', createProxyMiddleware({
  target: services.user,
  changeOrigin: true,
  pathRewrite: { '^/api/users': '' },
}));

app.use('/api/products', createProxyMiddleware({
  target: services.product,
  changeOrigin: true,
  pathRewrite: { '^/api/products': '' },
}));

app.listen(8000, () => console.log('API Gateway on :8000'));

2. Request aggregation

The gateway can fan out to several services and stitch the results into one response β€” the mobile client makes a single call instead of three. Fire the independent calls in parallel:

// GET /api/product-details/:id  β€” combine product, reviews, inventory
app.get('/api/product-details/:id', async (req, res) => {
  const { id } = req.params;
  try {
    const [product, reviews, inventory] = await Promise.all([
      fetch(`http://product-service/products/${id}`).then(r => r.json()),
      fetch(`http://review-service/reviews?productId=${id}`).then(r => r.json()),
      fetch(`http://inventory-service/inventory/${id}`).then(r => r.json()),
    ]);

    res.json({
      product,
      reviews,
      inventory,
      inStock: inventory.quantity > 0,
    });
  } catch (err) {
    console.error('Aggregation failed:', err);
    res.status(502).json({ error: 'Failed to load product details' });
  }
});

βœ… Why aggregate at the gateway

  • Fewer round trips β€” one client request instead of many (huge on mobile networks).
  • Simpler clients β€” the coordination logic lives server-side.
  • Backend freedom β€” you can restructure services without changing client code.

3. Protocol translation

Clients often speak HTTP/JSON while internal services use gRPC or messaging. The gateway translates between them, so clients stay simple and services use whatever is fastest internally.

graph LR A[Web Client] -->|HTTP / JSON| G[API Gateway] B[Mobile Client] -->|GraphQL| G G -->|gRPC| C[Product Service] G -->|REST| D[User Service] G -->|Messaging| E[Notification Service]

Security at the Gateway

Centralizing authentication and authorization is one of the biggest wins of a gateway: verify the caller once at the edge, then forward trusted identity to services instead of every service re-implementing token checks.

const jwt = require('jsonwebtoken');
const JWT_SECRET = process.env.JWT_SECRET;

// Authentication: verify the JWT, attach identity for downstream services
function authenticate(req, res, next) {
  const header = req.headers.authorization;
  if (!header?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'No token provided' });
  }
  try {
    const decoded = jwt.verify(header.slice(7), JWT_SECRET);
    req.user = decoded;
    // Pass trusted identity to backend services as headers
    req.headers['x-user-id'] = decoded.userId;
    req.headers['x-user-role'] = decoded.role;
    next();
  } catch {
    return res.status(401).json({ error: 'Invalid token' });
  }
}

// Authorization: gate routes by role
function authorize(...allowedRoles) {
  return (req, res, next) => {
    if (!req.user) return res.status(401).json({ error: 'Not authenticated' });
    if (!allowedRoles.includes(req.user.role)) {
      return res.status(403).json({ error: 'Insufficient permissions' });
    }
    next();
  };
}

// Public β€” no auth
app.use('/api/auth', createProxyMiddleware({ target: 'http://auth-service:3001', changeOrigin: true }));

// Protected β€” must be logged in
app.use('/api/users', authenticate, createProxyMiddleware({ target: 'http://user-service:3002', changeOrigin: true }));

// Admin only
app.use('/api/admin', authenticate, authorize('ADMIN'),
  createProxyMiddleware({ target: 'http://admin-service:3003', changeOrigin: true }));

⚠️ Don't fully trust the edge β€” defense in depth

The gateway is the first line, not the only one. Backend services should still validate the forwarded identity headers and never be reachable directly from the public internet (put them on a private network). A single check at the edge is a single point of bypass if a service is ever exposed.

πŸ’‘ Security responsibilities the gateway centralizes

  • Authentication β€” tokens, API keys, or mTLS certificates
  • Authorization β€” role/scope checks per route
  • Rate limiting β€” protect against abuse and DoS
  • TLS termination β€” handle HTTPS at the edge
  • Input validation & IP filtering β€” block malformed or suspicious traffic

Gateway Patterns

Single Gateway

One gateway for the whole system. Simplest to run and gives consistent policy enforcement, but it's a potential bottleneck and single point of failure (mitigate by running multiple replicas behind a load balancer).

graph TD A[All Clients] --> B[API Gateway] B --> C[Service A] B --> D[Service B] B --> E[Service C]

Backend for Frontend (BFF)

The most popular pattern at scale: a dedicated gateway per client type, each tailored to that client's needs. This avoids one bloated gateway trying to serve wildly different clients.

graph TD A[Mobile App] --> B[Mobile BFF] C[Web App] --> D[Web BFF] E[Partner API] --> F[Partner BFF] B --> G[Service A] B --> H[Service B] D --> G D --> H D --> I[Service C] F --> G

πŸ“– Real-world: Netflix

Netflix runs different BFFs per platform: a TV BFF optimized for big screens and playback, a Mobile BFF tuned for data efficiency and battery, a Web BFF with richer account and social features, and a Partner BFF with strict rate limits. Each exposes exactly what its client needs while sharing the same backend services.

PatternBest when…Watch out for…
Single GatewaySmall–mid systems, one main clientBottleneck; can get bloated
Gateway per ServiceTeams own their full stack; specialized policiesDuplicated infra & config
Backend for FrontendSeveral client types with different needsMore gateways to maintain

Build vs. Buy

You rarely need to write a gateway from scratch. For production, a mature product handles the hard edge cases (connection pooling, hot config reloads, observability) for you.

ProductTypeBest for
KongOpen source / commercialCloud-native, plugin-driven, Kubernetes
Amazon API GatewayManaged cloudAWS + Lambda, serverless, pay-per-use
Azure API ManagementManaged cloudAzure systems, developer portals
ApigeeCommercial / cloudEnterprise API programs, monetization
NGINX / OpenRestyOpen sourceRaw performance, Lua scripting
Spring Cloud GatewayOpen source (JVM)Reactive JVM stacks

πŸ’‘ When a custom gateway makes sense

Build your own (with Express, Fastify, or Spring Cloud Gateway) when you need unusual aggregation logic, tight control, or you're learning. Buy/adopt a product when you want battle-tested rate limiting, plugins, and observability without maintaining them yourself. The Express code in this lesson is perfect for understanding how gateways work β€” but reach for Kong or a cloud gateway in production.

Rate Limiting & Caching

Rate limiting

Protect backends from abuse and traffic spikes by capping how many requests a client can make. A shared store like Redis lets the limit hold across multiple gateway replicas:

const { RateLimiterRedis } = require('rate-limiter-flexible');
const Redis = require('ioredis');
const redis = new Redis({ host: 'redis-server', port: 6379 });

// 100 requests / minute for general traffic
const generalLimiter = new RateLimiterRedis({
  storeClient: redis, keyPrefix: 'general', points: 100, duration: 60,
});
// Stricter: 20 / minute for payments
const paymentLimiter = new RateLimiterRedis({
  storeClient: redis, keyPrefix: 'payment', points: 20, duration: 60,
});

function limit(limiter) {
  return async (req, res, next) => {
    const key = req.user?.userId || req.ip;   // per-user if known, else per-IP
    try {
      await limiter.consume(key);
      next();
    } catch (rej) {
      res.set('Retry-After', String(Math.ceil(rej.msBeforeNext / 1000)));
      res.status(429).json({ error: 'Too Many Requests' });
    }
  };
}

app.use(limit(generalLimiter));                 // global cap
app.use('/api/payments', limit(paymentLimiter)); // stricter on payments

πŸ“– Common rate-limit algorithms

Fixed window: N per clock period (simple, but bursty at boundaries). Sliding window: N over a rolling period (smoother). Token bucket: tokens refill at a steady rate; each request spends one (allows short bursts). Leaky bucket: requests drain at a constant rate; overflow is dropped.

Response caching

Cache responses to slow-changing, read-heavy endpoints so repeat requests skip the backend entirely. Cache only idempotent GETs and never user-specific data:

const cache = new Redis({ host: 'redis-server', port: 6379 });

function cacheFor(seconds) {
  return async (req, res, next) => {
    if (req.method !== 'GET') return next();      // never cache writes
    const key = `cache:${req.originalUrl}`;

    const hit = await cache.get(key);
    if (hit) {
      res.set('X-Cache', 'HIT');
      return res.type('application/json').send(hit);
    }

    // Intercept the response body to store it
    const originalSend = res.send.bind(res);
    res.send = (body) => {
      if (res.statusCode >= 200 && res.statusCode < 300) {
        cache.setex(key, seconds, body).catch(console.error);
      }
      res.set('X-Cache', 'MISS');
      return originalSend(body);
    };
    next();
  };
}

// Product listings change slowly β€” cache 5 min
app.use('/api/products', cacheFor(300),
  createProxyMiddleware({ target: 'http://product-service:3002', changeOrigin: true }));
Cache hit versus cache miss at the gateway On a cache hit the gateway responds immediately from Redis; on a miss it calls the backend service, stores the result, and then responds. Client Gateway Redis Cache Backend Service HIT β†’ return MISS β†’ fetch & store
Figure 1 β€” On a HIT the gateway answers from Redis in microseconds; on a MISS it fetches from the backend, caches the result, and returns it.

Observability

The gateway sees every request, which makes it the ideal place to measure and trace your system. Three pillars: metrics, logging, and distributed tracing.

Metrics & logging

Track request rate, latency percentiles (p50/p90/p99), error rate, cache-hit ratio, and rate-limit rejections. Attach a request ID to every call so you can follow one request through the logs:

const { randomUUID } = require('crypto');

// Give every request a traceable ID + structured access log
app.use((req, res, next) => {
  req.id = req.headers['x-request-id'] || randomUUID();
  res.setHeader('X-Request-ID', req.id);
  const start = Date.now();

  res.on('finish', () => {
    console.log(JSON.stringify({
      requestId: req.id,
      method: req.method,
      url: req.originalUrl,
      status: res.statusCode,
      durationMs: Date.now() - start,
      userId: req.user?.userId ?? null,
    }));
  });
  next();
});

Distributed tracing

A single client request may touch many services. Distributed tracing (OpenTelemetry β†’ Jaeger/Zipkin) propagates a shared trace ID across every hop so you can see the whole call tree and find the slow step:

sequenceDiagram participant Client participant Gateway participant ServiceA participant ServiceB Client->>Gateway: Request (trace-id: abc123) Gateway->>ServiceA: (trace-id: abc123, span: def) ServiceA->>ServiceB: (trace-id: abc123, span: ghi) ServiceB-->>ServiceA: Response ServiceA-->>Gateway: Response Gateway-->>Client: Response

The gateway starts the trace and injects the trace headers into every outgoing call, so each service continues the same trace instead of starting a fresh one.

Hands-on Exercise

πŸ‹οΈ Build a Mini API Gateway

Objective: Assemble a working Express gateway for three services with the core features from this lesson.

Services: User (auth, profiles) Β· Product (catalog, inventory) Β· Order (processing, history).

Requirements:

  1. Routing β€” proxy /api/users, /api/products, /api/orders to the right service.
  2. Auth β€” JWT middleware; leave /api/products public but protect users and orders.
  3. Aggregation β€” a /api/product-details/:id endpoint combining product + inventory.
  4. Caching β€” cache product listings for a few minutes.
  5. Logging β€” a request-ID + access-log middleware.
πŸ’‘ Hint

Register middleware in the right order: request-ID/logging first, then global rate limit, then per-route authenticate and cacheFor(...) before the proxy. Put the aggregation route above the catch-all proxies so it isn't swallowed. Use an in-memory Map (or node-cache) if you don't want to run Redis locally.

βœ… Starter skeleton
const express = require('express');
const jwt = require('jsonwebtoken');
const { createProxyMiddleware } = require('http-proxy-middleware');

const app = express();
app.use(express.json());

const SERVICES = {
  user:    'http://localhost:3001',
  product: 'http://localhost:3002',
  order:   'http://localhost:3003',
};
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret';
const cache = new Map();  // demo cache: key -> { body, expires }

// 1) request id + logging
app.use((req, res, next) => {
  req.id = req.headers['x-request-id'] || crypto.randomUUID();
  const start = Date.now();
  res.on('finish', () =>
    console.log(`[${req.id}] ${req.method} ${req.originalUrl} ${res.statusCode} ${Date.now() - start}ms`));
  next();
});

// 2) auth
function authenticate(req, res, next) {
  const h = req.headers.authorization;
  if (!h?.startsWith('Bearer ')) return res.status(401).json({ error: 'No token' });
  try { req.user = jwt.verify(h.slice(7), JWT_SECRET); next(); }
  catch { res.status(401).json({ error: 'Invalid token' }); }
}

// 3) aggregation (register BEFORE the product proxy)
app.get('/api/product-details/:id', async (req, res) => {
  const { id } = req.params;
  const [product, inventory] = await Promise.all([
    fetch(`${SERVICES.product}/products/${id}`).then(r => r.json()),
    fetch(`${SERVICES.product}/inventory/${id}`).then(r => r.json()),
  ]);
  res.json({ product, inStock: inventory.quantity > 0 });
});

// 4) caching for product listings
function cacheFor(seconds) {
  return (req, res, next) => {
    if (req.method !== 'GET') return next();
    const key = req.originalUrl, now = Date.now(), hit = cache.get(key);
    if (hit && hit.expires > now) { res.set('X-Cache', 'HIT'); return res.json(hit.body); }
    const send = res.json.bind(res);
    res.json = (body) => { cache.set(key, { body, expires: now + seconds * 1000 }); return send(body); };
    next();
  };
}

// 5) routes
app.use('/api/products', cacheFor(300),
  createProxyMiddleware({ target: SERVICES.product, changeOrigin: true, pathRewrite: { '^/api/products': '' } }));
app.use('/api/users', authenticate,
  createProxyMiddleware({ target: SERVICES.user, changeOrigin: true, pathRewrite: { '^/api/users': '' } }));
app.use('/api/orders', authenticate,
  createProxyMiddleware({ target: SERVICES.order, changeOrigin: true, pathRewrite: { '^/api/orders': '' } }));

app.listen(8000, () => console.log('API Gateway on :8000'));

🎯 Quick Quiz

Question 1: What is the main advantage of handling authentication at the API Gateway?

Question 2: Which requests are safe to cache at the gateway?

Question 3: In the Backend-for-Frontend (BFF) pattern, you create a separate gateway for each…

Summary & Quiz

πŸŽ‰ Key Takeaways

  • An API Gateway is the single front door to your services β€” routing, aggregating, securing, throttling, and observing all traffic.
  • Centralized auth verifies callers once at the edge and forwards trusted identity β€” but keep defense in depth in the services.
  • Rate limiting and caching protect backends and cut latency; cache only idempotent, non-personalized GETs.
  • Choose a topology: single gateway, gateway-per-service, or Backend-for-Frontend (per client type).
  • Build a custom gateway to learn and for special logic; adopt Kong, AWS API Gateway, or NGINX in production.

πŸ“š Further Reading

πŸš€ What's Next?

You've centralized the front door for your services. Next we move beyond always-on servers entirely with Serverless Computing Principles β€” running code as functions that scale to zero and bill per invocation.

πŸŽ‰ Excellent work!

You can now design and build the gateway that fronts an entire microservices system.