NestJS Microservices: REST, gRPC and Kafka

Build a 3-service NestJS system with REST, gRPC, and Kafka on Docker.

Introduction

30 Second Summary

When a single application handles everything from user requests to background processing, one slow feature can bring the whole system down. Microservices solve this by splitting responsibilities into independent services that communicate through purpose-built channels.

In this project, you will build a three-service e-commerce order system using NestJS, with each service connected by a different communication pattern: REST for external clients, gRPC for fast internal calls, and Apache Kafka for asynchronous event-driven messaging.

What You'll Build

An e-commerce order management system composed of three NestJS microservices demonstrating when and why to use each communication pattern.

By the end of this project, you'll have:

  • A NestJS monorepo with three applications: an API Gateway, an Order Service, and a Notification Service.
  • A shared Protocol Buffers contract (.proto file) defining type-safe gRPC communication between the gateway and Order Service, backed by MongoDB persistence.
  • A Docker Compose setup orchestrating MongoDB and Kafka in KRaft mode, with a working end-to-end flow from REST request to Kafka event.
  • Secret Mission: Add a dead-letter queue pattern with retry logic for failed notification processing.

Are there any prerequisites?

This project assumes solid TypeScript and NestJS experience, Docker basics, and an understanding of microservices concepts. You should be comfortable creating NestJS modules, controllers, and services before starting.

Set Up Infrastructure and Project Scaffold

Before you can build microservices, you need the infrastructure they run on. This step sets up a NestJS monorepo with three application slots, a MongoDB database, and a Kafka message broker.

You will scaffold the project structure, start the supporting infrastructure in Docker containers, and define the gRPC contract that two of your services will use to communicate.

In this step, get ready to:
  • Create a NestJS monorepo with three applications and install microservice dependencies.
  • Spin up MongoDB and Kafka containers using Docker Compose.
  • Define the gRPC service contract in a Protocol Buffers file.
Create the NestJS monorepo and install dependencies

A NestJS monorepo lets multiple applications share one package.json and node_modules. This keeps dependencies consistent across all three services without managing separate installs.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the NestJS project by running this command:
nest new order-system

What will the CLI ask?

The NestJS CLI prompts you to choose a package manager. Select npm and press Enter. The CLI then installs the base dependencies automatically.

  • Move into the project and generate the two sub-applications by running these commands:
cd order-system
nest generate app order-service
nest generate app notification-service

What does this do?

The first nest generate app converts your project to monorepo mode. It moves the original app into apps/order-system/ and creates apps/order-service/ alongside it.

The second command adds apps/notification-service/. You now have three apps sharing one workspace.

The original app at apps/order-system/ will become your API Gateway. Rename it so the folder name matches its role.

  • Rename the default app to gateway by running:
mv apps/order-system apps/gateway

Windows users

If you are on Windows without Git Bash or WSL, use ren apps\order-system gateway in Command Prompt instead.

After renaming, the auto-generated nest-cli.json still references the old name. You need to replace it with the correct monorepo configuration.

  • Open nest-cli.json in your code editor and replace its entire contents with:
{
  "$schema": "https://json.schemastore.org/nest-cli",
  "collection": "@nestjs/schematics",
  "sourceRoot": "apps/gateway/src",
  "compilerOptions": {
    "deleteOutDir": true,
    "webpack": true,
    "tsConfigPath": "apps/gateway/tsconfig.app.json",
    "assets": [
      {
        "include": "../proto/**/*.proto",
        "outDir": "dist",
        "watchAssets": true
      }
    ]
  },
  "monorepo": true,
  "root": "apps/gateway",
  "projects": {
    "gateway": {
      "type": "application",
      "root": "apps/gateway",
      "entryFile": "main",
      "sourceRoot": "apps/gateway/src",
      "compilerOptions": {
        "tsConfigPath": "apps/gateway/tsconfig.app.json"
      }
    },
    "order-service": {
      "type": "application",
      "root": "apps/order-service",
      "entryFile": "main",
      "sourceRoot": "apps/order-service/src",
      "compilerOptions": {
        "tsConfigPath": "apps/order-service/tsconfig.app.json"
      }
    },
    "notification-service": {
      "type": "application",
      "root": "apps/notification-service",
      "entryFile": "main",
      "sourceRoot": "apps/notification-service/src",
      "compilerOptions": {
        "tsConfigPath": "apps/notification-service/tsconfig.app.json"
      }
    }
  }
}

What does this configuration do?

The projects object registers all three apps so nest build and nest start can target each one by name.

The assets array tells the compiler to copy .proto files into dist/ on every build. Without this, your gRPC services cannot find the proto definition at runtime.

  • Save nest-cli.json.
  • Confirm the folder structure by running:
ls apps

You should see gateway, order-service, and notification-service listed.

Only seeing order-system instead of gateway?

The mv command may not have run successfully. Make sure you are inside the order-system project directory (not ~/Desktop) when running it.

help me rename apps/order-system to apps/gateway in my NestJS monorepo

Now install the packages your microservices need.

  • Install the microservices dependencies by running:
npm install @nestjs/microservices @nestjs/mongoose mongoose kafkajs @grpc/grpc-js @grpc/proto-loader
  • Verify the install completed without errors. You should see added X packages in the terminal output with no red error text.

Seeing peer dependency warnings?

Yellow WARN lines about peer dependencies are safe to ignore here. Red ERR! lines indicate an actual failure. If you see errors, make sure your Node.js version is 18+ and re-run the install.

help me fix npm install errors for NestJS microservices packages

✔️ Awesome, I've got everything!

Great. Make sure nest-cli.json is saved and all three folders appear under apps/.

ⓧ I'd like to double check the full code

Here is what nest-cli.json should contain at this point:

{
  "$schema": "https://json.schemastore.org/nest-cli",
  "collection": "@nestjs/schematics",
  "sourceRoot": "apps/gateway/src",
  "compilerOptions": {
    "deleteOutDir": true,
    "webpack": true,
    "tsConfigPath": "apps/gateway/tsconfig.app.json",
    "assets": [
      {
        "include": "../proto/**/*.proto",
        "outDir": "dist",
        "watchAssets": true
      }
    ]
  },
  "monorepo": true,
  "root": "apps/gateway",
  "projects": {
    "gateway": {
      "type": "application",
      "root": "apps/gateway",
      "entryFile": "main",
      "sourceRoot": "apps/gateway/src",
      "compilerOptions": {
        "tsConfigPath": "apps/gateway/tsconfig.app.json"
      }
    },
    "order-service": {
      "type": "application",
      "root": "apps/order-service",
      "entryFile": "main",
      "sourceRoot": "apps/order-service/src",
      "compilerOptions": {
        "tsConfigPath": "apps/order-service/tsconfig.app.json"
      }
    },
    "notification-service": {
      "type": "application",
      "root": "apps/notification-service",
      "entryFile": "main",
      "sourceRoot": "apps/notification-service/src",
      "compilerOptions": {
        "tsConfigPath": "apps/notification-service/tsconfig.app.json"
      }
    }
  }
}
Create Docker Compose and start infrastructure

