Building the Gateway

Article · 18:40

The gateway is the only thing the outside world talks to, and everything behind it trusts that it did its job. That makes it a short list of responsibilities, each of which is painful to add later: verify the token once, route, and propagate a request id.

What the gateway adds

The gateway verifies the caller token once, mints a request id, forwards the identity to the service as trusted headers, and the same request id travels down every hop so one trace covers the whole call.

Verify once, trace everywhere. Both of those are hard to add later.

The gateway is the only thing the outside world talks to. Everything behind it trusts that it did its job.

That makes it a short list of responsibilities, each of which is painful to add later.

Verify once

The gateway verifies the caller token and forwards the resulting identity to the service as headers. The service does not re-verify: it trusts those headers because they arrived with an internal token that only the gateway holds.

headers['x-user-id'] = req.user.id;
headers['x-user-roles'] = (req.user.roles ?? []).join(',');
headers[INTERNAL_TOKEN_HEADER] = config.INTERNAL_SERVICE_TOKEN;

The important half is what gets removed. Those same identity headers are stripped from whatever the client sent:

const STRIPPED_REQUEST_HEADERS = new Set([
  'host', 'connection', 'content-length',
  'x-user-id', 'x-user-email', 'x-user-roles',
  INTERNAL_TOKEN_HEADER,
]);

Forget that and a client can set x-user-id to anybody, and the service - which trusts it - will believe them. The stripping is the security boundary. The forwarding is just convenience.

One request id, every hop

The gateway mints a request id and passes it to every service it calls. Each service logs it with every line, and returns it in the response.

It is the difference between a five minute debugging session and a five hour one. A user reports an error and quotes an id; one search across every log shows the whole path, in order, including the service that actually failed.

A service that is down is a 503, not a hang

Every call out of the gateway has a deadline:

const upstream = await fetchImpl(url, {
  method: req.method,
  headers,
  signal: AbortSignal.timeout(config.SERVICE_TIMEOUT_MS),
});

Without the timeout, a service that stops answering turns into a browser tab that spins forever and a connection pool that fills up - and once the pool is full, requests to healthy services start failing too. A timeout turns one broken service into one broken route.

What the client gets back should say which thing is unavailable, with the request id attached, so the answer to "is it down for everyone" is one search rather than a guess.

What the gateway must not become

It routes, it authenticates, it observes. It does not know what a course is.

The moment domain logic appears in the gateway, every service's deployment is coupled to the gateway's again - which is the coupling the split was meant to remove. The one exception in this codebase is the composite endpoints, which fan out to four services for a single page; they are read-only, every part of them degrades to null, and they are confined to one file for exactly this reason.