Node.js & Express

Microservices Architecture with Node.js

20 min Lesson 30 of 40

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