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:
- Single Responsibility: Each service owns one business domain
- Database per Service: Avoid shared databases between services
- Decentralized Governance: Services can use different tech stacks
- Fault Tolerance: Services must handle partial failures gracefully
- Observable: Comprehensive logging, metrics, and tracing
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
- Start Small: Begin with a modular monolith, then extract services
- Design for Failure: Every external call can fail - plan accordingly
- Embrace Eventual Consistency: Not everything needs to be immediately consistent
- Monitor Everything: Logs, metrics, and traces are your best friends
- Test Service Boundaries: Integration tests are critical
- 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.