Skip to content
NestJS projects 4 min read

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.

ServiceResponsibilityPattern it handles
api-gatewayHTTP + auth, routes to servicesclient of all services
productsCatalogue, stock levelsproducts.find, stock.reserve
ordersOrder lifecycle, saga coordinationorders.create, emits order.created
paymentsCharges, refundsreacts 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 @MessagePattern only when the caller truly needs a reply; prefer @EventPattern so services stay decoupled and resilient to downtime.
  • Make event handlers idempotent — Kafka delivers at-least-once, so the same order.created may arrive twice.
  • Validate payloads at the gateway with a global ValidationPipe so 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 groupId per service so Kafka partitions work scale horizontally across replicas.
  • Keep DTO/event contracts in a shared library and version topic names when shapes change.
Last updated June 14, 2026
Was this helpful?