I’ve been working with distributed systems for years, and I keep seeing teams struggle with the same issues. They build microservices that communicate through direct HTTP calls, creating fragile spiderwebs of dependencies. When one service goes down, everything grinds to a halt. That’s why I want to share how we can build more resilient systems using event-driven architecture.
Have you ever wondered what happens when your order processing system can’t reach the notification service? In traditional architectures, the entire transaction fails. But with event-driven systems, the order gets processed, and notifications are sent when the service recovers.
Let me show you how to build production-ready microservices using NestJS, NATS, and MongoDB. This combination has served me well in high-load environments.
First, let’s set up our event bus. NATS provides the messaging backbone that keeps our services connected without direct dependencies.
// event-bus.service.ts
@Injectable()
export class EventBusService {
private connection: NatsConnection;
private jc = JSONCodec();
async publish<T extends BaseEvent>(subject: string, event: T): Promise<void> {
const js = this.connection.jetstream();
await js.publish(subject, this.jc.encode(event));
}
async subscribe(subject: string, handler: (event: any) => Promise<void>) {
const js = this.connection.jetstream();
const subscription = await js.pullSubscribe(subject);
for await (const message of subscription) {
try {
await handler(this.jc.decode(message.data));
message.ack();
} catch (error) {
message.nak();
}
}
}
}
Now, imagine a user registration flow. Instead of making synchronous calls to multiple services, we publish an event and let interested services react.
// user.service.ts
@Injectable()
export class UserService {
constructor(
private eventBus: EventBusService,
@InjectModel(User.name) private userModel: Model<User>
) {}
async createUser(createUserDto: CreateUserDto): Promise<User> {
const user = new this.userModel(createUserDto);
await user.save();
await this.eventBus.publish('user.created', {
id: uuidv4(),
type: 'UserCreated',
aggregateId: user.id,
email: user.email,
name: user.name,
timestamp: new Date()
});
return user;
}
}
What happens when the order service needs to know about new users? It simply subscribes to the user.created event without any direct connection to the user service.
// order.service.ts
@Injectable()
export class OrderService {
constructor(
private eventBus: EventBusService,
@InjectModel(Customer.name) private customerModel: Model<Customer>
) {}
async onModuleInit() {
await this.eventBus.subscribe('user.created', async (event) => {
const customer = new this.customerModel({
userId: event.aggregateId,
email: event.email,
name: event.name
});
await customer.save();
});
}
}
But what about complex business transactions that span multiple services? This is where the saga pattern comes in handy. Let me show you how to handle an order creation that involves inventory checks, payment processing, and notifications.
// order-saga.service.ts
@Injectable()
export class OrderSaga {
private readonly logger = new Logger(OrderSaga.name);
constructor(
private eventBus: EventBusService,
@InjectModel(Order.name) private orderModel: Model<Order>
) {}
async handleOrderCreated(event: OrderCreatedEvent) {
const order = await this.orderModel.findById(event.aggregateId);
try {
// Reserve inventory
await this.eventBus.publish('inventory.reserve.requested', {
orderId: order.id,
items: order.items
});
order.status = 'INVENTORY_RESERVED';
await order.save();
} catch (error) {
this.logger.error(`Failed to reserve inventory for order ${order.id}`);
await this.compensateOrder(order.id);
}
}
}
Error handling is crucial in distributed systems. We need to ensure our services can recover from failures and maintain data consistency.
// circuit-breaker.service.ts
@Injectable()
export class CircuitBreakerService {
private failures = 0;
private lastFailureTime: Date;
private state: 'CLOSED' | 'OPEN' | 'HALF_OPEN' = 'CLOSED';
async execute<T>(operation: () => Promise<T>): Promise<T> {
if (this.state === 'OPEN') {
if (Date.now() - this.lastFailureTime.getTime() > 30000) {
this.state = 'HALF_OPEN';
} else {
throw new Error('Circuit breaker is open');
}
}
try {
const result = await operation();
this.reset();
return result;
} catch (error) {
this.recordFailure();
throw error;
}
}
private recordFailure(): void {
this.failures++;
this.lastFailureTime = new Date();
if (this.failures >= 5) {
this.state = 'OPEN';
}
}
}
Monitoring is another critical aspect. We need to know what’s happening across our services. I always implement comprehensive logging and metrics collection.
// event-logging.interceptor.ts
@Injectable()
export class EventLoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger('EventLogger');
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
this.logger.log({
timestamp: new Date().toISOString(),
service: 'user-service',
event: 'HTTP_REQUEST',
method: request.method,
path: request.path,
userId: request.user?.id
});
return next.handle();
}
}
Did you notice how each service maintains its own data? The order service has its customer projection, updated asynchronously from user events. This separation allows each service to optimize its data model for its specific needs.
Here’s how I structure MongoDB collections for event sourcing:
// event-store.schema.ts
@Schema({ collection: 'events' })
export class EventStore extends Document {
@Prop({ required: true })
type: string;
@Prop({ required: true })
aggregateId: string;
@Prop({ type: Object, required: true })
data: any;
@Prop({ required: true })
timestamp: Date;
@Prop({ required: true })
version: number;
}
export const EventStoreSchema = SchemaFactory.createForClass(EventStore);
EventStoreSchema.index({ aggregateId: 1, version: 1 });
Testing event-driven systems requires a different approach. We need to verify that events are published and handled correctly.
// user.service.spec.ts
describe('UserService', () => {
let service: UserService;
let eventBus: EventBusService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UserService,
{
provide: EventBusService,
useValue: {
publish: jest.fn().mockResolvedValue(undefined)
}
}
],
}).compile();
service = module.get<UserService>(UserService);
eventBus = module.get<EventBusService>(EventBusService);
});
it('should publish UserCreated event when creating user', async () => {
const userDto = { email: '[email protected]', name: 'Test User' };
await service.createUser(userDto);
expect(eventBus.publish).toHaveBeenCalledWith(
'user.created',
expect.objectContaining({
type: 'UserCreated',
email: '[email protected]'
})
);
});
});
Building event-driven microservices requires shifting your mindset from synchronous request-response to asynchronous event flows. The initial complexity pays off in scalability and resilience.
What patterns have you found most effective in your distributed systems? I’m always curious to learn from others’ experiences.
Remember, the goal isn’t to make everything event-driven overnight. Start with the most critical workflows where loose coupling provides the most value.
I hope this gives you a solid foundation for building your own event-driven systems. If you found this helpful, I’d appreciate it if you could share it with others who might benefit. Feel free to leave comments with your experiences or questions - I learn as much from your feedback as you do from these articles.