Microservices with Node.js and TypeScript — Complete Guide 2026

Sanjeev SharmaSanjeev Sharma
6 min read

Advertisement

Introduction

Why This Matters

Microservices decompose a monolithic application into independently deployable services, each owning a bounded domain. This enables teams to deploy, scale, and iterate on individual services without coordinating with the entire organization.

The trade-off is complexity: distributed systems introduce network failures, eventual consistency, and operational overhead that monoliths avoid. This guide covers the patterns that make microservices manageable in production.

Service Decomposition and Project Structure

Each microservice is a standalone Node.js application with its own package.json, database, and deployment pipeline.

services/
  user-service/
    src/
      index.ts
      routes/
      controllers/
      repositories/
    package.json
    Dockerfile
  order-service/
    src/
      index.ts
      routes/
      controllers/
      repositories/
    package.json
    Dockerfile
  api-gateway/
    src/
      index.ts
      proxy.ts
    package.json
    Dockerfile
  shared/
    types/         # Shared TypeScript interfaces
    proto/         # gRPC proto definitions

Share TypeScript types as a local package or npm package to enforce contract compatibility across services:

// shared/types/src/user.ts
export interface User {
  id: string;
  email: string;
  name: string;
  createdAt: string; // ISO 8601
}
 
export interface CreateUserRequest {
  email: string;
  name: string;
  password: string;
}
 
export interface CreateUserResponse {
  user: User;
  token: string;
}

Building a Minimal Microservice

Each service is a focused Express application:

// user-service/src/index.ts
import express from 'express';
import { UserRepository } from './repositories/user.repository';
import { UserController } from './controllers/user.controller';
import { createUserRouter } from './routes/user.routes';
import { connectDatabase } from './infrastructure/database';
 
async function bootstrap(): Promise<void> {
  await connectDatabase();
 
  const app = express();
  app.use(express.json());
 
  // Health check — required for load balancer and K8s probes
  app.get('/health', (_req, res) => {
    res.json({ status: 'ok', service: 'user-service', pid: process.pid });
  });
 
  const userRepository = new UserRepository();
  const userController = new UserController(userRepository);
  app.use('/api/users', createUserRouter(userController));
 
  const PORT = Number(process.env.PORT) || 3001;
  app.listen(PORT, () => {
    console.log(`user-service listening on port ${PORT}`);
  });
}
 
bootstrap().catch((err) => {
  console.error('Fatal startup error:', err);
  process.exit(1);
});

API Gateway Pattern

The API gateway is the single entry point for all client requests. It handles routing, authentication, rate limiting, and request aggregation.

// api-gateway/src/index.ts
import express from 'express';
import { createProxyMiddleware } from 'http-proxy-middleware';
import { authenticate } from './middleware/auth';
 
const app = express();
 
const SERVICES: Record<string, string> = {
  '/api/users': process.env.USER_SERVICE_URL ?? 'http://user-service:3001',
  '/api/orders': process.env.ORDER_SERVICE_URL ?? 'http://order-service:3002',
  '/api/products': process.env.PRODUCT_SERVICE_URL ?? 'http://product-service:3003',
};
 
// Apply auth globally except health check
app.use('/health', (_req, res) => res.json({ status: 'ok' }));
app.use(authenticate);
 
// Proxy each service
for (const [prefix, target] of Object.entries(SERVICES)) {
  app.use(
    prefix,
    createProxyMiddleware({
      target,
      changeOrigin: true,
      on: {
        error: (err, _req, res: any) => {
          console.error(`Proxy error for ${prefix}:`, err.message);
          res.status(502).json({ error: 'Service temporarily unavailable' });
        },
      },
    })
  );
}
 
app.listen(3000, () => console.log('API Gateway on port 3000'));

Inter-Service Communication: REST vs Message Queues

Synchronous REST is suitable for request/response operations where the client needs an immediate answer:

// order-service calling user-service synchronously
import axios, { AxiosInstance } from 'axios';
import axiosRetry from 'axios-retry';
 
class UserServiceClient {
  private client: AxiosInstance;
 
  constructor() {
    this.client = axios.create({
      baseURL: process.env.USER_SERVICE_URL ?? 'http://user-service:3001',
      timeout: 5000,
    });
 
    axiosRetry(this.client, {
      retries: 3,
      retryDelay: axiosRetry.exponentialDelay,
      retryCondition: (err) => axiosRetry.isNetworkOrIdempotentRequestError(err),
    });
  }
 