Your microservices depend on two external systems: MongoDB for persistence and Apache Kafka for event streaming. Docker Compose lets you start both with a single command.

  • Create a file called docker-compose.yml in the root of your order-system project (the same level as package.json) and paste in the following:
version: '3.8'

services:
  mongodb:
    image: mongo:7
    container_name: order-system-mongodb
    ports:
      - '27017:27017'
    volumes:
      - mongo_data:/data/db

  kafka:
    image: bitnami/kafka:3.7
    container_name: order-system-kafka
    ports:
      - '9092:9092'
    environment:
      - KAFKA_CFG_NODE_ID=0
      - KAFKA_CFG_PROCESS_ROLES=controller,broker
      - KAFKA_CFG_CONTROLLER_QUORUM_VOTERS=0@kafka:9093
      - KAFKA_CFG_LISTENERS=PLAINTEXT://:9092,CONTROLLER://:9093
      - KAFKA_CFG_ADVERTISED_LISTENERS=PLAINTEXT://localhost:9092
      - KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP=CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT
      - KAFKA_CFG_CONTROLLER_LISTENER_NAMES=CONTROLLER
      - KAFKA_CFG_AUTO_CREATE_TOPICS_ENABLE=true

volumes:
  mongo_data:

What does this set up?

The mongodb service runs a MongoDB 7 instance on port 27017 with persistent storage via a named volume.

The kafka service runs in KRaft mode (no Zookeeper required). The KAFKA_CFG_PROCESS_ROLES=controller,broker setting makes a single node act as both the cluster controller and the message broker. This is the modern Kafka deployment pattern since version 3.3.

  • Save docker-compose.yml.
  • Start both containers in the background by running:
docker compose up -d
  • Verify both containers are healthy by running:
docker ps

You should see two containers listed: order-system-mongodb and order-system-kafka, both with status Up.

Containers not starting?

Make sure Docker Desktop is running before you execute docker compose up -d. If you see a port conflict on 27017 or 9092, another process is using that port. Stop the conflicting process or change the host port in docker-compose.yml.

help me troubleshoot Docker Compose containers not starting for MongoDB and Kafka

✔️ Awesome, I've got everything!

Great. Both containers are running and ready to accept connections.

ⓧ I'd like to double check the full code

Here is what docker-compose.yml should contain:

version: '3.8'

services:
  mongodb:
    image: mongo:7
    container_name: order-system-mongodb
    ports:
      - '27017:27017'
    volumes:
      - mongo_data:/data/db

  kafka:
    image: bitnami/kafka:3.7
    container_name: order-system-kafka
    ports:
      - '9092:9092'
    environment:
      - KAFKA_CFG_NODE_ID=0
      - KAFKA_CFG_PROCESS_ROLES=controller,broker
      - KAFKA_CFG_CONTROLLER_QUORUM_VOTERS=0@kafka:9093
      - KAFKA_CFG_LISTENERS=PLAINTEXT://:9092,CONTROLLER://:9093
      - KAFKA_CFG_ADVERTISED_LISTENERS=PLAINTEXT://localhost:9092
      - KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP=CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT
      - KAFKA_CFG_CONTROLLER_LISTENER_NAMES=CONTROLLER
      - KAFKA_CFG_AUTO_CREATE_TOPICS_ENABLE=true

volumes:
  mongo_data:
Define the gRPC service contract

gRPC uses Protocol Buffers (.proto files) as a contract between services. You define the available methods and message shapes once, and both the server and client use the same definition.

  • Create the proto directory by running:
mkdir proto
  • Create a file called order.proto inside the proto/ directory and paste in the following:
syntax = "proto3";

package order;

service OrderService {
  rpc CreateOrder (CreateOrderRequest) returns (Order);
  rpc FindOrder (OrderById) returns (Order);
  rpc FindAllOrders (Empty) returns (Orders);
}

message CreateOrderRequest {
  string productName = 1;
  int32 quantity = 2;
  double price = 3;
}

message OrderById {
  string id = 1;
}

message Order {
  string id = 1;
  string productName = 2;
  int32 quantity = 3;
  double price = 4;
  string status = 5;
  string createdAt = 6;
}

message Orders {
  repeated Order orders = 1;
}

message Empty {}

What does this proto file define?

  • OrderService declares three RPCs: CreateOrder, FindOrder, and FindAllOrders.
  • CreateOrderRequest specifies what data a client sends to create an order (product name, quantity, price).
  • Order is the response shape including server-generated fields like id and status.
  • Orders wraps a list of Order messages using the repeated keyword.
  • Save proto/order.proto.
  • Confirm the file exists by running:
ls proto

You should see order.proto in the output.

Don't see order.proto?

Make sure you created the proto directory at the project root (order-system/proto/), not inside one of the apps/ subdirectories. Run pwd to confirm you are in ~/Desktop/order-system.

help me find where to put my proto file in a NestJS monorepo

✔️ Awesome, I've got everything!

Great. Your gRPC contract is ready for the Order Service to implement.

ⓧ I'd like to double check the full code

Here is what proto/order.proto should contain:

syntax = "proto3";

package order;

service OrderService {
  rpc CreateOrder (CreateOrderRequest) returns (Order);
  rpc FindOrder (OrderById) returns (Order);
  rpc FindAllOrders (Empty) returns (Orders);
}

message CreateOrderRequest {
  string productName = 1;
  int32 quantity = 2;
  double price = 3;
}

message OrderById {
  string id = 1;
}

message Order {
  string id = 1;
  string productName = 2;
  int32 quantity = 3;
  double price = 4;
  string status = 5;
  string createdAt = 6;
}

message Orders {
  repeated Order orders = 1;
}

message Empty {}

Your infrastructure is live and the service contract is defined. Next up, you will build the Order Service that implements this gRPC contract and publishes events to Kafka.

Build the Order Service

Your infrastructure is running and the gRPC contract is defined. Now it is time to build the first microservice that actually does something with that contract.

