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.