Microservices Architecture with Node.js
Microservices architecture is a design pattern where an application is composed of small, independent services that communicate with each other. This approach offers scalability, flexibility, and easier maintenance compared to traditional monolithic architectures.
Monolith vs Microservices
Let's understand the fundamental differences:
Monolithic Architecture
- Single codebase containing all functionality
- All components deployed together as one unit
- Single database shared by all features
- Scaling requires duplicating the entire application
- One programming language/framework for everything
// Monolithic structure
project/
├── controllers/
│ ├── userController.js
│ ├── productController.js
│ ├── orderController.js
│ └── paymentController.js
├── models/
├── routes/
└── server.js // Single entry point
Microservices Architecture
- Multiple independent services, each with its own codebase
- Each service deployed independently
- Each service can have its own database
- Scale individual services based on demand
- Different services can use different technologies
// Microservices structure
microservices/
├── user-service/
│ ├── src/
│ ├── package.json
│ └── Dockerfile
├── product-service/
│ ├── src/
│ ├── package.json
│ └── Dockerfile
├── order-service/
│ ├── src/
│ ├── package.json
│ └── Dockerfile
└── payment-service/
├── src/
├── package.json
└── Dockerfile
Note: Microservices are not always better than monoliths. Choose based on your team size, application complexity, and scalability requirements.
Service Decomposition
Breaking down a monolith into microservices requires careful planning. Here's an example of decomposing an e-commerce application:
// Example service boundaries
1. User Service - Authentication, profiles, preferences
2. Product Service - Product catalog, inventory, search
3. Order Service - Shopping cart, order management
4. Payment Service - Payment processing, invoicing
5. Notification Service - Emails, SMS, push notifications
6. Analytics Service - User behavior, reports, metrics
Creating a Simple Microservice
Let's create a basic user service:
// user-service/src/index.js
const express = require('express');
const app = express();
app.use(express.json());
// In-memory user store (use database in production)
const users = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' }
];
// Health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'ok', service: 'user-service' });
});
// Get all users
app.get('/users', (req, res) => {
res.json(users);
});
// Get user by ID
app.get('/users/:id', (req, res) => {
const user = users.find(u => u.id === parseInt(req.params.id));
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json(user);
});
// Create user
app.post('/users', (req, res) => {
const newUser = {
id: users.length + 1,
name: req.body.name,
email: req.body.email
};
users.push(newUser);
res.status(201).json(newUser);
});
const PORT = process.env.PORT || 3001;
app.listen(PORT, () => {
console.log(`User service running on port ${PORT}`);
});
Inter-Service Communication
Services need to communicate with each other. There are two main approaches:
1. HTTP/REST Communication
// order-service/src/index.js
const express = require('express');
const axios = require('axios');
const app = express();
app.use(express.json());
const USER_SERVICE_URL = process.env.USER_SERVICE_URL || 'http://localhost:3001';
app.post('/orders', async (req, res) => {
try {
const { userId, productId, quantity } = req.body;
// Call user service to verify user exists
const userResponse = await axios.get(`${USER_SERVICE_URL}/users/${userId}`);
const user = userResponse.data;
// Create order
const order = {
id: Date.now(),
userId: user.id,
userName: user.name,
productId,
quantity,
createdAt: new Date()
};
res.status(201).json(order);
} catch (error) {
if (error.response?.status === 404) {
return res.status(404).json({ error: 'User not found' });
}
res.status(500).json({ error: 'Internal server error' });
}
});
const PORT = process.env.PORT || 3002;
app.listen(PORT, () => {
console.log(`Order service running on port ${PORT}`);
});
Warning: HTTP calls create tight coupling and can fail if a service is down. Always implement proper error handling and timeouts.
2. Message Queue Communication (Asynchronous)
Using a message queue like RabbitMQ or Redis for asynchronous communication:
// Using Redis pub/sub
const redis = require('redis');
const publisher = redis.createClient();
const subscriber = redis.createClient();
// Order service - Publish event
app.post('/orders', async (req, res) => {
const order = {
id: Date.now(),
userId: req.body.userId,
productId: req.body.productId,
quantity: req.body.quantity
};
// Publish order.created event
await publisher.publish('order.created', JSON.stringify(order));
res.status(201).json(order);
});
// Notification service - Subscribe to events
subscriber.subscribe('order.created');
subscriber.on('message', (channel, message) => {
if (channel === 'order.created') {
const order = JSON.parse(message);
console.log('Sending notification for order:', order.id);
// Send email/SMS notification
}
});
API Gateway Pattern
An API Gateway acts as a single entry point for all client requests and routes them to appropriate microservices:
// api-gateway/src/index.js
const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');
const app = express();
// Route to user service
app.use('/api/users', createProxyMiddleware({
target: 'http://localhost:3001',
changeOrigin: true,
pathRewrite: {
'^/api/users': '/users'
}
}));
// Route to order service
app.use('/api/orders', createProxyMiddleware({
target: 'http://localhost:3002',
changeOrigin: true,
pathRewrite: {
'^/api/orders': '/orders'
}
}));
// Route to product service
app.use('/api/products', createProxyMiddleware({
target: 'http://localhost:3003',
changeOrigin: true,
pathRewrite: {
'^/api/products': '/products'
}
}));
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`API Gateway running on port ${PORT}`);
});
Tip: The API Gateway can also handle authentication, rate limiting, logging, and request/response transformation.
Service Discovery
In dynamic environments, services need to discover each other. Here's a simple service registry:
// service-registry/src/index.js
const express = require('express');
const app = express();
app.use(express.json());
const services = new Map();
// Register a service
app.post('/register', (req, res) => {
const { name, host, port, healthCheck } = req.body;
services.set(name, {
name,
host,
port,
healthCheck,
registeredAt: new Date()
});
console.log(`Service registered: ${name} at ${host}:${port}`);
res.json({ message: 'Service registered successfully' });
});
// Discover a service
app.get('/discover/:serviceName', (req, res) => {
const service = services.get(req.params.serviceName);
if (!service) {
return res.status(404).json({ error: 'Service not found' });
}
res.json(service);
});
// List all services
app.get('/services', (req, res) => {
res.json(Array.from(services.values()));
});
app.listen(3000, () => {
console.log('Service registry running on port 3000');
});
// Services register themselves on startup
const axios = require('axios');
async function registerService() {
try {
await axios.post('http://localhost:3000/register', {
name: 'user-service',
host: 'localhost',
port: 3001,
healthCheck: '/health'
});
console.log('Registered with service registry');
} catch (error) {
console.error('Failed to register:', error.message);
}
}
registerService();
Docker Basics for Node.js
Docker allows you to package your microservices as containers:
# user-service/Dockerfile
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY src ./src
EXPOSE 3001
CMD ["node", "src/index.js"]
# user-service/.dockerignore
node_modules
npm-debug.log
.env
.git
# Build and run the container
docker build -t user-service .
docker run -p 3001:3001 user-service
# Or use docker-compose for multiple services
Docker Compose for Multiple Services
# docker-compose.yml
version: '3.8'
services:
user-service:
build: ./user-service
ports:
- "3001:3001"
environment:
- NODE_ENV=production
- DB_HOST=postgres
depends_on:
- postgres
order-service:
build: ./order-service
ports:
- "3002:3002"
environment:
- NODE_ENV=production
- USER_SERVICE_URL=http://user-service:3001
depends_on:
- user-service
- redis
api-gateway:
build: ./api-gateway
ports:
- "3000:3000"
depends_on:
- user-service
- order-service
postgres:
image: postgres:15
environment:
- POSTGRES_PASSWORD=secret
volumes:
- postgres-data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
postgres-data:
# Start all services
docker-compose up -d
# View logs
docker-compose logs -f
# Stop all services
docker-compose down
Microservices Best Practices
- Single Responsibility: Each service should do one thing well
- Decentralized Data: Each service should own its database
- API Versioning: Version your APIs to avoid breaking changes
- Health Checks: Implement /health endpoints for monitoring
- Logging: Use centralized logging (ELK stack, Datadog)
- Monitoring: Track metrics, latency, error rates
- Circuit Breaker: Fail fast when dependent services are down
- Retry Logic: Implement exponential backoff for failed requests
- Security: Use JWT tokens, API keys, service mesh
Circuit Breaker Pattern
const axios = require('axios');
class CircuitBreaker {
constructor(threshold = 5, timeout = 60000) {
this.failureCount = 0;
this.threshold = threshold;
this.timeout = timeout;
this.state = 'CLOSED'; // CLOSED, OPEN, HALF_OPEN
this.nextAttempt = Date.now();
}
async call(fn) {
if (this.state === 'OPEN') {
if (Date.now() < this.nextAttempt) {
throw new Error('Circuit breaker is OPEN');
}
this.state = 'HALF_OPEN';
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
onSuccess() {
this.failureCount = 0;
this.state = 'CLOSED';
}
onFailure() {
this.failureCount++;
if (this.failureCount >= this.threshold) {
this.state = 'OPEN';
this.nextAttempt = Date.now() + this.timeout;
console.log('Circuit breaker opened');
}
}
}
// Usage
const breaker = new CircuitBreaker();
async function callUserService(userId) {
return breaker.call(async () => {
const response = await axios.get(`http://user-service/users/${userId}`);
return response.data;
});
}
Practice Exercise:
Build a simple e-commerce microservices system with:
- 3 services: User Service, Product Service, Order Service
- API Gateway to route requests
- Inter-service communication (Order Service calls User and Product Services)
- Health check endpoints for each service
- Docker containers for each service
- Docker Compose to run all services together
- Basic error handling and logging
Summary
In this lesson, you learned:
- Differences between monolithic and microservices architectures
- How to decompose applications into microservices
- Inter-service communication patterns (HTTP and message queues)
- API Gateway pattern for centralized routing
- Service discovery and registration
- Containerization with Docker
- Managing multiple services with Docker Compose
- Best practices for microservices architecture
- Implementing circuit breaker pattern for resilience