The Order Service is the core of your system. It receives gRPC requests, persists orders to MongoDB, and publishes events to Apache Kafka whenever a new order is created. In this step, you will implement all three of these responsibilities.

In this step, get ready to:
  • Configure the Order Service as a gRPC microservice with a Mongoose schema.
  • Implement gRPC method handlers that map proto RPCs to TypeScript logic.
  • Register a Kafka producer and emit order events on creation.
Configure the gRPC microservice and Mongoose schema

Every NestJS microservice needs an entry point that tells the framework which transport to use. For the Order Service, that transport is gRPC with Protocol Buffers defining the contract.

  • Open apps/order-service/src/main.ts in your editor and replace its contents with this gRPC bootstrap configuration:
import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { join } from 'path';
import { OrderServiceModule } from './order-service.module';

async function bootstrap() {
  const app = await NestFactory.createMicroservice<MicroserviceOptions>(
    OrderServiceModule,
    {
      transport: Transport.GRPC,
      options: {
        package: 'order',
        protoPath: join(__dirname, '../../../proto/order.proto'),
        url: '0.0.0.0:5000',
      },
    },
  );
  await app.listen();
  console.log('Order Service (gRPC) is listening on port 5000');
}
bootstrap();

What does this code do?

NestFactory.createMicroservice tells NestJS to create a microservice (not an HTTP server). Transport.GRPC selects the gRPC transport layer.

The package option matches the package order; declaration in your order.proto file. The protoPath points to where the proto file lives relative to the compiled output. The URL 0.0.0.0:5000 binds to all network interfaces on port 5000.

  • Save main.ts.

You will not see a change yet. The module it references still needs to be updated.

Next, you need a Mongoose schema that defines what an order looks like in MongoDB. This follows the database-per-service pattern where each microservice owns its data model.

  • Create a new folder at apps/order-service/src/schemas/ by running:
mkdir -p apps/order-service/src/schemas
  • Create a new file at apps/order-service/src/schemas/order.schema.ts and add this schema definition:
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';

@Schema({ timestamps: true })
export class Order extends Document {
  @Prop({ required: true })
  productName: string;

  @Prop({ required: true })
  quantity: number;

  @Prop({ required: true })
  price: number;

  @Prop({ default: 'CREATED' })
  status: string;
}

export const OrderSchema = SchemaFactory.createForClass(Order);

What does this schema do?

The @Schema({ timestamps: true }) decorator tells Mongoose to automatically add createdAt and updatedAt fields to every document.

Each @Prop() decorator maps to a MongoDB field. The status field defaults to 'CREATED' so every new order starts in that state. MongoDB also auto-generates an _id field which serves as the order's unique identifier.

  • Save order.schema.ts.
  • Confirm the file exists by running:
ls apps/order-service/src/schemas/

You should see order.schema.ts in the output.

Don't see the file?

Make sure you created the schemas directory inside apps/order-service/src/ and not at the project root. Double-check you are running commands from the order-system directory.

Ask for help: My schemas directory isn't showing up in the right place for the Order Service.

Implement the gRPC controller

The controller is where gRPC method names from your proto file map to actual TypeScript handler functions. Each RPC defined in order.proto gets a corresponding method decorated with @GrpcMethod.

  • Create a new file at apps/order-service/src/order.controller.ts and add the following controller:
import { Controller } from '@nestjs/common';
import { GrpcMethod } from '@nestjs/microservices';
import { OrderService } from './order.service';

@Controller()
export class OrderController {
  constructor(private readonly orderService: OrderService) {}

  @GrpcMethod('OrderService', 'CreateOrder')
  async createOrder(data: {
    productName: string;
    quantity: number;
    price: number;
  }) {
    const order = await this.orderService.createOrder(data);
    return {
      id: order._id.toString(),
      productName: order.productName,
      quantity: order.quantity,
      price: order.price,
      status: order.status,
      createdAt: order.createdAt?.toISOString() ?? '',
    };
  }

  @GrpcMethod('OrderService', 'FindOrder')
  async findOrder(data: { id: string }) {
    const order = await this.orderService.findOrder(data.id);
    return {
      id: order._id.toString(),
      productName: order.productName,
      quantity: order.quantity,
      price: order.price,
      status: order.status,
      createdAt: order.createdAt?.toISOString() ?? '',
    };
  }

  @GrpcMethod('OrderService', 'FindAllOrders')
  async findAllOrders() {
    const orders = await this.orderService.findAllOrders();
    return {
      orders: orders.map((order) => ({
        id: order._id.toString(),
        productName: order.productName,
        quantity: order.quantity,
        price: order.price,
        status: order.status,
        createdAt: order.createdAt?.toISOString() ?? '',
      })),
    };
  }
}

What does this controller do?

Each @GrpcMethod('OrderService', 'CreateOrder') decorator tells NestJS which proto service and RPC name this handler responds to. The first argument matches the service OrderService in your proto file. The second matches the RPC name.

The handlers delegate business logic to OrderService (which you will create next). They also transform Mongoose documents into the shape your proto messages expect. For example, order._id.toString() converts MongoDB's ObjectId into the string id field your proto defines.

  • Save order.controller.ts.

You will not see a change yet. The service and module in the next substep connect everything.

Seeing a TypeScript error about OrderService?

Your editor may underline the OrderService import with a red squiggle because the file does not exist yet. This is expected. You will create it in the next substep.

Ask for help: My editor is showing errors in order.controller.ts that I'm not sure are expected.

Register the Kafka producer and implement the service

The Order Service does not just handle gRPC calls. It also publishes events to Kafka whenever an order is created. This is the event-driven piece that lets other services (like the Notification Service) react asynchronously without tight coupling.

Why produce to Kafka instead of calling the Notification Service directly?

If the Order Service called the Notification Service directly via gRPC, both services would need to be online simultaneously. A failure in notifications would block order creation.

With Kafka in between, the Order Service publishes an event and moves on. The Notification Service processes it whenever it is ready. If it is down, messages queue up and get processed once it recovers.

  • Create a new file at apps/order-service/src/order.service.ts and add the service implementation:
import { Injectable, Inject, OnModuleInit } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { ClientKafka } from '@nestjs/microservices';
import { Order } from './schemas/order.schema';

@Injectable()
export class OrderService implements OnModuleInit {
  constructor(
    @InjectModel(Order.name) private orderModel: Model<Order>,
    @Inject('KAFKA_SERVICE') private kafkaClient: ClientKafka,
  ) {}

