Table of contents
In this hands-on guide, we’ll dive into building a sample microservices project, walking through essential development tools and stack choices. From service communication to deployment, this post covers practical tips and best practices to help you create scalable and resilient microservices applications.
Development Stack: Choosing the Right Stack
Selecting the right tools for microservices development is crucial. Here are some popular options:
Node.js: Great for high-performance, scalable, and lightweight microservices, especially in cases where asynchronous processing is key.
Spring Boot: Offers extensive support for building Java-based microservices, with built-in configuration and deployment features.
Go: Known for simplicity and performance, Go is ideal for building efficient microservices that handle high-throughput data processing.
For our sample project, we’ll use Node.js and Spring Boot, leveraging the benefits of each based on service needs.
Related Resource: Check out my ongoing blog series on Building microservices in Go for insights into microservices development with Golang.
Service Communication: REST vs. gRPC vs. Messaging Queues
Each microservice must interact with others while remaining loosely coupled. Here’s an overview of popular communication patterns:
REST: Simple and widely adopted, ideal for synchronous HTTP-based requests. REST APIs are easy to implement and maintain, making them a popular choice.
gRPC: Offers faster, efficient communication using protocol buffers, perfect for low-latency scenarios where services need rapid data exchanges.
Messaging Queues (RabbitMQ, Kafka): Asynchronous messaging ensures reliability and scalability. Messaging queues are ideal for event-driven architectures, where services are loosely coupled and process tasks independently.
In this tutorial, we’ll construct a basic e-commerce microservices application with:
Node.js for backend service development.
Express to create RESTful APIs.
MongoDB for service-specific databases.
Docker for containerization.
Kubernetes for orchestration.
Each microservice is isolated, with its own database and Docker container. We’ll focus on four services:
User Service: Manages user data.
Product Service: Handles product catalog and inventory.
Order Service: Manages orders and communicates with the Product Service.
Payment Service: Simulates basic payment processing.
The microservices architecture is designed to support modularity, allowing each service to be scaled and deployed independently.
For our sample project:
We’ll use REST for straightforward service-to-service communication.
RabbitMQ will handle order events, helping the Order and Inventory services remain in sync.
Deploying Microservices: Docker and Kubernetes for Containerization and Orchestration
Deploying and managing microservices can be complex. Docker and Kubernetes simplify this with containerization and orchestration:
Each service will be a standalone Node.js project with its own Dockerfile
.
Docker: Each microservice can run in its own container, making services isolated and independent. Containers provide consistency across development and production environments.
Kubernetes: Automates the deployment, scaling, and management of containerized applications. Kubernetes offers features like load balancing, self-healing, and rolling updates, essential for running microservices in production.
To deploy our sample project:
Containerize each microservice with Docker.
Use Kubernetes to manage service instances, enabling auto-scaling based on demand and handling failovers when needed.
Directory Structure:
ecommerce-microservices/
├── docker-compose.yml
├── rabbitmq-deployment.yml # RabbitMQ Kubernetes deployment file
├── user-service/ # User Service
│ ├── Dockerfile
│ ├── package.json
│ ├── app.js
│ └── src/
│ ├── controllers/
│ │ └── userController.js # User logic (registration, login, etc.)
│ ├── models/
│ │ └── userModel.js # User schema definition
│ └── routes/
│ └── userRoutes.js # User service API endpoints
├── product-service/ # Product Service
│ ├── Dockerfile
│ ├── package.json
│ ├── app.js
│ └── src/
│ ├── controllers/
│ │ └── productController.js # Product CRUD operations
│ ├── models/
│ │ └── productModel.js # Product schema
│ └── routes/
│ └── productRoutes.js # Product service API endpoints
├── order-service/ # Order Service
│ ├── Dockerfile
│ ├── package.json
│ ├── app.js
│ └── src/
│ ├── controllers/
│ │ └── orderController.js # Order management logic
│ ├── models/
│ │ └── orderModel.js # Order schema
│ └── routes/
│ └── orderRoutes.js # Order service API endpoints
├── payment-service/ # Payment Service
│ ├── Dockerfile
│ ├── package.json
│ ├── app.js
│ └── src/
│ ├── controllers/
│ │ └── paymentController.js # Payment processing logic
│ ├── models/
│ │ └── paymentModel.js # Payment schema
│ └── routes/
│ └── paymentRoutes.js # Payment service API endpoints
├── notification-service/ # Notification Service (optional)
│ ├── Dockerfile
│ ├── package.json
│ ├── app.js
│ └── src/
│ ├── controllers/
│ │ └── notificationController.js # Logic to send notifications
│ └── routes/
│ └── notificationRoutes.js # Notification service API endpoints
├── rabbitmq/
│ ├── rabbitmqConfig.js # RabbitMQ connection configuration
│ └── messageQueue.js # Reusable RabbitMQ connection and message functions
└── README.md
Service Breakdown & Code Explanation
Let's go through the individual services and explain their functionality.
1. Product Service
The Product Service is responsible for storing and managing product details such as name, price, and description.
Dockerfile: This file defines how to build and run the product-service
container.
FROM node:14
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3001
CMD ["node", "app.js"]
app.js: This is the main entry point for the service, which connects to MongoDB and sets up the Express API to manage products.
const express = require('express');
const mongoose = require('mongoose');
const productRoutes = require('./src/routes/productRoutes');
const { sendMessage } = require('../../../shared/utils/messageQueue');
const app = express();
app.use(express.json());
app.use('/products', productRoutes);
mongoose.connect('mongodb://localhost:27017/productDB', { useNewUrlParser: true, useUnifiedTopology: true });
app.listen(3001, () => {
console.log("Product Service is running on port 3001");
});
2. Order Service
The Order Service creates orders based on the products selected by the user and sends an event to RabbitMQ indicating that an order has been placed.
app.js: This service listens for requests to create an order and then triggers an event via RabbitMQ.
const express = require('express');
const mongoose = require('mongoose');
const orderRoutes = require('./src/routes/orderRoutes');
const { sendMessage } = require('../../../shared/utils/messageQueue');
const app = express();
app.use(express.json());
app.use('/orders', orderRoutes);
mongoose.connect('mongodb://localhost:27017/orderDB', { useNewUrlParser: true, useUnifiedTopology: true });
app.listen(3002, () => {
console.log("Order Service is running on port 3002");
});
3. Payment Service
The Payment Service is responsible for handling the payment processing for an order. When an order is placed, the service waits for a payment event and then processes the payment.
app.js: This service listens for payment events from RabbitMQ and processes payments accordingly.
const express = require('express');
const mongoose = require('mongoose');
const paymentRoutes = require('./src/routes/paymentRoutes');
const { consumeMessage } = require('../../../shared/utils/messageQueue');
const app = express();
app.use(express.json());
app.use('/payments', paymentRoutes);
mongoose.connect('mongodb://localhost:27017/paymentDB', { useNewUrlParser: true, useUnifiedTopology: true });
consumeMessage('order_events', (message) => {
console.log("Payment Event Received:", message);
});
app.listen(3003, () => {
console.log("Payment Service is running on port 3003");
});
4. Notification Service
The Notification Service sends notifications to users about their order status. It listens to events from RabbitMQ that indicate when an order has been successfully placed or paid.
app.js: This service listens for order and payment events and sends notifications accordingly.
const express = require('express');
const mongoose = require('mongoose');
const notificationRoutes = require('./src/routes/notificationRoutes');
const { consumeMessage } = require('../../../shared/utils/messageQueue');
const app = express();
app.use(express.json());
app.use('/notifications', notificationRoutes);
mongoose.connect('mongodb://localhost:27017/notificationDB', { useNewUrlParser: true, useUnifiedTopology: true });
consumeMessage('notification_events', (message) => {
console.log("Notification Event Received:", message);
});
app.listen(3005, () => {
console.log("Notification Service is running on port 3005");
});
Shared Utility for Message Queue
All services communicate through RabbitMQ. The shared/utils/messageQueue.js
file provides the logic to send and consume messages between services.
messageQueue.js:
const amqp = require('amqplib');
let channel, connection;
async function connect() {
try {
connection = await amqp.connect('amqp://localhost');
channel = await connection.createChannel();
} catch (error) {
console.error('Failed to connect to RabbitMQ:', error);
process.exit(1);
}
}
async function sendMessage(queue, message) {
try {
if (!channel) await connect();
await channel.assertQueue(queue, { durable: true });
channel.sendToQueue(queue, Buffer.from(JSON.stringify(message)), { persistent: true });
console.log("Message sent to queue:", queue);
} catch (error) {
console.error('Failed to send message:', error);
}
}
async function consumeMessage(queue, callback) {
try {
if (!channel) await connect();
await channel.assertQueue(queue, { durable: true });
channel.consume(queue, (msg) => {
if (msg !== null) {
console.log('Received message:', msg.content.toString());
callback(JSON.parse(msg.content.toString()));
channel.ack(msg);
}
});
} catch (error) {
console.error('Failed to consume message:', error);
}
}
module.exports = { sendMessage, consumeMessage };
This shared utility allows each service to send and receive messages through RabbitMQ. When an event like ORDER_PLACED
occurs, the service sends a message to RabbitMQ. Other services like Payment Service or Notification Service can consume this message and take appropriate action.
Running the Microservices Locally
Start RabbitMQ: First, ensure RabbitMQ is running in a Docker container:
docker run -d -p 5672:5672 -p 15672:15672 --name rabbitmq rabbitmq:management
Start MongoDB: If using Docker, you can start MongoDB for persistent storage:
docker run -d -p 27017:27017 --name mongodb mongo
Start the Services: You can start each service by running the following:
docker build -t product-service ./product-service docker run -p 3001:3001 product-service
Repeat for the other services (
order-service
,payment-service
, andnotification-service
).
Conclusion
In this blog, we've built a microservices-based e-commerce system. Each service (Product, Order, Payment, and Notification) handles a specific responsibility, and they all communicate via RabbitMQ to trigger events asynchronously.
This hands-on guide provides a foundation for building, deploying, and managing a microservices-based e-commerce application using Node.js, Docker, and Kubernetes. By following these steps, you’ll gain experience in developing modular, scalable, and resilient applications suitable for high-demand environments.
For those interested in building microservices in Go, check out my Ongoing Series on Building Microservices with Go.