Real-Time Order Pipeline with Redis
Build an event-driven NestJS backend with Redis Pub/Sub, caching, and WebSockets.
Introduction
30 Second Summary
When you place an order on a fast e-commerce site, dozens of things happen behind the scenes. Stock updates, analytics records, and real-time notifications all fire without slowing down the checkout.
In this project, you will build an event-driven order pipeline using NestJS, Redis Pub/Sub, and MongoDB that decouples downstream concerns into independent subscribers reacting to a single event.
What You'll Build
A product catalog and order system backend where placing an order emits an event via NestJS Redis transport, and three independent subscribers react to invalidate cache, persist analytics, and push updates to connected clients in real time.
By the end of this project, you'll have:
- A NestJS hybrid application combining HTTP, Redis microservice transport, and a WebSocket gateway in a single process.
- A Redis cache-aside pattern with event-driven invalidation that keeps product data fresh without manual cache management.
- Three decoupled event handlers that independently react to order events for cache invalidation, analytics persistence, and real-time client push.
- Secret Mission: a wildcard pattern subscription system that routes notifications to clients based on their chosen event categories.
Are there any prerequisites?
This is a hard project that assumes solid NestJS experience (modules, services, dependency injection), TypeScript fluency, and basic familiarity with MongoDB and Mongoose.
You should also have Redis running locally on your machine before you begin.
Scaffold and Configure the Hybrid App
Every event-driven system starts with a solid foundation. In this project, you are building a NestJS hybrid application that combines a standard HTTP server with a Redis-powered microservice transport and a WebSocket gateway in a single process.
In this step, you will scaffold the project, install all necessary packages, and wire up the hybrid configuration so both the HTTP server and the Redis microservice transport start together.
In this step, get ready to:
- Scaffold a NestJS project with TypeScript strict mode.
- Install Redis, MongoDB, and WebSocket dependencies.
- Configure the app as a hybrid HTTP + microservice application.
Create the NestJS project and install dependencies
The NestJS CLI scaffolds a full TypeScript project with a module structure, testing config, and build tooling in one command. The --strict flag enables tighter TypeScript checks that catch more bugs at compile time.
- Move to your Desktop by running this command:
cd ~/Desktop
- Scaffold the NestJS project by running:
nest new order-event-pipeline --strict
What will the CLI ask?
The CLI will prompt: "Which package manager would you ❤️ to use?" with options for npm, yarn, and pnpm. Select npm using the arrow keys and press Enter.
The CLI then installs default dependencies automatically. Wait for it to finish before continuing.
- Confirm the project was created by running:
ls order-event-pipeline
You should see folders like src, node_modules, and files like package.json and tsconfig.json.
Command not found?
If nest is not recognized, make sure the NestJS CLI is installed globally. Run npm i -g @nestjs/cli and try again.
help me fix a 'nest command not found' error
- Move into the project folder by running:
cd order-event-pipeline
- Install the additional packages this project needs by running:
npm install @nestjs/microservices ioredis @nestjs/mongoose mongoose @nestjs/websockets @nestjs/platform-socket.io @nestjs/config
What are these packages?
- @nestjs/microservices adds the microservice transport layer (including Transport.REDIS).
- ioredis is the Redis client that powers both the transport and your cache layer.
- @nestjs/mongoose and mongoose provide MongoDB integration with schema decorators.
- @nestjs/websockets and @nestjs/platform-socket.io enable the WebSocket gateway for real-time push.
- @nestjs/config loads environment variables globally.
- Verify the install succeeded by checking that no errors appeared in the terminal output.
Seeing peer dependency warnings?
Peer dependency warnings (yellow text) are informational and safe to ignore. Errors (red text) indicate a failed install. If you see errors, check your Node.js version and try running the install command again.
help me fix npm install errors in my NestJS project
Configure main.ts as a hybrid application
A NestJS hybrid application runs both an HTTP server and a microservice transport in one process. The HTTP side handles REST requests while the microservice side listens for Redis Pub/Sub events.
- Open the order-event-pipeline folder in your code editor.
- Open src/main.ts and replace its entire contents with the following code:
import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.connectMicroservice<MicroserviceOptions>({
transport: Transport.REDIS,
options: {
host: 'localhost',
port: 6379,
},
});
await app.startAllMicroservices();
await app.listen(3000);
console.log('HTTP server on http://localhost:3000');
console.log('Redis microservice transport connected');
}
bootstrap();
What does this code do?
- NestFactory.create(AppModule) spins up the HTTP server (Express under the hood).
- app.connectMicroservice() attaches a Redis transport listener. This is what subscribes to Redis Pub/Sub channels for incoming events.
- Transport.REDIS tells NestJS to use the Redis transporter, which wraps ioredis internally.
- app.startAllMicroservices() starts the Redis subscriber BEFORE the HTTP server begins accepting requests.
- app.listen(3000) starts the HTTP server on port 3000.
- Save src/main.ts.
Set up AppModule and verify connectivity
The AppModule is the root module that registers all feature modules and global configuration. Before you can replace it, you need to create placeholder modules so the import paths resolve.
- Generate the four placeholder modules by running these commands:
nest generate module products
nest generate module orders
nest generate module event-handlers
nest generate module events-gateway
What do these commands do?
Each command creates a folder with an empty module file (e.g. src/products/products.module.ts) and auto-updates app.module.ts with the import. You will replace app.module.ts entirely in the next step, so the auto-updates do not matter.
- Delete the default controller and service files that the scaffold created (they are unused in this project):
rm src/app.controller.ts src/app.service.ts src/app.controller.spec.ts src/app.service.spec.ts
- Open src/app.module.ts and replace its entire contents with:
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { MongooseModule } from '@nestjs/mongoose';
import { ProductsModule } from './products/products.module';
import { OrdersModule } from './orders/orders.module';
import { EventHandlersModule } from './event-handlers/event-handlers.module';
import { EventsGatewayModule } from './events-gateway/events-gateway.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
MongooseModule.forRoot('mongodb://localhost:27017/order-event-pipeline'),
ProductsModule,
OrdersModule,
EventHandlersModule,
EventsGatewayModule,
],
})
export class AppModule {}
What does this module configure?
- ConfigModule.forRoot({ isGlobal: true }) loads .env variables and makes them available everywhere without re-importing.
- MongooseModule.forRoot(...) connects to your local MongoDB instance and creates the order-event-pipeline database automatically on first write.
- The four feature module imports register each domain area. They are empty shells right now and will be built out in the next steps.
- Save src/app.module.ts.
- Start the application by running:
npm run start:dev
You should see output confirming both connections are alive. Look for these lines in the console:
- HTTP server on http://localhost:3000 confirms the REST layer is up.
- Redis microservice transport connected confirms the Redis subscriber attached.
- A Mongoose log line mentioning Connected confirms MongoDB is reachable.
App not starting?
- If you see a Redis connection error, confirm Redis is running locally on port 6379. Run redis-cli ping in a separate terminal. You should get PONG back.
- If Mongoose fails to connect, confirm MongoDB is running on port 27017. Run mongosh --eval "db.version()" to verify.
- If you see "Cannot find module" errors, make sure all four nest generate module commands ran successfully. Check that src/products/products.module.ts exists.
help me debug my NestJS hybrid app startup errors
✔️ Awesome, I've got everything!
Great. Double check you have saved both src/main.ts and src/app.module.ts, and your terminal shows the app running on port 3000 with Redis connected.
ⓧ I'd like to double check the full code
Here is the full 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.create(AppModule);
app.connectMicroservice<MicroserviceOptions>({
transport: Transport.REDIS,
options: {
host: 'localhost',
port: 6379,
},
});
await app.startAllMicroservices();
await app.listen(3000);
console.log('HTTP server on http://localhost:3000');
console.log('Redis microservice transport connected');
}
bootstrap();
Here is the full src/app.module.ts:
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { MongooseModule } from '@nestjs/mongoose';
import { ProductsModule } from './products/products.module';
import { OrdersModule } from './orders/orders.module';
import { EventHandlersModule } from './event-handlers/event-handlers.module';
import { EventsGatewayModule } from './events-gateway/events-gateway.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
MongooseModule.forRoot('mongodb://localhost:27017/order-event-pipeline'),
ProductsModule,
OrdersModule,
EventHandlersModule,
EventsGatewayModule,
],
})
export class AppModule {}
Your hybrid app foundation is in place. Next up, you will build the Product module with a Redis cache-aside pattern that proves the caching layer works before any events enter the picture.
Build the Product Module with Redis Cache-Aside
Your hybrid app can connect to Redis and serve HTTP requests, but it has no data layer yet. The product catalog needs to respond fast under load, and repeatedly querying MongoDB for the same product is wasteful.
The cache-aside pattern solves this. On every read, the service checks Redis first. If the data exists there (a cache hit), it returns immediately without touching MongoDB. If not (a cache miss), it fetches from MongoDB and stores the result in Redis for next time.
In this step, get ready to:
- Build a reusable Redis cache service using ioredis directly.
- Create the Product schema, service, and controller with cache-aside reads.
- Verify the caching behaviour with curl and redis-cli.
Create the Redis cache service
You need a thin wrapper around ioredis that handles JSON serialization and TTL management. By using ioredis directly instead of NestJS cache-manager, you keep full control over the actual Redis commands.
- Create the folder src/redis-cache and add a file called redis-cache.service.ts with the following code:
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import Redis from 'ioredis';
@Injectable()
export class RedisCacheService implements OnModuleDestroy {
private readonly client: Redis;
constructor() {
// Connect to the local Redis instance
this.client = new Redis({ host: 'localhost', port: 6379 });
}
// Retrieve and deserialize a cached value by key
async get<T>(key: string): Promise<T | null> {
const data = await this.client.get(key);
if (!data) return null;
return JSON.parse(data) as T;
}
// Serialize and store a value with a TTL in seconds
async set(key: string, value: unknown, ttlSeconds = 60): Promise<void> {
await this.client.set(key, JSON.stringify(value), 'EX', ttlSeconds);
}
// Delete a single key
async del(key: string): Promise<void> {
await this.client.del(key);
}
// Find all keys matching a glob pattern and delete them
async delByPattern(pattern: string): Promise<void> {
const keys = await this.client.keys(pattern);
if (keys.length > 0) {
await this.client.del(...keys);
}
}
// Cleanly disconnect when the NestJS app shuts down
onModuleDestroy() {
this.client.disconnect();
}
}
What does this code do?
The service creates a single ioredis client connection on instantiation. Every value is JSON-serialized on write and deserialized on read, so you can cache any object shape.
The set method uses the EX flag to set a TTL in seconds. The delByPattern method uses KEYS to find matching keys and delete them in bulk. The OnModuleDestroy lifecycle hook cleanly disconnects when the app shuts down.
Why ioredis directly instead of cache-manager?
NestJS cache-manager adds a layer of abstraction that hides the actual Redis commands. For a production caching layer, you want explicit control over key patterns, TTL strategies, and bulk operations.
Using ioredis directly exposes the real commands (GET, SET, DEL, KEYS) you would use in any production Redis setup.
- In the same src/redis-cache folder, create redis-cache.module.ts to register the service as a global module:
import { Module, Global } from '@nestjs/common';
import { RedisCacheService } from './redis-cache.service';
// @Global makes RedisCacheService injectable anywhere without explicit imports
@Global()
@Module({
providers: [RedisCacheService],
exports: [RedisCacheService],
})
export class RedisCacheModule {}
What does this code do?
The @Global() decorator makes RedisCacheService injectable in any module without explicitly importing RedisCacheModule everywhere. You still import it in the consuming module to make the dependency visible in the module graph.
- Save both files.
You will not see a change yet. The Products module uses this cache service next.
✔️ Awesome, I've got everything!
Great. Make sure both files are saved in src/redis-cache/.
ⓧ I'd like to double check the full code
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import Redis from 'ioredis';
@Injectable()
export class RedisCacheService implements OnModuleDestroy {
private readonly client: Redis;
constructor() {
this.client = new Redis({ host: 'localhost', port: 6379 });
}
async get<T>(key: string): Promise<T | null> {
const data = await this.client.get(key);
if (!data) return null;
return JSON.parse(data) as T;
}
async set(key: string, value: unknown, ttlSeconds = 60): Promise<void> {
await this.client.set(key, JSON.stringify(value), 'EX', ttlSeconds);
}
async del(key: string): Promise<void> {
await this.client.del(key);
}
async delByPattern(pattern: string): Promise<void> {
const keys = await this.client.keys(pattern);
if (keys.length > 0) {
await this.client.del(...keys);
}
}
onModuleDestroy() {
this.client.disconnect();
}
}
import { Module, Global } from '@nestjs/common';
import { RedisCacheService } from './redis-cache.service';
@Global()
@Module({
providers: [RedisCacheService],
exports: [RedisCacheService],
})
export class RedisCacheModule {}
Build the Product schema and service with cache-aside
The Product model stores the catalog data in MongoDB. The service wraps every read with a cache check so repeated requests skip the database entirely.
- Create the folder src/products/schemas and add product.schema.ts:
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument } from 'mongoose';
export type ProductDocument = HydratedDocument<Product>;
// Three core fields: name, price, stock
@Schema({ timestamps: true })
export class Product {
@Prop({ required: true })
name: string;
@Prop({ required: true })
price: number;
@Prop({ required: true, default: 100 })
stock: number;
}
export const ProductSchema = SchemaFactory.createForClass(Product);
What does this code do?
This defines a Mongoose schema with three fields: name, price, and stock. The timestamps: true option auto-generates createdAt and updatedAt fields. Stock defaults to 100 if not specified.
- Create src/products/products.service.ts with the cache-aside logic:
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { Product, ProductDocument } from './schemas/product.schema';
import { RedisCacheService } from '../redis-cache/redis-cache.service';
@Injectable()
export class ProductsService {
constructor(
@InjectModel(Product.name) private productModel: Model<ProductDocument>,
private readonly cache: RedisCacheService,
) {}
async create(data: { name: string; price: number; stock: number }): Promise<Product> {
const product = await this.productModel.create(data);
// Invalidate list cache so the new product appears on next read
await this.cache.del('products:all');
return product;
}
async findAll(): Promise<{ data: Product[]; fromCache: boolean }> {
// Check Redis first
const cached = await this.cache.get<Product[]>('products:all');
if (cached) return { data: cached, fromCache: true };
// Cache miss: fetch from MongoDB and populate cache with 60s TTL
const products = await this.productModel.find().lean();
await this.cache.set('products:all', products, 60);
return { data: products, fromCache: false };
}
async findOne(id: string): Promise<{ data: Product | null; fromCache: boolean }> {
const cacheKey = `product:${id}`;
// Check Redis first
const cached = await this.cache.get<Product>(cacheKey);
if (cached) return { data: cached, fromCache: true };
// Cache miss: fetch from MongoDB and populate cache with 60s TTL
const product = await this.productModel.findById(id).lean();
if (product) {
await this.cache.set(cacheKey, product, 60);
}
return { data: product, fromCache: false };
}
// Used by the Orders module later to reduce stock after a purchase
async decrementStock(productId: string, quantity: number): Promise<void> {
await this.productModel.findByIdAndUpdate(productId, {
$inc: { stock: -quantity },
});
}
}
How does the cache-aside pattern work here?
- findOne checks Redis with key product:{id}. On a hit, it returns immediately with fromCache: true. On a miss, it queries MongoDB, stores the result in Redis with a 60-second TTL, and returns fromCache: false.
- findAll uses the same pattern with key products:all.
- create invalidates the products:all cache so the list reflects the new product on the next read.
- The fromCache flag lets you confirm caching is working directly from the API response.
- Create src/products/products.controller.ts to expose the REST endpoints:
import { Controller, Get, Post, Param, Body } from '@nestjs/common';
import { ProductsService } from './products.service';
@Controller('products')
export class ProductsController {
constructor(private readonly productsService: ProductsService) {}
@Post()
create(@Body() body: { name: string; price: number; stock: number }) {
return this.productsService.create(body);
}
@Get()
findAll() {
return this.productsService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.productsService.findOne(id);
}
}
What does this code do?
Three standard REST routes: POST /products to create, GET /products to list all, and GET /products/:id to fetch one. Each delegates to the service and returns the result directly, including the fromCache flag on reads.
- Create src/products/products.module.ts to wire everything together:
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { Product, ProductSchema } from './schemas/product.schema';
import { ProductsService } from './products.service';
import { ProductsController } from './products.controller';
import { RedisCacheModule } from '../redis-cache/redis-cache.module';
@Module({
imports: [
// Register the Product model for this module's scope
MongooseModule.forFeature([{ name: Product.name, schema: ProductSchema }]),
RedisCacheModule,
],
controllers: [ProductsController],
providers: [ProductsService],
// Export so the Orders module can use ProductsService later
exports: [ProductsService],
})
export class ProductsModule {}
What does this code do?
This registers the Product Mongoose model in this module's scope with MongooseModule.forFeature(). It imports RedisCacheModule explicitly to make the dependency visible in the module graph. It exports ProductsService so the Orders module can access product data later.
- Save all four files.
- Restart your NestJS app by pressing Ctrl+C in the terminal and running:
npm run start:dev
Seeing module resolution errors on startup?
Your app.module.ts imports OrdersModule, EventHandlersModule, and EventsGatewayModule which do not exist yet. If you see a TypeScript compilation error about missing modules, temporarily comment out those imports in app.module.ts until you create them in later steps.
Also confirm that Redis is running locally. If you see a connection refused error, start Redis with redis-server in a separate terminal.
help me debug a NestJS module resolution error on startup
You should see the app start with logs confirming the HTTP server on port 3000 and the Redis microservice transport connected.
✔️ Awesome, I've got everything!
Great. Double check you have saved all files in src/products/ and src/redis-cache/.
ⓧ I'd like to double check the full code
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument } from 'mongoose';
export type ProductDocument = HydratedDocument<Product>;
@Schema({ timestamps: true })
export class Product {
@Prop({ required: true })
name: string;
@Prop({ required: true })
price: number;
@Prop({ required: true, default: 100 })
stock: number;
}
export const ProductSchema = SchemaFactory.createForClass(Product);
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { Product, ProductDocument } from './schemas/product.schema';
import { RedisCacheService } from '../redis-cache/redis-cache.service';
@Injectable()
export class ProductsService {
constructor(
@InjectModel(Product.name) private productModel: Model<ProductDocument>,
private readonly cache: RedisCacheService,
) {}
async create(data: { name: string; price: number; stock: number }): Promise<Product> {
const product = await this.productModel.create(data);
await this.cache.del('products:all');
return product;
}
async findAll(): Promise<{ data: Product[]; fromCache: boolean }> {
const cached = await this.cache.get<Product[]>('products:all');
if (cached) return { data: cached, fromCache: true };
const products = await this.productModel.find().lean();
await this.cache.set('products:all', products, 60);
return { data: products, fromCache: false };
}
async findOne(id: string): Promise<{ data: Product | null; fromCache: boolean }> {
const cacheKey = `product:${id}`;
const cached = await this.cache.get<Product>(cacheKey);
if (cached) return { data: cached, fromCache: true };
const product = await this.productModel.findById(id).lean();
if (product) {
await this.cache.set(cacheKey, product, 60);
}
return { data: product, fromCache: false };
}
async decrementStock(productId: string, quantity: number): Promise<void> {
await this.productModel.findByIdAndUpdate(productId, {
$inc: { stock: -quantity },
});
}
}
import { Controller, Get, Post, Param, Body } from '@nestjs/common';
import { ProductsService } from './products.service';
@Controller('products')
export class ProductsController {
constructor(private readonly productsService: ProductsService) {}
@Post()
create(@Body() body: { name: string; price: number; stock: number }) {
return this.productsService.create(body);
}
@Get()
findAll() {
return this.productsService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.productsService.findOne(id);
}
}
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { Product, ProductSchema } from './schemas/product.schema';
import { ProductsService } from './products.service';
import { ProductsController } from './products.controller';
import { RedisCacheModule } from '../redis-cache/redis-cache.module';
@Module({
imports: [
MongooseModule.forFeature([{ name: Product.name, schema: ProductSchema }]),
RedisCacheModule,
],
controllers: [ProductsController],
providers: [ProductsService],
exports: [ProductsService],
})
export class ProductsModule {}
Test the cache-aside pattern
With the app running, you can prove the cache-aside behaviour by making repeated requests and observing the fromCache flag.
- Create a test product by running this in a new terminal:
curl -X POST http://localhost:3000/products \
-H "Content-Type: application/json" \
-d '{"name": "Wireless Keyboard", "price": 49.99, "stock": 200}'
What should I see?
The response is a JSON object with the product fields plus a generated _id field. Copy this _id value. You need it for the next requests.
- Fetch that product to trigger the first cache miss by running:
curl http://localhost:3000/products/[[PRODUCT_ID="your _id from the POST response"]]
You should see "fromCache":false in the response. The service fetched from MongoDB and populated Redis.
- Run the same GET request again immediately:
curl http://localhost:3000/products/[[PRODUCT_ID="your _id from the POST response"]]
This time you should see "fromCache":true. The data came from Redis without touching MongoDB.
- Verify the Redis keys and TTL by running:
redis-cli KEYS "product:*"
- Check the TTL on your product key by running:
redis-cli TTL "product:[[PRODUCT_ID="your _id from the POST response"]]"
You should see the key listed and a TTL value counting down from 60 seconds.
Still seeing fromCache: false on the second request?
- Confirm Redis is running. Run redis-cli ping and check that it returns PONG.
- Make sure you are using the exact same product ID in both requests. A typo creates a different cache key.
- Check that your RedisCacheService constructor points to localhost:6379. If Redis is on a different port, the service connects silently to nothing.
help me debug why my Redis cache-aside is not returning cached data on the second request
Your cache-aside layer is working. Every product read now hits Redis first, and the response tells you exactly where the data came from. Next up, you will emit order events through Redis transport so that independent handlers can invalidate this cache automatically.
Emit Order Events via Redis Transport
Your product catalog now has full CRUD with Redis caching in front of MongoDB. The next piece is the order pathway.
When a customer places an order, your system needs to notify multiple downstream consumers: a cache invalidator, an analytics recorder, and eventually a real-time WebSocket feed. Instead of calling each consumer synchronously (which couples your order code to every downstream dependency), you will emit a single event via Redis Pub/Sub. Any number of independent subscribers can then react to that event on their own terms.
In this step, get ready to:
- Create an Order schema and persist orders to MongoDB.
- Register a ClientProxy with Transport.REDIS to emit events.
- Build a POST /orders endpoint that emits an order.created event.
Create the Order schema
Every order needs to record which product was purchased, how many units, and the total price. Mongoose handles timestamps automatically when you enable them on the schema.
- Create a new folder src/orders/schemas inside your project.
- Create a file called order.schema.ts inside that folder with the following code:
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument } from 'mongoose';
export type OrderDocument = HydratedDocument<Order>;
@Schema({ timestamps: true })
export class Order {
@Prop({ required: true })
productId: string;
@Prop({ required: true })
quantity: number;
@Prop({ required: true })
totalPrice: number;
}
export const OrderSchema = SchemaFactory.createForClass(Order);
What does this code do?
This defines the shape of an order document in MongoDB. Each order stores a productId, quantity, and totalPrice.
The @Schema({ timestamps: true }) decorator tells Mongoose to automatically add createdAt and updatedAt fields to every document. You do not need to define those fields manually.
- Save src/orders/schemas/order.schema.ts.
Build the OrdersService with event emission
The service is where the real work happens. It fetches the product to calculate the total price, persists the order, decrements stock, and then emits an event over the NestJS Redis transport so downstream subscribers can react independently.
- Create a file called orders.service.ts inside src/orders/ with the following code:
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { ClientProxy } from '@nestjs/microservices';
import { Order, OrderDocument } from './schemas/order.schema';
import { ProductsService } from '../products/products.service';
@Injectable()
export class OrdersService {
constructor(
@InjectModel(Order.name) private orderModel: Model<OrderDocument>,
@Inject('ORDERS_SERVICE') private readonly ordersClient: ClientProxy,
private readonly productsService: ProductsService,
) {}
async create(data: { productId: string; quantity: number }): Promise<Order> {
// Look up the product to get its current price
const { data: product } = await this.productsService.findOne(data.productId);
if (!product) {
throw new NotFoundException(`Product ${data.productId} not found`);
}
// Calculate total from unit price and quantity
const totalPrice = product.price * data.quantity;
// Persist the order to MongoDB
const order = await this.orderModel.create({
productId: data.productId,
quantity: data.quantity,
totalPrice,
});
// Decrement product stock
await this.productsService.decrementStock(data.productId, data.quantity);
// Emit event via Redis transport for downstream subscribers
this.ordersClient.emit('order.created', {
orderId: order._id.toString(),
productId: data.productId,
quantity: data.quantity,
totalPrice,
timestamp: new Date().toISOString(),
});
return order;
}
}
What does this code do?
The create method orchestrates the entire order flow in sequence. It fetches the product price, calculates the total, saves the order, and decrements stock.
The critical line is this.ordersClient.emit('order.created', payload). This publishes the event payload to the order.created Redis channel. The emit() call is fire-and-forget. It does not wait for any subscriber to acknowledge receipt.
The @Inject('ORDERS_SERVICE') decorator tells NestJS to inject the ClientProxy instance registered under the token ORDERS_SERVICE. You will register that token in the module file next.
Why emit instead of calling handlers directly?
By emitting an event, the order service stays completely decoupled from whatever reacts to it. You can add, remove, or modify subscribers without touching the order code at all.
This is the pub/sub fan-out pattern: one publisher, many independent subscribers reacting to the same channel.
- Save src/orders/orders.service.ts.
Wire up the controller and module, then test event emission
The controller exposes a single POST endpoint. The module ties everything together and registers the ClientProxy with Transport.REDIS so the service can inject it.
- Create a file called orders.controller.ts inside src/orders/ with this code:
import { Controller, Post, Body } from '@nestjs/common';
import { OrdersService } from './orders.service';
@Controller('orders')
export class OrdersController {
constructor(private readonly ordersService: OrdersService) {}
@Post()
create(@Body() body: { productId: string; quantity: number }) {
return this.ordersService.create(body);
}
}
What does this code do?
This controller maps POST /orders to the service's create method. The request body must include productId and quantity. The controller does not calculate the price or emit events. All business logic stays in the service layer.
- Save src/orders/orders.controller.ts.
- Create a file called orders.module.ts inside src/orders/ with this code:
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { Order, OrderSchema } from './schemas/order.schema';
import { OrdersService } from './orders.service';
import { OrdersController } from './orders.controller';
import { ProductsModule } from '../products/products.module';
@Module({
imports: [
// Register the Order model with Mongoose
MongooseModule.forFeature([{ name: Order.name, schema: OrderSchema }]),
// Register a Redis ClientProxy for emitting events
ClientsModule.register([
{
name: 'ORDERS_SERVICE',
transport: Transport.REDIS,
options: {
host: 'localhost',
port: 6379,
},
},
]),
// Import ProductsModule to access ProductsService
ProductsModule,
],
controllers: [OrdersController],
providers: [OrdersService],
})
export class OrdersModule {}
What does this code do?
The ClientsModule.register() call creates a ClientProxy instance bound to the injection token 'ORDERS_SERVICE'. This proxy connects to Redis at localhost:6379 and handles publishing events to Redis channels when you call emit().
Importing ProductsModule makes ProductsService available for dependency injection (because ProductsModule already exports it). This is how the OrdersService can look up product prices without duplicating database queries.
- Save src/orders/orders.module.ts.
app.module.ts already imports OrdersModule
You do not need to modify app.module.ts. The scaffold step already added the OrdersModule import there.
Now test the full flow. You will open a Redis subscriber in one terminal to watch for the raw event, then place an order from a second terminal.
- Open a new terminal and start a Redis subscription by running:
redis-cli SUBSCRIBE order.created
- In a separate terminal, restart your NestJS app by running:
npm run start:dev
- First create a test product by running:
curl -X POST http://localhost:3000/products -H "Content-Type: application/json" -d '{"name": "Widget", "price": 25, "stock": 100}'
- Copy the _id value from the response. Place an order using that product ID by running:
curl -X POST http://localhost:3000/orders -H "Content-Type: application/json" -d '{"productId": "[[PRODUCT_ID="the _id from the product you just created"]]", "quantity": 2}'
- Switch to the terminal running redis-cli SUBSCRIBE order.created. You should see a message arrive containing the order payload with orderId, productId, quantity, totalPrice, and timestamp.
Not seeing a message in redis-cli?
Make sure your redis-cli SUBSCRIBE terminal was running before you placed the order. Redis Pub/Sub is fire-and-forget. If no subscriber is listening at the moment of publish, the message is lost.
If you see a connection error, confirm Redis is running locally on port 6379 with redis-cli ping. You should get PONG back.
If the POST /orders request returns a 404, double-check that you used a valid _id from the product creation response.
help me debug why my order event is not appearing in redis-cli SUBSCRIBE
✔️ Awesome, I've got everything!
Great. Double check you have saved all four files in the src/orders/ directory.
ⓧ I'd like to double check the full code
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument } from 'mongoose';
export type OrderDocument = HydratedDocument<Order>;
@Schema({ timestamps: true })
export class Order {
@Prop({ required: true })
productId: string;
@Prop({ required: true })
quantity: number;
@Prop({ required: true })
totalPrice: number;
}
export const OrderSchema = SchemaFactory.createForClass(Order);
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { ClientProxy } from '@nestjs/microservices';
import { Order, OrderDocument } from './schemas/order.schema';
import { ProductsService } from '../products/products.service';
@Injectable()
export class OrdersService {
constructor(
@InjectModel(Order.name) private orderModel: Model<OrderDocument>,
@Inject('ORDERS_SERVICE') private readonly ordersClient: ClientProxy,
private readonly productsService: ProductsService,
) {}
async create(data: { productId: string; quantity: number }): Promise<Order> {
const { data: product } = await this.productsService.findOne(data.productId);
if (!product) {
throw new NotFoundException(`Product ${data.productId} not found`);
}
const totalPrice = product.price * data.quantity;
const order = await this.orderModel.create({
productId: data.productId,
quantity: data.quantity,
totalPrice,
});
await this.productsService.decrementStock(data.productId, data.quantity);
this.ordersClient.emit('order.created', {
orderId: order._id.toString(),
productId: data.productId,
quantity: data.quantity,
totalPrice,
timestamp: new Date().toISOString(),
});
return order;
}
}
import { Controller, Post, Body } from '@nestjs/common';
import { OrdersService } from './orders.service';
@Controller('orders')
export class OrdersController {
constructor(private readonly ordersService: OrdersService) {}
@Post()
create(@Body() body: { productId: string; quantity: number }) {
return this.ordersService.create(body);
}
}
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { Order, OrderSchema } from './schemas/order.schema';
import { OrdersService } from './orders.service';
import { OrdersController } from './orders.controller';
import { ProductsModule } from '../products/products.module';
@Module({
imports: [
MongooseModule.forFeature([{ name: Order.name, schema: OrderSchema }]),
ClientsModule.register([
{
name: 'ORDERS_SERVICE',
transport: Transport.REDIS,
options: {
host: 'localhost',
port: 6379,
},
},
]),
ProductsModule,
],
controllers: [OrdersController],
providers: [OrdersService],
})
export class OrdersModule {}
Your order pipeline now publishes events into Redis. In the next step, you will build three independent subscribers that each react to the same order.created event: one to invalidate the product cache, one to record analytics, and one to push real-time updates over WebSockets.
Build Three Independent Event Subscribers
Your order system emits an order.created event through Redis, but nothing is listening for it yet.
In NestJS microservices, multiple controllers can subscribe to the same @EventPattern and all handlers fire in parallel when that event arrives. You will use this fan-out model to build three completely independent subscribers that each react to the same event with different responsibilities.
In this step, get ready to:
- Create a cache invalidation handler that wipes stale product data from Redis.
- Create an analytics handler that records order metrics to MongoDB.
- Create a WebSocket gateway that broadcasts order events to connected browser clients.
Create the cache invalidation handler
When an order is placed, the product's stock changes in MongoDB. The Redis cache still holds the old value. A dedicated handler listening for order.created can immediately delete those stale cache keys so the next read fetches fresh data.
- Create the folder src/event-handlers inside your project.
- Create a new file src/event-handlers/cache-invalidation.handler.ts and add the following code:
import { Controller } from '@nestjs/common';
import { EventPattern, Payload } from '@nestjs/microservices';
import { RedisCacheService } from '../redis-cache/redis-cache.service';
@Controller()
export class CacheInvalidationHandler {
constructor(private readonly cache: RedisCacheService) {}
@EventPattern('order.created')
async handleOrderCreated(@Payload() data: { productId: string }) {
console.log(`[CacheInvalidation] Invalidating cache for product ${data.productId}`);
await this.cache.del(`product:${data.productId}`);
await this.cache.del('products:all');
}
}
What does this code do?
The @Controller() decorator is required so NestJS recognizes this class as a microservice handler. The @EventPattern('order.created') decorator tells the Redis transport to invoke this method whenever a message is published to the order.created channel.
The handler deletes two cache keys: the specific product entry (product:{id}) and the full product list (products:all). This forces the next read to hit MongoDB and populate a fresh cache value.
- Save cache-invalidation.handler.ts.
Seeing an import error for RedisCacheService?
Make sure the relative path uses ../redis-cache/redis-cache.service (going up one directory from event-handlers to src, then into redis-cache). Double-check that your RedisCacheModule is still marked @Global() so it is available without explicit imports.
help me fix my RedisCacheService import in cache-invalidation.handler.ts
Create the analytics handler
A completely separate subscriber can record order metrics to its own MongoDB collection. The order service knows nothing about this analytics pipeline. It only emits the event and moves on.
- Create the folder src/event-handlers/schemas inside the event-handlers directory.
- Create src/event-handlers/schemas/order-analytics.schema.ts with the following schema definition:
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument } from 'mongoose';
export type OrderAnalyticsDocument = HydratedDocument<OrderAnalytics>;
@Schema()
export class OrderAnalytics {
@Prop({ required: true })
orderId: string;
@Prop({ required: true })
productId: string;
@Prop({ required: true })
quantity: number;
@Prop({ required: true })
revenue: number;
@Prop({ required: true })
recordedAt: Date;
}
export const OrderAnalyticsSchema = SchemaFactory.createForClass(OrderAnalytics);
What does this schema track?
This is a dedicated analytics collection, separate from the orders collection. It captures revenue (mapped from totalPrice in the event payload) and recordedAt (the timestamp from the event). Storing analytics separately means you can query, aggregate, and even move this collection to a different database later without touching the core orders data.
- Save order-analytics.schema.ts.
- Create src/event-handlers/analytics.handler.ts with the following code:
import { Controller } from '@nestjs/common';
import { EventPattern, Payload } from '@nestjs/microservices';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { OrderAnalytics, OrderAnalyticsDocument } from './schemas/order-analytics.schema';
@Controller()
export class AnalyticsHandler {
constructor(
@InjectModel(OrderAnalytics.name) private analyticsModel: Model<OrderAnalyticsDocument>,
) {}
@EventPattern('order.created')
async handleOrderCreated(
@Payload() data: { orderId: string; productId: string; quantity: number; totalPrice: number; timestamp: string },
) {
console.log(`[Analytics] Recording analytics for order ${data.orderId}`);
await this.analyticsModel.create({
orderId: data.orderId,
productId: data.productId,
quantity: data.quantity,
revenue: data.totalPrice,
recordedAt: new Date(data.timestamp),
});
}
}
What does this handler do?
This handler also uses @EventPattern('order.created'). NestJS triggers all matching handlers in parallel, so both this analytics handler and the cache invalidation handler fire independently from the same event.
The @Payload() decorator extracts the event data that OrdersService emitted. The handler maps totalPrice to revenue and converts the ISO timestamp string to a Date object before persisting.
- Save analytics.handler.ts.
- Now wire both handlers together by creating src/event-handlers/event-handlers.module.ts:
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { CacheInvalidationHandler } from './cache-invalidation.handler';
import { AnalyticsHandler } from './analytics.handler';
import { OrderAnalytics, OrderAnalyticsSchema } from './schemas/order-analytics.schema';
import { RedisCacheModule } from '../redis-cache/redis-cache.module';
@Module({
imports: [
MongooseModule.forFeature([{ name: OrderAnalytics.name, schema: OrderAnalyticsSchema }]),
RedisCacheModule,
],
controllers: [CacheInvalidationHandler, AnalyticsHandler],
})
export class EventHandlersModule {}
Why are handlers listed in controllers?
In NestJS microservices, classes decorated with @Controller() that use @EventPattern() must be registered in the module's controllers array. This tells the microservice transport layer to scan them for event subscriptions. They are not HTTP controllers even though they share the same decorator.
- Save event-handlers.module.ts.
- Your app.module.ts already imports EventHandlersModule from the scaffolding step, so no changes are needed there.
Module not found error for event-handlers.module?
Verify your file is at exactly src/event-handlers/event-handlers.module.ts (with the hyphen). The import path in app.module.ts uses ./event-handlers/event-handlers.module, which must match your folder name character-for-character.
help me fix the module import path for EventHandlersModule
✔️ Awesome, I've got everything!
Great. Make sure all three files in src/event-handlers/ are saved before moving on.
ⓧ I'd like to double check the full code
import { Controller } from '@nestjs/common';
import { EventPattern, Payload } from '@nestjs/microservices';
import { RedisCacheService } from '../redis-cache/redis-cache.service';
@Controller()
export class CacheInvalidationHandler {
constructor(private readonly cache: RedisCacheService) {}
@EventPattern('order.created')
async handleOrderCreated(@Payload() data: { productId: string }) {
console.log(`[CacheInvalidation] Invalidating cache for product ${data.productId}`);
await this.cache.del(`product:${data.productId}`);
await this.cache.del('products:all');
}
}
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument } from 'mongoose';
export type OrderAnalyticsDocument = HydratedDocument<OrderAnalytics>;
@Schema()
export class OrderAnalytics {
@Prop({ required: true })
orderId: string;
@Prop({ required: true })
productId: string;
@Prop({ required: true })
quantity: number;
@Prop({ required: true })
revenue: number;
@Prop({ required: true })
recordedAt: Date;
}
export const OrderAnalyticsSchema = SchemaFactory.createForClass(OrderAnalytics);
import { Controller } from '@nestjs/common';
import { EventPattern, Payload } from '@nestjs/microservices';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { OrderAnalytics, OrderAnalyticsDocument } from './schemas/order-analytics.schema';
@Controller()
export class AnalyticsHandler {
constructor(
@InjectModel(OrderAnalytics.name) private analyticsModel: Model<OrderAnalyticsDocument>,
) {}
@EventPattern('order.created')
async handleOrderCreated(
@Payload() data: { orderId: string; productId: string; quantity: number; totalPrice: number; timestamp: string },
) {
console.log(`[Analytics] Recording analytics for order ${data.orderId}`);
await this.analyticsModel.create({
orderId: data.orderId,
productId: data.productId,
quantity: data.quantity,
revenue: data.totalPrice,
recordedAt: new Date(data.timestamp),
});
}
}
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { CacheInvalidationHandler } from './cache-invalidation.handler';
import { AnalyticsHandler } from './analytics.handler';
import { OrderAnalytics, OrderAnalyticsSchema } from './schemas/order-analytics.schema';
import { RedisCacheModule } from '../redis-cache/redis-cache.module';
@Module({
imports: [
MongooseModule.forFeature([{ name: OrderAnalytics.name, schema: OrderAnalyticsSchema }]),
RedisCacheModule,
],
controllers: [CacheInvalidationHandler, AnalyticsHandler],
})
export class EventHandlersModule {}
Create the WebSocket gateway
The third subscriber bridges the Redis event system to the browser. A WebSocket gateway maintains persistent connections with browser clients and pushes events to them in real time. It subscribes to the same order.created event pattern, then broadcasts the payload to every connected socket.
- Create the folder src/events-gateway inside your project.
- Create src/events-gateway/events.gateway.ts with the following code:
import { Controller } from '@nestjs/common';
import {
WebSocketGateway,
WebSocketServer,
OnGatewayConnection,
OnGatewayDisconnect,
} from '@nestjs/websockets';
import { EventPattern, Payload } from '@nestjs/microservices';
import { Server, Socket } from 'socket.io';
@WebSocketGateway({ cors: { origin: '*' } })
@Controller()
export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server: Server;
private connectedClients = 0;
handleConnection(client: Socket) {
this.connectedClients++;
console.log(`[WebSocket] Client connected: ${client.id} (total: ${this.connectedClients})`);
}
handleDisconnect(client: Socket) {
this.connectedClients--;
console.log(`[WebSocket] Client disconnected: ${client.id} (total: ${this.connectedClients})`);
}
@EventPattern('order.created')
handleOrderCreated(
@Payload() data: { orderId: string; productId: string; quantity: number; totalPrice: number; timestamp: string },
) {
console.log(`[WebSocket] Broadcasting order.created to ${this.connectedClients} clients`);
this.server.emit('orderCreated', data);
}
}
Why does this class have two decorators?
The @WebSocketGateway() decorator sets up the socket.io server and handles client connections. The @Controller() decorator is needed so NestJS scans the class for @EventPattern() subscriptions. Both decorators on the same class is valid in NestJS and lets a single class act as both a WebSocket gateway and a microservice event subscriber.
The handleConnection and handleDisconnect lifecycle hooks come from the OnGatewayConnection and OnGatewayDisconnect interfaces. They track how many clients are currently connected.
- Save events.gateway.ts.
- Create the module file src/events-gateway/events-gateway.module.ts:
import { Module } from '@nestjs/common';
import { EventsGateway } from './events.gateway';
@Module({
providers: [EventsGateway],
controllers: [EventsGateway],
})
export class EventsGatewayModule {}
Why is EventsGateway in both providers and controllers?
The providers array registers the gateway as a WebSocket provider (so NestJS initializes the socket.io server). The controllers array registers it as a microservice handler (so NestJS scans it for @EventPattern decorators). Both registrations are necessary for the dual-role pattern to work.
- Save events-gateway.module.ts.
- Your app.module.ts already imports EventsGatewayModule, so no further changes are needed.
Cannot find module socket.io?
The socket.io types come from the @nestjs/platform-socket.io package you installed in Step 1. If your editor shows a type error on Server or Socket, confirm that @nestjs/platform-socket.io and @nestjs/websockets are listed in your package.json dependencies.
help me resolve socket.io type errors in my events.gateway.ts
✔️ Awesome, I've got everything!
Great. Make sure both files in src/events-gateway/ are saved.
ⓧ I'd like to double check the full code
import { Controller } from '@nestjs/common';
import {
WebSocketGateway,
WebSocketServer,
OnGatewayConnection,
OnGatewayDisconnect,
} from '@nestjs/websockets';
import { EventPattern, Payload } from '@nestjs/microservices';
import { Server, Socket } from 'socket.io';
@WebSocketGateway({ cors: { origin: '*' } })
@Controller()
export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server: Server;
private connectedClients = 0;
handleConnection(client: Socket) {
this.connectedClients++;
console.log(`[WebSocket] Client connected: ${client.id} (total: ${this.connectedClients})`);
}
handleDisconnect(client: Socket) {
this.connectedClients--;
console.log(`[WebSocket] Client disconnected: ${client.id} (total: ${this.connectedClients})`);
}
@EventPattern('order.created')
handleOrderCreated(
@Payload() data: { orderId: string; productId: string; quantity: number; totalPrice: number; timestamp: string },
) {
console.log(`[WebSocket] Broadcasting order.created to ${this.connectedClients} clients`);
this.server.emit('orderCreated', data);
}
}
import { Module } from '@nestjs/common';
import { EventsGateway } from './events.gateway';
@Module({
providers: [EventsGateway],
controllers: [EventsGateway],
})
export class EventsGatewayModule {}
Verify the full fan-out flow
All three subscribers are wired up. Time to restart the app and confirm that a single POST /orders triggers all three handlers simultaneously.
- Stop your running NestJS app (Ctrl+C) and restart it by running:
npm run start:dev
- Confirm the console shows both HTTP server on http://localhost:3000 and Redis microservice transport connected.
- First, create a test product by running:
curl -X POST http://localhost:3000/products -H "Content-Type: application/json" -d '{"name": "Widget", "price": 25, "stock": 50}'
- Copy the _id from the response. You will use it in the next command.
- Place an order using that product ID by running:
curl -X POST http://localhost:3000/orders -H "Content-Type: application/json" -d '{"productId": "YOUR_PRODUCT_ID", "quantity": 2}'
- Replace YOUR_PRODUCT_ID with the actual _id value from the product response.
Check your terminal output. You should see three log lines appear:
- [CacheInvalidation] Invalidating cache for product ... confirms the cache handler fired.
- [Analytics] Recording analytics for order ... confirms the analytics handler fired.
- [WebSocket] Broadcasting order.created to 0 clients confirms the gateway fired (0 clients is expected since no browser is connected yet).
- Verify the cache was invalidated by running in a separate terminal:
redis-cli KEYS "product:*"
- You should see (empty array) or (empty list or set), confirming the cache keys were deleted.
- Verify the analytics document was created by running:
mongosh --eval "use order-event-pipeline" --eval "db.orderanalytics.find().pretty()"
- You should see a document with orderId, productId, quantity, revenue, and recordedAt fields populated.
Why is this architecture powerful?
One emit() call in OrdersService triggered three completely independent reactions. The order service has zero knowledge of cache invalidation, analytics recording, or WebSocket broadcasting. You can add a fourth subscriber tomorrow without touching any existing code.
This is the Redis Pub/Sub fan-out model. The publisher fires and forgets. Subscribers react on their own terms.
Only seeing some of the log lines?
Make sure both EventHandlersModule and EventsGatewayModule are listed in the imports array of app.module.ts. If a module is missing, its handlers will not be registered with the Redis transport.
Also confirm that each handler class is listed in its module's controllers array, not providers. Event pattern handlers must be in controllers for the transport to discover them.
help me debug why my event handlers are not firing on order.created
Three independent handlers, one event, zero coupling. Next up, you will build a test page that connects to the WebSocket gateway so you can watch order events arrive in the browser in real time.
Build the Real-Time Client Test Page
In the previous step, you built three independent event subscribers that react to the same order.created event. Cache invalidation, analytics persistence, and WebSocket broadcasting all happen behind the scenes.
Now it is time to close the loop with a real browser client. You will create a static HTML page that connects to your WebSocket gateway and displays order events as they happen. This is the final piece that proves the entire pub/sub pipeline works end-to-end.
In this step, get ready to:
- Create a static HTML test page that connects to the WebSocket gateway.
- Wire up the full end-to-end flow from order placement to real-time display.
- Test the fan-out pattern across multiple browser tabs.
Create the static HTML test page
Your EventsGateway is already broadcasting orderCreated events to all connected WebSocket clients. You need a browser-side client to receive and display them.
- Create a public folder at the root of your order-event-pipeline project (next to src) by running this command:
mkdir public
- Create public/index.html and paste the following code into it:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Order Event Feed</title>
<script src="https://cdn.socket.io/4.8.3/socket.io.min.js"></script>
<style>
body { font-family: monospace; background: #1a1a2e; color: #eee; padding: 20px; }
h1 { color: #e94560; }
#status { padding: 8px 16px; border-radius: 4px; display: inline-block; margin-bottom: 20px; }
.connected { background: #0f3460; color: #00d2d3; }
.disconnected { background: #3d0000; color: #e94560; }
#events { list-style: none; padding: 0; }
#events li { background: #16213e; padding: 12px; margin: 8px 0; border-radius: 4px; border-left: 4px solid #e94560; }
.timestamp { color: #888; font-size: 0.85em; }
</style>
</head>
<body>
<h1>Real-Time Order Event Feed</h1>
<div id="status" class="disconnected">Disconnected</div>
<ul id="events"></ul>
<script>
const socket = io('http://localhost:3000');
const statusEl = document.getElementById('status');
const eventsEl = document.getElementById('events');
socket.on('connect', () => {
statusEl.textContent = 'Connected: ' + socket.id;
statusEl.className = 'connected';
});
socket.on('disconnect', () => {
statusEl.textContent = 'Disconnected';
statusEl.className = 'disconnected';
});
socket.on('orderCreated', (data) => {
const li = document.createElement('li');
li.innerHTML = `
<strong>Order Created</strong> - ${data.orderId}<br>
Product: ${data.productId} | Qty: ${data.quantity} | Total: $${data.totalPrice}<br>
<span class="timestamp">${data.timestamp}</span>
`;
eventsEl.prepend(li);
});
</script>
</body>
</html>
What does this code do?
- The <script src="https://cdn.socket.io/4.8.3/socket.io.min.js"> tag loads the socket.io client library from a CDN.
- io('http://localhost:3000') opens a WebSocket connection to your NestJS server.
- The connect and disconnect listeners update the status badge so you always know whether the client is connected.
- The orderCreated listener catches events broadcast by your EventsGateway and prepends them to the list. Newest events appear at the top.
- Save public/index.html.
- Confirm the file exists by running:
ls public
- You should see index.html in the output.
Why open the file directly instead of serving it from NestJS?
Your WebSocket gateway is configured with cors: { origin: '*' }, which means any origin can connect to it. Opening the HTML file directly from your filesystem works perfectly for local development and testing.
In production you would serve static files through @nestjs/serve-static or a reverse proxy like Nginx. For this project, opening the file directly keeps things simple and avoids an extra dependency.
Wire up the full end-to-end flow
With the HTML page ready, you can now prove the entire pipeline works. You will open the page in a browser, place an order via curl, and watch the event appear in real-time.
- Make sure your NestJS app is running. If you stopped it earlier, restart it by running:
npm run start:dev
- Open public/index.html in your browser by double-clicking the file in Finder (macOS) or File Explorer (Windows).
- You should see the status badge change from "Disconnected" (red) to "Connected: " followed by your socket ID (teal).
Still seeing Disconnected?
- Make sure your NestJS app is running on port 3000. Check the terminal for HTTP server on http://localhost:3000.
- Check your browser's DevTools console for connection errors. The socket.io client needs to reach http://localhost:3000.
- help me debug why my WebSocket client is not connecting to the NestJS gateway
Now create a test product so you can place orders against it.
- Open a new terminal tab and create a product by running:
curl -X POST http://localhost:3000/products \
-H "Content-Type: application/json" \
-d '{"name": "Mechanical Keyboard", "price": 149, "stock": 50}'
- Copy the _id value from the response. You will use it in the next command.
- Place an order using that product ID by running:
curl -X POST http://localhost:3000/orders \
-H "Content-Type: application/json" \
-d '{"productId": "YOUR_PRODUCT_ID", "quantity": 2}'
What should I see?
Three things happen simultaneously after you place the order:
- Your browser tab shows a new event card with the order ID, product ID, quantity, total price, and timestamp.
- Your NestJS terminal prints log lines from [CacheInvalidation], [Analytics], and [WebSocket] confirming all three handlers fired.
- The Redis cache for that product is now invalidated.
- Verify cache invalidation by fetching the product (replace the ID with your product's _id):
curl http://localhost:3000/products/YOUR_PRODUCT_ID
- The response should show "fromCache": false because the CacheInvalidationHandler deleted the cached entry when the order event arrived.
Not seeing events in the browser?
- Make sure the status badge shows "Connected" before placing the order. Events are only delivered to clients connected at the time of broadcast.
- Check that you replaced YOUR_PRODUCT_ID in the curl command with the actual _id from the product creation response.
- help me figure out why order events are not reaching my browser client
Test the fan-out pattern with multiple tabs
The pub/sub broadcast model means one event reaches all connected subscribers simultaneously. You can prove this by opening multiple browser tabs.
- Open public/index.html in 2-3 additional browser tabs.
- Confirm each tab shows "Connected" with a unique socket ID.
- Place another order from your terminal by running:
curl -X POST http://localhost:3000/orders \
-H "Content-Type: application/json" \
-d '{"productId": "YOUR_PRODUCT_ID", "quantity": 1}'
- Check all browser tabs. Every tab should display the same event card at the same time.
- In your NestJS terminal, you should see the [WebSocket] Broadcasting order.created to N clients log line where N matches the number of tabs you opened.
Why does this matter?
This fan-out behaviour is the core value of Redis Pub/Sub in real systems. A single order event triggers cache invalidation, analytics recording, and real-time push to every connected client. The producer (OrdersService) knows nothing about any of these consumers.
Adding a fourth subscriber (email notifications, Slack alerts, inventory sync) requires zero changes to the order-creation code. You just add another handler with @EventPattern('order.created').
- Verify the analytics collection grew by checking MongoDB. Run this in a new terminal:
mongosh --eval "use order-event-pipeline" --eval "db.orderanalytics.find().pretty()"
- You should see one analytics document for each order you placed, with orderId, productId, quantity, revenue, and recordedAt fields.
Analytics documents missing?
- Make sure MongoDB is running locally on port 27017. The AnalyticsHandler depends on a live MongoDB connection.
- Check your NestJS terminal for [Analytics] Recording analytics for order log lines. If these are missing, the event is not reaching the handler.
- help me debug why analytics documents are not being created in MongoDB
✔️ Awesome, I've got everything!
Great. Double check you have saved public/index.html and confirmed the full end-to-end flow works.
ⓧ I'd like to double check the full code
Here is the complete public/index.html file:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Order Event Feed</title>
<script src="https://cdn.socket.io/4.8.3/socket.io.min.js"></script>
<style>
body { font-family: monospace; background: #1a1a2e; color: #eee; padding: 20px; }
h1 { color: #e94560; }
#status { padding: 8px 16px; border-radius: 4px; display: inline-block; margin-bottom: 20px; }
.connected { background: #0f3460; color: #00d2d3; }
.disconnected { background: #3d0000; color: #e94560; }
#events { list-style: none; padding: 0; }
#events li { background: #16213e; padding: 12px; margin: 8px 0; border-radius: 4px; border-left: 4px solid #e94560; }
.timestamp { color: #888; font-size: 0.85em; }
</style>
</head>
<body>
<h1>Real-Time Order Event Feed</h1>
<div id="status" class="disconnected">Disconnected</div>
<ul id="events"></ul>
<script>
const socket = io('http://localhost:3000');
const statusEl = document.getElementById('status');
const eventsEl = document.getElementById('events');
socket.on('connect', () => {
statusEl.textContent = 'Connected: ' + socket.id;
statusEl.className = 'connected';
});
socket.on('disconnect', () => {
statusEl.textContent = 'Disconnected';
statusEl.className = 'disconnected';
});
socket.on('orderCreated', (data) => {
const li = document.createElement('li');
li.innerHTML = `
<strong>Order Created</strong> - ${data.orderId}<br>
Product: ${data.productId} | Qty: ${data.quantity} | Total: $${data.totalPrice}<br>
<span class="timestamp">${data.timestamp}</span>
`;
eventsEl.prepend(li);
});
</script>
</body>
</html>
Secret mission
Wildcard Pattern Subscriptions for Notification Routing
Right now, every connected WebSocket client receives every order event. In production, different services care about different event types. What if a shipping dashboard only wants order.shipped events, while an analytics panel wants everything? Build a wildcard pattern subscription system that lets clients choose exactly which events they receive.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- Local MongoDB database order-event-pipeline with collections for products, orders, and analytics.
- Redis cache keys with patterns product:* and products:all (auto-expire with 60s TTL).
- Project folder order-event-pipeline on your Desktop.
Keep everything running
No action needed. Choose this if you're still actively building or experimenting with the event pipeline.
- Leave Redis and MongoDB running for continued experimentation.
- Your NestJS app will keep serving on port 3000 as long as the process is running.
- Redis cache keys will auto-expire after their 60s TTL, so stale data clears itself.
Pause - I'll come back to this later
Shut down the running NestJS process to free up your terminal. MongoDB data persists on disk and Redis keys will auto-expire.
- In the terminal running your NestJS app, press Ctrl+C to stop the process.
- Your MongoDB data remains on disk. When you come back, just run npm run start:dev from the project folder.
- Redis cache keys will have expired by the time you return. They will repopulate automatically on first read.
Delete - I don't want to use this again
Remove all project resources and start fresh. This drops your database, flushes Redis, and deletes the project folder.
- Stop the NestJS app by pressing Ctrl+C in the terminal where it is running.
- Drop the MongoDB database by running:
mongosh --eval "use order-event-pipeline; db.dropDatabase()"
- Flush your Redis cache by running:
redis-cli FLUSHDB
- Delete the project folder. On macOS/Linux:
cd ~/Desktop
rm -rf order-event-pipeline
On Windows:
cd ~\Desktop
rmdir /s /q order-event-pipeline
Nice Work!
Nice Work!
You just built a production-grade event-driven backend with NestJS, Redis Pub/Sub, and MongoDB. This is the exact architecture pattern used in real systems where order placement must stay fast while downstream concerns react independently.
You've learned how to:
- Build a NestJS hybrid application combining HTTP, Redis microservice transport, and a WebSocket gateway in a single process.
- Implement the cache-aside pattern with event-driven invalidation so cached product data stays fresh without relying on lazy TTL expiry.
- Create three decoupled event handlers (cache invalidation, analytics persistence, real-time push) that independently react to the same Redis Pub/Sub event.
- Secret Mission: Implemented wildcard pattern routing so WebSocket clients subscribe to specific event categories and receive only their matching events.
Ready to quiz yourself?