Since 02-02 we have been talking about an API Gateway on port 8080 "in front of" the services: it is the piece that makes the strangler fig possible (the web keeps calling a single address while routes move from the monolith to the services) and the only door that the web, the mobile app and external partners will ever see. In 03-03 we also left two questions open: where the GraphQL that aggregates Orders and Catalog for the mobile app lives, and why we talk about a backend for frontend. This lesson answers both.

We will look at which problems a gateway solves (one entry point, not exposing the 300x ports, and the cross-cutting responsibilities: routing, delegated authentication, rate limiting, CORS, TLS termination, aggregation, logging), what it should not do, TechCorp's gateway route table and its role in the strangler fig, the implementation options, a minimal but complete gateway in Express with http-proxy-middleware explained step by step, the equivalent declarative configuration in Traefik, the Backend for Frontend pattern with a mobile BFF that composes GET /orders/{id} + GET /products?ids=, and the gateway's risks. How the gateway finds the services (discovery and load balancing) belongs to 03-05, authentication in detail to 07-01 and the gateway's deployment to module 5.

Contents

  1. The problem: many services, a single client
  2. Cross-cutting responsibilities of the gateway
  3. What a gateway must NOT do
  4. TechCorp's gateway routes and the strangler fig
  5. Implementation options
  6. A minimal gateway in Express with http-proxy-middleware
  7. The same configuration in Traefik (declarative YAML)
  8. The Backend for Frontend pattern
  9. A mobile BFF that composes order and products
  10. Gateway risks

  1. The problem: many services, a single client

Without a gateway, TechCorp's web would have to know that products live at catalog-service:3001, orders at orders-service:3002 and customers at customers-service:3004. That brings five immediate problems:

  1. Client coupling to the topology. Every time a service changes host, is split or merged, the web has to be redeployed and a new version of the mobile app published (and then you wait for users to update).
  2. Attack surface. Exposing six ports to the Internet means exposing six surfaces to authenticate, patch and monitor. Inventory and Payments, moreover, must not be reachable from outside under any circumstances.
  3. Duplication of cross-cutting concerns. Authentication, CORS, request limits, TLS, access logging: either every service does it (six times, with six slightly different versions) or someone in front of all of them does.
  4. Many network round trips. A screen that needs data from three services makes three requests from the phone, each with its latency and its handshake.
  5. The strangler fig is impossible. If the web talks directly to the monolith, moving /products to the new service requires changing the web. With a gateway, you change one routing rule and nobody else notices.

An API Gateway is a server that receives all external requests and forwards them to the appropriate internal service, applying the common policies along the way. It is a reverse proxy with judgment.

flowchart LR
    W[Web] --> G
    M[Mobile app] --> G
    S[Partners / ERP] --> G
    G[API Gateway :8080]
    G -- "/api/products/*" --> C[catalog-service:3001]
    G -- "/api/orders/*" --> P[orders-service:3002]
    G -- "/api/customers/*" --> K[customers-service:3004]
    G -- "the rest (for now)" --> MO[techcorp-shop monolith:3000]
    I[inventory-service:3006]:::internal
    PA[payments-service:3003]:::internal
    N[notifications-service:3005]:::internal
    classDef internal stroke-dasharray: 5 5

The services with a dashed outline have no route in the gateway: they only talk through events (03-02) or receive internal calls.

  1. Cross-cutting responsibilities of the gateway