  async onModuleInit() {
    await this.kafkaClient.connect();
  }

  async createOrder(data: {
    productName: string;
    quantity: number;
    price: number;
  }): Promise<Order> {
    const order = new this.orderModel(data);
    const savedOrder = await order.save();

    this.kafkaClient.emit('order.created', {
      orderId: savedOrder._id.toString(),
      productName: savedOrder.productName,
      quantity: savedOrder.quantity,
      price: savedOrder.price,
      status: savedOrder.status,
      createdAt: savedOrder.createdAt,
    });

    return savedOrder;
  }

  async findOrder(id: string): Promise<Order> {
    return this.orderModel.findById(id).exec();
  }

  async findAllOrders(): Promise<Order[]> {
    return this.orderModel.find().exec();
  }
}

What does this service do?

The onModuleInit() lifecycle hook connects the Kafka producer when the module starts. This ensures the connection is ready before any orders come in.

In createOrder(), the service first saves the order to MongoDB, then calls this.kafkaClient.emit('order.created', {...}) to publish an event. The emit method is fire-and-forget. The Order Service does not wait for any consumer to acknowledge the message.

The findOrder and findAllOrders methods are simple Mongoose queries that the gRPC controller calls for read operations.

  • Save order.service.ts.

The next chunk wires the schema, controller, service, and Kafka client together in the module.

  • Open apps/order-service/src/order-service.module.ts and replace its contents with:
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { OrderController } from './order.controller';
import { OrderService } from './order.service';
import { Order, OrderSchema } from './schemas/order.schema';

@Module({
  imports: [
    MongooseModule.forRoot('mongodb://localhost:27017/orders-db'),
    MongooseModule.forFeature([{ name: Order.name, schema: OrderSchema }]),
    ClientsModule.register([
      {
        name: 'KAFKA_SERVICE',
        transport: Transport.KAFKA,
        options: {
          client: {
            clientId: 'order-service',
            brokers: ['localhost:9092'],
          },
          producerOnlyMode: true,
        },
      },
    ]),
  ],
  controllers: [OrderController],
  providers: [OrderService],
})
export class OrderServiceModule {}

What does this module do?

MongooseModule.forRoot connects to a dedicated orders-db database. Each microservice gets its own database. MongooseModule.forFeature registers the Order schema so it can be injected into services.

ClientsModule.register creates a Kafka client named 'KAFKA_SERVICE'. The producerOnlyMode: true option tells NestJS this client only publishes messages. It will not join a consumer group or subscribe to any topics.

  • Save order-service.module.ts.
  • Start the Order Service by running:
nest start order-service

You should see Order Service (gRPC) is listening on port 5000 in your terminal output.

Seeing a connection error?

If you see an error about connecting to MongoDB or Kafka, make sure your Docker containers are still running. Switch back to your terminal and run:

docker compose ps

Both order-system-mongodb and order-system-kafka should show a status of running. If either is stopped, run docker compose up -d to restart them.

Ask for help: My Order Service won't start and I'm seeing connection errors in the terminal.

  • Press Ctrl+C in the terminal to stop the service for now.

✔️ Awesome, I've got everything!

Great. Double check you have saved all five files before moving on.

ⓧ I'd like to double check the full code

import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { join } from 'path';
import { OrderServiceModule } from './order-service.module';

async function bootstrap() {
  const app = await NestFactory.createMicroservice<MicroserviceOptions>(
    OrderServiceModule,
    {
      transport: Transport.GRPC,
      options: {
        package: 'order',
        protoPath: join(__dirname, '../../../proto/order.proto'),
        url: '0.0.0.0:5000',
      },
    },
  );
  await app.listen();
  console.log('Order Service (gRPC) is listening on port 5000');
}
bootstrap();
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { OrderController } from './order.controller';
import { OrderService } from './order.service';
import { Order, OrderSchema } from './schemas/order.schema';

@Module({
  imports: [
    MongooseModule.forRoot('mongodb://localhost:27017/orders-db'),
    MongooseModule.forFeature([{ name: Order.name, schema: OrderSchema }]),
    ClientsModule.register([
      {
        name: 'KAFKA_SERVICE',
        transport: Transport.KAFKA,
        options: {
          client: {
            clientId: 'order-service',
            brokers: ['localhost:9092'],
          },
          producerOnlyMode: true,
        },
      },
    ]),
  ],
  controllers: [OrderController],
  providers: [OrderService],
})
export class OrderServiceModule {}
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';

@Schema({ timestamps: true })
export class Order extends Document {
  @Prop({ required: true })
  productName: string;

  @Prop({ required: true })
  quantity: number;

  @Prop({ required: true })
  price: number;

  @Prop({ default: 'CREATED' })
  status: string;
}

export const OrderSchema = SchemaFactory.createForClass(Order);
import { Injectable, Inject, OnModuleInit } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { ClientKafka } from '@nestjs/microservices';
import { Order } from './schemas/order.schema';

@Injectable()
export class OrderService implements OnModuleInit {
  constructor(
    @InjectModel(Order.name) private orderModel: Model<Order>,
    @Inject('KAFKA_SERVICE') private kafkaClient: ClientKafka,
  ) {}

  async onModuleInit() {
    await this.kafkaClient.connect();
  }

  async createOrder(data: {
    productName: string;
    quantity: number;
    price: number;
  }): Promise<Order> {
    const order = new this.orderModel(data);
    const savedOrder = await order.save();

    this.kafkaClient.emit('order.created', {
      orderId: savedOrder._id.toString(),
      productName: savedOrder.productName,
      quantity: savedOrder.quantity,
      price: savedOrder.price,
      status: savedOrder.status,
      createdAt: savedOrder.createdAt,
    });

    return savedOrder;
  }

  async findOrder(id: string): Promise<Order> {
    return this.orderModel.findById(id).exec();
  }

  async findAllOrders(): Promise<Order[]> {
    return this.orderModel.find().exec();
  }
}
import { Controller } from '@nestjs/common';
import { GrpcMethod } from '@nestjs/microservices';
import { OrderService } from './order.service';

@Controller()
export class OrderController {
  constructor(private readonly orderService: OrderService) {}

  @GrpcMethod('OrderService', 'CreateOrder')
  async createOrder(data: {
    productName: string;
    quantity: number;
    price: number;
  }) {
    const order = await this.orderService.createOrder(data);
    return {
      id: order._id.toString(),
      productName: order.productName,
      quantity: order.quantity,
      price: order.price,
      status: order.status,
      createdAt: order.createdAt?.toISOString() ?? '',
    };
  }

