Project: Microservices E-Commerce
E-commerce is a natural fit for microservices: orders, product catalogues, and payments evolve at different rates, scale under different loads, and often belong to different teams. In this project we split a shopping backend into three independent NestJS services that communicate asynchronously over a Kafka broker, with a thin API gateway that handles HTTP, authentication, and request fan-out. NestJS makes this surprisingly approachable through its built-in @nestjs/microservices transport layer, so the same decorators and DI you already know carry straight over.
System architecture
The gateway is the only service exposed to the public internet. It authenticates requests, then forwards work to internal services either by request/response (synchronous, used when the client needs an answer now) or by events (fire-and-forget, used to broadcast state changes). Kafka acts as the message backbone and as a durable event log.
| Service | Responsibility | Pattern it handles |
|---|---|---|
api-gateway | HTTP + auth, routes to services | client of all services |
products | Catalogue, stock levels | products.find, stock.reserve |
orders | Order lifecycle, saga coordination | orders.create, emits order.created |
payments | Charges, refunds | reacts to order.created, emits payment.settled |
Keep the gateway logic-free. Its job is transport, auth, and shaping responses — never business rules. Domain decisions belong inside the owning service.
Wiring a microservice transport
Each downstream service is a NestJS app bootstrapped with connectMicroservice instead of (or alongside) an HTTP listener. Here is the products service connecting to Kafka.
// products/src/main.ts
import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.createMicroservice<MicroserviceOptions>(
AppModule,
{
transport: Transport.KAFKA,
options: {
client: { clientId: 'products', brokers: ['kafka:9092'] },
consumer: { groupId: 'products-consumer' },
},
},
);
await app.listen();
}
bootstrap();
A controller in the service responds to message patterns (request/response) and event patterns (one-way) using decorators:
// products/src/products.controller.ts
import { Controller } from '@nestjs/common';
import { MessagePattern, EventPattern, Payload } from '@nestjs/microservices';
import { ProductsService } from './products.service';
@Controller()
export class ProductsController {
constructor(private readonly products: ProductsService) {}
@MessagePattern('products.find')
findOne(@Payload() id: string) {
return this.products.findById(id); // returns the product, replied to caller
}
@EventPattern('order.created')
async onOrderCreated(@Payload() event: { items: { id: string; qty: number }[] }) {
await this.products.reserveStock(event.items); // no reply, just react
}
}
The API gateway
The gateway registers a Kafka client per downstream service through ClientsModule, then injects ClientKafka to dispatch calls. It stays a normal HTTP Nest app.
// gateway/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: 'PRODUCTS',
transport: Transport.KAFKA,
options: {
client: { clientId: 'gateway', brokers: ['kafka:9092'] },
consumer: { groupId: 'gateway-consumer' },
},
},
]),
],
controllers: [GatewayController],
})
export class AppModule {}
// gateway/src/gateway.controller.ts
import { Controller, Get, Param, Inject, UseGuards, OnModuleInit } from '@nestjs/common';
import { ClientKafka } from '@nestjs/microservices';
import { JwtAuthGuard } from './auth/jwt.guard';
@Controller('products')
export class GatewayController implements OnModuleInit {
constructor(@Inject('PRODUCTS') private readonly products: ClientKafka) {}
async onModuleInit() {
// request/response patterns must subscribe to their reply topic
this.products.subscribeToResponseOf('products.find');
await this.products.connect();
}
@UseGuards(JwtAuthGuard)
@Get(':id')
findOne(@Param('id') id: string) {
return this.products.send('products.find', id); // returns an Observable
}
}
Authentication lives only here. A standard JwtAuthGuard validates the bearer token before any message reaches the broker, so internal services trust their callers and skip auth entirely.
Orchestrating an order
Creating an order touches all three services. The orders service writes the order, emits order.created, and lets products and payments react independently — a lightweight choreography saga.
// orders/src/orders.controller.ts
import { Controller } from '@nestjs/common';
import { MessagePattern, EventPattern, Payload, ClientKafka } from '@nestjs/microservices';
import { Inject } from '@nestjs/common';
import { OrdersService } from './orders.service';
@Controller()
export class OrdersController {
constructor(
private readonly orders: OrdersService,
@Inject('EVENTS') private readonly bus: ClientKafka,
) {}
@MessagePattern('orders.create')
async create(@Payload() dto: { userId: string; items: { id: string; qty: number }[] }) {
const order = await this.orders.create(dto, 'PENDING');
this.bus.emit('order.created', { orderId: order.id, ...dto });
return order;
}
@EventPattern('payment.settled')
async onPaid(@Payload() e: { orderId: string }) {
await this.orders.markPaid(e.orderId); // PENDING -> CONFIRMED
}
}
When a request flows through, the gateway logs the round trip:
Output:
[Gateway] POST /orders user=u_91 -> orders.create
[Orders] created order o_5f2 status=PENDING emit order.created
[Products] order.created reserved stock for 2 items
[Payments] order.created charged $48.00 emit payment.settled
[Orders] payment.settled o_5f2 status=CONFIRMED
Best practices
- Give each service its own database; never let two services share tables. Cross-service reads happen through messages, not joins.
- Use
@MessagePatternonly when the caller truly needs a reply; prefer@EventPatternso services stay decoupled and resilient to downtime. - Make event handlers idempotent — Kafka delivers at-least-once, so the same
order.createdmay arrive twice. - Validate payloads at the gateway with a global
ValidationPipeso malformed input never reaches the broker. - Emit compensating events (e.g.
payment.failed) to unwind sagas instead of relying on distributed transactions. - Run one consumer
groupIdper service so Kafka partitions work scale horizontally across replicas. - Keep DTO/event contracts in a shared library and version topic names when shapes change.