Published on Monday, 19 May 2025

Tags: microservices1 api-gateway1 architecture1 backend1

Why Your Microservices Need an API Gateway (And Why You Probably Got It Wrong)

A practical guide to API gateways in microservice architectures — what they solve, what they break, and how to avoid the most common implementation mistakes senior devs make.


Table of Content

  • What an API Gateway Actually Does
  • The Two Most Common Mistakes
  • Mistake 1: The Leaky Gateway
  • Mistake 2: The Single Point of Pain
  • When You Shouldn't Use an API Gateway
  • The One Pattern That Saved My Sanity
  • The Bottom Line
  • I've lost count of how many times I've seen a team slap an API gateway in front of their microservices and call it a day. The gateway becomes a dumping ground for every cross-cutting concern: auth, rate limiting, logging, request transformation, response caching, protocol translation, and — my personal favorite — business logic that should have stayed in the service.

    Let's be clear: an API gateway is not a silver bullet. It's a sharp tool that solves a specific set of problems, but it introduces its own. I learned this the hard way after untangling a monolithic gateway that had become the single most critical failure point in a production system. Here's what I wish someone had told me before we started.

    What an API Gateway Actually Does

    An API gateway sits between your clients and your microservices. It's the single entry point for all external requests. The core responsibilities are:

    • Routing: Forward requests to the correct internal service based on path, headers, or query parameters.
    • Authentication and Authorization: Validate tokens, enforce policies before the request reaches your service.
    • Rate Limiting: Protect downstream services from traffic spikes.
    • Request/Response Transformation: Adapt payloads between client expectations and internal contracts.
    • Aggregation: Combine results from multiple services into a single response.
    • Protocol Translation: Convert between HTTP, WebSocket, gRPC, or message queues.

    Here's what it should not do: implement business logic, store state, or become a proxy for database queries.

    The Two Most Common Mistakes

    Mistake #1: The Leaky Gateway

    I once worked on a system where the gateway was doing field-level validation, enriching payloads with data from three different databases, and even running a cron job to clean up stale sessions. The gateway had become a microservice itself — an untestable, unscaleable monolith that was harder to deploy than any of the actual services.

    The rule of thumb: if your gateway needs a database migration, you've already lost.

    // ❌ Bad: Gateway doing business logic and database calls
    app.post('/orders', async (req, res) => {
      const user = await db.users.findById(req.body.userId);
      if (!user.active) return res.status(403).send('Inactive user');
      
      const inventory = await db.products.checkStock(req.body.productId);
      if (inventory < req.body.quantity) return res.status(400).send('Out of stock');
      
      // Gateway should not be calculating discounts
      const discount = user.tier === 'premium' ? 0.1 : 0;
      const total = req.body.quantity * req.body.price * (1 - discount);
      
      // Forward to order service
      const order = await axios.post('http://order-service/orders', {
        ...req.body,
        total,
        appliedDiscount: discount
      });
      
      res.json(order.data);
    });

    The fix is simple: the gateway should validate that the request is structurally sound (required fields, correct types), then forward it. Let the order service handle business rules, stock checks, and pricing.

    // ✅ Good: Gateway only validates structure and forwards
    app.post('/orders', async (req, res) => {
      const { error } = validateOrderSchema(req.body);
      if (error) return res.status(400).json({ error: error.details });
      
      // Forward with auth context attached
      const response = await axios.post('http://order-service/orders', req.body, {
        headers: { 'x-user-id': req.user.id, 'x-user-tier': req.user.tier }
      });
      
      res.status(response.status).json(response.data);
    });

    Mistake #2: The Single Point of Pain

    If your gateway goes down, everything goes down. I've seen teams put all their traffic through a single gateway instance with no redundancy, then wonder why a deployment window caused a 45-minute outage.

    The solution is not to avoid a gateway — it's to design for failure. Use a load balancer in front of multiple gateway instances. Implement circuit breakers so that if a downstream service is slow, the gateway fails fast instead of hanging. And for god's sake, don't put state in the gateway.

    version: '3.8'
    services:
      gateway:
        image: myapp/gateway:latest
        deploy:
          replicas: 3
          resources:
            limits:
              cpus: '0.5'
              memory: 256M
        environment:
          - REDIS_URL=redis://redis:6379
          - RATE_LIMIT=100
        depends_on:
          - redis
        ports:
          - "80:8080"

    Notice the environment variables for configuration. Your gateway should be stateless and configurable via environment or a centralized config service, not hardcoded.

    When You Shouldn't Use an API Gateway

    Not every architecture needs one. If you have:

    • Fewer than 3 microservices
    • All services speaking the same protocol (usually HTTP/JSON)
    • Simple routing that can be handled by a reverse proxy (nginx, Caddy)
    • No need for cross-cutting concerns like rate limiting or auth at the edge

    ...then you might be overengineering it. Start with a reverse proxy and add a gateway when the pain becomes real. Premature gateway adoption is a form of accidental complexity.

    The One Pattern That Saved My Sanity

    After years of trial and error, the pattern that consistently works is thin gateway, thick services. The gateway handles edge concerns (auth, TLS termination, rate limiting, basic validation). The services handle everything else.

    Here's the concrete implementation I use now:

    const express = require('express');
    const rateLimit = require('express-rate-limit');
    const { authenticate } = require('./middleware/auth');
    const { validateSchema } = require('./middleware/validation');
    const { proxy } = require('./lib/proxy');
    
    const app = express();
    
    // Edge concerns only
    app.use(rateLimit({ windowMs: 15 * 60 * 1000, max: 100 }));
    app.use(authenticate);
    app.use(express.json());
    
    // Each route is a thin proxy
    app.post('/api/orders', validateSchema(orderSchema), proxy('http://order-service'));
    app.get('/api/products/:id', proxy('http://catalog-service'));
    app.post('/api/payments', validateSchema(paymentSchema), proxy('http://payment-service'));
    
    // Health check — don't proxy this
    app.get('/health', (req, res) => res.json({ status: 'ok' }));
    
    app.listen(8080);

    The proxy function is a generic handler that forwards the request and returns the response. No business logic, no database calls, no state.

    The Bottom Line

    An API gateway is a tool for managing edge complexity, not a place to dump your cross-cutting concerns. Keep it thin. Keep it stateless. And if you ever find yourself writing business logic in a gateway middleware, step away from the keyboard and think about what you're doing.

    Your future self — and the poor developer who has to debug a production incident at 3 AM — will thank you.