  @GrpcMethod('OrderService', 'FindOrder')
  async findOrder(data: { id: string }) {
    const order = await this.orderService.findOrder(data.id);
    return {
      id: order._id.toString(),
      productName: order.productName,
      quantity: order.quantity,
      price: order.price,
      status: order.status,
      createdAt: order.createdAt?.toISOString() ?? '',
    };
  }

  @GrpcMethod('OrderService', 'FindAllOrders')
  async findAllOrders() {
    const orders = await this.orderService.findAllOrders();
    return {
      orders: orders.map((order) => ({
        id: order._id.toString(),
        productName: order.productName,
        quantity: order.quantity,
        price: order.price,
        status: order.status,
        createdAt: order.createdAt?.toISOString() ?? '',
      })),
    };
  }
}

Your Order Service is fully wired up. It can receive gRPC calls, persist data, and broadcast events. Next up, you will build the API Gateway that gives external clients a REST interface to this service.

Build the API Gateway

Your Order Service is running as a gRPC server, ready to accept requests. But external clients (browsers, mobile apps, other teams) don't speak gRPC. They speak REST.

The API Gateway pattern solves this by placing a lightweight HTTP service in front of your internal microservices. In this step, you will build a REST gateway that translates incoming HTTP requests into gRPC calls to the Order Service.

In this step, get ready to:
  • Configure the gateway module with a gRPC client connection to the Order Service.
  • Create REST endpoints that forward requests to the Order Service via gRPC.
  • Add request validation with DTOs and test the end-to-end flow.
Configure the gateway module and entry point

The gateway needs two things: a standard HTTP listener for external clients, and a gRPC client that connects to the Order Service on port 5000. NestJS handles both through its module system and ClientsModule registration.

  • Open apps/gateway/src/gateway.module.ts and replace its entire contents with the following code:
import { Module } from '@nestjs/common';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { join } from 'path';
import { OrdersController } from './orders.controller';

@Module({
  imports: [
    // Register a gRPC client that connects to the Order Service
    ClientsModule.register([
      {
        name: 'ORDER_PACKAGE',
        transport: Transport.GRPC,
        options: {
          package: 'order',
          protoPath: join(__dirname, '../../../proto/order.proto'),
          url: 'localhost:5000',
        },
      },
    ]),
  ],
  controllers: [OrdersController],
})
export class GatewayModule {}

What does this code do?

ClientsModule.register creates a gRPC client named ORDER_PACKAGE that you can inject into controllers. The package and protoPath options must match the Order Service's gRPC configuration exactly so both sides agree on the contract.

The url: 'localhost:5000' tells the client where to find the Order Service's gRPC server. This is the same port the Order Service listens on.

You won't be able to start the gateway yet because OrdersController does not exist. You will create it in the next substep.

  • Now open apps/gateway/src/main.ts and replace its contents with:
import { NestFactory } from '@nestjs/core';
import { GatewayModule } from './gateway.module';

async function bootstrap() {
  // Create a standard HTTP application (Express under the hood)
  const app = await NestFactory.create(GatewayModule);
  await app.listen(3000);
  console.log('API Gateway (REST) is listening on port 3000');
}
bootstrap();

What does this code do?

Unlike the Order Service (which uses createMicroservice), the gateway uses NestFactory.create to spin up a standard HTTP server. This is the entry point for all external REST requests on port 3000.

The gateway itself is not a microservice. It is a regular web app that happens to call microservices internally.

Why REST for the gateway and gRPC internally?

REST is universally supported by browsers, mobile SDKs, and third-party integrations. gRPC is faster and type-safe but requires generated client stubs that external consumers cannot easily use.

The API Gateway pattern gives you the best of both worlds: a simple REST surface for external clients with high-performance gRPC between internal services.

  • Save both files.
Create REST endpoints that call gRPC

The controller translates HTTP verbs into gRPC method calls. Each REST endpoint injects the gRPC client, calls the matching RPC, and returns the result as JSON.

  • Create the DTO file first. Create a new folder dto inside apps/gateway/src/, then create create-order.dto.ts inside it with the following code:
import { IsString, IsNumber, IsPositive } from 'class-validator';

export class CreateOrderDto {
  @IsString()
  productName: string;

  @IsNumber()
  @IsPositive()
  quantity: number;

  @IsNumber()
  @IsPositive()
  price: number;
}

What does this code do?

The CreateOrderDto class uses class-validator decorators to define validation rules. When a request arrives, @IsString() ensures productName is text, while @IsNumber() and @IsPositive() enforce that quantity and price are positive numbers.

This catches malformed requests at the gateway before they ever reach the Order Service.

  • Save create-order.dto.ts.
  • Now create apps/gateway/src/orders.controller.ts with the following code:
import { Controller, Get, Post, Body, Param, OnModuleInit, Inject } from '@nestjs/common';
import { ClientGrpc } from '@nestjs/microservices';
import { Observable, firstValueFrom } from 'rxjs';
import { CreateOrderDto } from './dto/create-order.dto';

// Define the shape of the gRPC service interface
interface OrderServiceGrpc {
  createOrder(data: {
    productName: string;
    quantity: number;
    price: number;
  }): Observable<any>;
  findOrder(data: { id: string }): Observable<any>;
  findAllOrders(data: {}): Observable<any>;
}

@Controller('orders')
export class OrdersController implements OnModuleInit {
  private orderService: OrderServiceGrpc;

  constructor(@Inject('ORDER_PACKAGE') private client: ClientGrpc) {}

  onModuleInit() {
    // Obtain the gRPC service stub from the client package
    this.orderService =
      this.client.getService<OrderServiceGrpc>('OrderService');
  }

  @Post()
  async create(@Body() createOrderDto: CreateOrderDto) {
    // Forward the REST POST body to the gRPC CreateOrder RPC
    return firstValueFrom(this.orderService.createOrder(createOrderDto));
  }

  @Get(':id')
  async findOne(@Param('id') id: string) {
    // Forward the REST GET request to the gRPC FindOrder RPC
    return firstValueFrom(this.orderService.findOrder({ id }));
  }

  @Get()
  async findAll() {
    // Forward the REST GET request to the gRPC FindAllOrders RPC
    return firstValueFrom(this.orderService.findAllOrders({}));
  }
}