  async getUser(userId: string): Promise<User | null> {
    try {
      const response = await this.client.get<User>(`/api/users/${userId}`);
      return response.data;
    } catch (err: any) {
      if (err.response?.status === 404) return null;
      throw err;
    }
  }
}

Asynchronous messaging with RabbitMQ or Kafka is suitable for fire-and-forget operations and event-driven workflows:

import amqp from 'amqplib';
 
interface OrderCreatedEvent {
  orderId: string;
  userId: string;
  items: Array<{ productId: string; quantity: number }>;
  total: number;
  createdAt: string;
}
 
async function publishOrderCreated(event: OrderCreatedEvent): Promise<void> {
  const connection = await amqp.connect(process.env.RABBITMQ_URL ?? 'amqp://localhost');
  const channel = await connection.createChannel();
 
  await channel.assertExchange('orders', 'topic', { durable: true });
  channel.publish(
    'orders',
    'order.created',
    Buffer.from(JSON.stringify(event)),
    { persistent: true, contentType: 'application/json' }
  );
 
  await channel.close();
  await connection.close();
}

Circuit Breaker Pattern for Resilience

When a downstream service fails, a circuit breaker prevents cascading failures by stopping calls after a threshold of errors:

type CircuitState = 'CLOSED' | 'OPEN' | 'HALF_OPEN';
 
class CircuitBreaker<T> {
  private state: CircuitState = 'CLOSED';
  private failureCount = 0;
  private lastFailureTime = 0;
 
  constructor(
    private fn: (...args: any[]) => Promise<T>,
    private options: {
      failureThreshold: number;
      recoveryTimeMs: number;
    } = { failureThreshold: 5, recoveryTimeMs: 30_000 }
  ) {}
 
  async call(...args: any[]): Promise<T> {
    if (this.state === 'OPEN') {
      if (Date.now() - this.lastFailureTime > this.options.recoveryTimeMs) {
        this.state = 'HALF_OPEN';
      } else {
        throw new Error('Circuit breaker is OPEN — service unavailable');
      }
    }
 
    try {
      const result = await this.fn(...args);
      this.onSuccess();
      return result;
    } catch (err) {
      this.onFailure();
      throw err;
    }
  }
 
  private onSuccess(): void {
    this.failureCount = 0;
    this.state = 'CLOSED';
  }
 
  private onFailure(): void {
    this.failureCount++;
    this.lastFailureTime = Date.now();
    if (this.failureCount >= this.options.failureThreshold) {
      this.state = 'OPEN';
      console.warn(`Circuit breaker OPEN after ${this.failureCount} failures`);
    }
  }
}

Common Mistakes

  • Sharing a database between services — this couples services at the data layer and defeats service independence. Each service must own its data store.
  • Synchronous chains of 5+ services — long synchronous chains amplify latency and failure probability. Break chains with async messaging or aggregate at the gateway.
  • No health checks — every service needs /health and /ready endpoints for load balancers and container orchestrators.
  • Ignoring distributed tracing — without correlation IDs passed through headers, debugging cross-service failures is nearly impossible.
  • Starting with microservices — start with a modular monolith and extract services when team boundaries or scaling requirements demand it.

Best Practices

  • Pass a X-Request-ID (or traceparent for OpenTelemetry) header through all inter-service calls and log it in every service.
  • Use a service mesh (Istio, Linkerd) for mTLS, retries, and circuit breaking in Kubernetes rather than reimplementing in each service.
  • Contract testing with Pact prevents API breaking changes from reaching production.
  • Implement idempotency keys on all mutating async operations so that message redelivery is safe.
  • Use structured logging (JSON) in every service to enable log aggregation with Elasticsearch or Loki.

Key Takeaways

  • Microservices decompose applications into independently deployable services, each owning a bounded domain and its own database.
  • The API gateway pattern provides a single entry point, handling routing, authentication, and rate limiting for all client requests.
  • Services communicate synchronously via REST (for request/response) and asynchronously via message queues (for event-driven workflows).
  • The circuit breaker pattern prevents cascading failures by stopping calls to a failing service after a configurable error threshold.
  • Each service requires /health and /ready endpoints for load balancer and Kubernetes probe compatibility.
  • Distributed tracing with correlation IDs (or OpenTelemetry traceparent) is mandatory for debugging cross-service request flows.
  • Share TypeScript types as a versioned package across services to enforce API contract compatibility at compile time.
  • Start with a modular monolith and extract microservices when team size, deployment frequency, or scaling requirements justify the complexity.

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading