I’ve been thinking a lot about how modern applications handle complexity and scale. The shift toward distributed systems isn’t just a trend—it’s a necessity as our applications grow more sophisticated. That’s why I want to share a practical approach to building event-driven microservices using NestJS, NATS, and MongoDB.
Why choose this stack? NestJS provides a structured framework that feels familiar to Angular developers, while NATS offers lightweight, high-performance messaging. MongoDB’s document model complements event-driven architectures beautifully. Together, they create a foundation that’s both powerful and maintainable.
Let me show you how to set up a basic event handler in NestJS with NATS:
// user-created.handler.ts
@Controller()
export class UserCreatedHandler {
constructor(private readonly userService: UserService) {}
@EventPattern('user.created')
async handleUserCreated(data: UserCreatedEvent) {
console.log('Received user created event:', data);
await this.userService.processNewUser(data);
}
}
Have you considered what happens when services need to communicate without direct dependencies? Event-driven patterns solve this by letting services broadcast changes without knowing who’s listening. This loose coupling means you can add new functionality without modifying existing services.
Here’s how you might structure a user creation event:
// user-created.event.ts
export class UserCreatedEvent {
constructor(
public readonly userId: string,
public readonly email: string,
public readonly timestamp: Date
) {}
}
What about data consistency across services? MongoDB’s atomic operations and NestJS’s transaction support help maintain integrity. When processing orders, for example, you might need to reserve inventory while creating the order record:
// order.service.ts
async createOrder(orderData: CreateOrderDto) {
const session = await this.connection.startSession();
session.startTransaction();
try {
const order = await this.orderModel.create([orderData], { session });
await this.inventoryService.reserveItems(orderData.items, session);
await session.commitTransaction();
this.natsClient.emit('order.created', new OrderCreatedEvent(order[0]));
} catch (error) {
await session.abortTransaction();
throw error;
} finally {
session.endSession();
}
}
How do you ensure messages aren’t lost if a service goes down? NATS JetStream provides persistent storage for messages, while NestJS offers built-in retry mechanisms:
// nats-config.service.ts
@Module({
imports: [
ClientsModule.register([
{
name: 'NATS_CLIENT',
transport: Transport.NATS,
options: {
servers: ['nats://localhost:4222'],
durable: 'user-service',
ackWait: 5000,
maxRedeliveries: 5
}
}
])
]
})
Monitoring becomes crucial in distributed systems. You’ll want to track events across service boundaries:
// logging.interceptor.ts
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler) {
const request = context.switchToHttp().getRequest();
const correlationId = request.headers['x-correlation-id'] || uuidv4();
console.log(`[${correlationId}] Event started`);
return next.handle().pipe(
tap(() => console.log(`[${correlationId}] Event completed`))
);
}
}
Testing event-driven systems requires simulating different scenarios. NestJS makes this straightforward with testing utilities:
// user.service.spec.ts
describe('UserService', () => {
let service: UserService;
let natsClient: NatsClient;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
UserService,
{
provide: 'NATS_CLIENT',
useValue: { emit: jest.fn() }
}
]
}).compile();
service = module.get<UserService>(UserService);
natsClient = module.get('NATS_CLIENT');
});
it('should publish user.created event', async () => {
await service.createUser(testUser);
expect(natsClient.emit).toHaveBeenCalledWith(
'user.created',
expect.any(UserCreatedEvent)
);
});
});
Deployment considerations include managing connections and scaling individual services based on load. Docker Compose helps orchestrate the different components:
# docker-compose.yml
version: '3.8'
services:
user-service:
build: ./packages/user-service
environment:
- NATS_URL=nats://nats:4222
- MONGODB_URI=mongodb://mongodb:27017/users
depends_on:
- nats
- mongodb
nats:
image: nats:2.9-alpine
ports:
- "4222:4222"
mongodb:
image: mongo:6.0
ports:
- "27017:27017"
What patterns have you found effective for handling failed events? Dead letter queues and manual review processes often provide the safety net needed for production systems.
Building with event-driven microservices requires shifting your mindset from request-response to event-based thinking. The initial setup might feel complex, but the payoff in scalability and maintainability is substantial. Services become more focused, teams can work independently, and the system as a whole becomes more resilient to failure.
I’d love to hear about your experiences with event-driven architectures. What challenges have you faced? What patterns have worked well for your team? Share your thoughts in the comments below, and if you found this helpful, please like and share with others who might benefit from this approach.