What does this code do?

  • @Inject('ORDER_PACKAGE') injects the gRPC client registered in the module.
  • this.client.getService<OrderServiceGrpc>('OrderService') obtains a proxy for the OrderService defined in order.proto. NestJS generates this stub from the proto file at runtime.
  • gRPC calls return Observables (from RxJS). firstValueFrom converts them into Promises so the REST endpoint can await the result and return plain JSON.
  • Save orders.controller.ts.
  • Start the gateway by running:
npx nest start gateway
  • You should see API Gateway (REST) is listening on port 3000 in the terminal.

Seeing a module not found error?

  • Check that apps/gateway/src/dto/create-order.dto.ts exists and the import path in orders.controller.ts matches: './dto/create-order.dto'.
  • Make sure OrdersController is imported correctly in gateway.module.ts from './orders.controller'.
  • If you see errors about class-validator decorators, you will install that package in the next substep.

Help me debug my gateway startup error.

Add request validation and test the full flow

The DTO decorators define rules, but NestJS does not enforce them unless you enable a ValidationPipe globally. You also need the class-validator and class-transformer packages installed.

  • Stop the gateway if it is still running (press Ctrl+C).
  • Install the validation packages by running:
npm i --save class-validator class-transformer
  • You should see the packages added to your package.json dependencies.

Install failing?

  • Make sure you are running the command from the order-system root folder (where package.json lives), not from inside apps/gateway.
  • If you see permission errors, try running with sudo (macOS/Linux) or an elevated terminal (Windows).

Help me fix my npm install error.

  • Now update apps/gateway/src/main.ts to enable the ValidationPipe. Find this line:
import { NestFactory } from '@nestjs/core';
import { GatewayModule } from './gateway.module';
  • Replace the entire file with the following updated version that adds the ValidationPipe:
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { GatewayModule } from './gateway.module';

async function bootstrap() {
  const app = await NestFactory.create(GatewayModule);
  // Enable global validation so DTOs enforce their rules
  app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
  await app.listen(3000);
  console.log('API Gateway (REST) is listening on port 3000');
}
bootstrap();

What does the whitelist option do?

ValidationPipe({ whitelist: true }) strips any properties from the request body that are not defined in the DTO. This prevents clients from injecting unexpected fields into your service calls.

  • Save main.ts.
  • Start the Order Service in one terminal by running:
npx nest start order-service
  • Open a second terminal and start the gateway by running:
npx nest start gateway
  • You should see both Order Service (gRPC) is listening on port 5000 and API Gateway (REST) is listening on port 3000 in your terminals.
  • Open a third terminal and test the full REST-to-gRPC flow by running:
curl -X POST http://localhost:3000/orders \
  -H "Content-Type: application/json" \
  -d '{"productName": "Mechanical Keyboard", "quantity": 2, "price": 89.99}'
  • You should see a JSON response containing the created order with an id, productName, quantity, price, status, and createdAt fields.

Getting connection refused?

  • Confirm the Order Service started first and shows the port 5000 message. The gateway's gRPC client connects to localhost:5000, so the Order Service must be running.
  • Verify Docker containers are still running with docker compose ps. MongoDB must be up for the Order Service to save data.
  • Check that the protoPath in gateway.module.ts resolves to proto/order.proto. A wrong path causes a silent gRPC failure.

Help me debug my curl request to the gateway.

  • Test validation by sending an invalid request:
curl -X POST http://localhost:3000/orders \
  -H "Content-Type: application/json" \
  -d '{"productName": 123, "quantity": -1}'
  • You should see a 400 Bad Request response with validation error messages. This confirms the ValidationPipe is catching invalid data before it reaches the Order Service.

✔️ Awesome, I've got everything!

Great. Double check you have saved all four gateway files before moving on.

ⓧ I'd like to double check the full code

import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { GatewayModule } from './gateway.module';

async function bootstrap() {
  const app = await NestFactory.create(GatewayModule);
  app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
  await app.listen(3000);
  console.log('API Gateway (REST) is listening on port 3000');
}
bootstrap();
import { Module } from '@nestjs/common';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { join } from 'path';
import { OrdersController } from './orders.controller';

@Module({
  imports: [
    ClientsModule.register([
      {
        name: 'ORDER_PACKAGE',
        transport: Transport.GRPC,
        options: {
          package: 'order',
          protoPath: join(__dirname, '../../../proto/order.proto'),
          url: 'localhost:5000',
        },
      },
    ]),
  ],
  controllers: [OrdersController],
})
export class GatewayModule {}
import { Controller, Get, Post, Body, Param, OnModuleInit, Inject } from '@nestjs/common';
import { ClientGrpc } from '@nestjs/microservices';
import { Observable, firstValueFrom } from 'rxjs';
import { CreateOrderDto } from './dto/create-order.dto';

interface OrderServiceGrpc {
  createOrder(data: {
    productName: string;
    quantity: number;
    price: number;
  }): Observable<any>;
  findOrder(data: { id: string }): Observable<any>;
  findAllOrders(data: {}): Observable<any>;
}

@Controller('orders')
export class OrdersController implements OnModuleInit {
  private orderService: OrderServiceGrpc;

  constructor(@Inject('ORDER_PACKAGE') private client: ClientGrpc) {}

  onModuleInit() {
    this.orderService =
      this.client.getService<OrderServiceGrpc>('OrderService');
  }

  @Post()
  async create(@Body() createOrderDto: CreateOrderDto) {
    return firstValueFrom(this.orderService.createOrder(createOrderDto));
  }

  @Get(':id')
  async findOne(@Param('id') id: string) {
    return firstValueFrom(this.orderService.findOrder({ id }));
  }

  @Get()
  async findAll() {
    return firstValueFrom(this.orderService.findAllOrders({}));
  }
}
import { IsString, IsNumber, IsPositive } from 'class-validator';

export class CreateOrderDto {
  @IsString()
  productName: string;

  @IsNumber()
  @IsPositive()
  quantity: number;

  @IsNumber()
  @IsPositive()
  price: number;
}

Your gateway is live and translating REST into gRPC. Next up, you will build the Notification Service that listens for Kafka events produced by the Order Service and reacts to them asynchronously.

Build the Notification Service

Your API Gateway accepts REST requests and forwards them via gRPC to the Order Service, which persists orders and emits events to Apache Kafka. Those events are currently disappearing into the void because nothing is listening.

In this step, you will build the Notification Service that consumes order.created events from Kafka and persists notification records to its own MongoDB database. This completes the async event-driven leg of your architecture.

