API Gateway Pattern
In a microservice system, clients should not talk to each individual service directly. An API gateway is a single HTTP entry point that fronts your microservices: it exposes a clean REST surface, routes each request to the right downstream service over a transport like TCP or NATS, aggregates responses from several services into one payload, and centralizes cross-cutting concerns such as authentication, rate limiting, and logging. In NestJS the gateway is just a normal HTTP application that holds one or more ClientProxy instances and delegates work through them.
Why front services with a gateway
Without a gateway, every client must know the network location of every service, speak each service’s transport, and stitch together data itself. That couples clients to your internal topology and duplicates auth logic everywhere. A gateway solves this by acting as a Backend-for-Frontend (BFF): it presents one stable, HTTP-friendly contract while the messy microservice details stay hidden behind it.
| Concern | Without gateway | With gateway |
|---|---|---|
| Client coupling | Knows every service host | Knows one URL |
| Transport | Client speaks TCP/NATS/gRPC | Gateway translates HTTP to internal transport |
| Auth | Repeated per service | Centralized at the edge |
| Aggregation | Client makes N calls | Gateway makes N calls, returns one |
| Cross-cutting | Scattered | One place for logging, rate limiting |
Registering client proxies
The gateway is a standard HTTP Nest app. Register a ClientProxy for each downstream service with ClientsModule. Here we wire up a users service and an orders service over TCP.
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { GatewayController } from './gateway.controller';
@Module({
imports: [
ClientsModule.register([
{
name: 'USERS_SERVICE',
transport: Transport.TCP,
options: { host: '127.0.0.1', port: 3001 },
},
{
name: 'ORDERS_SERVICE',
transport: Transport.TCP,
options: { host: '127.0.0.1', port: 3002 },
},
]),
],
controllers: [GatewayController],
})
export class AppModule {}
This is a plain HTTP application, so bootstrap it the usual way with NestFactory.create:
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
console.log('API gateway listening on http://localhost:3000');
}
bootstrap();
Routing requests to services
The controller exposes REST endpoints and forwards each one to the right service via send (request-response) or emit (fire-and-forget). Because ClientProxy returns RxJS Observables, the simplest approach is to convert them to promises with firstValueFrom.
// src/gateway.controller.ts
import { Controller, Get, Post, Body, Param, Inject } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { firstValueFrom } from 'rxjs';
@Controller()
export class GatewayController {
constructor(
@Inject('USERS_SERVICE') private readonly users: ClientProxy,
@Inject('ORDERS_SERVICE') private readonly orders: ClientProxy,
) {}
@Get('users/:id')
getUser(@Param('id') id: string) {
return this.users.send({ cmd: 'get_user' }, { id });
}
@Post('orders')
createOrder(@Body() dto: { userId: string; items: string[] }) {
return this.orders.send({ cmd: 'create_order' }, dto);
}
}
Nest’s HTTP layer automatically subscribes to an
Observablereturned from a route handler, so returningthis.users.send(...)directly works withoutfirstValueFrom. UsefirstValueFromonly when you need the resolved value inside the handler, for example to aggregate.
Aggregating multiple services
The real power of a gateway is composing data. Fetch a user and their orders in parallel, then return a single combined response. Running calls concurrently with Promise.all keeps latency close to the slowest call rather than the sum.
// src/gateway.controller.ts (excerpt)
@Get('users/:id/profile')
async getProfile(@Param('id') id: string) {
const [user, orders] = await Promise.all([
firstValueFrom(this.users.send({ cmd: 'get_user' }, { id })),
firstValueFrom(this.orders.send({ cmd: 'list_orders' }, { userId: id })),
]);
return {
id: user.id,
name: user.name,
orderCount: orders.length,
orders,
};
}
Output:
$ curl http://localhost:3000/users/42/profile
{
"id": "42",
"name": "Ada Lovelace",
"orderCount": 2,
"orders": [
{ "id": "o-1", "items": ["book"] },
{ "id": "o-2", "items": ["pen", "ink"] }
]
}
Centralizing authentication
Because every request passes through the gateway, it is the natural place to authenticate once and forward an identity downstream. A guard validates the incoming token at the edge; services can then trust the gateway.
// src/auth.guard.ts
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
@Injectable()
export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest();
const token = req.headers['authorization']?.replace('Bearer ', '');
if (!token || token !== process.env.GATEWAY_TOKEN) {
throw new UnauthorizedException('Invalid or missing token');
}
req.user = { id: 'verified-from-token' };
return true;
}
}
Apply it globally so the whole gateway surface is protected, then pass the verified user to downstream calls in the message payload:
// src/main.ts (excerpt)
import { AuthGuard } from './auth.guard';
app.useGlobalGuards(new AuthGuard());
Best Practices
- Run downstream calls in parallel with
Promise.allwhenever they are independent so gateway latency stays low. - Authenticate and rate-limit at the gateway edge; let internal services trust requests coming from it rather than re-validating everything.
- Keep the gateway thin: route, aggregate, and translate, but push business logic into the services themselves.
- Shape responses for the client (BFF style) so the public contract stays stable even when internal service schemas change.
- Set sensible timeouts on
ClientProxycalls and handle partial failures gracefully so one slow service does not hang the whole response. - Use clear, versioned message patterns like
{ cmd: 'get_user' }so routing intent is explicit and easy to evolve.