I’ve been thinking a lot about how modern applications need to handle complexity at scale. That’s what led me to explore event-driven microservices – a pattern that can transform how we build resilient, scalable systems. Today, I want to share my approach using NestJS, RabbitMQ, and MongoDB.
Setting up the foundation is straightforward with Docker. Here’s how I configure my development environment:
# docker-compose.yml
services:
rabbitmq:
image: rabbitmq:3-management
ports: ["5672:5672", "15672:15672"]
environment:
RABBITMQ_DEFAULT_USER: admin
RABBITMQ_DEFAULT_PASS: admin
mongodb:
image: mongo:6
ports: ["27017:27017"]
environment:
MONGO_INITDB_ROOT_USERNAME: admin
MONGO_INITDB_ROOT_PASSWORD: admin
Have you ever wondered how services stay decoupled while still communicating effectively? Event-driven architecture provides the answer. Let me show you how I define events that services can publish and consume:
// shared/events/order.events.ts
export class OrderCreatedEvent {
constructor(
public readonly orderId: string,
public readonly userId: string,
public readonly items: OrderItem[],
public readonly total: number
) {}
}
The Order Service becomes the heart of our system. Notice how I use CQRS patterns to separate commands from queries:
// order.module.ts
@Module({
imports: [
MongooseModule.forFeature([{ name: Order.name, schema: OrderSchema }]),
ClientsModule.register([{
name: 'RABBITMQ_SERVICE',
transport: Transport.RMQ,
options: {
urls: ['amqp://admin:admin@localhost:5672'],
queue: 'orders_queue',
queueOptions: { durable: true }
}
}]),
CqrsModule
],
controllers: [OrderController],
providers: [OrderService, OrderCreatedHandler, CreateOrderHandler]
})
export class OrderModule {}
What happens when an order is created? Multiple services need to react without being tightly coupled. Here’s how I handle command execution and event publishing:
@CommandHandler(CreateOrderCommand)
export class CreateOrderHandler implements ICommandHandler<CreateOrderCommand> {
constructor(
@InjectModel(Order.name) private orderModel: Model<OrderDocument>,
private eventBus: EventBus
) {}
async execute(command: CreateOrderCommand): Promise<Order> {
const order = new this.orderModel({
orderId: uuidv4(),
userId: command.userId,
items: command.items,
total: command.items.reduce((sum, item) =>
sum + (item.price * item.quantity), 0)
});
await order.save();
this.eventBus.publish(new OrderCreatedEvent(
order.orderId,
order.userId,
order.items,
order.total
));
return order;
}
}
RabbitMQ acts as our message broker, ensuring reliable delivery between services. The Inventory Service listens for order events and updates stock levels accordingly:
// inventory.controller.ts
@Controller()
export class InventoryController {
constructor(private readonly inventoryService: InventoryService) {}
@EventPattern('order_created')
async handleOrderCreated(data: OrderCreatedEvent) {
await this.inventoryService.updateInventory(data.items);
}
}
But what about data consistency across services? I implement compensating transactions for rollback scenarios. If inventory update fails, the order service needs to know:
// order.controller.ts
@MessagePattern('inventory_update_failed')
async handleInventoryFailure(data: { orderId: string; reason: string }) {
await this.orderService.cancelOrder(data.orderId, data.reason);
this.eventBus.publish(new OrderCancelledEvent(
data.orderId,
'Inventory insufficient'
));
}
Monitoring becomes crucial in distributed systems. I add structured logging and metrics collection:
// logging.interceptor.ts
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
logger.info(`Incoming request: ${request.method} ${request.url}`);
return next.handle().pipe(
tap(() => logger.info('Request completed successfully'))
);
}
}
Testing event-driven systems requires special attention. I use Docker Testcontainers for integration tests:
// order.e2e-spec.ts
describe('Order Service (e2e)', () => {
let app: INestApplication;
let rabbitmqContainer: StartedTestContainer;
beforeAll(async () => {
rabbitmqContainer = await new GenericContainer('rabbitmq:3-management')
.withExposedPorts(5672)
.start();
});
afterAll(async () => {
await app.close();
await rabbitmqContainer.stop();
});
});
Deployment considerations include health checks and graceful shutdown:
// main.ts
async function bootstrap() {
const app = await NestFactory.createMicroservice<MicroserviceOptions>(
OrderModule,
{
transport: Transport.RMQ,
options: {
urls: [process.env.RABBITMQ_URL],
queue: 'orders_queue',
queueOptions: { durable: true },
noAck: false,
prefetchCount: 1
}
}
);
app.enableShutdownHooks();
await app.listen();
}
Building event-driven microservices has transformed how I approach distributed systems. The combination of NestJS’s structure, RabbitMQ’s reliability, and MongoDB’s flexibility creates a powerful foundation. But remember – every system has unique requirements. What challenges have you faced with microservices?
I’d love to hear your thoughts and experiences. If this approach resonates with you, please share it with others who might benefit. Your comments and feedback help all of us learn and grow together.