In this step, get ready to:
  • Configure the Notification Service as a Kafka consumer microservice.
  • Create an event handler that reacts to order events and persists notifications.
  • Test the full end-to-end flow across all three services.
Configure the Notification Service as a Kafka microservice

The Notification Service uses a fundamentally different transport than the Order Service. Instead of serving RPC calls, it subscribes to a Kafka topic and reacts whenever a message arrives.

Why Kafka instead of gRPC here?

The Order Service uses gRPC because the gateway needs an immediate response (the created order). Notifications are fire-and-forget. The Order Service should never wait for the notification to finish before responding to the client.

Kafka decouples the two services in time. The Order Service publishes an event and moves on. The Notification Service picks it up whenever it is ready.

You will create three files: the Mongoose schema for notifications, the module wiring MongoDB, and the entry point that boots the Kafka consumer.

  • Create the notification schema file by adding a new file at apps/notification-service/src/schemas/notification.schema.ts with the following code:
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';

@Schema({ timestamps: true })
export class Notification extends Document {
  @Prop({ required: true })
  orderId: string;

  @Prop({ required: true })
  message: string;

  @Prop({ default: 'sent' })
  status: string;
}

export const NotificationSchema = SchemaFactory.createForClass(Notification);

What does this code do?

This schema defines the shape of notification documents in MongoDB. Each notification records which order triggered it (orderId), a human-readable message, and a status defaulting to 'sent'.

The @Schema({ timestamps: true }) decorator automatically adds createdAt and updatedAt fields to every document.

  • Save notification.schema.ts.
  • Now set up the module by replacing the contents of apps/notification-service/src/notification-service.module.ts with this code:
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { NotificationController } from './notification.controller';
import { Notification, NotificationSchema } from './schemas/notification.schema';

@Module({
  imports: [
    MongooseModule.forRoot('mongodb://localhost:27017/notifications-db'),
    MongooseModule.forFeature([
      { name: Notification.name, schema: NotificationSchema },
    ]),
  ],
  controllers: [NotificationController],
})
export class NotificationServiceModule {}

What does this code do?

This module connects to a separate MongoDB database called notifications-db. This follows the database-per-service pattern where each microservice owns its own data store.

MongooseModule.forFeature registers the Notification model so it can be injected into controllers. The NotificationController (which you will create next) handles incoming Kafka events.

  • Save notification-service.module.ts.
  • Configure the Kafka transport by replacing the contents of apps/notification-service/src/main.ts with this code:
import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { NotificationServiceModule } from './notification-service.module';

async function bootstrap() {
  const app = await NestFactory.createMicroservice<MicroserviceOptions>(
    NotificationServiceModule,
    {
      transport: Transport.KAFKA,
      options: {
        client: {
          clientId: 'notification-service',
          brokers: ['localhost:9092'],
        },
        consumer: {
          groupId: 'notification-group',
        },
      },
    },
  );
  await app.listen();
  console.log('Notification Service (Kafka Consumer) is listening');
}
bootstrap();

What does this code do?

This bootstraps the Notification Service as a pure Kafka consumer. Unlike the Order Service (which uses Transport.GRPC), this service uses Transport.KAFKA and joins a consumer group called notification-group.

The clientId identifies this service to the Kafka broker. The groupId ensures that if you scale to multiple instances, each message is processed by only one consumer in the group.

  • Save main.ts.

You won't see a change yet. The module references NotificationController which does not exist yet. You will create it in the next substep.

Seeing a TypeScript import error?

Your editor may show a red underline on the NotificationController import in the module file. This is expected because the controller file does not exist yet. The error disappears after the next substep.

help me fix import errors in my NestJS notification service module

Create the event handler controller

In NestJS Kafka microservices, you use the @EventPattern decorator to subscribe a handler method to a specific topic. When a message arrives on that topic, NestJS automatically deserializes it and invokes your handler.

  • Create the controller file at apps/notification-service/src/notification.controller.ts with the following code:
import { Controller, Logger } from '@nestjs/common';
import { EventPattern, Payload } from '@nestjs/microservices';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { Notification } from './schemas/notification.schema';

@Controller()
export class NotificationController {
  private readonly logger = new Logger(NotificationController.name);

  constructor(
    @InjectModel(Notification.name)
    private notificationModel: Model<Notification>,
  ) {}

  @EventPattern('order.created')
  async handleOrderCreated(
    @Payload() message: {
      orderId: string;
      productName: string;
      quantity: number;
      price: number;
      status: string;
      createdAt: string;
    },
  ) {
    this.logger.log(
      `Received order.created event for order: ${message.orderId}`,
    );

    const notification = new this.notificationModel({
      orderId: message.orderId,
      message: `Order ${message.orderId} created: ${message.quantity}x ${message.productName} at $${message.price}`,
      status: 'sent',
    });

    await notification.save();
    this.logger.log(`Notification saved for order: ${message.orderId}`);
  }
}

What does this code do?

@EventPattern('order.created') tells NestJS to invoke handleOrderCreated every time a message arrives on the order.created Kafka topic. The @Payload() decorator extracts the deserialized message body.

The handler logs the incoming event, constructs a human-readable notification message, and persists it to notifications-db with status 'sent'. The NestJS Logger class provides structured console output with the controller name as context.

  • Save notification.controller.ts.

The TypeScript import error from earlier should now be resolved.

✔️ Awesome, I've got everything!

Great. Make sure all four Notification Service files are saved before moving on.

ⓧ I'd like to double check the full code

Here is the full code for each Notification Service file:

import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';

@Schema({ timestamps: true })
export class Notification extends Document {
  @Prop({ required: true })
  orderId: string;

  @Prop({ required: true })
  message: string;

  @Prop({ default: 'sent' })
  status: string;
}

export const NotificationSchema = SchemaFactory.createForClass(Notification);
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { NotificationController } from './notification.controller';
import { Notification, NotificationSchema } from './schemas/notification.schema';

@Module({
  imports: [
    MongooseModule.forRoot('mongodb://localhost:27017/notifications-db'),
    MongooseModule.forFeature([
      { name: Notification.name, schema: NotificationSchema },
    ]),
  ],
  controllers: [NotificationController],
})
export class NotificationServiceModule {}
import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { NotificationServiceModule } from './notification-service.module';

