Microservices in JavaScript: From Monolith to Distributed System
The transition from a monolithic application to a microservices architecture is not just a technical refactoring, but a change in development philosophy. At an advanced level of working with Node.js, we face the need not just to split code into modules, but to create a resilient, scalable, and fault-tolerant distributed system. In this article, we will break down the key architectural decisions and practical patterns that will help you build production-ready microservices in JavaScript.
The main challenges a developer faces: synchronizing data between services, maintaining transaction integrity, service discovery, and efficient communication. A mistake at the design stage can lead to chaos in production. Let's look at how to avoid this.
1. Communication Between Services: RabbitMQ and gRPC
In the world of microservices, choosing the communication protocol is key. For Node.js, two approaches are most popular: an asynchronous message queue (RabbitMQ) and synchronous high-performance RPC (gRPC).
Asynchronous Queue (RabbitMQ)
Used for event-driven architecture. The producer service does not wait for a response but publishes an event. This reduces coupling. Let's look at an example of sending an order to the delivery service.
// "Orders" Service — Producerconst amqp = require('amqplib');
async function publishOrderCreated(order) { const connection = await amqp.connect('amqp://rabbitmq:5672'); const channel = await connection.createChannel(); const exchange = 'order.events'; await channel.assertExchange(exchange, 'topic', { durable: true });
channel.publish(exchange, 'order.created', Buffer.from(JSON.stringify(order)), { persistent: true });
console.log(`[x] Sent order: ${order.id}`); await channel.close(); await connection.close();}
// Called when an order is createdpublishOrderCreated({ id: '123', userId: '456', amount: 1500 });The consumer service (delivery) subscribes to the queue:
// "Delivery" Service — Consumerconst amqp = require('amqplib');
async function consumeOrders() { const connection = await amqp.connect('amqp://rabbitmq:5672'); const channel = await connection.createChannel(); const exchange = 'order.events'; await channel.assertExchange(exchange, 'topic', { durable: true });
const q = await channel.assertQueue('', { exclusive: true }); await channel.bindQueue(q.queue, exchange, 'order.created');
channel.consume(q.queue, (msg) => { if (msg.content) { const order = JSON.parse(msg.content.toString()); console.log(`[x] Received order: ${order.id}`); // Delivery creation logic channel.ack(msg); } });}
consumeOrders();Synchronous gRPC
For scenarios requiring an instant response (e.g., checking user balance), gRPC provides strict typing and high speed via HTTP/2. We define the proto file:
// user.protoservice UserService { rpc GetUserBalance (UserRequest) returns (UserResponse);}
message UserRequest { string user_id = 1;}
message UserResponse { double balance = 1;}Server implementation on Node.js:
const grpc = require('@grpc/grpc-js');const protoLoader = require('@grpc/proto-loader');const PROTO_PATH = './user.proto';
const packageDefinition = protoLoader.loadSync(PROTO_PATH, { keepCase: true, longs: String, enums: String, defaults: true, oneofs: true});const userProto = grpc.loadPackageDefinition(packageDefinition).userservice;
function getBalance(call, callback) { const userId = call.request.user_id; // Simulating a database query const balance = userId === '456' ? 5000 : 0; callback(null, { balance });}
const server = new grpc.Server();server.addSe