I’ve been reflecting on how modern applications handle growing complexity. During a recent project, we hit scaling limits with our monolithic architecture. That experience led me to explore event-driven microservices as a solution. Today, I’ll walk you through building a resilient system using Node.js, RabbitMQ, and MongoDB - tools I’ve found exceptionally effective for decoupled, scalable systems. You’ll learn practical patterns that solve real-world distributed system challenges.
Our architecture centers around three core services: user management, order processing, and payment handling. They communicate through events rather than direct API calls. When a user registers, for example, the User Service publishes an event that triggers downstream processes. This approach keeps services independent - if the Order Service goes down, users can still register. How might this isolation benefit your own projects?
Let’s set up our foundation. We’ll use Docker Compose to orchestrate RabbitMQ, MongoDB, and Redis with this configuration:
# docker-compose.yml
version: '3.8'
services:
rabbitmq:
image: rabbitmq:3-management
ports: ["5672:5672", "15672:15672"]
environment:
RABBITMQ_DEFAULT_USER: admin
RABBITMQ_DEFAULT_PASS: password
mongodb:
image: mongo:6
ports: ["27017:27017"]
redis:
image: redis:7-alpine
ports: ["6379:6379"]
Notice how we’re using RabbitMQ’s dead-letter exchanges for error handling. This ensures failed messages don’t disappear but get rerouted for inspection. The real magic happens in our message broker implementation:
// shared/messaging/message-broker.ts
export class MessageBroker {
private channel: Channel | null = null;
async connect(url: string = 'amqp://admin:password@localhost') {
const connection = await amqp.connect(url);
this.channel = await connection.createChannel();
await this.channel.assertExchange('dlx', 'direct', { durable: true });
}
async publishEvent(exchange: string, event: BaseEvent) {
if (!this.channel) return;
const message = Buffer.from(JSON.stringify(event));
this.channel.publish(exchange, event.type, message, {
persistent: true,
messageId: event.id
});
}
async subscribeToEvents(exchange: string, queueName: string, handler: Function) {
await this.channel!.assertExchange(exchange, 'topic', { durable: true });
const queue = await this.channel!.assertQueue(queueName, {
durable: true,
arguments: { 'x-dead-letter-exchange': 'dlx' }
});
this.channel!.consume(queue.queue, async (msg) => {
if (!msg) return;
try {
await handler(JSON.parse(msg.content.toString()));
this.channel!.ack(msg);
} catch (error) {
this.channel!.nack(msg, false, false); // Send to DLX
}
});
}
}
This broker handles both publishing and consuming events with automatic dead-letter routing. For event persistence, we use MongoDB as an event store:
// shared/event-store/event-store.ts
export class EventStore {
private eventsCollection: Collection | null = null;
async connect(url: string = 'mongodb://localhost:27017') {
const client = new MongoClient(url);
await client.connect();
this.eventsCollection = client.db('eventstore').collection('events');
await this.eventsCollection.createIndex({ aggregateId: 1, version: 1 });
}
async saveEvent(event: BaseEvent) {
await this.eventsCollection!.insertOne(event);
}
async getEvents(aggregateId: string) {
return this.eventsCollection!
.find({ aggregateId })
.sort({ version: 1 })
.toArray();
}
}
Now let’s implement our User Service. When a user registers, we store their data and publish an event:
// user-service/src/user.service.ts
export class UserService {
async registerUser(email: string, password: string) {
const user = new User({ email, password });
await user.save();
const event = new UserRegisteredEvent(user.id, { email });
await messageBroker.publishEvent('user-events', event);
await eventStore.saveEvent(event);
}
}
The Order Service listens for this event and starts order processing. But what happens when payment fails after order creation? We solve this with the Saga pattern:
// order-service/src/sagas/order-saga.ts
export class OrderSaga {
@SagaStart()
async handleOrderCreated(event: OrderCreatedEvent) {
const paymentCommand = new ProcessPaymentCommand(event.orderId);
await messageBroker.sendCommand('payment-commands', paymentCommand);
}
@SagaStep()
async handlePaymentFailed(event: PaymentFailedEvent) {
const compensateCommand = new CancelOrderCommand(event.orderId);
await messageBroker.sendCommand('order-commands', compensateCommand);
}
}
Notice how each service has its own database. This isolation prevents tight coupling - the Order Service doesn’t need direct access to user data. For monitoring, we add logging middleware:
// shared/middleware/logging.ts
export const loggingMiddleware = (req: Request, res: Response, next: NextFunction) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
logger.info(`${req.method} ${req.path} - ${res.statusCode} ${duration}ms`);
});
next();
};
Testing requires special attention in distributed systems. We use contract testing to verify event schemas:
// tests/contracts/user-registered.contract.js
describe('UserRegisteredEvent Contract', () => {
it('should have required properties', () => {
const event = new UserRegisteredEvent('123', { email: '[email protected]' });
expect(event).to.have.property('id');
expect(event).to.have.property('type', 'UserRegistered');
expect(event.data).to.have.property('email');
});
});
Common pitfalls? Message ordering challenges top the list. RabbitMQ’s consistent hashing exchange helps:
await channel.assertExchange('order-events', 'x-consistent-hash', { durable: true });
Another gotcha: event versioning. We add version checks when applying events:
applyEvent(event: BaseEvent) {
if (event.version !== this.version + 1) throw new VersionConflictError();
// Apply event logic
this.version = event.version;
}
Throughout this journey, we’ve seen how event-driven architectures create resilient, scalable systems. The separation of concerns allows teams to work independently while maintaining system integrity. What challenges have you faced with distributed systems? Share your experiences below - I’d love to hear how you’ve solved similar problems.
If you found this guide useful, please like and share it with your network. Have questions or insights? Let’s discuss in the comments!