async function bootstrap() {
  const app = await NestFactory.createMicroservice<MicroserviceOptions>(
    NotificationServiceModule,
    {
      transport: Transport.KAFKA,
      options: {
        client: {
          clientId: 'notification-service',
          brokers: ['localhost:9092'],
        },
        consumer: {
          groupId: 'notification-group',
        },
      },
    },
  );
  await app.listen();
  console.log('Notification Service (Kafka Consumer) is listening');
}
bootstrap();
import { Controller, Logger } from '@nestjs/common';
import { EventPattern, Payload } from '@nestjs/microservices';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { Notification } from './schemas/notification.schema';

@Controller()
export class NotificationController {
  private readonly logger = new Logger(NotificationController.name);

  constructor(
    @InjectModel(Notification.name)
    private notificationModel: Model<Notification>,
  ) {}

  @EventPattern('order.created')
  async handleOrderCreated(
    @Payload() message: {
      orderId: string;
      productName: string;
      quantity: number;
      price: number;
      status: string;
      createdAt: string;
    },
  ) {
    this.logger.log(
      `Received order.created event for order: ${message.orderId}`,
    );

    const notification = new this.notificationModel({
      orderId: message.orderId,
      message: `Order ${message.orderId} created: ${message.quantity}x ${message.productName} at $${message.price}`,
      status: 'sent',
    });

    await notification.save();
    this.logger.log(`Notification saved for order: ${message.orderId}`);
  }
}
Run all three services and test end-to-end

With all three services implemented, you can now start the full system and verify the complete flow: REST request hits the gateway, gRPC call creates the order, and a Kafka event triggers the notification.

  • Make sure your Docker containers are still running by checking their status:
docker compose ps

You should see both order-system-mongodb and order-system-kafka with a status of running.

  • Open three separate terminal windows (or tabs) inside the order-system folder. Start each service in its own terminal by running these commands one per terminal:

Terminal 1 (Order Service):

npx nest start order-service --watch

Terminal 2 (API Gateway):

npx nest start gateway --watch

Terminal 3 (Notification Service):

npx nest start notification-service --watch

What should I see?

Each terminal prints a startup confirmation. Terminal 1 shows "Order Service (gRPC) is listening on port 5000". Terminal 2 shows "API Gateway (REST) is listening on port 3000". Terminal 3 shows "Notification Service (Kafka Consumer) is listening".

Service fails to start?

If the Order Service or Notification Service cannot connect to MongoDB or Kafka, confirm your Docker containers are running with docker compose ps. If they are stopped, run docker compose up -d again.

If you see a port conflict on 5000 or 3000, make sure you do not have another process using those ports.

help me debug why my NestJS microservice won't start

  • Once all three services are running, test the end-to-end flow by creating an order. Open a fourth terminal and run:
curl -X POST http://localhost:3000/orders -H "Content-Type: application/json" -d '{"productName": "Mechanical Keyboard", "quantity": 2, "price": 89.99}'

You should see a JSON response with the created order, including an id, productName, quantity, price, and status: "CREATED".

  • Check Terminal 3 (Notification Service). You should see log output showing the event was received and the notification was saved.
  • Verify the notification was persisted in MongoDB by running this command to inspect the notifications-db database directly:
docker exec order-system-mongodb mongosh --eval "use notifications-db" --eval "db.notifications.find().pretty()"

You should see a notification document with the orderId matching the order you just created, a message string describing the order, and status: "sent".

  • You can also verify the order was saved in the orders database by running:
docker exec order-system-mongodb mongosh --eval "use orders-db" --eval "db.orders.find().pretty()"

You should see your order document with productName: "Mechanical Keyboard" and status: "CREATED".

Order created but no notification appeared?

Check that the Notification Service terminal shows it successfully connected to Kafka. If you started the Notification Service before Kafka was fully ready, restart it.

Verify the Order Service is actually emitting events by checking its terminal for any Kafka connection errors. The KAFKA_CFG_AUTO_CREATE_TOPICS_ENABLE=true setting in Docker Compose ensures the topic is created automatically on first publish.

help me debug why my Kafka consumer is not receiving messages

Your full microservices system is now running. You have REST for external clients, gRPC for fast internal calls, and Kafka for asynchronous event propagation. Next, the Secret Mission challenges you to handle what happens when things go wrong in the notification pipeline.

Secret mission

Dead Letter Queue with Retry Logic

Your Notification Service works great when everything goes smoothly. But what happens when a notification fails to process? In production, failed messages disappear silently unless you build a safety net. Add a dead-letter queue pattern with automatic retry logic so no event is ever lost.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally using Docker containers, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Docker containers (order-system-mongodb and order-system-kafka).
  • Docker volume (mongo_data) storing MongoDB data.
  • The order-system project folder on your Desktop.
  • NestJS CLI (globally installed via npm).

Keep everything running

No action needed. Choose this if you are still actively building or experimenting with the microservices system.

  • Your Docker containers will continue running in the background, using system memory and CPU.
  • Your NestJS services can be restarted at any time from the order-system folder.
  • The NestJS CLI remains available globally for future projects.

Pause - I'll come back to this later

Shut down running processes to free up memory. Your project files and Docker images stay on disk so you can resume later.

  • Stop any running NestJS services by pressing Ctrl+C in each terminal window.
  • Stop the Docker containers by running this command from your order-system folder:
docker compose down

This stops and removes the containers but keeps your Docker images and the mongo_data volume intact. When you are ready to resume, run docker compose up -d from the same folder.

Delete - I don't want to use this again

Remove all project resources and start fresh. This permanently deletes your code, Docker volumes, and stored data.

  • Stop any running NestJS services by pressing Ctrl+C in each terminal window.
  • Stop the Docker containers and remove the associated volume by running this command from your order-system folder:
docker compose down -v

The -v flag removes the mongo_data volume, permanently deleting all MongoDB data stored during the project.

  • Delete the project folder by running:
cd ~/Desktop
rm -rf order-system

On Windows, use this command instead:

cd ~\Desktop
rmdir /s /q order-system
  • Uninstall the NestJS CLI globally by running:
npm uninstall -g @nestjs/cli

Nice Work!

Nice Work!

You just built a production-grade distributed system with three NestJS microservices, each using the right communication pattern for its role in the architecture.

What you learned:

  • Architected a NestJS monorepo with three independently running services, each owning its own MongoDB database for true service isolation.
  • Connected services using gRPC with Protocol Buffers for type-safe internal communication and REST for the external API Gateway layer.
  • Integrated Apache Kafka in KRaft mode for asynchronous event-driven messaging, with Docker Compose orchestrating all infrastructure locally.
  • Secret Mission: Added a dead-letter queue pattern with retry logic for production-grade error handling in Kafka event consumers.

Ready to quiz yourself?