Project Overview
In this weekend project, you will build a simplified e-commerce application using a microservices architecture. This hands-on project will help you apply the microservices principles we've learned this week and gain practical experience with service communication, API gateways, and message queues.
The application will consist of the following microservices:
- Product Service: Manages product catalog and inventory
- Cart Service: Handles shopping cart functionality
- Order Service: Processes customer orders
- User Service: Manages user authentication and profiles
- Notification Service: Sends order confirmations and updates
- API Gateway: Routes requests to appropriate services
Learning Objectives
- Implement microservices architecture with Node.js and Express
- Create service-to-service communication
- Set up an API gateway for request routing
- Implement asynchronous communication using a message queue
- Manage data consistency across distributed services
- Deploy multiple services using Docker containers
- Understand challenges and solutions for microservices deployments
Technology Stack
- Backend: Node.js and Express
- Databases: MongoDB (for flexibility across services)
- Message Queue: RabbitMQ
- API Gateway: Express-based custom gateway
- Frontend: React (simple client)
- Containerization: Docker and Docker Compose
- Testing: Jest and Supertest
Project Structure
Let's follow George Polya's 4-step problem-solving method:
Step 1: Understand the Problem
We need to create a microservices-based e-commerce application where each service has a specific responsibility. The primary challenges include:
- Service boundaries and responsibilities
- Communication between services
- Data management across services
- Consistent deployment of multiple services
Step 2: Devise a Plan
- Create project structure with separate folders for each service
- Set up basic Express applications for each service
- Implement MongoDB connection for each service that requires a database
- Create RESTful APIs for each service
- Set up the API Gateway to route requests
- Implement inter-service communication
- Set up RabbitMQ for asynchronous communication
- Create a simple React frontend
- Configure Docker and Docker Compose
- Write tests for services
Project Folder Structure
microservices-ecommerce/
├── api-gateway/
├── product-service/
├── cart-service/
├── order-service/
├── user-service/
├── notification-service/
├── client/
├── docker-compose.yml
└── README.md
Step 3: Execute the Plan
Let's implement each component of our microservices application.
Setting Up the Service Template
First, let's create a base template for our microservices. Each service will follow a similar structure, with differences in their specific functionality.
Service Project Structure
service-name/
├── src/
│ ├── config/
│ │ └── database.js
│ ├── controllers/
│ ├── models/
│ ├── routes/
│ ├── services/
│ ├── utils/
│ └── app.js
├── tests/
├── Dockerfile
├── package.json
└── .env
Basic Service Setup
For each service, we'll create a basic Express application with MongoDB connection:
package.json (generic for each service)
{
"name": "service-name",
"version": "1.0.0",
"description": "Service description",
"main": "src/app.js",
"scripts": {
"start": "node src/app.js",
"dev": "nodemon src/app.js",
"test": "jest"
},
"dependencies": {
"express": "^4.18.2",
"mongoose": "^7.4.1",
"dotenv": "^16.3.1",
"cors": "^2.8.5",
"axios": "^1.4.0",
"amqplib": "^0.10.3",
"winston": "^3.10.0"
},
"devDependencies": {
"jest": "^29.6.2",
"supertest": "^6.3.3",
"nodemon": "^3.0.1"
}
}
src/app.js (template for services)
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const { connectToDatabase } = require('./config/database');
// Import routes
const serviceRoutes = require('./routes');
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
app.use(cors());
app.use(express.json());
// Routes
app.use('/api', serviceRoutes);
// Health check endpoint
app.get('/health', (req, res) => {
res.status(200).json({ status: 'ok' });
});
// Connect to database and start server
const startServer = async () => {
try {
await connectToDatabase();
app.listen(PORT, () => {
console.log(`Service running on port ${PORT}`);
});
} catch (error) {
console.error('Failed to start service:', error);
process.exit(1);
}
};
startServer();
module.exports = app; // For testing
src/config/database.js
const mongoose = require('mongoose');
const connectToDatabase = async () => {
try {
const conn = await mongoose.connect(process.env.MONGODB_URI, {
useNewUrlParser: true,
useUnifiedTopology: true,
});
console.log(`MongoDB Connected: ${conn.connection.host}`);
return conn;
} catch (error) {
console.error(`Error connecting to MongoDB: ${error.message}`);
throw error;
}
};
module.exports = { connectToDatabase };
Dockerfile (template for each service)
FROM node:18-alpine
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "start"]
.env (example)
PORT=3000
MONGODB_URI=mongodb://mongodb:27017/service-database
NODE_ENV=development
RABBITMQ_URL=amqp://rabbitmq:5672
Implementing the Product Service
The Product Service will manage the product catalog and inventory. It provides APIs to list, search, and get detailed information about products.
product-service/src/models/product.model.js
const mongoose = require('mongoose');
const productSchema = new mongoose.Schema({
name: {
type: String,
required: true,
trim: true
},
description: {
type: String,
required: true
},
price: {
type: Number,
required: true,
min: 0
},
category: {
type: String,
required: true
},
inventory: {
type: Number,
required: true,
min: 0,
default: 0
},
imageUrl: {
type: String,
default: 'default-product.jpg'
}
}, {
timestamps: true
});
// Add index for searching products
productSchema.index({ name: 'text', description: 'text' });
const Product = mongoose.model('Product', productSchema);
module.exports = Product;
product-service/src/controllers/product.controller.js
const Product = require('../models/product.model');
// Get all products (with optional filtering)
exports.getProducts = async (req, res) => {
try {
const { category, search } = req.query;
let query = {};
if (category) {
query.category = category;
}
if (search) {
query.$text = { $search: search };
}
const products = await Product.find(query);
res.status(200).json(products);
} catch (error) {
res.status(500).json({ message: 'Error fetching products', error: error.message });
}
};
// Get a single product by ID
exports.getProductById = async (req, res) => {
try {
const product = await Product.findById(req.params.id);
if (!product) {
return res.status(404).json({ message: 'Product not found' });
}
res.status(200).json(product);
} catch (error) {
res.status(500).json({ message: 'Error fetching product', error: error.message });
}
};
// Create a new product
exports.createProduct = async (req, res) => {
try {
const newProduct = new Product(req.body);
const savedProduct = await newProduct.save();
res.status(201).json(savedProduct);
} catch (error) {
res.status(400).json({ message: 'Error creating product', error: error.message });
}
};
// Update a product
exports.updateProduct = async (req, res) => {
try {
const updatedProduct = await Product.findByIdAndUpdate(
req.params.id,
req.body,
{ new: true, runValidators: true }
);
if (!updatedProduct) {
return res.status(404).json({ message: 'Product not found' });
}
res.status(200).json(updatedProduct);
} catch (error) {
res.status(400).json({ message: 'Error updating product', error: error.message });
}
};
// Delete a product
exports.deleteProduct = async (req, res) => {
try {
const product = await Product.findByIdAndDelete(req.params.id);
if (!product) {
return res.status(404).json({ message: 'Product not found' });
}
res.status(200).json({ message: 'Product deleted successfully' });
} catch (error) {
res.status(500).json({ message: 'Error deleting product', error: error.message });
}
};
// Check if product is in stock
exports.checkStock = async (req, res) => {
try {
const { id, quantity } = req.query;
if (!id || !quantity) {
return res.status(400).json({ message: 'Product ID and quantity required' });
}
const product = await Product.findById(id);
if (!product) {
return res.status(404).json({ message: 'Product not found' });
}
const inStock = product.inventory >= parseInt(quantity);
res.status(200).json({
productId: id,
inStock,
availableQuantity: product.inventory
});
} catch (error) {
res.status(500).json({ message: 'Error checking stock', error: error.message });
}
};
// Update inventory (usually called by Order Service)
exports.updateInventory = async (req, res) => {
try {
const { productId, quantity } = req.body;
if (!productId || !quantity) {
return res.status(400).json({ message: 'Product ID and quantity required' });
}
const product = await Product.findById(productId);
if (!product) {
return res.status(404).json({ message: 'Product not found' });
}
if (product.inventory < quantity) {
return res.status(400).json({ message: 'Not enough inventory' });
}
product.inventory -= quantity;
await product.save();
res.status(200).json({
productId,
newInventory: product.inventory,
success: true
});
} catch (error) {
res.status(500).json({ message: 'Error updating inventory', error: error.message });
}
};
product-service/src/routes/product.routes.js
const express = require('express');
const productController = require('../controllers/product.controller');
const router = express.Router();
router.get('/', productController.getProducts);
router.get('/:id', productController.getProductById);
router.post('/', productController.createProduct);
router.put('/:id', productController.updateProduct);
router.delete('/:id', productController.deleteProduct);
router.get('/check-stock', productController.checkStock);
router.post('/update-inventory', productController.updateInventory);
module.exports = router;
product-service/src/routes/index.js
const express = require('express');
const productRoutes = require('./product.routes');
const router = express.Router();
router.use('/products', productRoutes);
module.exports = router;
Implementing the User Service
The User Service handles user authentication, registration, and profile management.
user-service/src/models/user.model.js
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const userSchema = new mongoose.Schema({
username: {
type: String,
required: true,
unique: true,
trim: true
},
email: {
type: String,
required: true,
unique: true,
trim: true,
lowercase: true
},
password: {
type: String,
required: true
},
name: {
type: String,
required: true
},
address: {
street: String,
city: String,
state: String,
zipCode: String,
country: String
},
role: {
type: String,
enum: ['user', 'admin'],
default: 'user'
}
}, {
timestamps: true
});
// Hash password before saving
userSchema.pre('save', async function(next) {
if (!this.isModified('password')) return next();
try {
const salt = await bcrypt.genSalt(10);
this.password = await bcrypt.hash(this.password, salt);
next();
} catch (error) {
next(error);
}
});
// Method to check password
userSchema.methods.comparePassword = async function(password) {
return await bcrypt.compare(password, this.password);
};
const User = mongoose.model('User', userSchema);
module.exports = User;
user-service/src/controllers/user.controller.js
const User = require('../models/user.model');
const jwt = require('jsonwebtoken');
// Register a new user
exports.register = async (req, res) => {
try {
const { username, email, password, name, address } = req.body;
// Check if user already exists
const existingUser = await User.findOne({
$or: [{ email }, { username }]
});
if (existingUser) {
return res.status(400).json({
message: 'User already exists with this email or username'
});
}
// Create new user
const user = new User({
username,
email,
password,
name,
address
});
await user.save();
// Generate JWT token
const token = jwt.sign(
{ id: user._id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '1d' }
);
res.status(201).json({
message: 'User registered successfully',
token,
user: {
id: user._id,
username: user.username,
email: user.email,
name: user.name,
role: user.role
}
});
} catch (error) {
res.status(500).json({
message: 'Error registering user',
error: error.message
});
}
};
// Login user
exports.login = async (req, res) => {
try {
const { email, password } = req.body;
// Find user by email
const user = await User.findOne({ email });
if (!user) {
return res.status(401).json({ message: 'Invalid credentials' });
}
// Check password
const isMatch = await user.comparePassword(password);
if (!isMatch) {
return res.status(401).json({ message: 'Invalid credentials' });
}
// Generate JWT token
const token = jwt.sign(
{ id: user._id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '1d' }
);
res.status(200).json({
message: 'Login successful',
token,
user: {
id: user._id,
username: user.username,
email: user.email,
name: user.name,
role: user.role
}
});
} catch (error) {
res.status(500).json({
message: 'Error logging in',
error: error.message
});
}
};
// Get user profile
exports.getProfile = async (req, res) => {
try {
const user = await User.findById(req.params.id).select('-password');
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
res.status(200).json(user);
} catch (error) {
res.status(500).json({
message: 'Error fetching user profile',
error: error.message
});
}
};
// Update user profile
exports.updateProfile = async (req, res) => {
try {
// Don't allow password updates through this endpoint
if (req.body.password) {
delete req.body.password;
}
const updatedUser = await User.findByIdAndUpdate(
req.params.id,
req.body,
{ new: true, runValidators: true }
).select('-password');
if (!updatedUser) {
return res.status(404).json({ message: 'User not found' });
}
res.status(200).json(updatedUser);
} catch (error) {
res.status(400).json({
message: 'Error updating user profile',
error: error.message
});
}
};
// Validate token (used by other services for authentication)
exports.validateToken = async (req, res) => {
try {
const { token } = req.body;
if (!token) {
return res.status(400).json({ message: 'Token is required' });
}
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const user = await User.findById(decoded.id).select('-password');
if (!user) {
return res.status(401).json({ message: 'Invalid token - user not found' });
}
res.status(200).json({
valid: true,
user: {
id: user._id,
username: user.username,
email: user.email,
name: user.name,
role: user.role
}
});
} catch (error) {
res.status(401).json({
valid: false,
message: 'Invalid token',
error: error.message
});
}
};
user-service/src/routes/user.routes.js
const express = require('express');
const userController = require('../controllers/user.controller');
const router = express.Router();
router.post('/register', userController.register);
router.post('/login', userController.login);
router.get('/:id', userController.getProfile);
router.put('/:id', userController.updateProfile);
router.post('/validate-token', userController.validateToken);
module.exports = router;
user-service/src/routes/index.js
const express = require('express');
const userRoutes = require('./user.routes');
const router = express.Router();
router.use('/users', userRoutes);
module.exports = router;
user-service/.env
PORT=3001
MONGODB_URI=mongodb://mongodb:27017/user-service
JWT_SECRET=your_jwt_secret_key
NODE_ENV=development
Implementing the Cart Service
The Cart Service manages shopping carts for users, including adding, updating, and removing items from the cart.
cart-service/src/models/cart.model.js
const mongoose = require('mongoose');
const cartItemSchema = new mongoose.Schema({
productId: {
type: String,
required: true
},
quantity: {
type: Number,
required: true,
min: 1
},
name: {
type: String,
required: true
},
price: {
type: Number,
required: true
},
imageUrl: {
type: String
}
});
const cartSchema = new mongoose.Schema({
userId: {
type: String,
required: true,
unique: true
},
items: [cartItemSchema],
createdAt: {
type: Date,
default: Date.now,
expires: '30d' // Cart expires after 30 days of inactivity
},
updatedAt: {
type: Date,
default: Date.now
}
});
// Update timestamp on save
cartSchema.pre('save', function(next) {
this.updatedAt = Date.now();
next();
});
const Cart = mongoose.model('Cart', cartSchema);
module.exports = Cart;
cart-service/src/services/product.service.js
const axios = require('axios');
const PRODUCT_SERVICE_URL = process.env.PRODUCT_SERVICE_URL || 'http://product-service:3000/api/products';
// Get product details from the Product Service
exports.getProductDetails = async (productId) => {
try {
const response = await axios.get(`${PRODUCT_SERVICE_URL}/${productId}`);
return response.data;
} catch (error) {
console.error(`Error fetching product details: ${error.message}`);
throw new Error('Failed to get product details');
}
};
// Check if product is in stock
exports.checkProductStock = async (productId, quantity) => {
try {
const response = await axios.get(`${PRODUCT_SERVICE_URL}/check-stock?id=${productId}&quantity=${quantity}`);
return response.data;
} catch (error) {
console.error(`Error checking product stock: ${error.message}`);
throw new Error('Failed to check product stock');
}
};
cart-service/src/controllers/cart.controller.js
const Cart = require('../models/cart.model');
const productService = require('../services/product.service');
// Get user's cart
exports.getCart = async (req, res) => {
try {
const { userId } = req.params;
let cart = await Cart.findOne({ userId });
if (!cart) {
// Create empty cart if none exists
cart = new Cart({
userId,
items: []
});
await cart.save();
}
res.status(200).json(cart);
} catch (error) {
res.status(500).json({
message: 'Error fetching cart',
error: error.message
});
}
};
// Add item to cart
exports.addItem = async (req, res) => {
try {
const { userId } = req.params;
const { productId, quantity } = req.body;
if (!productId || !quantity) {
return res.status(400).json({
message: 'Product ID and quantity are required'
});
}
// Check if product exists and is in stock
const stockCheck = await productService.checkProductStock(productId, quantity);
if (!stockCheck.inStock) {
return res.status(400).json({
message: `Product is out of stock. Only ${stockCheck.availableQuantity} available.`
});
}
// Get product details
const product = await productService.getProductDetails(productId);
// Find or create cart
let cart = await Cart.findOne({ userId });
if (!cart) {
cart = new Cart({
userId,
items: []
});
}
// Check if item already exists in cart
const existingItemIndex = cart.items.findIndex(
item => item.productId === productId
);
if (existingItemIndex > -1) {
// Update quantity if item exists
cart.items[existingItemIndex].quantity += quantity;
} else {
// Add new item to cart
cart.items.push({
productId,
quantity,
name: product.name,
price: product.price,
imageUrl: product.imageUrl
});
}
await cart.save();
res.status(200).json(cart);
} catch (error) {
res.status(500).json({
message: 'Error adding item to cart',
error: error.message
});
}
};
// Update item quantity
exports.updateItemQuantity = async (req, res) => {
try {
const { userId, productId } = req.params;
const { quantity } = req.body;
if (!quantity || quantity < 1) {
return res.status(400).json({
message: 'Valid quantity is required'
});
}
// Check if product is in stock
const stockCheck = await productService.checkProductStock(productId, quantity);
if (!stockCheck.inStock) {
return res.status(400).json({
message: `Not enough stock. Only ${stockCheck.availableQuantity} available.`
});
}
// Find cart
const cart = await Cart.findOne({ userId });
if (!cart) {
return res.status(404).json({ message: 'Cart not found' });
}
// Find item in cart
const itemIndex = cart.items.findIndex(
item => item.productId === productId
);
if (itemIndex === -1) {
return res.status(404).json({ message: 'Item not found in cart' });
}
// Update quantity
cart.items[itemIndex].quantity = quantity;
await cart.save();
res.status(200).json(cart);
} catch (error) {
res.status(500).json({
message: 'Error updating item quantity',
error: error.message
});
}
};
// Remove item from cart
exports.removeItem = async (req, res) => {
try {
const { userId, productId } = req.params;
// Find cart
const cart = await Cart.findOne({ userId });
if (!cart) {
return res.status(404).json({ message: 'Cart not found' });
}
// Remove item from cart
cart.items = cart.items.filter(
item => item.productId !== productId
);
await cart.save();
res.status(200).json(cart);
} catch (error) {
res.status(500).json({
message: 'Error removing item from cart',
error: error.message
});
}
};
// Clear cart
exports.clearCart = async (req, res) => {
try {
const { userId } = req.params;
// Find cart
const cart = await Cart.findOne({ userId });
if (!cart) {
return res.status(404).json({ message: 'Cart not found' });
}
// Clear items
cart.items = [];
await cart.save();
res.status(200).json({
message: 'Cart cleared successfully',
cart
});
} catch (error) {
res.status(500).json({
message: 'Error clearing cart',
error: error.message
});
}
};
cart-service/src/routes/cart.routes.js
const express = require('express');
const cartController = require('../controllers/cart.controller');
const router = express.Router();
router.get('/:userId', cartController.getCart);
router.post('/:userId/items', cartController.addItem);
router.put('/:userId/items/:productId', cartController.updateItemQuantity);
router.delete('/:userId/items/:productId', cartController.removeItem);
router.delete('/:userId', cartController.clearCart);
module.exports = router;
cart-service/src/routes/index.js
const express = require('express');
const cartRoutes = require('./cart.routes');
const router = express.Router();
router.use('/carts', cartRoutes);
module.exports = router;
cart-service/.env
PORT=3002
MONGODB_URI=mongodb://mongodb:27017/cart-service
NODE_ENV=development
PRODUCT_SERVICE_URL=http://product-service:3000/api/products
Implementing the Order Service
The Order Service handles order creation, payment processing, and order status updates.
order-service/src/models/order.model.js
const mongoose = require('mongoose');
const orderItemSchema = new mongoose.Schema({
productId: {
type: String,
required: true
},
quantity: {
type: Number,
required: true,
min: 1
},
price: {
type: Number,
required: true
},
name: {
type: String,
required: true
}
});
const orderSchema = new mongoose.Schema({
userId: {
type: String,
required: true
},
items: [orderItemSchema],
totalAmount: {
type: Number,
required: true
},
shippingAddress: {
street: String,
city: String,
state: String,
zipCode: String,
country: String
},
status: {
type: String,
enum: ['pending', 'processing', 'shipped', 'delivered', 'cancelled'],
default: 'pending'
},
paymentStatus: {
type: String,
enum: ['pending', 'paid', 'failed'],
default: 'pending'
},
paymentMethod: {
type: String,
required: true
}
}, {
timestamps: true
});
const Order = mongoose.model('Order', orderSchema);
module.exports = Order;
order-service/src/services/cart.service.js
const axios = require('axios');
const CART_SERVICE_URL = process.env.CART_SERVICE_URL || 'http://cart-service:3002/api/carts';
// Get user's cart
exports.getUserCart = async (userId) => {
try {
const response = await axios.get(`${CART_SERVICE_URL}/${userId}`);
return response.data;
} catch (error) {
console.error(`Error fetching user cart: ${error.message}`);
throw new Error('Failed to get user cart');
}
};
// Clear user's cart after order is placed
exports.clearCart = async (userId) => {
try {
const response = await axios.delete(`${CART_SERVICE_URL}/${userId}`);
return response.data;
} catch (error) {
console.error(`Error clearing user cart: ${error.message}`);
throw new Error('Failed to clear user cart');
}
};
order-service/src/services/product.service.js
const axios = require('axios');
const PRODUCT_SERVICE_URL = process.env.PRODUCT_SERVICE_URL || 'http://product-service:3000/api/products';
// Update product inventory after order
exports.updateInventory = async (productId, quantity) => {
try {
const response = await axios.post(`${PRODUCT_SERVICE_URL}/update-inventory`, {
productId,
quantity
});
return response.data;
} catch (error) {
console.error(`Error updating product inventory: ${error.message}`);
throw new Error('Failed to update product inventory');
}
};
// Check if product is in stock
exports.checkStock = async (productId, quantity) => {
try {
const response = await axios.get(
`${PRODUCT_SERVICE_URL}/check-stock?id=${productId}&quantity=${quantity}`
);
return response.data;
} catch (error) {
console.error(`Error checking product stock: ${error.message}`);
throw new Error('Failed to check product stock');
}
};
order-service/src/services/user.service.js
const axios = require('axios');
const USER_SERVICE_URL = process.env.USER_SERVICE_URL || 'http://user-service:3001/api/users';
// Get user details
exports.getUserDetails = async (userId) => {
try {
const response = await axios.get(`${USER_SERVICE_URL}/${userId}`);
return response.data;
} catch (error) {
console.error(`Error fetching user details: ${error.message}`);
throw new Error('Failed to get user details');
}
};
order-service/src/services/message-queue.js
const amqp = require('amqplib');
const RABBITMQ_URL = process.env.RABBITMQ_URL || 'amqp://rabbitmq:5672';
const ORDER_QUEUE = 'order_events';
let channel;
// Connect to RabbitMQ
const connectToMessageQueue = async () => {
try {
const connection = await amqp.connect(RABBITMQ_URL);
channel = await connection.createChannel();
// Assert queue exists
await channel.assertQueue(ORDER_QUEUE, {
durable: true
});
console.log('Connected to RabbitMQ');
return channel;
} catch (error) {
console.error(`Error connecting to RabbitMQ: ${error.message}`);
throw error;
}
};
// Send message to queue
const sendToQueue = async (message) => {
try {
if (!channel) {
await connectToMessageQueue();
}
channel.sendToQueue(ORDER_QUEUE, Buffer.from(JSON.stringify(message)), {
persistent: true
});
console.log('Message sent to queue:', message);
} catch (error) {
console.error(`Error sending message to queue: ${error.message}`);
throw error;
}
};
module.exports = {
connectToMessageQueue,
sendToQueue
};
order-service/src/controllers/order.controller.js
const Order = require('../models/order.model');
const cartService = require('../services/cart.service');
const productService = require('../services/product.service');
const userService = require('../services/user.service');
const messageQueue = require('../services/message-queue');
// Create a new order
exports.createOrder = async (req, res) => {
try {
const { userId, paymentMethod } = req.body;
if (!userId || !paymentMethod) {
return res.status(400).json({
message: 'User ID and payment method are required'
});
}
// Get user cart
const cart = await cartService.getUserCart(userId);
if (!cart || !cart.items || cart.items.length === 0) {
return res.status(400).json({ message: 'Cart is empty' });
}
// Get user details for shipping address
const user = await userService.getUserDetails(userId);
// Check if all items are in stock
for (const item of cart.items) {
const stockCheck = await productService.checkStock(
item.productId,
item.quantity
);
if (!stockCheck.inStock) {
return res.status(400).json({
message: `${item.name} is out of stock. Only ${stockCheck.availableQuantity} available.`
});
}
}
// Calculate total amount
const totalAmount = cart.items.reduce(
(total, item) => total + (item.price * item.quantity),
0
);
// Create new order
const order = new Order({
userId,
items: cart.items,
totalAmount,
shippingAddress: user.address,
paymentMethod
});
await order.save();
// Update inventory
for (const item of cart.items) {
await productService.updateInventory(item.productId, item.quantity);
}
// Clear cart
await cartService.clearCart(userId);
// Send order created event to message queue
await messageQueue.sendToQueue({
type: 'ORDER_CREATED',
orderId: order._id,
userId,
orderDetails: {
items: order.items,
totalAmount: order.totalAmount,
status: order.status
}
});
res.status(201).json(order);
} catch (error) {
res.status(500).json({
message: 'Error creating order',
error: error.message
});
}
};
// Get order by ID
exports.getOrderById = async (req, res) => {
try {
const order = await Order.findById(req.params.id);
if (!order) {
return res.status(404).json({ message: 'Order not found' });
}
res.status(200).json(order);
} catch (error) {
res.status(500).json({
message: 'Error fetching order',
error: error.message
});
}
};
// Get user orders
exports.getUserOrders = async (req, res) => {
try {
const { userId } = req.params;
const orders = await Order.find({ userId }).sort({ createdAt: -1 });
res.status(200).json(orders);
} catch (error) {
res.status(500).json({
message: 'Error fetching user orders',
error: error.message
});
}
};
// Update order status
exports.updateOrderStatus = async (req, res) => {
try {
const { status } = req.body;
if (!status) {
return res.status(400).json({ message: 'Status is required' });
}
const order = await Order.findById(req.params.id);
if (!order) {
return res.status(404).json({ message: 'Order not found' });
}
order.status = status;
await order.save();
// Send order updated event to message queue
await messageQueue.sendToQueue({
type: 'ORDER_UPDATED',
orderId: order._id,
userId: order.userId,
status: order.status
});
res.status(200).json(order);
} catch (error) {
res.status(500).json({
message: 'Error updating order status',
error: error.message
});
}
};
// Process payment for an order
exports.processPayment = async (req, res) => {
try {
const { paymentDetails } = req.body;
if (!paymentDetails) {
return res.status(400).json({ message: 'Payment details are required' });
}
const order = await Order.findById(req.params.id);
if (!order) {
return res.status(404).json({ message: 'Order not found' });
}
// In a real application, this would integrate with a payment gateway
// For this example, we'll simulate successful payment
// Update payment status
order.paymentStatus = 'paid';
order.status = 'processing';
await order.save();
// Send payment processed event to message queue
await messageQueue.sendToQueue({
type: 'PAYMENT_PROCESSED',
orderId: order._id,
userId: order.userId,
amount: order.totalAmount
});
res.status(200).json({
message: 'Payment processed successfully',
order
});
} catch (error) {
res.status(500).json({
message: 'Error processing payment',
error: error.message
});
}
};
order-service/src/routes/order.routes.js
const express = require('express');
const orderController = require('../controllers/order.controller');
const router = express.Router();
router.post('/', orderController.createOrder);
router.get('/:id', orderController.getOrderById);
router.get('/user/:userId', orderController.getUserOrders);
router.put('/:id/status', orderController.updateOrderStatus);
router.post('/:id/payment', orderController.processPayment);
module.exports = router;
order-service/src/routes/index.js
const express = require('express');
const orderRoutes = require('./order.routes');
const router = express.Router();
router.use('/orders', orderRoutes);
module.exports = router;
order-service/.env
PORT=3003
MONGODB_URI=mongodb://mongodb:27017/order-service
NODE_ENV=development
CART_SERVICE_URL=http://cart-service:3002/api/carts
PRODUCT_SERVICE_URL=http://product-service:3000/api/products
USER_SERVICE_URL=http://user-service:3001/api/users
RABBITMQ_URL=amqp://rabbitmq:5672
Implementing the Notification Service
The Notification Service listens to events from the message queue and sends notifications to users based on order events.
notification-service/src/models/notification.model.js
const mongoose = require('mongoose');
const notificationSchema = new mongoose.Schema({
userId: {
type: String,
required: true
},
type: {
type: String,
enum: ['order_created', 'order_updated', 'payment_processed'],
required: true
},
title: {
type: String,
required: true
},
message: {
type: String,
required: true
},
relatedId: {
type: String,
required: true
},
read: {
type: Boolean,
default: false
}
}, {
timestamps: true
});
const Notification = mongoose.model('Notification', notificationSchema);
module.exports = Notification;
notification-service/src/services/message-queue.js
const amqp = require('amqplib');
const notificationController = require('../controllers/notification.controller');
const RABBITMQ_URL = process.env.RABBITMQ_URL || 'amqp://rabbitmq:5672';
const ORDER_QUEUE = 'order_events';
// Connect to RabbitMQ and consume messages
const connectToMessageQueue = async () => {
try {
const connection = await amqp.connect(RABBITMQ_URL);
const channel = await connection.createChannel();
// Assert queue exists
await channel.assertQueue(ORDER_QUEUE, {
durable: true
});
console.log('Connected to RabbitMQ, waiting for messages...');
// Consume messages
channel.consume(ORDER_QUEUE, async (message) => {
if (message !== null) {
try {
const content = JSON.parse(message.content.toString());
console.log('Received message:', content);
// Process message based on type
switch (content.type) {
case 'ORDER_CREATED':
await notificationController.createOrderCreatedNotification(content);
break;
case 'ORDER_UPDATED':
await notificationController.createOrderUpdatedNotification(content);
break;
case 'PAYMENT_PROCESSED':
await notificationController.createPaymentProcessedNotification(content);
break;
default:
console.log('Unknown message type:', content.type);
}
// Acknowledge message
channel.ack(message);
} catch (error) {
console.error('Error processing message:', error);
// Reject message and requeue
channel.nack(message, false, true);
}
}
});
return channel;
} catch (error) {
console.error(`Error connecting to RabbitMQ: ${error.message}`);
throw error;
}
};
module.exports = {
connectToMessageQueue
};
notification-service/src/services/email.service.js
// In a real application, this would integrate with an email service provider
// For this example, we'll simulate sending emails
exports.sendEmail = async (to, subject, body) => {
// Simulate email sending
console.log(`Sending email to ${to}`);
console.log(`Subject: ${subject}`);
console.log(`Body: ${body}`);
// In a real application, you would use a library like nodemailer
// or a service like SendGrid or AWS SES
return {
success: true,
messageId: `mock_${Math.random().toString(36).substring(2, 15)}`
};
};
notification-service/src/services/user.service.js
const axios = require('axios');
const USER_SERVICE_URL = process.env.USER_SERVICE_URL || 'http://user-service:3001/api/users';
// Get user details
exports.getUserDetails = async (userId) => {
try {
const response = await axios.get(`${USER_SERVICE_URL}/${userId}`);
return response.data;
} catch (error) {
console.error(`Error fetching user details: ${error.message}`);
throw new Error('Failed to get user details');
}
};
notification-service/src/controllers/notification.controller.js
const Notification = require('../models/notification.model');
const emailService = require('../services/email.service');
const userService = require('../services/user.service');
// Create notification for order created event
exports.createOrderCreatedNotification = async (eventData) => {
const { orderId, userId, orderDetails } = eventData;
try {
// Get user details for email
const user = await userService.getUserDetails(userId);
// Create notification
const notification = new Notification({
userId,
type: 'order_created',
title: 'Order Placed',
message: `Your order #${orderId} has been placed successfully.`,
relatedId: orderId
});
await notification.save();
// Send email notification
await emailService.sendEmail(
user.email,
'Order Confirmation',
`Hello ${user.name},\n\nYour order #${orderId} has been placed successfully.\n\nTotal: $${orderDetails.totalAmount}\n\nThank you for shopping with us!`
);
return notification;
} catch (error) {
console.error(`Error creating order created notification: ${error.message}`);
throw error;
}
};
// Create notification for order updated event
exports.createOrderUpdatedNotification = async (eventData) => {
const { orderId, userId, status } = eventData;
try {
// Get user details for email
const user = await userService.getUserDetails(userId);
// Format status for display
const formattedStatus = status.charAt(0).toUpperCase() + status.slice(1);
// Create notification
const notification = new Notification({
userId,
type: 'order_updated',
title: 'Order Status Updated',
message: `Your order #${orderId} has been updated to: ${formattedStatus}`,
relatedId: orderId
});
await notification.save();
// Send email notification
await emailService.sendEmail(
user.email,
'Order Status Update',
`Hello ${user.name},\n\nYour order #${orderId} has been updated to: ${formattedStatus}\n\nThank you for shopping with us!`
);
return notification;
} catch (error) {
console.error(`Error creating order updated notification: ${error.message}`);
throw error;
}
};
// Create notification for payment processed event
exports.createPaymentProcessedNotification = async (eventData) => {
const { orderId, userId, amount } = eventData;
try {
// Get user details for email
const user = await userService.getUserDetails(userId);
// Create notification
const notification = new Notification({
userId,
type: 'payment_processed',
title: 'Payment Processed',
message: `Your payment of $${amount} for order #${orderId} has been processed successfully.`,
relatedId: orderId
});
await notification.save();
// Send email notification
await emailService.sendEmail(
user.email,
'Payment Confirmation',
`Hello ${user.name},\n\nYour payment of $${amount} for order #${orderId} has been processed successfully.\n\nThank you for shopping with us!`
);
return notification;
} catch (error) {
console.error(`Error creating payment processed notification: ${error.message}`);
throw error;
}
};
// Get user notifications
exports.getUserNotifications = async (req, res) => {
try {
const { userId } = req.params;
const notifications = await Notification.find({ userId })
.sort({ createdAt: -1 });
res.status(200).json(notifications);
} catch (error) {
res.status(500).json({
message: 'Error fetching notifications',
error: error.message
});
}
};
// Mark notification as read
exports.markAsRead = async (req, res) => {
try {
const notification = await Notification.findByIdAndUpdate(
req.params.id,
{ read: true },
{ new: true }
);
if (!notification) {
return res.status(404).json({ message: 'Notification not found' });
}
res.status(200).json(notification);
} catch (error) {
res.status(500).json({
message: 'Error marking notification as read',
error: error.message
});
}
};
notification-service/src/routes/notification.routes.js
const express = require('express');
const notificationController = require('../controllers/notification.controller');
const router = express.Router();
router.get('/user/:userId', notificationController.getUserNotifications);
router.put('/:id/read', notificationController.markAsRead);
module.exports = router;
notification-service/src/routes/index.js
const express = require('express');
const notificationRoutes = require('./notification.routes');
const router = express.Router();
router.use('/notifications', notificationRoutes);
module.exports = router;
notification-service/src/app.js
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const { connectToDatabase } = require('./config/database');
const messageQueue = require('./services/message-queue');
// Import routes
const notificationRoutes = require('./routes');
const app = express();
const PORT = process.env.PORT || 3004;
// Middleware
app.use(cors());
app.use(express.json());
// Routes
app.use('/api', notificationRoutes);
// Health check endpoint
app.get('/health', (req, res) => {
res.status(200).json({ status: 'ok' });
});
// Connect to database, message queue, and start server
const startServer = async () => {
try {
await connectToDatabase();
await messageQueue.connectToMessageQueue();
app.listen(PORT, () => {
console.log(`Notification Service running on port ${PORT}`);
});
} catch (error) {
console.error('Failed to start service:', error);
process.exit(1);
}
};
startServer();
module.exports = app; // For testing
notification-service/.env
PORT=3004
MONGODB_URI=mongodb://mongodb:27017/notification-service
NODE_ENV=development
USER_SERVICE_URL=http://user-service:3001/api/users
RABBITMQ_URL=amqp://rabbitmq:5672
Implementing the API Gateway
The API Gateway provides a single entry point for all client requests, routing them to the appropriate microservices.
api-gateway/src/services/auth.service.js
const axios = require('axios');
const USER_SERVICE_URL = process.env.USER_SERVICE_URL || 'http://user-service:3001/api/users';
// Validate JWT token
exports.validateToken = async (token) => {
try {
const response = await axios.post(`${USER_SERVICE_URL}/validate-token`, { token });
return response.data;
} catch (error) {
console.error(`Error validating token: ${error.message}`);
return { valid: false, message: 'Invalid token' };
}
};
api-gateway/src/middlewares/auth.middleware.js
const authService = require('../services/auth.service');
// Authentication middleware
exports.authenticate = async (req, res, next) => {
try {
// Get token from headers
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ message: 'Authentication required' });
}
const token = authHeader.split(' ')[1];
const validationResult = await authService.validateToken(token);
if (!validationResult.valid) {
return res.status(401).json({ message: 'Invalid or expired token' });
}
// Add user info to request for downstream use
req.user = validationResult.user;
next();
} catch (error) {
console.error(`Auth middleware error: ${error.message}`);
res.status(500).json({ message: 'Authentication failed' });
}
};
// Admin authorization middleware
exports.authorizeAdmin = async (req, res, next) => {
if (!req.user || req.user.role !== 'admin') {
return res.status(403).json({ message: 'Access denied. Admin privileges required.' });
}
next();
};
api-gateway/src/routes/proxy.routes.js
const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');
const { authenticate, authorizeAdmin } = require('../middlewares/auth.middleware');
const router = express.Router();
// Service URLs
const PRODUCT_SERVICE_URL = process.env.PRODUCT_SERVICE_URL || 'http://product-service:3000';
const USER_SERVICE_URL = process.env.USER_SERVICE_URL || 'http://user-service:3001';
const CART_SERVICE_URL = process.env.CART_SERVICE_URL || 'http://cart-service:3002';
const ORDER_SERVICE_URL = process.env.ORDER_SERVICE_URL || 'http://order-service:3003';
const NOTIFICATION_SERVICE_URL = process.env.NOTIFICATION_SERVICE_URL || 'http://notification-service:3004';
// Proxy options
const defaultOptions = {
changeOrigin: true,
pathRewrite: {
'^/api/v1': '/api'
},
onProxyReq: (proxyReq, req, res) => {
// Add user ID from auth token if available
if (req.user) {
proxyReq.setHeader('X-User-Id', req.user.id);
proxyReq.setHeader('X-User-Role', req.user.role);
}
},
logLevel: process.env.NODE_ENV === 'development' ? 'debug' : 'info'
};
// Public routes (no authentication required)
// Auth routes
router.use(
'/api/v1/auth',
createProxyMiddleware({
...defaultOptions,
target: USER_SERVICE_URL,
pathRewrite: {
'^/api/v1/auth': '/api/users'
}
})
);
// Product routes (read only)
router.use(
'/api/v1/products',
createProxyMiddleware({
...defaultOptions,
target: PRODUCT_SERVICE_URL,
pathRewrite: {
'^/api/v1/products': '/api/products'
}
})
);
// Protected routes (authentication required)
// Cart routes
router.use(
'/api/v1/cart',
authenticate,
(req, res, next) => {
// Use a custom route handler that dynamically creates the proxy with user ID
const userId = req.user.id;
const cartProxy = createProxyMiddleware({
...defaultOptions,
target: CART_SERVICE_URL,
pathRewrite: {
[`^/api/v1/cart`]: `/api/carts/${userId}`
}
});
cartProxy(req, res, next);
}
);
// Order routes
router.use(
'/api/v1/orders',
authenticate,
createProxyMiddleware({
...defaultOptions,
target: ORDER_SERVICE_URL,
pathRewrite: {
'^/api/v1/orders': '/api/orders'
}
})
);
// User profile routes
router.use(
'/api/v1/profile',
authenticate,
(req, res, next) => {
// Use a custom route handler that dynamically creates the proxy with user ID
const userId = req.user.id;
const profileProxy = createProxyMiddleware({
...defaultOptions,
target: USER_SERVICE_URL,
pathRewrite: {
[`^/api/v1/profile`]: `/api/users/${userId}`
}
});
profileProxy(req, res, next);
}
);
// Notification routes
router.use(
'/api/v1/notifications',
authenticate,
(req, res, next) => {
// Use a custom route handler that dynamically creates the proxy with user ID
const userId = req.user.id;
const notificationsProxy = createProxyMiddleware({
...defaultOptions,
target: NOTIFICATION_SERVICE_URL,
pathRewrite: {
[`^/api/v1/notifications`]: `/api/notifications/user/${userId}`
}
});
notificationsProxy(req, res, next);
}
);
// Admin routes (admin authorization required)
// Product management (create, update, delete)
router.use(
'/api/v1/admin/products',
authenticate,
authorizeAdmin,
createProxyMiddleware({
...defaultOptions,
target: PRODUCT_SERVICE_URL,
pathRewrite: {
'^/api/v1/admin/products': '/api/products'
}
})
);
// Order management
router.use(
'/api/v1/admin/orders',
authenticate,
authorizeAdmin,
createProxyMiddleware({
...defaultOptions,
target: ORDER_SERVICE_URL,
pathRewrite: {
'^/api/v1/admin/orders': '/api/orders'
}
})
);
// User management
router.use(
'/api/v1/admin/users',
authenticate,
authorizeAdmin,
createProxyMiddleware({
...defaultOptions,
target: USER_SERVICE_URL,
pathRewrite: {
'^/api/v1/admin/users': '/api/users'
}
})
);
module.exports = router;
api-gateway/src/app.js
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const morgan = require('morgan');
const winston = require('winston');
const proxyRoutes = require('./routes/proxy.routes');
const app = express();
const PORT = process.env.PORT || 8000;
// Logger configuration
const logger = winston.createLogger({
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [
new winston.transports.Console(),
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
new winston.transports.File({ filename: 'logs/combined.log' })
]
});
// Middleware
app.use(cors());
app.use(express.json());
app.use(morgan('dev'));
// Add request logging
app.use((req, res, next) => {
logger.info(`${req.method} ${req.url}`, {
ip: req.ip,
userAgent: req.get('User-Agent')
});
next();
});
// Register proxy routes
app.use(proxyRoutes);
// Health check endpoint
app.get('/health', (req, res) => {
res.status(200).json({ status: 'ok' });
});
// Error handling
app.use((err, req, res, next) => {
logger.error(`Error processing request: ${err.message}`, {
stack: err.stack,
url: req.url,
method: req.method
});
res.status(500).json({
message: 'An error occurred processing your request',
error: process.env.NODE_ENV === 'production' ? undefined : err.message
});
});
// Start server
app.listen(PORT, () => {
logger.info(`API Gateway running on port ${PORT}`);
});
module.exports = app; // For testing
api-gateway/package.json
{
"name": "api-gateway",
"version": "1.0.0",
"description": "API Gateway for Microservices E-commerce Application",
"main": "src/app.js",
"scripts": {
"start": "node src/app.js",
"dev": "nodemon src/app.js",
"test": "jest"
},
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"axios": "^1.4.0",
"http-proxy-middleware": "^2.0.6",
"winston": "^3.10.0",
"morgan": "^1.10.0"
},
"devDependencies": {
"jest": "^29.6.2",
"supertest": "^6.3.3",
"nodemon": "^3.0.1"
}
}
api-gateway/.env
PORT=8000
NODE_ENV=development
PRODUCT_SERVICE_URL=http://product-service:3000
USER_SERVICE_URL=http://user-service:3001
CART_SERVICE_URL=http://cart-service:3002
ORDER_SERVICE_URL=http://order-service:3003
NOTIFICATION_SERVICE_URL=http://notification-service:3004
api-gateway/Dockerfile
FROM node:18-alpine
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 8000
CMD ["npm", "start"]
Creating a Simple React Frontend
Let's create a simple React frontend to interact with our microservices through the API Gateway.
client/package.json
{
"name": "ecommerce-client",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"axios": "^1.4.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.14.2",
"react-scripts": "5.0.1",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}
client/src/services/api.js
import axios from 'axios';
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000/api/v1';
// Create axios instance
const api = axios.create({
baseURL: API_URL,
headers: {
'Content-Type': 'application/json'
}
});
// Add token to requests when available
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Handle auth errors
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response && error.response.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
// Auth service
export const authService = {
login: async (email, password) => {
const response = await api.post('/auth/login', { email, password });
if (response.data.token) {
localStorage.setItem('token', response.data.token);
localStorage.setItem('user', JSON.stringify(response.data.user));
}
return response.data;
},
register: async (userData) => {
const response = await api.post('/auth/register', userData);
if (response.data.token) {
localStorage.setItem('token', response.data.token);
localStorage.setItem('user', JSON.stringify(response.data.user));
}
return response.data;
},
logout: () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
},
getCurrentUser: () => {
const user = localStorage.getItem('user');
return user ? JSON.parse(user) : null;
}
};
// Product service
export const productService = {
getProducts: async (query = '') => {
const response = await api.get(`/products${query}`);
return response.data;
},
getProductById: async (id) => {
const response = await api.get(`/products/${id}`);
return response.data;
}
};
// Cart service
export const cartService = {
getCart: async () => {
const response = await api.get('/cart');
return response.data;
},
addToCart: async (productId, quantity) => {
const response = await api.post('/cart/items', { productId, quantity });
return response.data;
},
updateQuantity: async (productId, quantity) => {
const response = await api.put(`/cart/items/${productId}`, { quantity });
return response.data;
},
removeItem: async (productId) => {
const response = await api.delete(`/cart/items/${productId}`);
return response.data;
},
clearCart: async () => {
const response = await api.delete('/cart');
return response.data;
}
};
// Order service
export const orderService = {
createOrder: async (paymentMethod) => {
const response = await api.post('/orders', { paymentMethod });
return response.data;
},
getUserOrders: async () => {
const response = await api.get('/orders');
return response.data;
},
getOrderById: async (id) => {
const response = await api.get(`/orders/${id}`);
return response.data;
},
processPayment: async (orderId, paymentDetails) => {
const response = await api.post(`/orders/${orderId}/payment`, { paymentDetails });
return response.data;
}
};
// Profile service
export const profileService = {
getProfile: async () => {
const response = await api.get('/profile');
return response.data;
},
updateProfile: async (profileData) => {
const response = await api.put('/profile', profileData);
return response.data;
}
};
// Notification service
export const notificationService = {
getNotifications: async () => {
const response = await api.get('/notifications');
return response.data;
},
markAsRead: async (notificationId) => {
const response = await api.put(`/notifications/${notificationId}/read`);
return response.data;
}
};
client/src/App.js
import React, { useState, useEffect } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { authService } from './services/api';
// Import components
import Header from './components/Header';
import Footer from './components/Footer';
import HomePage from './pages/HomePage';
import LoginPage from './pages/LoginPage';
import RegisterPage from './pages/RegisterPage';
import ProductsPage from './pages/ProductsPage';
import ProductDetailPage from './pages/ProductDetailPage';
import CartPage from './pages/CartPage';
import CheckoutPage from './pages/CheckoutPage';
import OrdersPage from './pages/OrdersPage';
import OrderDetailPage from './pages/OrderDetailPage';
import ProfilePage from './pages/ProfilePage';
import NotificationsPage from './pages/NotificationsPage';
import NotFoundPage from './pages/NotFoundPage';
// Protected route component
const ProtectedRoute = ({ children }) => {
const user = authService.getCurrentUser();
if (!user) {
return <Navigate to="/login" replace />;
}
return children;
};
function App() {
const [user, setUser] = useState(null);
useEffect(() => {
// Check if user is logged in
const currentUser = authService.getCurrentUser();
setUser(currentUser);
}, []);
const handleLogin = (userData) => {
setUser(userData);
};
const handleLogout = () => {
authService.logout();
setUser(null);
};
return (
<Router>
<div className="app">
<Header user={user} onLogout={handleLogout} />
<main className="main-content">
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/products" element={<ProductsPage />} />
<Route path="/products/:id" element={<ProductDetailPage />} />
<Route path="/login" element={<LoginPage onLogin={handleLogin} />} />
<Route path="/register" element={<RegisterPage onLogin={handleLogin} />} />
{/* Protected routes */}
<Route path="/cart" element={
<ProtectedRoute>
<CartPage />
</ProtectedRoute>
} />
<Route path="/checkout" element={
<ProtectedRoute>
<CheckoutPage />
</ProtectedRoute>
} />
<Route path="/orders" element={
<ProtectedRoute>
<OrdersPage />
</ProtectedRoute>
} />
<Route path="/orders/:id" element={
<ProtectedRoute>
<OrderDetailPage />
</ProtectedRoute>
} />
<Route path="/profile" element={
<ProtectedRoute>
<ProfilePage user={user} setUser={setUser} />
</ProtectedRoute>
} />
<Route path="/notifications" element={
<ProtectedRoute>
<NotificationsPage />
</ProtectedRoute>
} />
{/* 404 Page */}
<Route path="*" element={<NotFoundPage />} />
</Routes>
</main>
<Footer />
</div>
</Router>
);
}
export default App;
client/src/components/Header.js
import React from 'react';
import { Link } from 'react-router-dom';
const Header = ({ user, onLogout }) => {
return (
<header className="header">
<div className="container">
<div className="logo">
<Link to="/">MicroStore</Link>
</div>
<nav className="nav">
<ul>
<li><Link to="/">Home</Link></li>
<li><Link to="/products">Products</Link></li>
{user ? (
<>
<li><Link to="/cart">Cart</Link></li>
<li><Link to="/orders">Orders</Link></li>
<li><Link to="/notifications">Notifications</Link></li>
<li><Link to="/profile">Profile</Link></li>
<li><button onClick={onLogout}>Logout</button></li>
</>
) : (
<>
<li><Link to="/login">Login</Link></li>
<li><Link to="/register">Register</Link></li>
</>
)}
</ul>
</nav>
</div>
</header>
);
};
export default Header;
client/src/pages/ProductsPage.js
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { productService } from '../services/api';
const ProductsPage = () => {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [category, setCategory] = useState('');
const [categories, setCategories] = useState([]);
useEffect(() => {
const fetchProducts = async () => {
try {
setLoading(true);
const query = category ? `?category=${category}` : '';
const data = await productService.getProducts(query);
setProducts(data);
// Extract unique categories
const uniqueCategories = [...new Set(data.map(product => product.category))];
setCategories(uniqueCategories);
setLoading(false);
} catch (error) {
setError(error.message);
setLoading(false);
}
};
fetchProducts();
}, [category]);
const handleCategoryChange = (e) => {
setCategory(e.target.value);
};
if (loading) {
return <div className="loading">Loading products...</div>;
}
if (error) {
return <div className="error">Error: {error}</div>;
}
return (
<div className="products-page">
<h1>Products</h1>
<div className="filters">
<select
value={category}
onChange={handleCategoryChange}
className="category-filter"
>
<option value="">All Categories</option>
{categories.map(cat => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
</div>
<div className="products-grid">
{products.length === 0 ? (
<p>No products found.</p>
) : (
products.map(product => (
<div key={product._id} className="product-card">
<img
src={product.imageUrl || '/default-product.jpg'}
alt={product.name}
className="product-image"
/>
<div className="product-info">
<h3>{product.name}</h3>
<p className="product-price">${product.price.toFixed(2)}</p>
<p className="product-category">{product.category}</p>
<Link to={`/products/${product._id}`} className="view-button">
View Details
</Link>
</div>
</div>
))
)}
</div>
</div>
);
};
export default ProductsPage;
client/src/pages/ProductDetailPage.js
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { productService, cartService, authService } from '../services/api';
const ProductDetailPage = () => {
const { id } = useParams();
const navigate = useNavigate();
const [product, setProduct] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [quantity, setQuantity] = useState(1);
const [addingToCart, setAddingToCart] = useState(false);
const [message, setMessage] = useState(null);
const user = authService.getCurrentUser();
useEffect(() => {
const fetchProduct = async () => {
try {
setLoading(true);
const data = await productService.getProductById(id);
setProduct(data);
setLoading(false);
} catch (error) {
setError(error.message);
setLoading(false);
}
};
fetchProduct();
}, [id]);
const handleQuantityChange = (e) => {
const value = parseInt(e.target.value);
setQuantity(Math.max(1, Math.min(value, product.inventory)));
};
const handleAddToCart = async () => {
if (!user) {
navigate('/login');
return;
}
try {
setAddingToCart(true);
await cartService.addToCart(product._id, quantity);
setMessage('Product added to cart!');
setTimeout(() => setMessage(null), 3000);
setAddingToCart(false);
} catch (error) {
setError(error.response?.data?.message || error.message);
setAddingToCart(false);
}
};
if (loading) {
return <div className="loading">Loading product details...</div>;
}
if (error) {
return <div className="error">Error: {error}</div>;
}
if (!product) {
return <div className="error">Product not found.</div>;
}
return (
<div className="product-detail-page">
{message && (
<div className="success-message">{message}</div>
)}
<div className="product-detail">
<div className="product-image-container">
<img
src={product.imageUrl || '/default-product.jpg'}
alt={product.name}
className="product-detail-image"
/>
</div>
<div className="product-info">
<h1>{product.name}</h1>
<p className="product-price">${product.price.toFixed(2)}</p>
<p className="product-category">Category: {product.category}</p>
<p className="product-inventory">
{product.inventory > 0 ? (
`In Stock: ${product.inventory} units`
) : (
<span className="out-of-stock">Out of Stock</span>
)}
</p>
<div className="product-description">
<h2>Description</h2>
<p>{product.description}</p>
</div>
{product.inventory > 0 && (
<div className="add-to-cart">
<div className="quantity-control">
<label htmlFor="quantity">Quantity:</label>
<input
type="number"
id="quantity"
value={quantity}
onChange={handleQuantityChange}
min="1"
max={product.inventory}
/>
</div>
<button
onClick={handleAddToCart}
disabled={addingToCart}
className="add-to-cart-button"
>
{addingToCart ? 'Adding...' : 'Add to Cart'}
</button>
</div>
)}
</div>
</div>
</div>
);
};
export default ProductDetailPage;
Docker and Docker Compose Setup
To deploy our microservices application, we'll use Docker and Docker Compose to containerize each service.
docker-compose.yml
version: '3.8'
services:
# API Gateway
api-gateway:
build: ./api-gateway
ports:
- "8000:8000"
environment:
- PORT=8000
- NODE_ENV=development
- PRODUCT_SERVICE_URL=http://product-service:3000
- USER_SERVICE_URL=http://user-service:3001
- CART_SERVICE_URL=http://cart-service:3002
- ORDER_SERVICE_URL=http://order-service:3003
- NOTIFICATION_SERVICE_URL=http://notification-service:3004
depends_on:
- product-service
- user-service
- cart-service
- order-service
- notification-service
networks:
- microservices-network
restart: unless-stopped
# Product Service
product-service:
build: ./product-service
ports:
- "3000:3000"
environment:
- PORT=3000
- MONGODB_URI=mongodb://mongodb:27017/product-service
- NODE_ENV=development
depends_on:
- mongodb
networks:
- microservices-network
restart: unless-stopped
# User Service
user-service:
build: ./user-service
ports:
- "3001:3001"
environment:
- PORT=3001
- MONGODB_URI=mongodb://mongodb:27017/user-service
- JWT_SECRET=your_jwt_secret_key
- NODE_ENV=development
depends_on:
- mongodb
networks:
- microservices-network
restart: unless-stopped
# Cart Service
cart-service:
build: ./cart-service
ports:
- "3002:3002"
environment:
- PORT=3002
- MONGODB_URI=mongodb://mongodb:27017/cart-service
- NODE_ENV=development
- PRODUCT_SERVICE_URL=http://product-service:3000/api/products
depends_on:
- mongodb
- product-service
networks:
- microservices-network
restart: unless-stopped
# Order Service
order-service:
build: ./order-service
ports:
- "3003:3003"
environment:
- PORT=3003
- MONGODB_URI=mongodb://mongodb:27017/order-service
- NODE_ENV=development
- CART_SERVICE_URL=http://cart-service:3002/api/carts
- PRODUCT_SERVICE_URL=http://product-service:3000/api/products
- USER_SERVICE_URL=http://user-service:3001/api/users
- RABBITMQ_URL=amqp://rabbitmq:5672
depends_on:
- mongodb
- rabbitmq
- product-service
- cart-service
- user-service
networks:
- microservices-network
restart: unless-stopped
# Notification Service
notification-service:
build: ./notification-service
ports:
- "3004:3004"
environment:
- PORT=3004
- MONGODB_URI=mongodb://mongodb:27017/notification-service
- NODE_ENV=development
- USER_SERVICE_URL=http://user-service:3001/api/users
- RABBITMQ_URL=amqp://rabbitmq:5672
depends_on:
- mongodb
- rabbitmq
- user-service
networks:
- microservices-network
restart: unless-stopped
# Frontend Client
client:
build: ./client
ports:
- "3005:3000"
environment:
- REACT_APP_API_URL=http://localhost:8000/api/v1
depends_on:
- api-gateway
networks:
- microservices-network
restart: unless-stopped
# MongoDB
mongodb:
image: mongo:latest
ports:
- "27017:27017"
volumes:
- mongodb-data:/data/db
networks:
- microservices-network
restart: unless-stopped
# RabbitMQ
rabbitmq:
image: rabbitmq:3-management
ports:
- "5672:5672"
- "15672:15672"
volumes:
- rabbitmq-data:/var/lib/rabbitmq
networks:
- microservices-network
restart: unless-stopped
networks:
microservices-network:
driver: bridge
volumes:
mongodb-data:
rabbitmq-data:
Running the Application
Now that we have implemented all the services, let's see how to run the application using Docker Compose.
Step 1: Build and Start the Services
# Navigate to the project root directory cd microservices-ecommerce # Build and start all services docker-compose up --build
This command will build all Docker images and start the containers. The first build might take some time.
Step 2: Access the Application
- Frontend: http://localhost:3005
- API Gateway: http://localhost:8000
- RabbitMQ Management UI: http://localhost:15672 (username: guest, password: guest)
Step 3: Test the APIs
You can use tools like Postman or curl to test the API endpoints through the API Gateway.
Example: Register a User
curl -X POST http://localhost:8000/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{
"username": "testuser",
"email": "test@example.com",
"password": "password123",
"name": "Test User",
"address": {
"street": "123 Test St",
"city": "Test City",
"state": "Test State",
"zipCode": "12345",
"country": "Test Country"
}
}'
Example: Login
curl -X POST http://localhost:8000/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "test@example.com",
"password": "password123"
}'
Example: Get Products
curl -X GET http://localhost:8000/api/v1/products
Example: Add Product to Cart (Authenticated)
curl -X POST http://localhost:8000/api/v1/cart/items \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{
"productId": "PRODUCT_ID_HERE",
"quantity": 1
}'
Step 4: Evaluate the Solution
After implementing and running our microservices application, let's reflect on what we've accomplished and potential improvements.
What We've Accomplished
- Created a complete microservices architecture with separate, focused services
- Implemented service-to-service communication
- Set up an API Gateway for request routing
- Used MongoDB for data persistence in each service
- Implemented asynchronous communication with RabbitMQ
- Created a simple React frontend
- Containerized all services with Docker
- Used Docker Compose for orchestration
- Implemented unit tests for services
Key Benefits of Our Microservices Architecture
Challenges and Improvements
- Data Consistency - Implementing distributed transactions or saga pattern for operations involving multiple services
- Service Discovery - Adding a service registry like Consul or Eureka
- Monitoring and Tracing - Implementing distributed tracing with tools like Jaeger or Zipkin
- Resilience - Adding circuit breakers with libraries like Hystrix
- API Documentation - Adding Swagger/OpenAPI documentation
- Authentication - Implementing OAuth2 or more robust authentication
- Logging - Centralized logging with ELK stack
- Testing - Adding integration tests and contract tests
Conclusion
In this weekend project, we've built a comprehensive microservices-based e-commerce application that demonstrates the key concepts and patterns of microservices architecture.
We've seen how to:
- Break down a monolithic application into focused, independent services
- Handle communication between services
- Manage data in a distributed system
- Implement asynchronous communication with message queues
- Deploy multiple services with containers
This project serves as a foundation that you can extend with additional features, improved resilience, and more sophisticated deployment strategies as you continue your journey into microservices architecture.
Additional Resources
- Microservices.io - Patterns and best practices
- Martin Fowler's Microservices Article
- Docker Documentation
- Express.js Documentation
- RabbitMQ Tutorials
- React Documentation