Microservices with Node.js
Microservices decompose a monolith into independently deployable services, each owning a bounded business domain. Node.js is a popular choice for microservices due to its lightweight I/O model and fast startup time.
Monolith vs Microservices
Monolith: Microservices:
┌─────────────────────┐ ┌──────┐ ┌──────┐ ┌──────┐
│ Users │ Orders │ Pay│ │ User │ │ Order│ │ Pay │
│ Shared Database │ │ DB │ │ DB │ │ DB │
└─────────────────────┘ └──────┘ └──────┘ └──────┘
↑
API Gateway
| Factor | Monolith | Microservices |
|---|---|---|
| Deployment | All-or-nothing | Independent |
| Scaling | Scale entire app | Scale hot services |
| Complexity | Lower initially | Higher operational overhead |
| Team structure | Shared codebase | Team per service |
| Data | Single database | Database per service |
Start with a monolith; extract microservices when team size, scale, or deployment conflicts demand it.
Service Decomposition
Decompose by business capability, not technical layer:
❌ user-controller-service, user-repository-service
✅ user-service, order-service, payment-service, notification-service
Each service:
- Owns its data (no direct DB access from other services)
- Exposes an API (REST, GraphQL, or gRPC)
- Deploys independently
- Can use different technology if justified
User Service Example
// user-service/src/index.ts
import express from 'express';
import { PrismaClient } from '@prisma/client';
const app = express();
const prisma = new PrismaClient();
app.use(express.json());
app.get('/health', (_, res) => res.json({ status: 'ok' }));
app.get('/users/:id', async (req, res) => {
const user = await prisma.user.findUnique({
where: { id: req.params.id },
});
if (!user) return res.status(404).json({ error: 'Not found' });
res.json(user);
});
app.post('/users', async (req, res) => {
const { name, email } = req.body;
const user = await prisma.user.create({ data: { name, email } });
res.status(201).json(user);
});
app.listen(3001, () => console.log('User service on :3001'));
Inter-Service Communication
Synchronous — HTTP/REST
Order service calls User service:
// order-service/src/services/userClient.ts
async function getUser(userId: string) {
const response = await fetch(`http://user-service:3001/users/${userId}`, {
headers: { 'X-Service-Token': process.env.INTERNAL_TOKEN! },
signal: AbortSignal.timeout(5000),
});
if (!response.ok) throw new Error(`User service error: ${response.status}`);
return response.json();
}
app.post('/orders', async (req, res) => {
const user = await getUser(req.body.userId);
const order = await prisma.order.create({
data: { userId: user.id, items: req.body.items },
});
res.status(201).json(order);
});
Downside: cascading failures if User service is down — use circuit breakers.
Circuit Breaker Pattern
import CircuitBreaker from 'opossum';
const breaker = new CircuitBreaker(getUser, {
timeout: 5000,
errorThresholdPercentage: 50,
resetTimeout: 30000,
});
breaker.fallback(() => ({ id: 'unknown', name: 'Unknown User' }));
app.post('/orders', async (req, res) => {
const user = await breaker.fire(req.body.userId);
// ...
});
Asynchronous — Message Queue
Decouple services with RabbitMQ or Redis Streams:
// order-service — publish event
import amqp from 'amqplib';
async function publishOrderCreated(order: Order) {
const conn = await amqp.connect(process.env.RABBITMQ_URL!);
const channel = await conn.createChannel();
await channel.assertQueue('order.created');
channel.sendToQueue('order.created', Buffer.from(JSON.stringify(order)));
await channel.close();
await conn.close();
}
// notification-service — consume event
channel.consume('order.created', async (msg) => {
const order = JSON.parse(msg!.content.toString());
await sendEmail(order.userId, `Order ${order.id} confirmed`);
channel.ack(msg!);
});
Events enable: order → notification, order → inventory, order → analytics — without tight coupling.
API Gateway
Single entry point for clients:
Mobile App → API Gateway → User Service
Web App → (Kong/Nginx) → Order Service
→ Payment Service
// api-gateway/src/index.ts
import express from 'express';
import { createProxyMiddleware } from 'http-proxy-middleware';
const app = express();
app.use('/api/users', createProxyMiddleware({
target: 'http://user-service:3001',
changeOrigin: true,
pathRewrite: { '^/api/users': '/users' },
}));
app.use('/api/orders', createProxyMiddleware({
target: 'http://order-service:3002',
changeOrigin: true,
pathRewrite: { '^/api/orders': '/orders' },
}));
app.listen(3000);
Gateway handles: authentication, rate limiting, SSL termination, request routing.
Docker Compose for Local Development
services:
api-gateway:
build: ./api-gateway
ports: ["3000:3000"]
depends_on: [user-service, order-service]
user-service:
build: ./user-service
environment:
DATABASE_URL: postgres://user:pass@user-db:5432/users
order-service:
build: ./order-service
environment:
DATABASE_URL: postgres://user:pass@order-db:5432/orders
USER_SERVICE_URL: http://user-service:3001
user-db:
image: postgres:16-alpine
environment:
POSTGRES_DB: users
order-db:
image: postgres:16-alpine
environment:
POSTGRES_DB: orders
rabbitmq:
image: rabbitmq:3-management-alpine
ports: ["15672:15672"]
Service Discovery
In Kubernetes, services discover each other by DNS name:
# user-service is reachable at http://user-service:3001
For dynamic environments, use Consul or etcd.
Observability
Each service needs:
| Concern | Tool |
|---|---|
| Logging | Winston + centralized (ELK, Loki) |
| Metrics | Prometheus + Grafana |
| Tracing | OpenTelemetry + Jaeger |
| Health | /health and /ready endpoints |
import { trace } from '@opentelemetry/api';
app.get('/users/:id', async (req, res) => {
const span = trace.getTracer('user-service').startSpan('getUser');
try {
const user = await prisma.user.findUnique({ where: { id: req.params.id } });
res.json(user);
} finally {
span.end();
}
});
Pass trace IDs across service calls via headers (traceparent).
Database Per Service
Each service owns its schema:
user-service → users_db
order-service → orders_db
payment-service → payments_db
Cross-service queries use API calls or event-driven data replication — never shared database tables.
Deployment
# Kubernetes deployment excerpt
apiVersion: apps/v1
kind: Deployment
metadata:
name: user-service
spec:
replicas: 3
template:
spec:
containers:
- name: user-service
image: myregistry/user-service:1.0.0
ports:
- containerPort: 3001
livenessProbe:
httpGet:
path: /health
port: 3001
Scale services independently based on load.
When NOT to Use Microservices
- Team smaller than ~10 developers
- Product still finding market fit (monolith is faster to change)
- No DevOps maturity (CI/CD, monitoring, container orchestration)
- Simple CRUD application with low traffic
Microservices with Node.js excel at scale — but earn the complexity only when the organization and traffic justify it.