Node.js Microservices Architecture: Design Patterns and Best Practices
Introduction
Microservices architecture has revolutionized how we build and scale applications. By breaking down monolithic applications into smaller, independent services, teams can develop, deploy, and scale components independently. This comprehensive guide explores microservices architecture with Node.js, covering essential design patterns, best practices, and real-world implementation strategies.
Understanding Microservices Architecture
What are Microservices?
Microservices are an architectural approach where applications are composed of small, independent services that communicate over well-defined APIs. Each service is:
- Independently deployable
- Loosely coupled
- Organized around business capabilities
- Owned by a small team
Monoliths vs Microservices
Monolithic Architecture
// Single application handling all functionality
const express = require('express');
const app = express();
// User management
app.post('/users', handleUserCreation);
app.get('/users/:id', getUserById);
// Order processing
app.post('/orders', createOrder);
app.get('/orders/:id', getOrderById);
// Payment processing
app.post('/payments', processPayment);
// Inventory management
app.put('/inventory/:id', updateInventory);
app.listen(3000);
Microservices Architecture
// User Service (Port 3001)
const userApp = express();
userApp.post('/users', handleUserCreation);
userApp.get('/users/:id', getUserById);
userApp.listen(3001);
// Order Service (Port 3002)
const orderApp = express();
orderApp.post('/orders', createOrder);
orderApp.get('/orders/:id', getOrderById);
orderApp.listen(3002);
// Payment Service (Port 3003)
const paymentApp = express();
paymentApp.post('/payments', processPayment);
paymentApp.listen(3003);
Core Design Patterns
1. API Gateway Pattern
The API Gateway acts as a single entry point for all client requests, routing them to appropriate microservices.
// api-gateway/server.js
const express = require('express');
const httpProxy = require('http-proxy-middleware');
const app = express();
// Route to User Service
app.use(
'/api/users',
httpProxy({
target: 'http://user-service:3001',
changeOrigin: true,
pathRewrite: {
'^/api/users': '/users',
},
})
);
// Route to Order Service
app.use(
'/api/orders',
httpProxy({
target: 'http://order-service:3002',
changeOrigin: true,
pathRewrite: {
'^/api/orders': '/orders',
},
})
);
// Route to Payment Service
app.use(
'/api/payments',
httpProxy({
target: 'http://payment-service:3003',
changeOrigin: true,
pathRewrite: {
'^/api/payments': '/payments',
},
})
);
app.listen(3000, () => {
console.log('API Gateway running on port 3000');
});
Advanced API Gateway with Authentication
// api-gateway/middleware/auth.js
const jwt = require('jsonwebtoken');
const authenticateToken = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Access denied' });
}
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
if (err) {
return res.status(403).json({ error: 'Invalid token' });
}
req.user = user;
next();
});
};
// Apply authentication to protected routes
app.use('/api/orders', authenticateToken);
app.use('/api/payments', authenticateToken);
2. Service Discovery Pattern
Service discovery helps services find and communicate with each other dynamically.
// service-registry/registry.js
class ServiceRegistry {
constructor() {
this.services = new Map();
}
register(serviceName, serviceUrl, healthCheckUrl) {
this.services.set(serviceName, {
url: serviceUrl,
healthCheck: healthCheckUrl,
lastSeen: Date.now(),
});
console.log(`Service ${serviceName} registered at ${serviceUrl}`);
}
discover(serviceName) {
const service = this.services.get(serviceName);
if (!service) {
throw new Error(`Service ${serviceName} not found`);
}
return service;
}
unregister(serviceName) {
this.services.delete(serviceName);
console.log(`Service ${serviceName} unregistered`);
}
getAllServices() {
return Array.from(this.services.entries());
}
}
module.exports = new ServiceRegistry();
Service Registration Example
// user-service/server.js
const express = require('express');
const axios = require('axios');
const app = express();
const SERVICE_NAME = 'user-service';
const SERVICE_URL = 'http://localhost:3001';
const REGISTRY_URL = 'http://localhost:3000';
// Register service on startup
const registerService = async () => {
try {
await axios.post(`${REGISTRY_URL}/register`, {
name: SERVICE_NAME,
url: SERVICE_URL,
healthCheck: `${SERVICE_URL}/health`,
});
console.log('Service registered successfully');
} catch (error) {
console.error('Failed to register service:', error.message);
}
};
// Health check endpoint
app.get('/health', (req, res) => {
res.status(200).json({ status: 'healthy', timestamp: Date.now() });
});
app.listen(3001, () => {
console.log('User service running on port 3001');
registerService();
});
3. Circuit Breaker Pattern
Prevent cascading failures by monitoring service health and stopping requests to failing services.
// utils/circuit-breaker.js
class CircuitBreaker {
constructor(request, options = {}) {
this.request = request;
this.state = 'CLOSED'; // CLOSED, OPEN, HALF_OPEN
this.failureCount = 0;
this.failureThreshold = options.failureThreshold || 5;
this.resetTimeout = options.resetTimeout || 60000;
this.monitor = options.monitor || false;
}
async call(...args) {
if (this.state === 'OPEN') {
if (this.nextAttempt <= Date.now()) {
this.state = 'HALF_OPEN';
} else {
throw new Error('Circuit breaker is OPEN');
}
}
try {
const result = await this.request(...args);
return this.onSuccess(result);
} catch (error) {
return this.onFailure(error);
}
}
onSuccess(result) {
this.failureCount = 0;
if (this.state === 'HALF_OPEN') {
this.state = 'CLOSED';
}
return result;
}
onFailure(error) {
this.failureCount++;
if (this.failureCount >= this.failureThreshold) {
this.state = 'OPEN';
this.nextAttempt = Date.now() + this.resetTimeout;
}
throw error;
}
}
module.exports = CircuitBreaker;
Using Circuit Breaker
// order-service/services/paymentService.js
const axios = require('axios');
const CircuitBreaker = require('../utils/circuit-breaker');
const paymentRequest = async (paymentData) => {
const response = await axios.post(
'http://payment-service:3003/payments',
paymentData
);
return response.data;
};
const paymentCircuitBreaker = new CircuitBreaker(paymentRequest, {
failureThreshold: 3,
resetTimeout: 30000,
});
const processPayment = async (paymentData) => {
try {
return await paymentCircuitBreaker.call(paymentData);
} catch (error) {
console.error('Payment service unavailable:', error.message);
// Implement fallback logic
return { status: 'pending', message: 'Payment will be processed later' };
}
};
module.exports = { processPayment };
4. Event-Driven Architecture
Use events to enable loose coupling between services.
// shared/eventBus.js
const EventEmitter = require('events');
const Redis = require('redis');
class EventBus extends EventEmitter {
constructor() {
super();
this.redisClient = Redis.createClient();
this.subscriber = Redis.createClient();
this.setupRedisSubscriber();
}
async publish(event, data) {
const eventData = {
event,
data,
timestamp: Date.now(),
id: this.generateId(),
};
// Emit locally
this.emit(event, eventData);
// Publish to Redis for other services
await this.redisClient.publish(
'microservices-events',
JSON.stringify(eventData)
);
}
setupRedisSubscriber() {
this.subscriber.subscribe('microservices-events');
this.subscriber.on('message', (channel, message) => {
try {
const eventData = JSON.parse(message);
this.emit(eventData.event, eventData);
} catch (error) {
console.error('Error parsing event:', error);
}
});
}
generateId() {
return Math.random().toString(36).substr(2, 9);
}
}
module.exports = new EventBus();
Event Handlers
// order-service/handlers/orderEvents.js
const eventBus = require('../shared/eventBus');
const emailService = require('../services/emailService');
const inventoryService = require('../services/inventoryService');
// Handle order creation
eventBus.on('order.created', async (eventData) => {
const { order } = eventData.data;
try {
// Update inventory
await inventoryService.reserveItems(order.items);
// Send confirmation email
await emailService.sendOrderConfirmation(order.customerEmail, order);
console.log(`Order ${order.id} processed successfully`);
} catch (error) {
console.error(`Failed to process order ${order.id}:`, error);
// Publish compensation event
await eventBus.publish('order.processing.failed', {
order,
error: error.message,
});
}
});
// Handle payment confirmation
eventBus.on('payment.confirmed', async (eventData) => {
const { orderId, paymentId } = eventData.data;
try {
// Update order status
await orderService.updateOrderStatus(orderId, 'paid');
// Trigger fulfillment
await eventBus.publish('order.fulfillment.requested', {
orderId,
paymentId,
});
} catch (error) {
console.error(`Failed to handle payment confirmation:`, error);
}
});
5. Saga Pattern
Manage distributed transactions across multiple services.
// order-service/sagas/orderSaga.js
class OrderSaga {
constructor() {
this.steps = [];
this.compensations = [];
}
addStep(stepFunction, compensationFunction) {
this.steps.push(stepFunction);
this.compensations.unshift(compensationFunction); // Reverse order for rollback
}
async execute(data) {
const completedSteps = [];
try {
for (let i = 0; i < this.steps.length; i++) {
const result = await this.steps[i](data);
completedSteps.push(i);
data = { ...data, ...result };
}
return data;
} catch (error) {
console.error('Saga execution failed:', error);
await this.compensate(completedSteps, data);
throw error;
}
}
async compensate(completedSteps, data) {
for (const stepIndex of completedSteps.reverse()) {
try {
await this.compensations[stepIndex](data);
} catch (compensationError) {
console.error(
`Compensation failed for step ${stepIndex}:`,
compensationError
);
}
}
}
}
// Usage example
const createOrderSaga = new OrderSaga();
createOrderSaga.addStep(
// Step: Reserve inventory
async (data) => {
const reservation = await inventoryService.reserve(data.items);
return { reservationId: reservation.id };
},
// Compensation: Release inventory
async (data) => {
if (data.reservationId) {
await inventoryService.release(data.reservationId);
}
}
);
createOrderSaga.addStep(
// Step: Process payment
async (data) => {
const payment = await paymentService.charge(data.paymentInfo);
return { paymentId: payment.id };
},
// Compensation: Refund payment
async (data) => {
if (data.paymentId) {
await paymentService.refund(data.paymentId);
}
}
);
createOrderSaga.addStep(
// Step: Create order
async (data) => {
const order = await orderService.create(data);
return { orderId: order.id };
},
// Compensation: Cancel order
async (data) => {
if (data.orderId) {
await orderService.cancel(data.orderId);
}
}
);
module.exports = { createOrderSaga };
Service Communication Patterns
1. Synchronous Communication (HTTP/REST)
// user-service/controllers/userController.js
const express = require('express');
const axios = require('axios');
const router = express.Router();
// Get user with order history
router.get('/:id/orders', async (req, res) => {
try {
const userId = req.params.id;
// Get user information
const user = await userService.findById(userId);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
// Get orders from order service
const ordersResponse = await axios.get(
`http://order-service:3002/orders/user/${userId}`
);
const orders = ordersResponse.data;
res.json({
user,
orders,
});
} catch (error) {
console.error('Error fetching user orders:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
module.exports = router;
2. Asynchronous Communication (Message Queues)
// shared/messageQueue.js
const amqp = require('amqplib');
class MessageQueue {
constructor() {
this.connection = null;
this.channel = null;
}
async connect() {
try {
this.connection = await amqp.connect(process.env.RABBITMQ_URL);
this.channel = await this.connection.createChannel();
console.log('Connected to RabbitMQ');
} catch (error) {
console.error('Failed to connect to RabbitMQ:', error);
throw error;
}
}
async publishToQueue(queueName, message) {
await this.channel.assertQueue(queueName, { durable: true });
this.channel.sendToQueue(queueName, Buffer.from(JSON.stringify(message)), {
persistent: true,
});
}
async consumeFromQueue(queueName, callback) {
await this.channel.assertQueue(queueName, { durable: true });
this.channel.prefetch(1);
this.channel.consume(queueName, async (msg) => {
if (msg) {
try {
const content = JSON.parse(msg.content.toString());
await callback(content);
this.channel.ack(msg);
} catch (error) {
console.error('Error processing message:', error);
this.channel.nack(msg, false, false); // Dead letter the message
}
}
});
}
}
module.exports = new MessageQueue();
Using Message Queue
// notification-service/server.js
const messageQueue = require('../shared/messageQueue');
const emailService = require('./services/emailService');
const processNotifications = async () => {
await messageQueue.connect();
// Listen for order confirmation notifications
await messageQueue.consumeFromQueue(
'order-notifications',
async (message) => {
const { type, data } = message;
switch (type) {
case 'ORDER_CONFIRMED':
await emailService.sendOrderConfirmation(data.email, data.order);
break;
case 'PAYMENT_PROCESSED':
await emailService.sendPaymentConfirmation(data.email, data.payment);
break;
case 'ORDER_SHIPPED':
await emailService.sendShippingNotification(
data.email,
data.tracking
);
break;
default:
console.log(`Unknown notification type: ${type}`);
}
}
);
};
processNotifications();
Data Management Strategies
1. Database per Service
Each microservice manages its own database, ensuring loose coupling and independence.
// user-service/models/User.js
const mongoose = require('mongoose');
const userSchema = new mongoose.Schema({
email: { type: String, required: true, unique: true },
name: { type: String, required: true },
passwordHash: { type: String, required: true },
createdAt: { type: Date, default: Date.now },
profile: {
firstName: String,
lastName: String,
phone: String,
address: {
street: String,
city: String,
country: String,
zipCode: String,
},
},
});
module.exports = mongoose.model('User', userSchema);
// order-service/models/Order.js
const mongoose = require('mongoose');
const orderSchema = new mongoose.Schema({
userId: { type: String, required: true }, // Reference to user service
items: [
{
productId: String,
quantity: Number,
price: Number,
name: String,
},
],
status: {
type: String,
enum: ['pending', 'confirmed', 'paid', 'shipped', 'delivered', 'cancelled'],
default: 'pending',
},
totalAmount: { type: Number, required: true },
shippingAddress: {
street: String,
city: String,
country: String,
zipCode: String,
},
createdAt: { type: Date, default: Date.now },
});
module.exports = mongoose.model('Order', orderSchema);
2. Data Synchronization
Implement eventual consistency through event-driven data synchronization.
// shared/dataSynchronizer.js
class DataSynchronizer {
constructor(eventBus) {
this.eventBus = eventBus;
this.setupEventHandlers();
}
setupEventHandlers() {
// Sync user data changes
this.eventBus.on('user.updated', async (eventData) => {
const { userId, changes } = eventData.data;
// Update user data in order service cache
await this.updateUserCache('order-service', userId, changes);
// Update user data in notification service cache
await this.updateUserCache('notification-service', userId, changes);
});
// Sync product data changes
this.eventBus.on('product.updated', async (eventData) => {
const { productId, changes } = eventData.data;
// Update product cache in order service
await this.updateProductCache('order-service', productId, changes);
});
}
async updateUserCache(serviceName, userId, changes) {
try {
// Implementation depends on the caching strategy
// Could be Redis, in-memory cache, or read replica
console.log(`Updating user cache in ${serviceName} for user ${userId}`);
} catch (error) {
console.error(`Failed to update user cache in ${serviceName}:`, error);
}
}
async updateProductCache(serviceName, productId, changes) {
try {
console.log(
`Updating product cache in ${serviceName} for product ${productId}`
);
} catch (error) {
console.error(`Failed to update product cache in ${serviceName}:`, error);
}
}
}
module.exports = DataSynchronizer;
Service Resilience and Monitoring
1. Health Checks
Implement comprehensive health checks for service monitoring.
// shared/healthCheck.js
class HealthChecker {
constructor() {
this.checks = new Map();
}
addCheck(name, checkFunction) {
this.checks.set(name, checkFunction);
}
async runChecks() {
const results = {};
const promises = [];
for (const [name, checkFunction] of this.checks) {
promises.push(
this.runSingleCheck(name, checkFunction).then((result) => {
results[name] = result;
})
);
}
await Promise.all(promises);
const overall = Object.values(results).every(
(check) => check.status === 'healthy'
)
? 'healthy'
: 'unhealthy';
return {
status: overall,
timestamp: new Date().toISOString(),
checks: results,
};
}
async runSingleCheck(name, checkFunction) {
const startTime = Date.now();
try {
await checkFunction();
return {
status: 'healthy',
responseTime: Date.now() - startTime,
};
} catch (error) {
return {
status: 'unhealthy',
error: error.message,
responseTime: Date.now() - startTime,
};
}
}
}
module.exports = HealthChecker;
Service-specific Health Checks
// user-service/health.js
const HealthChecker = require('../shared/healthCheck');
const mongoose = require('mongoose');
const redis = require('redis');
const healthChecker = new HealthChecker();
// Database connectivity check
healthChecker.addCheck('database', async () => {
if (mongoose.connection.readyState !== 1) {
throw new Error('Database not connected');
}
// Test database query
await mongoose.connection.db.admin().ping();
});
// Redis connectivity check
healthChecker.addCheck('redis', async () => {
const client = redis.createClient();
await client.ping();
await client.quit();
});
// External service dependency check
healthChecker.addCheck('order-service', async () => {
const axios = require('axios');
const response = await axios.get('http://order-service:3002/health', {
timeout: 5000,
});
if (response.status !== 200) {
throw new Error('Order service unhealthy');
}
});
module.exports = healthChecker;
2. Distributed Logging
Implement centralized logging with correlation IDs.
// shared/logger.js
const winston = require('winston');
const { v4: uuidv4 } = require('uuid');
class Logger {
constructor(serviceName) {
this.serviceName = serviceName;
this.logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
defaultMeta: { service: serviceName },
transports: [
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'combined.log' }),
new winston.transports.Console({
format: winston.format.simple(),
}),
],
});
}
createCorrelationId() {
return uuidv4();
}
info(message, meta = {}) {
this.logger.info(message, meta);
}
error(message, error = null, meta = {}) {
this.logger.error(message, {
...meta,
error: error?.stack || error?.message || error,
});
}
warn(message, meta = {}) {
this.logger.warn(message, meta);
}
debug(message, meta = {}) {
this.logger.debug(message, meta);
}
}
// Middleware to add correlation ID to requests
const correlationMiddleware = (req, res, next) => {
req.correlationId = req.headers['x-correlation-id'] || uuidv4();
res.setHeader('x-correlation-id', req.correlationId);
next();
};
module.exports = { Logger, correlationMiddleware };
3. Distributed Tracing
Implement distributed tracing to track requests across services.
// shared/tracing.js
const opentelemetry = require('@opentelemetry/api');
const { NodeTracerProvider } = require('@opentelemetry/sdk-node');
const { Resource } = require('@opentelemetry/resources');
const {
SemanticResourceAttributes,
} = require('@opentelemetry/semantic-conventions');
const { JaegerExporter } = require('@opentelemetry/exporter-jaeger');
const { BatchSpanProcessor } = require('@opentelemetry/sdk-trace-base');
const { registerInstrumentations } = require('@opentelemetry/instrumentation');
const { HttpInstrumentation } = require('@opentelemetry/instrumentation-http');
const {
ExpressInstrumentation,
} = require('@opentelemetry/instrumentation-express');
class TracingSetup {
static initialize(serviceName) {
const provider = new NodeTracerProvider({
resource: new Resource({
[SemanticResourceAttributes.SERVICE_NAME]: serviceName,
[SemanticResourceAttributes.SERVICE_VERSION]: '1.0.0',
}),
});
const jaegerExporter = new JaegerExporter({
endpoint:
process.env.JAEGER_ENDPOINT || 'http://localhost:14268/api/traces',
});
provider.addSpanProcessor(new BatchSpanProcessor(jaegerExporter));
provider.register();
registerInstrumentations({
instrumentations: [
new HttpInstrumentation(),
new ExpressInstrumentation(),
],
});
return opentelemetry.trace.getTracer(serviceName);
}
static createSpan(tracer, name, operation) {
return tracer.startSpan(name, {
kind: opentelemetry.SpanKind.INTERNAL,
attributes: {
'operation.type': operation,
},
});
}
}
module.exports = TracingSetup;
Deployment and Orchestration
1. Docker Configuration
Create production-ready Docker images for each service.
# user-service/Dockerfile
FROM node:18-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
# Copy source code
COPY . .
# Create non-root user
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nodeuser -u 1001
# Change ownership
RUN chown -R nodeuser:nodejs /app
USER nodeuser
# Expose port
EXPOSE 3001
# Health check
HEALTHCHECK \
CMD node healthcheck.js
# Start the application
CMD ["node", "server.js"]
Multi-stage Build for Production
# Build stage
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage
FROM node:18-alpine AS production
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
COPY /app/dist ./dist
COPY /app/public ./public
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodeuser -u 1001
RUN chown -R nodeuser:nodejs /app
USER nodeuser
EXPOSE 3001
CMD ["node", "dist/server.js"]
2. Docker Compose for Development
# docker-compose.yml
version: '3.8'
services:
api-gateway:
build: ./api-gateway
ports:
- '3000:3000'
environment:
- NODE_ENV=development
depends_on:
- user-service
- order-service
- payment-service
user-service:
build: ./user-service
ports:
- '3001:3001'
environment:
- NODE_ENV=development
- MONGODB_URL=mongodb://mongo:27017/users
- REDIS_URL=redis://redis:6379
depends_on:
- mongo
- redis
order-service:
build: ./order-service
ports:
- '3002:3002'
environment:
- NODE_ENV=development
- MONGODB_URL=mongodb://mongo:27017/orders
- REDIS_URL=redis://redis:6379
depends_on:
- mongo
- redis
payment-service:
build: ./payment-service
ports:
- '3003:3003'
environment:
- NODE_ENV=development
- MONGODB_URL=mongodb://mongo:27017/payments
depends_on:
- mongo
mongo:
image: mongo:5.0
ports:
- '27017:27017'
volumes:
- mongo_data:/data/db
redis:
image: redis:7-alpine
ports:
- '6379:6379'
volumes:
- redis_data:/data
rabbitmq:
image: rabbitmq:3-management
ports:
- '5672:5672'
- '15672:15672'
environment:
- RABBITMQ_DEFAULT_USER=admin
- RABBITMQ_DEFAULT_PASS=password
volumes:
- rabbitmq_data:/var/lib/rabbitmq
volumes:
mongo_data:
redis_data:
rabbitmq_data:
3. Kubernetes Deployment
# k8s/user-service-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: user-service
labels:
app: user-service
spec:
replicas: 3
selector:
matchLabels:
app: user-service
template:
metadata:
labels:
app: user-service
spec:
containers:
- name: user-service
image: user-service:latest
ports:
- containerPort: 3001
env:
- name: NODE_ENV
value: 'production'
- name: MONGODB_URL
valueFrom:
secretKeyRef:
name: db-secrets
key: mongodb-url
- name: REDIS_URL
valueFrom:
configMapKeyRef:
name: app-config
key: redis-url
livenessProbe:
httpGet:
path: /health
port: 3001
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: 3001
initialDelaySeconds: 5
periodSeconds: 5
resources:
requests:
memory: '128Mi'
cpu: '100m'
limits:
memory: '256Mi'
cpu: '200m'
---
apiVersion: v1
kind: Service
metadata:
name: user-service
spec:
selector:
app: user-service
ports:
- protocol: TCP
port: 80
targetPort: 3001
type: ClusterIP
Security Best Practices
1. Service-to-Service Authentication
Implement JWT-based authentication between services.
// shared/serviceAuth.js
const jwt = require('jsonwebtoken');
const axios = require('axios');
class ServiceAuth {
constructor() {
this.serviceToken = null;
this.tokenExpiry = null;
}
async getServiceToken() {
if (this.serviceToken && this.tokenExpiry > Date.now()) {
return this.serviceToken;
}
try {
const response = await axios.post(
'http://auth-service:3004/auth/service',
{
serviceId: process.env.SERVICE_ID,
serviceSecret: process.env.SERVICE_SECRET,
}
);
this.serviceToken = response.data.token;
this.tokenExpiry = Date.now() + response.data.expiresIn * 1000;
return this.serviceToken;
} catch (error) {
throw new Error(`Failed to get service token: ${error.message}`);
}
}
async authenticatedRequest(url, options = {}) {
const token = await this.getServiceToken();
return axios({
...options,
url,
headers: {
...options.headers,
Authorization: `Bearer ${token}`,
'X-Service-ID': process.env.SERVICE_ID,
},
});
}
}
module.exports = new ServiceAuth();
2. API Rate Limiting
// shared/rateLimiter.js
const redis = require('redis');
class RateLimiter {
constructor(redisClient) {
this.redis = redisClient;
}
async isAllowed(key, maxRequests, windowSize) {
const now = Date.now();
const window = Math.floor(now / windowSize);
const redisKey = `rate_limit:${key}:${window}`;
const current = await this.redis.incr(redisKey);
if (current === 1) {
await this.redis.expire(redisKey, Math.ceil(windowSize / 1000));
}
return current <= maxRequests;
}
middleware(maxRequests = 100, windowSize = 60000) {
return async (req, res, next) => {
const key = req.ip || req.connection.remoteAddress;
try {
const allowed = await this.isAllowed(key, maxRequests, windowSize);
if (!allowed) {
return res.status(429).json({
error: 'Too many requests',
retryAfter: Math.ceil(windowSize / 1000),
});
}
next();
} catch (error) {
console.error('Rate limiting error:', error);
next(); // Fail open
}
};
}
}
module.exports = RateLimiter;
3. Input Validation and Sanitization
// shared/validation.js
const Joi = require('joi');
const validator = require('validator');
class InputValidator {
static validate(schema) {
return (req, res, next) => {
const { error, value } = schema.validate(req.body);
if (error) {
return res.status(400).json({
error: 'Validation failed',
details: error.details.map((detail) => ({
field: detail.path.join('.'),
message: detail.message,
})),
});
}
req.validatedBody = value;
next();
};
}
static sanitizeInput(input) {
if (typeof input === 'string') {
return validator.escape(input.trim());
}
if (Array.isArray(input)) {
return input.map((item) => this.sanitizeInput(item));
}
if (typeof input === 'object' && input !== null) {
const sanitized = {};
for (const [key, value] of Object.entries(input)) {
sanitized[key] = this.sanitizeInput(value);
}
return sanitized;
}
return input;
}
}
// Validation schemas
const schemas = {
createUser: Joi.object({
email: Joi.string().email().required(),
name: Joi.string().min(2).max(100).required(),
password: Joi.string()
.min(8)
.pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
.required(),
profile: Joi.object({
firstName: Joi.string().max(50),
lastName: Joi.string().max(50),
phone: Joi.string().pattern(/^\+?[\d\s-()]+$/),
}),
}),
createOrder: Joi.object({
items: Joi.array()
.items(
Joi.object({
productId: Joi.string().required(),
quantity: Joi.number().integer().min(1).required(),
price: Joi.number().positive().required(),
})
)
.min(1)
.required(),
shippingAddress: Joi.object({
street: Joi.string().required(),
city: Joi.string().required(),
country: Joi.string().required(),
zipCode: Joi.string().required(),
}).required(),
}),
};
module.exports = { InputValidator, schemas };
Performance Optimization
1. Caching Strategies
Implement multi-level caching for improved performance.
// shared/cache.js
const redis = require('redis');
class CacheManager {
constructor() {
this.redisClient = redis.createClient();
this.localCache = new Map();
this.maxLocalCacheSize = 1000;
}
async get(key, options = {}) {
const { useLocal = true, ttl = 300 } = options;
// Try local cache first
if (useLocal && this.localCache.has(key)) {
const cached = this.localCache.get(key);
if (cached.expiry > Date.now()) {
return cached.value;
}
this.localCache.delete(key);
}
// Try Redis cache
try {
const value = await this.redisClient.get(key);
if (value) {
const parsed = JSON.parse(value);
// Store in local cache
if (useLocal) {
this.setLocal(key, parsed, ttl);
}
return parsed;
}
} catch (error) {
console.error('Redis cache error:', error);
}
return null;
}
async set(key, value, options = {}) {
const { ttl = 300, useLocal = true } = options;
// Set in Redis
try {
await this.redisClient.setex(key, ttl, JSON.stringify(value));
} catch (error) {
console.error('Redis cache set error:', error);
}
// Set in local cache
if (useLocal) {
this.setLocal(key, value, ttl);
}
}
setLocal(key, value, ttl) {
if (this.localCache.size >= this.maxLocalCacheSize) {
// Remove oldest entry
const firstKey = this.localCache.keys().next().value;
this.localCache.delete(firstKey);
}
this.localCache.set(key, {
value,
expiry: Date.now() + ttl * 1000,
});
}
async invalidate(pattern) {
// Clear matching keys from local cache
for (const key of this.localCache.keys()) {
if (key.includes(pattern)) {
this.localCache.delete(key);
}
}
// Clear matching keys from Redis
try {
const keys = await this.redisClient.keys(`*${pattern}*`);
if (keys.length > 0) {
await this.redisClient.del(keys);
}
} catch (error) {
console.error('Redis cache invalidation error:', error);
}
}
}
module.exports = new CacheManager();
2. Database Optimization
// shared/database.js
const mongoose = require('mongoose');
class DatabaseOptimizer {
static setupConnection(uri, options = {}) {
const defaultOptions = {
maxPoolSize: 10,
serverSelectionTimeoutMS: 5000,
socketTimeoutMS: 45000,
bufferMaxEntries: 0,
useNewUrlParser: true,
useUnifiedTopology: true,
};
return mongoose.connect(uri, { ...defaultOptions, ...options });
}
static createIndexes(model, indexes) {
indexes.forEach((index) => {
model.createIndex(index.fields, index.options);
});
}
static setupReadPreference(schema, preference = 'secondary') {
schema.set('read', preference);
}
// Pagination helper
static async paginate(model, query = {}, options = {}) {
const {
page = 1,
limit = 10,
sort = { createdAt: -1 },
populate = null,
} = options;
const skip = (page - 1) * limit;
const [results, total] = await Promise.all([
model
.find(query)
.sort(sort)
.skip(skip)
.limit(limit)
.populate(populate)
.lean(),
model.countDocuments(query),
]);
return {
results,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit),
},
};
}
}
module.exports = DatabaseOptimizer;
Testing Strategies
1. Unit Testing
// user-service/tests/services/userService.test.js
const { describe, it, beforeEach, afterEach } = require('mocha');
const { expect } = require('chai');
const sinon = require('sinon');
const UserService = require('../../services/userService');
const User = require('../../models/User');
describe('UserService', () => {
let userService;
let userModelStub;
beforeEach(() => {
userService = new UserService();
userModelStub = sinon.stub(User);
});
afterEach(() => {
sinon.restore();
});
describe('createUser', () => {
it('should create a user successfully', async () => {
const userData = {
email: 'test@example.com',
name: 'Test User',
password: 'password123',
};
const expectedUser = { id: '123', ...userData };
userModelStub.create.resolves(expectedUser);
const result = await userService.createUser(userData);
expect(result).to.deep.equal(expectedUser);
expect(userModelStub.create.calledOnce).to.be.true;
expect(userModelStub.create.calledWith(userData)).to.be.true;
});
it('should throw error for duplicate email', async () => {
const userData = {
email: 'existing@example.com',
name: 'Test User',
password: 'password123',
};
const error = new Error('Email already exists');
error.code = 11000;
userModelStub.create.rejects(error);
try {
await userService.createUser(userData);
expect.fail('Should have thrown an error');
} catch (err) {
expect(err.message).to.equal('Email already exists');
}
});
});
});
2. Integration Testing
// tests/integration/orderFlow.test.js
const request = require('supertest');
const { expect } = require('chai');
const app = require('../../api-gateway/server');
describe('Order Flow Integration', () => {
let authToken;
let userId;
before(async () => {
// Setup test user and get auth token
const userResponse = await request(app).post('/api/users').send({
email: 'test@example.com',
name: 'Test User',
password: 'password123',
});
userId = userResponse.body.id;
const loginResponse = await request(app).post('/api/auth/login').send({
email: 'test@example.com',
password: 'password123',
});
authToken = loginResponse.body.token;
});
it('should complete order creation flow', async () => {
// 1. Create order
const orderResponse = await request(app)
.post('/api/orders')
.set('Authorization', `Bearer ${authToken}`)
.send({
items: [
{
productId: 'product-1',
quantity: 2,
price: 29.99,
name: 'Test Product',
},
],
shippingAddress: {
street: '123 Test St',
city: 'Test City',
country: 'US',
zipCode: '12345',
},
});
expect(orderResponse.status).to.equal(201);
expect(orderResponse.body).to.have.property('id');
expect(orderResponse.body.status).to.equal('pending');
const orderId = orderResponse.body.id;
// 2. Process payment
const paymentResponse = await request(app)
.post('/api/payments')
.set('Authorization', `Bearer ${authToken}`)
.send({
orderId,
amount: 59.98,
paymentMethod: 'credit_card',
cardDetails: {
number: '4111111111111111',
expiryMonth: 12,
expiryYear: 2025,
cvv: '123',
},
});
expect(paymentResponse.status).to.equal(200);
expect(paymentResponse.body.status).to.equal('completed');
// 3. Verify order status updated
const updatedOrderResponse = await request(app)
.get(`/api/orders/${orderId}`)
.set('Authorization', `Bearer ${authToken}`);
expect(updatedOrderResponse.status).to.equal(200);
expect(updatedOrderResponse.body.status).to.equal('paid');
});
});
3. Contract Testing
// tests/contracts/userService.contract.js
const { Pact } = require('@pact-foundation/pact');
const { expect } = require('chai');
const axios = require('axios');
describe('User Service Contract', () => {
const provider = new Pact({
consumer: 'order-service',
provider: 'user-service',
port: 1234,
});
before(() => provider.setup());
after(() => provider.finalize());
afterEach(() => provider.verify());
it('should get user by ID', async () => {
const userResponse = {
id: '123',
email: 'test@example.com',
name: 'Test User',
};
await provider
.given('user with ID 123 exists')
.uponReceiving('a request for user 123')
.withRequest({
method: 'GET',
path: '/users/123',
headers: {
Accept: 'application/json',
},
})
.willRespondWith({
status: 200,
headers: {
'Content-Type': 'application/json',
},
body: userResponse,
});
const response = await axios.get('http://localhost:1234/users/123', {
headers: { Accept: 'application/json' },
});
expect(response.data).to.deep.equal(userResponse);
});
});
Best Practices Summary
1. Design Principles
- Single Responsibility: Each service should have one business responsibility
- Loose Coupling: Services should be independent and communicate through well-defined interfaces
- High Cohesion: Related functionality should be grouped together within a service
- Autonomous Teams: Each service should be owned by a small, autonomous team
2. Communication Guidelines
- Prefer Async: Use asynchronous communication when possible to improve resilience
- Idempotency: Ensure operations can be safely retried
- Timeouts: Always set reasonable timeouts for external calls
- Graceful Degradation: Implement fallback mechanisms for service failures
3. Data Management
- Database per Service: Each service should own its data
- Eventual Consistency: Accept eventual consistency for better performance and availability
- Event Sourcing: Consider event sourcing for audit trails and complex business logic
- CQRS: Separate read and write models when appropriate
4. Security Considerations
- Zero Trust: Never trust data from other services without validation
- Encryption: Encrypt data in transit and at rest
- Authentication: Implement proper service-to-service authentication
- Authorization: Use fine-grained authorization controls
5. Operational Excellence
- Observability: Implement comprehensive logging, metrics, and tracing
- Health Checks: Provide detailed health check endpoints
- Graceful Shutdown: Handle shutdown signals properly
- Resource Limits: Set appropriate resource limits and requests
Conclusion
Microservices architecture with Node.js offers powerful capabilities for building scalable, maintainable applications. Success depends on careful design, proper implementation of communication patterns, and adherence to best practices around data management, security, and operational excellence.
Key takeaways:
- Start Simple: Begin with a modular monolith and extract services as needed
- Focus on Business Capabilities: Design services around business domains
- Embrace Async Communication: Use events and messaging for loose coupling
- Invest in Tooling: Proper monitoring, logging, and deployment tools are essential
- Team Ownership: Align service ownership with team boundaries
Remember that microservices introduce complexity and should only be adopted when the benefits outweigh the costs. Consider your team's expertise, organizational structure, and application requirements when making architectural decisions.
The patterns and practices outlined in this guide provide a solid foundation for building robust microservices architectures with Node.js. Adapt them to your specific use cases and continue learning from the broader microservices community.