Microservices with Node.js and TypeScript — Complete Guide 2026
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 definitionsShare 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
/healthand/readyendpoints 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(ortraceparentfor 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
/healthand/readyendpoints 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