Responsibility What the gateway does Why here and not in every service
Routing Maps public routes to internal services (/api/orders/*orders-service:3002), rewriting the prefix It is its reason for being; it enables the strangler fig
Delegated authentication Validates the JWT (signature, expiry, Keycloak issuer) and rejects with 401 before touching any service; propagates the identity in headers or the token itself A single place to keep up to date with keys and algorithms; services receive already-authenticated requests (although in 07-01 we will see they must verify too)
Rate limiting Limits requests per IP, per client or per token (429 + Retry-After) Protects every service at once; a saturated service cannot protect itself
CORS Answers the preflight OPTIONS requests and adds Access-Control-Allow-* The browser only sees one origin (the gateway); configuring CORS in six services guarantees inconsistency
TLS termination Receives HTTPS from outside and speaks HTTP (or mTLS with the mesh from 05-05) inward A single certificate to renew; services do not manage private keys
Aggregation Composes several internal responses into one (with caveats: section 8) Saves round trips for the mobile client
Logging and access metrics Records method, route, status code, latency and X-Request-Id of every request Single view of incoming traffic; the entry point to 06-01
Correlation Generates X-Request-Id if absent and propagates it It is the first hop: if it does not do it, nobody else can
Timeouts Cuts off requests a service does not answer within N seconds (504) Prevents a slow service from tying up client connections
Light transformation Rewrite routes, add/remove headers, compress Adaptation between the public and the internal

  1. What a gateway must NOT do

In 02-01 we formulated "smart endpoints, dumb pipes": the intelligence lives in the services; the pipes (broker, gateway) only move messages. Applied to the gateway:

  • No business logic. The gateway does not compute totals, does not validate that an order has lines, does not decide whether a customer may order. If it does, it becomes a mini-monolith that every team must touch for any change, and the worst organizational bottleneck.
  • It does not access the services' databases. Not even for "a quick query".
  • It does not transform business payloads (renaming price to precio for one client). That is a BFF (section 8) or an API version (03-06).
  • It does not orchestrate sagas. The order flow lives in the services and in RabbitMQ.
  • It does not replace each service's security. The gateway authenticating does not exempt Orders from checking that the order belongs to whoever is asking for it (07-01: defense in depth).

The litmus test: if changing a business rule requires redeploying the gateway, the rule is in the wrong place.

  1. TechCorp's gateway routes and the strangler fig

Route table in the current state of the plan (Catalog, Orders and Customers already extracted or being extracted):

Public route (gateway :8080) Internal target Rewrite Authentication Notes
GET /api/products, GET /api/products/{id} catalog-service:3001 /api/products/products Public (anonymous read) First migrated route (02-02); 30 s cache allowed
POST /api/orders, GET /api/orders, GET /api/orders/{id}, POST /api/orders/{id}/cancellation orders-service:3002 /api/orders/orders JWT required Requires Idempotency-Key on POST
GET /api/customers/{id}, PUT /api/customers/{id}/address customers-service:3004 /api/customers/customers JWT; only the customer themselves or admin
/api/graphql (mobile) bff-mobile:3010 none JWT Section 9
/api/* (everything else) techcorp-shop monolith:3000 none as per the monolith Routes not yet migrated: they are being drained
/health The gateway itself Public For the load balancer in front
(no route) inventory-service:3006, payments-service:3003, notifications-service:3005 Not exposed. They are reached only through events or from other services

The strangler fig is visible in the last /api/* row: at the start of the project everything went to the monolith. The Platform team added the /api/products/* rule when Catalog was ready; it will add /api/orders/* when Orders is, and so on until the monolith rule receives no traffic and is deleted. Each step is a gateway configuration change, reversible in seconds: if the new Catalog fails, /api/products/* is pointed back at the monolith.

Two conventions: public routes carry the /api/ prefix to tell them apart from the web's static assets, and the gateway strips that prefix before forwarding, so that services expose /products, /orders, exactly as in the contracts from 03-01, without knowing there is a gateway in front.

  1. Implementation options

Option What it is Advantages Drawbacks Fit at TechCorp
Kong Gateway on NGINX/OpenResty with plugins (auth, rate limit, transformations) and declarative or API-driven configuration Very complete, plugin ecosystem, DB-less declarative mode One more piece to operate; medium curve Good medium-term option
NGINX Classic reverse proxy configured with nginx.conf Ubiquitous, blazing fast, stable Static configuration, no native discovery; rate limit and auth require modules Simple but rigid for the strangler fig
Traefik Container-native reverse proxy; discovers services from Docker/Kubernetes and is configured with YAML or labels Natural integration with Docker Compose and Kubernetes (Ingress), middlewares for rate limit, headers, auth Fewer plugins than Kong TechCorp's choice for production
Spring Cloud Gateway Programmable gateway in Java/Spring Very integrated in the Spring ecosystem TechCorp does not use Java Mention only
Home-grown gateway in Node.js (http-proxy-middleware or express-http-proxy) An Express app acting as a proxy Full control, same language as everything else, ideal for learning and for composition logic You must implement and maintain what Kong/Traefik give you for free; risk of putting business logic in For this lesson and for the BFFs

TechCorp's strategy: understand the gateway by building a minimal one in Node.js (section 6), operate a declarative one in production (Traefik, section 7; in Kubernetes it will act as the Ingress, 05-02), and program only the BFFs, which do have legitimate composition logic.

  1. A minimal gateway in Express with http-proxy-middleware

npm install express http-proxy-middleware express-rate-limit cors
// gateway/server.js
const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');
const rateLimit = require('express-rate-limit');
const cors = require('cors');
const { randomUUID } = require('node:crypto');

const app = express();

// 1. Internal targets. They come from configuration (04-03); the stable names are justified in 03-05.
const TARGETS = {
  catalog:   process.env.CATALOG_URL    ?? 'http://catalog-service:3001',
  orders:    process.env.ORDERS_URL     ?? 'http://orders-service:3002',
  customers: process.env.CUSTOMERS_URL  ?? 'http://customers-service:3004',
  bffMobile: process.env.BFF_MOBILE_URL ?? 'http://bff-mobile:3010',
  monolith:  process.env.MONOLITH_URL   ?? 'http://techcorp-shop:3000'
};

// 2. Correlation: if the client does not bring an X-Request-Id, we generate it here. Everything that
//    happens afterwards (gateway logs, header to the service, response to the client) carries it.
app.use((req, res, next) => {
  req.requestId = req.get('X-Request-Id') ?? `req-${randomUUID()}`;
  res.set('X-Request-Id', req.requestId);
  next();
});

// 3. CORS: only TechCorp's origins. The browser sends OPTIONS (preflight) and this
//    middleware answers; the internal services know nothing about CORS.
app.use(cors({
  origin: ['https://shop.techcorp.example', 'https://admin.techcorp.example'],
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization', 'Idempotency-Key', 'If-Match', 'X-Request-Id'],
  exposedHeaders: ['Location', 'ETag', 'X-Request-Id', 'Retry-After'],
  maxAge: 600
}));

// 4. Rate limiting: 300 requests per minute per IP across the whole API. With several gateway
//    replicas a shared store (Redis) would be needed; in-memory is enough for learning.
app.use('/api', rateLimit({
  windowMs: 60_000,
  limit: 300,
  standardHeaders: 'draft-7',      // adds RateLimit-* and Retry-After
  legacyHeaders: false,
  message: { type: 'about:blank', title: 'Too many requests', status: 429, code: 'RATE_LIMIT_EXCEEDED' }
}));

// 5. Minimal access log (06-01 will replace it with structured logs)
app.use((req, res, next) => {
  const start = Date.now();
  res.on('finish', () => {
    console.log(JSON.stringify({ requestId: req.requestId, method: req.method, route: req.originalUrl, status: res.statusCode, ms: Date.now() - start }));
  });
  next();
});

// 6. Delegated authentication (skeleton). The real validation of the Keycloak JWT is covered in 07-01;
//    here we only check that the header exists for protected routes.
function requireToken(req, res, next) {
  const auth = req.get('Authorization') ?? '';
  if (!auth.startsWith('Bearer ')) {
    return res.status(401).type('application/problem+json').json({ type: 'about:blank', title: 'Not authenticated', status: 401, code: 'UNAUTHENTICATED' });
  }
  next();
}

// 7. Proxy factory: same configuration for all, only target and rewrite change
function proxyTo(target, { stripPrefix }) {
  return createProxyMiddleware({
    target,
    changeOrigin: true,                       // sets the target's Host on the forwarded request
    pathRewrite: stripPrefix ? { [`^${stripPrefix}`]: '' } : undefined, // /api/orders/x → /orders/x
    proxyTimeout: 5_000,                      // if the service does not respond within 5 s → 504 to the client
    timeout: 10_000,                          // maximum time for the incoming connection
    on: {
      proxyReq: (proxyReq, req) => {
        proxyReq.setHeader('X-Request-Id', req.requestId);          // propagate correlation
        proxyReq.setHeader('X-Forwarded-Prefix', stripPrefix ?? ''); // the service can build absolute Location headers if it wants
      },
      error: (err, req, res) => {
        // The service is down or closed the connection: 503 in RFC 7807 format, without leaking details
        if (!res.headersSent) {
          res.status(503).type('application/problem+json').json({
            type: 'about:blank', title: 'Service unavailable', status: 503,
            code: 'DEPENDENCY_UNAVAILABLE', detail: `Please retry later (ref ${req.requestId})`
          });
        }
      }
    }
  });
}

// 8. Route table. ORDER matters: Express evaluates top to bottom, and the last one is the catch-all to the monolith.
app.use('/api/products',  proxyTo(TARGETS.catalog, { stripPrefix: '/api' }));
app.use('/api/orders',    requireToken, proxyTo(TARGETS.orders,    { stripPrefix: '/api' }));
app.use('/api/customers', requireToken, proxyTo(TARGETS.customers, { stripPrefix: '/api' }));
app.use('/api/graphql',   requireToken, proxyTo(TARGETS.bffMobile, { stripPrefix: '/api' }));
app.use('/api',           proxyTo(TARGETS.monolith, { stripPrefix: null })); // strangler fig: whatever is not migrated

// 9. Health of the gateway itself (03-05 explains liveness/readiness)
app.get('/health', (_req, res) => res.json({ status: 'ok' }));

app.listen(8080, () => console.log('API Gateway listening on 8080'));

Explanation of the decisions:

  • There is no express.json(). The gateway does not need to parse bodies: it forwards them as is, as a stream. Parsing them would cost CPU and break the transmission of large bodies. When in a real project a global express.json() gets added "out of habit", http-proxy-middleware stops forwarding the body and every POST arrives empty: it is the classic mistake.
  • pathRewrite is the translation between the public contract (/api/orders) and the internal one (/orders). The services do not know there is a prefix.
  • proxyTimeout: 5000 is the minimum good practice we talk about in every lesson; without it, a hung Catalog holds gateway connections until they run out. Retries and the circuit breaker belong to 06-03.
  • The order of the routes implements the strangler fig: specific ones first, the catch-all to the monolith last. Migrating a route means adding one line above the catch-all.
  • requireToken is a skeleton: in 07-01 it will verify signature, expiry and audience of the Keycloak JWT and pass the identity on to the service.
  • The proxy's error responds in the same RFC 7807 format from 03-01, with the requestId so that support can look it up in the logs.

Manual test of our usual order through the gateway (with Orders listening on 3002):

curl -i -X POST http://localhost:8080/api/orders \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 7f3c9a2e-1b4d-4e8f-9c21-5a6b7c8d9e0f" \
  -d '{"customerId":"c-1024","lines":[{"productId":"p-501","quantity":1},{"productId":"p-777","quantity":2}],"shippingAddress":{"street":"Gran Vía 12","postalCode":"28013","city":"Madrid","country":"ES"}}'

It should return 202 Accepted, Location: /orders/ord-88213 (the service builds the internal path; the gateway could rewrite it to /api/orders/... with an onProxyRes, or the client adds the prefix; TechCorp opts for the client knowing the /api prefix), and X-Request-Id.

  1. The same configuration in Traefik (declarative YAML)

In production, maintaining a hand-written gateway does not pay off: Traefik does the same with YAML. Its model has three concepts: routers (which requests I capture: host and path rules), middlewares (what I do to them: strip prefix, limit, headers) and services (where I send them, with load balancing). Dynamic configuration equivalent to the gateway above:

# traefik/dynamic.yml
http:
  routers:
    products:
      rule: "PathPrefix(`/api/products`)"
      entryPoints: [web]
      middlewares: [strip-api, global-limit, request-id]
      service: catalog
    orders:
      rule: "PathPrefix(`/api/orders`)"
      entryPoints: [web]
      middlewares: [strip-api, global-limit, request-id, require-token]
      service: orders
    customers:
      rule: "PathPrefix(`/api/customers`)"
      entryPoints: [web]
      middlewares: [strip-api, global-limit, request-id, require-token]
      service: customers
    monolith:
      rule: "PathPrefix(`/api`)"
      entryPoints: [web]
      priority: 1                        # the lowest: only if none of the previous ones matches (strangler fig)
      middlewares: [global-limit, request-id]
      service: monolith

  middlewares:
    strip-api:
      stripPrefix:
        prefixes: ["/api"]
    global-limit:
      rateLimit:
        average: 300                     # requests per minute (period)
        period: 1m
        burst: 50
    request-id:
      headers:
        customRequestHeaders:
          X-Forwarded-Prefix: "/api"     # Traefik already adds X-Request-Id if the accessLog is enabled with that field; here only the prefix
    require-token:
      forwardAuth:                       # delegates validation to an auth service (07-01)
        address: "http://auth-gateway:3020/verify"
        authResponseHeaders: ["X-User-Id", "X-Roles"]

  services:
    catalog:
      loadBalancer:
        servers:
          - url: "http://catalog-service:3001"
        healthCheck: { path: /health/ready, interval: 10s }
    orders:
      loadBalancer:
        servers:
          - url: "http://orders-service:3002"
    customers:
      loadBalancer:
        servers:
          - url: "http://customers-service:3004"
    monolith:
      loadBalancer:
        servers:
          - url: "http://techcorp-shop:3000"

And the minimal static configuration (ports, and in production TLS with Let's Encrypt, whose certificate side is covered in 07-02):

# traefik/traefik.yml
entryPoints:
  web:
    address: ":8080"
providers:
  file:
    filename: /etc/traefik/dynamic.yml
    watch: true                          # reloads when the file changes: migrating a route requires no restart
accessLog: {}

Correspondence with the Express code: each app.use('/api/x', ...) is a router + stripPrefix; express-rate-limit is the rateLimit middleware; requireToken is forwardAuth; the catch-all to the monolith is the router with priority: 1; and TARGETS are the services. When in 05-02 we deploy to Kubernetes, these services will point to the cluster's Service objects and Traefik will discover them on its own (03-05 explains how).

  1. The Backend for Frontend pattern

A gateway serves all clients alike. But the desktop web, the mobile app and a partner's ERP need different things: the app wants small, composed responses (one screen, one request); the web tolerates several calls and wants caching; the partner wants stable, documented REST. If the gateway tries to please everyone with transformations and aggregations, it bloats (risk from section 10).

The Backend for Frontend (BFF) pattern proposes one facade service per type of client, owned by the team that builds that client: bff-mobile is maintained by the app team; bff-web, by the web team. Each BFF composes and adapts the services' APIs to the needs of its front end, and only that one.

API Gateway BFF
How many One (or one per zone) One per type of client
Knows the business No Yes, the presentation side: what each screen needs
Contains logic Cross-cutting (auth, limits) Composition and adaptation (aggregate, filter, shape)
Who maintains it Platform The corresponding front-end team
Where it sits In front of everything Behind the gateway, in front of the services
Changes when The topology or a policy changes A screen changes

At TechCorp: the gateway routes /api/graphql to bff-mobile:3010 (table in section 4). The web, for now, consumes the REST services directly through the gateway; if its screens get complicated, it will get its bff-web. The partners' ERP uses the public REST API without a BFF.

  1. A mobile BFF that composes order and products

The app's "order detail" screen needs the order (Orders) and the image and current availability of each product (Catalog). In 03-03 we solved it with GraphQL + DataLoader, and that code is exactly the mobile BFF. Here we show the REST alternative, simpler, to make the composition idea clear; both are valid and TechCorp will try REST first and move to GraphQL when the screens call for it.

// bff-mobile/routes/orders.js
const express = require('express');
const router = express.Router();
const ordersApi = require('../clients/ordersClient');      // GET /orders/{id} (03-01)
const catalogApi = require('../clients/catalogClient');    // GET /products?ids= (03-01)

// GET /mobile/orders/:id → a single response with order + live product data
router.get('/mobile/orders/:id', async (req, res, next) => {
  const ctx = { requestId: req.get('X-Request-Id') };
  try {
    // 1. First the order: without it we do not know which products to request
    const order = await ordersApi.getOrder(req.params.id, ctx);

    // 2. Then ONE batch call to Catalog (never N+1)
    const ids = order.lines.map(l => l.productId);
    let products = [];
    try {
      products = await catalogApi.getProducts(ids, ctx);
    } catch (err) {
      // 3. Graceful degradation: if Catalog fails, the screen is shown without images.
      //    The BFF decides this because it knows the screen; the gateway never could.
      console.warn('Catalog unavailable; response without live data', { requestId: ctx.requestId });
    }
    const byId = new Map(products.map(p => [p.id, p]));

    // 4. Shape it for the screen: only what the app renders, with names designed for the front end
    res.set('Cache-Control', 'no-store').json({
      id: order.id,
      status: order.status,
      readableStatus: READABLE_STATUSES[order.status] ?? order.status,   // "Confirmed"
      total: order.total,
      canCancel: Boolean(order._links?.cancel),                          // uses the HATEOAS from 03-01
      lines: order.lines.map(l => ({
        name: l.name,
        quantity: l.quantity,
        unitPrice: l.unitPrice,
        imageUrl: byId.get(l.productId)?.imageUrl ?? null,
        availableNow: byId.get(l.productId)?.available ?? null
      }))
    });
  } catch (err) {
    if (err.code === 'ORDER_NOT_FOUND') return res.status(404).json({ code: err.code });
    next(err);
  }
});

const READABLE_STATUSES = {
  PENDING: 'Processing your order', STOCK_RESERVED: 'Stock reserved', PAID: 'Payment received',
  CONFIRMED: 'Confirmed', CANCELLED: 'Cancelled'
};

module.exports = router;

What makes this code legitimate in a BFF and not in the gateway: it knows which screen calls it, decides what to degrade if a dependency fails, translates statuses into texts and fields into front-end names. All of that is presentation knowledge, not business knowledge (the BFF does not change the order status or recompute totals), and it belongs to the app team. When the app has five screens like this, the GraphQL schema from 03-03 will replace five composition routes with a single flexible endpoint.

  1. Gateway risks

Risk Symptom Mitigation
Single point of failure The gateway goes down, the whole API goes down even though the six services are healthy Several replicas behind a load balancer (03-05); stateless gateway (the rate limit in Redis, not in memory); /health monitored
Bottleneck Every request goes through it: if it is slow, everything is slow; if it saturates CPU, everything waits Have it do little (proxy, no body parsing); horizontal scaling; measure its added latency (06-04)
"Fat" gateway Business rules, per-client transformations, DB calls; every team touches its repository The rule from section 3; BFFs for what is specific to each client; change review by Platform
False sense of security "The gateway already authenticates" and services accept any internal request Defense in depth (07-01, 07-02): services verify identity; the mesh encrypts internal traffic
Coupling to the gateway Services build URLs assuming /api/ or depend on gateway headers Services expose their "clean" contracts; what belongs to the gateway stays in the gateway
Configuration as unversioned code A rule is changed by hand in production and nobody knows what is there Traefik's YAML in the repository, deployed by CI/CD (05-03)

Common Mistakes and Tips

  • Global express.json() in the gateway. It consumes the body stream and the proxy forwards empty POSTs. If you need to parse in some route of the gateway's own, do it only on that route.
  • Routes in the wrong order. The /api catch-all before /api/orders captures everything and the strangler fig stops working. Specific first, catch-all last (or priority in Traefik).
  • No timeout on the proxy. A hung service exhausts the gateway's connections and takes down the entire API. proxyTimeout always.
  • CORS "everywhere". With origin: '*' and credentials, the browser rejects it and you also open the API to any website. Allowlist of origins.
  • Rate limit in memory with several replicas. Each replica counts on its own and the real limit is N times higher. Shared store as soon as there is more than one instance.
  • Exposing Inventory and Payments "for debugging" with a temporary route. Temporary routes stay. To debug, kubectl port-forward (05-02) or the internal admin panel.
  • Putting aggregation in the gateway "because it is just one screen". The second screen arrives in a week. BFF from the first one.
  • One BFF shared between web and mobile. It stops being "for frontend": it becomes a fat gateway under another name. One per type of client.
  • Forgetting to expose headers in CORS (exposedHeaders). The browser hides Location, ETag and X-Request-Id from the web even though the server sends them, and the polling after the 202 cannot find the URL.

Exercises

Exercise 1. The Orders team has finished the extraction and /api/orders/* already points to the new service. Now it is /api/customers/*'s turn, but Marta asks for a cautious rollout: for one week, only requests with the header X-Canary: customers should go to customers-service:3004; the rest, to the monolith. Write the change to the Express gateway (a router function of http-proxy-middleware or a preceding middleware) and explain how you would revert it in seconds. Mention which lesson of the course covers this kind of deployment in general.

Exercise 2. The mobile app needs a "my latest orders" screen that shows, for each of the customer's last 10 orders, its status, total and the image of the first product. Design the mobile BFF route (GET /mobile/my-orders), state which internal calls it makes (with the contracts from 03-01), how many there are in total with and without batching, and write the example JSON response with two orders.

Exercise 3. A developer proposes adding a check to the gateway: "if the POST /api/orders carries more than 50 lines, reject it with 422 before it reaches Orders, so we protect the service". Argue whether it is the responsibility of the gateway, of the Orders service, or of both, and which version of that idea would be acceptable in the gateway.

Solutions

Solution 1.

http-proxy-middleware accepts in router a function that returns the target per request:

app.use('/api/customers', requireToken, createProxyMiddleware({
  target: TARGETS.monolith,                // default target
  changeOrigin: true,
  proxyTimeout: 5_000,
  router: (req) => req.get('X-Canary') === 'customers' ? TARGETS.customers : TARGETS.monolith,
  pathRewrite: (path, req) => req.get('X-Canary') === 'customers' ? path.replace(/^\/api/, '') : path,
  on: { proxyReq: (proxyReq, req) => proxyReq.setHeader('X-Request-Id', req.requestId) }
}));

It has to be declared before the /api catch-all. Rollback: change router so it always returns TARGETS.monolith (or remove the route and let the catch-all absorb it) and redeploy the gateway, or, in Traefik, edit the router with watch: true without a restart. When the week ends well, the condition is removed and /api/customers always goes to the new service. This is a header-driven canary deployment; deployment strategies (rolling, blue-green, canary by traffic percentage) are covered in 05-04.

Solution 2.

Route: GET /mobile/my-orders (the customer comes from the JWT; in 07-01 the gateway or the BFF extract it). Internal calls:

  1. GET /orders?customerId=c-1024&sort=-createdAt&limit=10 to Orders (cursor pagination from 03-01) → 1 call.
  2. Collect the productId of the first line of each order (up to 10 ids, deduplicated) and call GET /products?ids=p-501,p-777,... on Catalog → 1 call.

Total: 2 calls with batching; 11 without (1 + one per order). Response:

{
  "orders": [
    { "id": "ord-88213", "status": "CONFIRMED", "readableStatus": "Confirmed", "total": 79.70, "createdAt": "2026-08-15T10:32:07Z", "imageUrl": "https://cdn.techcorp.example/p-501.webp", "lineCount": 2 },
    { "id": "ord-88102", "status": "CONFIRMED", "readableStatus": "Confirmed", "total": 24.50, "createdAt": "2026-08-14T18:05:44Z", "imageUrl": "https://cdn.techcorp.example/p-310.webp", "lineCount": 1 }
  ],
  "nextCursor": "eyJjcmVhdGVkQXQiOi..."
}

If Catalog fails, imageUrl becomes null and the list is shown all the same (degradation decided by the BFF).

Solution 3.

The rule "at most 50 lines per order" is a business rule of the Order aggregate (02-03): the Orders service decides and enforces it, responding 422 INVALID_DATA according to its contract (03-01). If it lives in the gateway, the day the business changes the limit to 100 the gateway will have to be redeployed, and Luis's team will depend on Platform for a rule of their own; besides, an internal POST /reservations or the admin panel, which do not go through the gateway, would not enforce it. It is exactly the "fat" gateway from section 10.

What is acceptable in the gateway is a cross-cutting protection with no business semantics: a body size limit (for example, 256 KB for any POST, with 413 Payload Too Large) that protects every service from giant bodies without knowing what an "order line" is. The service keeps validating the 50 lines; the gateway just prevents 50 MB from reaching it.

Conclusion

The API Gateway on port 8080 is TechCorp's only door: it routes the public routes /api/products, /api/orders and /api/customers to their services (and the rest, less and less, to the monolith, which is the strangler fig in action), and concentrates the cross-cutting concerns: correlation with X-Request-Id, CORS, rate limiting, delegated authentication, timeouts and access logging. We built it in Express with http-proxy-middleware to understand it and declared it in Traefik to operate it. And we clearly separated what does not belong to it (business logic, bespoke aggregations) and whom it does belong to: the backends for frontend, one per type of client, maintained by the front-end team, where both the REST composition of bff-mobile and the GraphQL from 03-03 fit. Inventory, Payments and Notifications have no public route: they only talk through events.

Now, throughout the code in this lesson we have written targets like http://catalog-service:3001 as if they were fixed addresses. They are not: in production there will be eight replicas of Catalog at the peaks, each with an ephemeral IP that Kubernetes assigns and destroys. Which one does the gateway call? And Orders, when it queries the catalog? How does anyone know which instances are alive and which are not? That is service discovery and load balancing, the next lesson.

Microservices Course

Module 1: Introduction to Microservices

Module 2: Microservice Design

Module 3: Communication between Microservices

Module 4: Implementing Microservices

Module 5: Deployment and Orchestration

Module 6: Monitoring and Maintenance

Module 7: Security in Microservices

Module 8: Case Studies and Practical Examples

© Copyright 2026. All rights reserved