Zum Inhalt springen

API-Gateway-Muster für Microservices

Wie API-Gateways Routing, Authentifizierung, Rate Limiting und Antwortaggregation handhaben – und wie du das richtige baust oder auswählst.

5 Min. Lesezeit
Architekturdiagramm, das ein API-Gateway zeigt, das Anfragen an mehrere Backend-Dienste weiterleitet

Microservices erzeugen ein Routing-Problem. Zehn Services bedeuten zehn verschiedene Endpunkte, zehn Authentifizierungsprüfungen, zehn Rate Limiter. Clients sollten diese Komplexität nicht verwalten müssen. Ein API-Gateway sitzt zwischen Clients und Services und bietet einen einzigen Einstiegspunkt, der querschnittliche Belange übernimmt – Routing, Authentifizierung, Rate Limiting und Antworttransformation.

Das Gateway-Muster gibt es in mehreren Varianten. Welche die richtige ist, hängt davon ab, ob du einfaches Routing oder komplexe Antwortaggregation brauchst.

Das Reverse-Proxy-Gateway

Das einfachste Gateway ist ein Reverse Proxy. Es leitet Anfragen anhand des URL-Pfads an den richtigen Service weiter und reicht die Antwort unverändert durch.

nginxnginx
# Nginx as a basic API gateway
upstream user-service {
    server user-service:3001;
    server user-service:3002;
}
 
upstream order-service {
    server order-service:3003;
}
 
upstream product-service {
    server product-service:3004;
}
 
server {
    listen 80;
 
    # Route based on path prefix
    location /api/users {
        proxy_pass http://user-service;
        proxy_set_header Host $host;
        proxy_set_header X-Request-ID $request_id;
    }
 
    location /api/orders {
        proxy_pass http://order-service;
        proxy_set_header Host $host;
        proxy_set_header X-Request-ID $request_id;
    }
 
    location /api/products {
        proxy_pass http://product-service;
        proxy_set_header Host $host;
        proxy_set_header X-Request-ID $request_id;
    }
 
    # Health check endpoint
    location /health {
        return 200 '{"status": "ok"}';
        add_header Content-Type application/json;
    }
}

Das funktioniert für einfache Architekturen. Aber es kann keine Antworten mehrerer Services aggregieren, keine Payloads transformieren und keine eigene Authentifizierungslogik ausführen. Dafür brauchst du ein Gateway auf Anwendungsebene.

Gateway auf Anwendungsebene

Ein eigenes Gateway, geschrieben in der Sprache deiner Anwendung, übernimmt komplexes Routing, Antwortaggregation und Middleware-Ketten.

tstypescript
// src/gateway.ts — Express-based API gateway
import express from 'express';
import { createProxyMiddleware } from 'http-proxy-middleware';
 
const app = express();
 
// Middleware chain: logging → auth → rate limit → proxy
app.use(requestLogger);
app.use(authMiddleware);
app.use(rateLimitMiddleware);
 
// Service routing
const services: Record<string, string> = {
  '/api/users': 'http://user-service:3001',
  '/api/orders': 'http://order-service:3003',
  '/api/products': 'http://product-service:3004',
  '/api/notifications': 'http://notification-service:3005',
};
 
for (const [path, target] of Object.entries(services)) {
  app.use(
    path,
    createProxyMiddleware({
      target,
      changeOrigin: true,
      pathRewrite: { [`^${path}`]: '' },
      onError: (err, req, res) => {
        console.error(`Proxy error for ${path}:`, err.message);
        res.status(502).json({ error: 'Service unavailable' });
      },
    })
  );
}
tstypescript
// Authentication middleware at the gateway level
// Services behind the gateway trust the X-User-Id header
async function authMiddleware(
  req: Request,
  res: Response,
  next: NextFunction
) {
  const token = req.headers.authorization?.replace('Bearer ', '');
 
  if (!token) {
    return res.status(401).json({ error: 'Missing authentication token' });
  }
 
  try {
    const payload = await verifyJwt(token);
    req.headers['x-user-id'] = payload.sub;
    req.headers['x-user-role'] = payload.role;
    next();
  } catch {
    return res.status(401).json({ error: 'Invalid token' });
  }
}

Wenn die Authentifizierung im Gateway erledigt wird, können die einzelnen Services die Token-Verifizierung komplett überspringen. Sie vertrauen dem Header X-User-Id, weil nur das Gateway ihn setzen kann.

Backend for Frontend (BFF)

Verschiedene Clients brauchen verschiedene API-Formen. Eine mobile App braucht schlanke Payloads. Ein Web-Dashboard braucht reichhaltige, aggregierte Daten. Das BFF-Muster erstellt für jeden Client-Typ ein eigenes Gateway.

tstypescript
// web-bff/src/routes/dashboard.ts — Web BFF aggregates multiple services
app.get('/api/dashboard', async (req, res) => {
  const userId = req.headers['x-user-id'] as string;
 
  // Fetch from multiple services in parallel
  const [user, orders, notifications, analytics] = await Promise.all([
    userService.getProfile(userId),
    orderService.getRecent(userId, { limit: 5 }),
    notificationService.getUnread(userId),
    analyticsService.getSummary(userId),
  ]);
 
  // Aggregate into a single response optimized for the web dashboard
  res.json({
    user: {
      name: user.name,
      avatar: user.avatarUrl,
      memberSince: user.createdAt,
    },
    recentOrders: orders.map((o) => ({
      id: o.id,
      total: o.total,
      status: o.status,
      date: o.createdAt,
    })),
    unreadNotifications: notifications.count,
    analytics: {
      totalSpent: analytics.totalSpent,
      ordersThisMonth: analytics.monthlyOrders,
    },
  });
});
tstypescript
// mobile-bff/src/routes/dashboard.ts — Mobile BFF returns minimal data
app.get('/api/dashboard', async (req, res) => {
  const userId = req.headers['x-user-id'] as string;
 
  // Mobile only needs user name and notification count
  const [user, notifications] = await Promise.all([
    userService.getProfile(userId),
    notificationService.getUnread(userId),
  ]);
 
  res.json({
    name: user.name,
    avatar: user.avatarUrl,
    unread: notifications.count,
  });
});

Zwei BFFs, dieselben Backend-Services. Das Web-BFF macht vier Service-Aufrufe und liefert ein umfangreiches Objekt. Das Mobile-BFF macht zwei Aufrufe und liefert einen minimalen Payload. Kein Client muss mehrere API-Anfragen stellen.

Rate Limiting am Gateway

Das Gateway ist der natürliche Ort für Rate Limiting, weil es den gesamten eingehenden Traffic sieht, bevor er irgendeinen Service erreicht.

tstypescript
// src/middleware/rate-limit.ts
import { RateLimiterRedis } from 'rate-limiter-flexible';
import Redis from 'ioredis';
 
const redis = new Redis(process.env.REDIS_URL);
 
// Different limits for different endpoint tiers
const limiters = {
  default: new RateLimiterRedis({
    storeClient: redis,
    keyPrefix: 'rl:default',
    points: 100,      // 100 requests
    duration: 60,      // per 60 seconds
  }),
  auth: new RateLimiterRedis({
    storeClient: redis,
    keyPrefix: 'rl:auth',
    points: 5,         // 5 attempts
    duration: 900,      // per 15 minutes
  }),
  heavy: new RateLimiterRedis({
    storeClient: redis,
    keyPrefix: 'rl:heavy',
    points: 10,        // 10 requests
    duration: 60,      // per 60 seconds
  }),
};
 
function getRateLimiter(path: string): RateLimiterRedis {
  if (path.startsWith('/api/auth')) return limiters.auth;
  if (path.startsWith('/api/reports')) return limiters.heavy;
  return limiters.default;
}
 
export async function rateLimitMiddleware(
  req: Request,
  res: Response,
  next: NextFunction
) {
  const limiter = getRateLimiter(req.path);
  const key = req.headers['x-user-id'] as string || req.ip;
 
  try {
    const result = await limiter.consume(key);
    res.set('X-RateLimit-Remaining', String(result.remainingPoints));
    res.set('X-RateLimit-Reset', String(
      Math.ceil(result.msBeforeNext / 1000)
    ));
    next();
  } catch (rateLimitRes: any) {
    res.set('Retry-After', String(
      Math.ceil(rateLimitRes.msBeforeNext / 1000)
    ));
    res.status(429).json({
      error: 'Too many requests',
      retryAfter: Math.ceil(rateLimitRes.msBeforeNext / 1000),
    });
  }
}

Auth-Endpunkte bekommen strikte Limits (5 Versuche pro 15 Minuten), um Brute Force zu verhindern. Endpunkte für die Berichtserstellung bekommen moderate Limits (10/Min.), um teure Abfragen zu schützen. Alles andere bekommt einen großzügigen Standardwert.

Transformation von Anfragen und Antworten

Gateways können Anfragen und Antworten transformieren, um Abwärtskompatibilität zu wahren oder Services zu vereinheitlichen.

tstypescript
// Version transformation — old clients send v1 format, service expects v2
app.post('/api/v1/orders', async (req, res) => {
  // Transform v1 request to v2 format
  const v2Body = {
    items: req.body.products.map((p: any) => ({
      productId: p.id,
      quantity: p.qty,        // v1 used 'qty', v2 uses 'quantity'
      unitPrice: p.price,     // v1 used 'price', v2 uses 'unitPrice'
    })),
    shippingAddress: {
      ...req.body.address,
      country: req.body.address.countryCode,  // field rename
    },
  };
 
  const response = await orderService.create(v2Body);
 
  // Transform v2 response back to v1 format
  res.json({
    orderId: response.id,
    total: response.totalAmount,
    status: response.orderStatus,
  });
});
tstypescript
// Response filtering — remove internal fields before sending to clients
function stripInternalFields(data: Record<string, unknown>): Record<string, unknown> {
  const internal = ['internalId', 'tenantId', 'auditLog', 'debugInfo'];
  const filtered = { ...data };
  for (const field of internal) {
    delete filtered[field];
  }
  return filtered;
}
 
app.use('/api', (req, res, next) => {
  const originalJson = res.json.bind(res);
  res.json = (body: any) => {
    const cleaned = Array.isArray(body)
      ? body.map(stripInternalFields)
      : stripInternalFields(body);
    return originalJson(cleaned);
  };
  next();
});

Health Checks und Circuit Breaking

Das Gateway sollte keinen Traffic an ungesunde Services weiterleiten. Health Checks und Circuit Breaker verhindern kaskadierende Ausfälle.

tstypescript
// src/health/service-registry.ts
interface ServiceHealth {
  url: string;
  healthy: boolean;
  lastCheck: Date;
  consecutiveFailures: number;
}
 
const services = new Map<string, ServiceHealth>();
 
async function checkServiceHealth(name: string, url: string): Promise<void> {
  const service = services.get(name) || {
    url,
    healthy: true,
    lastCheck: new Date(),
    consecutiveFailures: 0,
  };
 
  try {
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), 3000);
    await fetch(`${url}/health`, { signal: controller.signal });
    clearTimeout(timeout);
 
    service.healthy = true;
    service.consecutiveFailures = 0;
  } catch {
    service.consecutiveFailures++;
    // Mark unhealthy after 3 consecutive failures
    if (service.consecutiveFailures >= 3) {
      service.healthy = false;
    }
  }
 
  service.lastCheck = new Date();
  services.set(name, service);
}
 
// Check all services every 10 seconds
setInterval(() => {
  for (const [name, svc] of services) {
    checkServiceHealth(name, svc.url);
  }
}, 10000);

Wenn ein Service ungesund ist, gibt das Gateway sofort ein 503 zurück, statt auf einen Timeout zu warten. Clients bekommen einen schnellen Fehler und können es erneut versuchen oder eine eingeschränkte Ansicht anzeigen.

Die wichtigsten Erkenntnisse

  1. Beginne mit einem Reverse Proxy — Nginx erledigt einfaches pfadbasiertes Routing ohne eigenen Code
  2. Nutze ein Anwendungs-Gateway für die Aggregation — wenn Clients Daten mehrerer Services in einem Aufruf brauchen
  3. Das BFF-Muster bedient verschiedene Clients — Mobile und Web erhalten optimierte API-Formen von ihrem eigenen Gateway
  4. Zentralisiere Auth und Rate Limiting — einmal im Gateway behandeln, nicht in jedem Service
  5. Transformiere für Abwärtskompatibilität — das Gateway kann zwischen API-Versionen übersetzen, ohne die Services zu ändern
  6. Prüfe die Gesundheit der Backend-Services — höre auf, an ungesunde Services zu routen, bevor Clients Fehler erleben
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX