Building Scalable Microservices Architecture with Node.js and TypeScript

Microservices architecture has become the standard for building scalable, maintainable applications. After architecting distributed systems for real estate analytics platforms and decentralized storage solutions, I’ve learned valuable lessons about what works in production. Let’s dive into building robust microservices with Node.js and TypeScript.

Architectural Principles

Before writing code, establish these foundational principles:

Project Structure and Service Template

Start with a consistent service template:

microservice-template/
├── src/
   ├── controllers/
   ├── services/
   ├── repositories/
   ├── models/
   ├── middleware/
   ├── utils/
   └── app.ts
├── tests/
├── docker/
├── k8s/
├── package.json
├── tsconfig.json
└── Dockerfile

Core Service Implementation

Create a base service class for common functionality:

// src/base/BaseService.ts
import express, { Application } from "express";
import cors from "cors";
import helmet from "helmet";
import compression from "compression";
import { createServer, Server } from "http";
import { Logger } from "./Logger";
import { HealthController } from "../controllers/HealthController";
import { MetricsController } from "../controllers/MetricsController";

export abstract class BaseService {
  protected app: Application;
  protected server: Server;
  protected logger: Logger;
  protected port: number;
  protected serviceName: string;

  constructor(serviceName: string, port: number) {
    this.serviceName = serviceName;
    this.port = port;
    this.app = express();
    this.logger = new Logger(serviceName);
    this.server = createServer(this.app);

    this.setupMiddleware();
    this.setupRoutes();
  }

  private setupMiddleware(): void {
    this.app.use(helmet());
    this.app.use(cors());
    this.app.use(compression());
    this.app.use(express.json({ limit: "10mb" }));
    this.app.use(express.urlencoded({ extended: true }));

    // Request logging
    this.app.use((req, res, next) => {
      this.logger.info(`${req.method} ${req.path}`, {
        correlationId: req.headers["x-correlation-id"],
        userAgent: req.headers["user-agent"],
      });
      next();
    });
  }

  private setupRoutes(): void {
    // Health and metrics endpoints
    this.app.use("/health", new HealthController().router);
    this.app.use("/metrics", new MetricsController().router);

    // Service-specific routes
    this.setupServiceRoutes();
  }

  protected abstract setupServiceRoutes(): void;

  public async start(): Promise<void> {
    return new Promise((resolve) => {
      this.server.listen(this.port, () => {
        this.logger.info(`${this.serviceName} started on port ${this.port}`);
        resolve();
      });
    });
  }

  public async stop(): Promise<void> {
    return new Promise((resolve) => {
      this.server.close(() => {
        this.logger.info(`${this.serviceName} stopped`);
        resolve();
      });
    });
  }
}

Implementing Domain-Specific Services

Here’s an example user service with proper separation of concerns:

// src/services/UserService.ts
import { UserRepository } from "../repositories/UserRepository";
import { CacheService } from "./CacheService";
import { EventBus } from "./EventBus";
import { User, CreateUserDto, UpdateUserDto } from "../models/User";
import { ValidationError, NotFoundError } from "../errors";

export class UserService {
  constructor(
    private userRepository: UserRepository,
    private cacheService: CacheService,
    private eventBus: EventBus,
  ) {}

  async createUser(userData: CreateUserDto): Promise<User> {
    // Validation
    await this.validateUserData(userData);

    // Check for existing user
    const existingUser = await this.userRepository.findByEmail(userData.email);
    if (existingUser) {
      throw new ValidationError("User already exists");
    }

    // Create user
    const user = await this.userRepository.create(userData);

    // Cache user data
    await this.cacheService.set(`user:${user.id}`, user, 3600);

    // Publish event
    await this.eventBus.publish("user.created", {
      userId: user.id,
      email: user.email,
      timestamp: new Date().toISOString(),
    });

    return user;
  }

  async getUserById(id: string): Promise<User> {
    // Check cache first
    const cached = await this.cacheService.get(`user:${id}`);
    if (cached) {
      return cached as User;
    }

    // Fetch from database
    const user = await this.userRepository.findById(id);
    if (!user) {
      throw new NotFoundError("User not found");
    }

    // Cache for future requests
    await this.cacheService.set(`user:${id}`, user, 3600);

    return user;
  }

  async updateUser(id: string, updates: UpdateUserDto): Promise<User> {
    const user = await this.getUserById(id);

    await this.validateUserData(updates);

    const updatedUser = await this.userRepository.update(id, updates);

    // Invalidate cache
    await this.cacheService.delete(`user:${id}`);

    // Publish event
    await this.eventBus.publish("user.updated", {
      userId: id,
      changes: updates,
      timestamp: new Date().toISOString(),
    });

    return updatedUser;
  }

  private async validateUserData(data: Partial<CreateUserDto>): Promise<void> {
    // Implement validation logic
    if (data.email && !this.isValidEmail(data.email)) {
      throw new ValidationError("Invalid email format");
    }
  }

  private isValidEmail(email: string): boolean {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    return emailRegex.test(email);
  }
}

Inter-Service Communication

Implement both synchronous and asynchronous communication patterns:

// src/services/ServiceCommunication.ts
import axios, { AxiosInstance } from "axios";
import { EventEmitter } from "events";
import Redis from "ioredis";

export class HttpClient {
  private client: AxiosInstance;

  constructor(baseURL: string, timeout = 5000) {
    this.client = axios.create({
      baseURL,
      timeout,
      headers: {
        "Content-Type": "application/json",
      },
    });

    // Request interceptor for correlation ID
    this.client.interceptors.request.use((config) => {
      config.headers["x-correlation-id"] =
        config.headers["x-correlation-id"] || this.generateCorrelationId();
      return config;
    });

    // Response interceptor for error handling
    this.client.interceptors.response.use(
      (response) => response,
      (error) => {
        if (error.code === "ECONNABORTED") {
          throw new Error("Service timeout");
        }
        throw error;
      },
    );
  }

  private generateCorrelationId(): string {
    return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
  }

  async get<T>(endpoint: string, params?: any): Promise<T> {
    const response = await this.client.get(endpoint, { params });
    return response.data;
  }

  async post<T>(endpoint: string, data: any): Promise<T> {
    const response = await this.client.post(endpoint, data);
    return response.data;
  }
}

export class EventBus extends EventEmitter {
  private redis: Redis;
  private subscriber: Redis;

  constructor(redisUrl: string) {
    super();
    this.redis = new Redis(redisUrl);
    this.subscriber = new Redis(redisUrl);

    this.setupSubscriber();
  }

  private setupSubscriber(): void {
    this.subscriber.on("message", (channel: string, message: string) => {
      try {
        const event = JSON.parse(message);
        this.emit(channel, event);
      } catch (error) {
        console.error("Failed to parse event message:", error);
      }
    });
  }

  async publish(event: string, data: any): Promise<void> {
    await this.redis.publish(event, JSON.stringify(data));
  }

  async subscribe(event: string, handler: (data: any) => void): Promise<void> {
    await this.subscriber.subscribe(event);
    this.on(event, handler);
  }
}

Circuit Breaker Pattern

Implement circuit breaker for handling service failures:

// src/utils/CircuitBreaker.ts
export class CircuitBreaker {
  private failures = 0;
  private nextAttempt = Date.now();
  private state: "CLOSED" | "OPEN" | "HALF_OPEN" = "CLOSED";

  constructor(
    private threshold: number = 5,
    private timeout: number = 60000,
    private monitor?: (state: string) => void,
  ) {}

  async execute<T>(operation: () => Promise<T>): Promise<T> {
    if (this.state === "OPEN") {
      if (this.nextAttempt <= Date.now()) {
        this.state = "HALF_OPEN";
        this.monitor?.("HALF_OPEN");
      } else {
        throw new Error("Circuit breaker is OPEN");
      }
    }

    try {
      const result = await operation();
      this.onSuccess();
      return result;
    } catch (error) {
      this.onFailure();
      throw error;
    }
  }

  private onSuccess(): void {
    this.failures = 0;
    if (this.state === "HALF_OPEN") {
      this.state = "CLOSED";
      this.monitor?.("CLOSED");
    }
  }

  private onFailure(): void {
    this.failures++;
    if (this.failures >= this.threshold) {
      this.state = "OPEN";
      this.nextAttempt = Date.now() + this.timeout;
      this.monitor?.("OPEN");
    }
  }
}

// Usage in service
export class ExternalServiceClient {
  private circuitBreaker: CircuitBreaker;
  private httpClient: HttpClient;

  constructor(baseUrl: string) {
    this.httpClient = new HttpClient(baseUrl);
    this.circuitBreaker = new CircuitBreaker(3, 30000);
  }

  async callExternalService(data: any): Promise<any> {
    return this.circuitBreaker.execute(async () => {
      return await this.httpClient.post("/api/endpoint", data);
    });
  }
}

Database Integration with Connection Pooling

// src/database/DatabaseManager.ts
import { Pool, PoolClient } from "pg";
import { Logger } from "../utils/Logger";

export class DatabaseManager {
  private pool: Pool;
  private logger: Logger;

  constructor() {
    this.logger = new Logger("DatabaseManager");
    this.pool = new Pool({
      host: process.env.DB_HOST,
      database: process.env.DB_NAME,
      user: process.env.DB_USER,
      password: process.env.DB_PASSWORD,
      port: parseInt(process.env.DB_PORT || "5432"),
      max: 20, // Maximum pool size
      idleTimeoutMillis: 30000,
      connectionTimeoutMillis: 2000,
    });

    this.setupEventHandlers();
  }

  private setupEventHandlers(): void {
    this.pool.on("connect", () => {
      this.logger.info("New database connection established");
    });

    this.pool.on("error", (err) => {
      this.logger.error("Database pool error:", err);
    });
  }

  async query<T>(text: string, params?: any[]): Promise<T[]> {
    const start = Date.now();
    try {
      const result = await this.pool.query(text, params);
      const duration = Date.now() - start;

      this.logger.info("Query executed", {
        query: text,
        duration,
        rows: result.rowCount,
      });

      return result.rows;
    } catch (error) {
      this.logger.error("Query failed:", { query: text, error });
      throw error;
    }
  }

  async transaction<T>(
    callback: (client: PoolClient) => Promise<T>,
  ): Promise<T> {
    const client = await this.pool.connect();

    try {
      await client.query("BEGIN");
      const result = await callback(client);
      await client.query("COMMIT");
      return result;
    } catch (error) {
      await client.query("ROLLBACK");
      throw error;
    } finally {
      client.release();
    }
  }

  async close(): Promise<void> {
    await this.pool.end();
    this.logger.info("Database pool closed");
  }
}

Docker and Docker Compose Setup

# Dockerfile
FROM node:18-alpine

WORKDIR /app

# Copy package files
COPY package*.json ./
RUN npm ci --only=production

# Copy source code
COPY dist/ ./dist/

# Create non-root user
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nodejs -u 1001
USER nodejs

EXPOSE 3000

CMD ["node", "dist/app.js"]
# docker-compose.yml
version: "3.8"

services:
  user-service:
    build: ./services/user-service
    ports:
      - "3001:3000"
    environment:
      - NODE_ENV=production
      - DB_HOST=postgres
      - REDIS_URL=redis://redis:6379
    depends_on:
      - postgres
      - redis

  postgres:
    image: postgres:14-alpine
    environment:
      POSTGRES_DB: microservices
      POSTGRES_USER: admin
      POSTGRES_PASSWORD: password
    volumes:
      - postgres_data:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine
    volumes:
      - redis_data:/data

volumes:
  postgres_data:
  redis_data:

Monitoring and Observability

// src/middleware/MetricsMiddleware.ts
import { Request, Response, NextFunction } from "express";
import client from "prom-client";

const httpRequestDuration = new client.Histogram({
  name: "http_request_duration_seconds",
  help: "Duration of HTTP requests in seconds",
  labelNames: ["method", "route", "status_code"],
});

const httpRequestTotal = new client.Counter({
  name: "http_requests_total",
  help: "Total number of HTTP requests",
  labelNames: ["method", "route", "status_code"],
});

export const metricsMiddleware = (
  req: Request,
  res: Response,
  next: NextFunction,
) => {
  const start = Date.now();

  res.on("finish", () => {
    const duration = (Date.now() - start) / 1000;
    const labels = {
      method: req.method,
      route: req.route?.path || req.path,
      status_code: res.statusCode.toString(),
    };

    httpRequestDuration.observe(labels, duration);
    httpRequestTotal.inc(labels);
  });

  next();
};

Key Takeaways

  1. Start Small: Begin with a modular monolith, then extract services
  2. Design for Failure: Every external call can fail - plan accordingly
  3. Embrace Eventual Consistency: Not everything needs to be immediately consistent
  4. Monitor Everything: Logs, metrics, and traces are your best friends
  5. Test Service Boundaries: Integration tests are critical
  6. Version Your APIs: Breaking changes should be handled gracefully

Building microservices is complex, but following these patterns will help you create a system that scales with your business. Remember: the architecture should serve your business goals, not the other way around.

Focus on solving real problems, not just following trends. The best architecture is the one that enables your team to ship features quickly and reliably.

MirzoDev

© 2025 MirzoDev

Email Telegram GitHub