Post 14 of 15 | Phase 9: Final Project Review and Best Practices
Putting It All Together — Complete E-Commerce Project Review and Best Practices
You have come a long way. In the previous thirteen posts you learned every major concept in Moleculer from scratch. This post ties everything together. We will do a full review of the complete project, fill in the gaps, establish best practices for real production code, and look at common mistakes that developers make when moving from development to production.
What You Have Built
Let us take a clear inventory of what exists in your project right now:
Infrastructure:
MongoDB — primary database for all services
Redis — transporter between services and shared cache
Jaeger — distributed tracing and request visualization
Docker — containerization of every component
Services:
api — HTTP entry point, authentication, routing
user — registration, login, profile management
product — product catalog, stock management
order — order creation, status management
email — reacts to events, sends emails
notification — reacts to events, sends push notifications
Patterns Used:
Request-Reply — actions between services
Event-Driven — order.created triggers email and notification
Caching — product and user data cached in Redis
Fault Tolerance — circuit breaker, retry, timeout, bulkhead
Authentication — JWT verified in API Gateway, user in ctx.meta
Tracing — full request chain visible in Jaeger
This is a production-grade microservices architecture. Not a toy project. Real companies run architectures exactly like this.
Completing the Email Service
In earlier posts the email service just printed log messages. Let us make it real using nodemailer:
npm install nodemailer
services/email.service.js
"use strict";
const nodemailer = require("nodemailer");
module.exports = {
name: "email",
settings: {
// In production use real SMTP credentials from environment
smtp: {
host: process.env.SMTP_HOST || "smtp.ethereal.email",
port: process.env.SMTP_PORT || 587,
secure: false,
auth: {
user: process.env.SMTP_USER || "",
pass: process.env.SMTP_PASS || ""
}
},
from: process.env.SMTP_FROM || "noreply@ecommerce.com"
},
created() {
// Create nodemailer transporter when service starts
this.transporter = nodemailer.createTransport(this.settings.smtp);
},
actions: {
// Can also be called directly as an action
send: {
params: {
to: "email",
subject: "string",
html: "string"
},
async handler(ctx) {
await this.sendEmail(
ctx.params.to,
ctx.params.subject,
ctx.params.html
);
return { sent: true };
}
}
},
events: {
// User registered
"user.registered": {
async handler(ctx) {
const { name, email } = ctx.params;
await this.sendEmail(
email,
"Welcome to our store",
`<h1>Hi ${name}</h1><p>Thank you for registering. Start shopping now.</p>`
);
}
},
// Order placed
"order.created": {
async handler(ctx) {
const { userId, productName, totalPrice, orderId } = ctx.params;
this.logger.info(`Sending order confirmation for order ${orderId}`);
// In real project, get user email from user service
// For now we just log
this.logger.info(`Email: Order ${orderId} confirmed. Total: ${totalPrice}`);
}
},
// Order status changed
"order.statusUpdated": {
async handler(ctx) {
const { orderId, status } = ctx.params;
this.logger.info(`Email: Order ${orderId} status updated to ${status}`);
}
}
},
methods: {
async sendEmail(to, subject, html) {
try {
const info = await this.transporter.sendMail({
from: this.settings.from,
to,
subject,
html
});
this.logger.info(`Email sent to ${to}: ${info.messageId}`);
return info;
} catch (err) {
this.logger.error(`Failed to send email to ${to}`, err);
// Do not rethrow — email failure should not crash the caller
}
}
}
};
For testing emails locally without a real SMTP server, use Ethereal. Go to ethereal.email, click Create Account, and you get free test SMTP credentials. Emails sent through Ethereal are captured and visible in the browser. No real emails are sent.
Completing the User Service — Emitting Events
Update your user service to emit events when users register and login:
// Inside user service create action, after successful user creation
const newUser = await this.adapter.insert({ ... });
// Emit event for email service to send welcome email
ctx.emit("user.registered", {
userId: newUser._id.toString(),
name: newUser.name,
email: newUser.email
});
return this.sanitizeUser(newUser);
Service Communication Patterns — A Complete Reference
By now you have used many communication patterns. Here they all are in one place:
Pattern 1: Direct action call — synchronous request-reply
Use when you need a result before continuing.
const user = await ctx.call("user.get", { id: "123" });
console.log(user.name); // You have the result
Pattern 2: Parallel action calls — multiple results at once
Use when two calls are independent of each other.
const [user, product] = await Promise.all([
ctx.call("user.get", { id: userId }),
ctx.call("product.get", { id: productId })
]);
Pattern 3: Event emit — fire and forget
Use when you want to announce something happened without waiting for reactions.
ctx.emit("order.created", { orderId, userId, total });
// Continue immediately
Pattern 4: Event broadcast — notify all instances
Use when every node needs to know, like cache clearing.
await ctx.broadcast("cache.clean.product");
Pattern 5: Chained calls — sequential with shared context
Each call passes ctx forward maintaining requestID and meta.
const product = await ctx.call("product.get", { id: productId });
const stock = await ctx.call("inventory.check", { productId });
const order = await ctx.call("order.create", { productId, userId });
Best Practices — Service Design
1. One service, one responsibility
A service should have one clear domain. If you cannot describe what a service does in one sentence, it is doing too much. Split it.
Bad: user-order-product.service.js does users, orders, and products. Good: user.service.js, order.service.js, product.service.js each do one thing.
2. Services should not share databases
Each service should own its own data. If user service and order service both directly query the same MongoDB collection, they are tightly coupled. Instead, order service calls user service to get user data.
// Wrong — order service directly queries user collection
const user = await UserModel.findById(userId); // Never do this in order service
// Correct — order service asks user service
const user = await ctx.call("user.get", { id: userId });
3. Always validate input at the action level
Never trust incoming data. Always define params schema on every action.
// Wrong — no validation
actions: {
create(ctx) {
return this.adapter.insert(ctx.params); // Dangerous
}
}
// Correct — explicit validation
actions: {
create: {
params: {
name: { type: "string", min: 2, max: 100 },
email: "email",
price: { type: "number", min: 0 }
},
handler(ctx) {
return this.adapter.insert(ctx.params);
}
}
}
4. Use methods for reusable logic
Anything used in more than one action belongs in methods.
methods: {
sanitizeUser(user) {
const obj = user.toObject ? user.toObject() : { ...user };
delete obj.password;
delete obj.__v;
return obj;
},
generateToken(user) {
return jwt.sign({ id: user._id, role: user.role }, JWT_SECRET, { expiresIn: "24h" });
}
}
5. Never put business logic in the API Gateway
The API Gateway should only handle HTTP concerns — auth, routing, CORS, rate limiting. All business logic belongs in the appropriate service.
// Wrong — business logic in API Gateway
onBeforeCall(ctx, route, req, res) {
const user = await validateUser(req.params.userId); // No
ctx.meta.user = user;
}
// Correct — API Gateway only verifies the token
async authenticate(ctx, route, req) {
const token = req.headers["authorization"]?.split(" ")[1];
const decoded = jwt.verify(token, JWT_SECRET);
return decoded; // Just verify and return, nothing more
}
Best Practices — Error Handling
Use Moleculer error classes consistently:
const { MoleculerClientError, MoleculerServerError } = require("moleculer").Errors;
// Client errors — bad input, not found, unauthorized
// These should NOT be retried
throw new MoleculerClientError("User not found", 404, "NOT_FOUND");
throw new MoleculerClientError("Email already exists", 422, "EMAIL_EXISTS");
throw new MoleculerClientError("Unauthorized", 401, "UNAUTHORIZED");
// Server errors — internal failures worth retrying
// Mark as retryable so retry policy kicks in
const err = new MoleculerServerError("Database timeout", 503, "DB_TIMEOUT");
err.retryable = true;
throw err;
Always handle errors in event handlers:
events: {
"order.created": {
async handler(ctx) {
try {
await this.sendConfirmationEmail(ctx.params);
} catch (err) {
// Log but do not rethrow
// Event handler errors should never affect the emitter
this.logger.error("Failed to send order email", err);
}
}
}
}
Add a global error handler in the API Gateway:
settings: {
onError(req, res, err) {
const code = err.code || err.status || 500;
const response = {
success: false,
error: {
message: err.message,
code: err.type || "INTERNAL_ERROR"
}
};
// Do not expose internal error details in production
if (process.env.NODE_ENV === "development") {
response.error.stack = err.stack;
}
res.setHeader("Content-Type", "application/json");
res.writeHead(code);
res.end(JSON.stringify(response));
}
}
Best Practices — Security
1. Never expose internal services directly
Only the API Gateway should have a port mapped in docker-compose.yml. All other services communicate over the internal Docker network.
# Correct
api:
ports:
- "3000:3000" # Only this service
user:
# No ports mapping — internal only
networks:
- moleculer-net
2. Use environment variables for all secrets
// Wrong
const JWT_SECRET = "hardcoded-secret-never-do-this";
// Correct
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) {
throw new Error("JWT_SECRET environment variable is required");
}
3. Whitelist specific actions in the API Gateway
Do not use wildcard in production routes. Be explicit:
routes: [
{
path: "/api",
whitelist: [
"user.me",
"user.update",
"product.list",
"product.get",
"order.create",
"order.myOrders",
"order.get"
]
// Admin actions are NOT in this list
},
{
path: "/admin",
whitelist: [
"user.list",
"user.remove",
"product.create",
"product.update",
"product.remove",
"order.list",
"order.updateStatus"
],
async authorize(ctx, route, req) {
if (ctx.meta.user.role !== "admin") {
throw new Error("Admin access required");
}
}
}
]
4. Sanitize data before returning
Never return sensitive fields like passwords from actions:
methods: {
sanitizeUser(user) {
const obj = user.toObject ? user.toObject() : { ...user };
delete obj.password;
delete obj.__v;
delete obj.__id;
return obj;
}
}
Best Practices — Performance
1. Cache aggressively but invalidate correctly
Cache reads. Never cache writes. Always invalidate when data changes.
// READ — cache for 5 minutes
getById: {
cache: { keys: ["id"], ttl: 300 },
handler(ctx) { ... }
}
// WRITE — always invalidate after
update: {
async handler(ctx) {
const result = await this.adapter.updateById(ctx.params.id, ctx.params);
await this.broker.broadcast("cache.clean.product");
return result;
}
}
2. Run independent calls in parallel
// Slow — sequential, 300ms total if each takes 100ms
const user = await ctx.call("user.get", { id: userId });
const product = await ctx.call("product.get", { id: productId });
const address = await ctx.call("address.get", { id: addressId });
// Fast — parallel, 100ms total
const [user, product, address] = await Promise.all([
ctx.call("user.get", { id: userId }),
ctx.call("product.get", { id: productId }),
ctx.call("address.get", { id: addressId })
]);
3. Add MongoDB indexes for frequently queried fields
// In your Mongoose schema
const OrderSchema = new mongoose.Schema({
userId: { type: mongoose.Schema.Types.ObjectId, ref: "User", index: true },
status: { type: String, index: true },
createdAt: { type: Date, index: true }
});
// Compound index for common query patterns
OrderSchema.index({ userId: 1, createdAt: -1 });
4. Set appropriate timeouts per action type
// Fast actions — short timeout
const user = await ctx.call("user.get", { id }, { timeout: 3000 });
// Slow actions — longer timeout
const report = await ctx.call("report.generate", { month: "2026-05" }, { timeout: 60000 });
Common Mistakes to Avoid
Mistake 1: Using broker.call() inside service actions
Always use ctx.call() to preserve the call chain for tracing and metadata propagation.
Mistake 2: Not setting a namespace
Without a namespace, your app's messages mix with any other Moleculer app using the same transporter. Always set namespace in moleculer.config.js.
Mistake 3: Forgetting to await broker.stop()
When writing scripts or tests, always stop the broker cleanly:
broker.start()
.then(async () => {
const result = await broker.call("service.action", {});
console.log(result);
})
.finally(() => broker.stop());
Mistake 4: Catching errors silently
// Wrong — swallows the error, debugging nightmare
try {
const result = await ctx.call("user.get", { id });
} catch (err) {
// Empty catch — you will never know this failed
}
// Correct — at minimum log it
try {
const result = await ctx.call("user.get", { id });
} catch (err) {
this.logger.error("Failed to get user", { id, error: err.message });
throw err; // Rethrow unless you have a deliberate fallback
}
Mistake 5: Hardcoding service names
If you rename a service, all callers break. Use constants:
// constants/services.js
module.exports = {
USER: "user",
ORDER: "order",
PRODUCT: "product"
};
// In your service
const { USER } = require("../constants/services");
const user = await ctx.call(`${USER}.get`, { id });
Mistake 6: Not using dependencies
If order service starts before user service is ready and immediately tries to call user, it will fail. Always declare dependencies:
module.exports = {
name: "order",
dependencies: ["user", "product"],
// order service will not start until user and product are ready
};
Project Checklist Before Going to Production
Go through this list before deploying:
Authentication and Security:
- JWT_SECRET is a strong random string from environment variable
- .env is in .gitignore and never committed to git
- Only API Gateway is publicly exposed
- Whitelist is explicit, no wildcard in production routes
- Passwords are hashed with bcrypt before saving
- All actions have params validation
Performance:
- Frequently read actions have caching enabled
- Cache is invalidated on writes
- MongoDB indexes exist on queried fields
- Independent calls use Promise.all
Reliability:
- Circuit breaker is enabled in moleculer.config.js
- Retry policy is configured
- requestTimeout is set
- Services declare dependencies
- Event handlers catch their own errors
Observability:
- Logger format is json in production
- Metrics are enabled and accessible to Prometheus
- Tracing is enabled and Jaeger is running
- requestID is logged with every significant operation
Docker:
- .dockerignore excludes node_modules and .env
- All environment variables are in docker-compose.yml not hardcoded
- Volumes are defined for MongoDB and Redis data
- Services use container names as hostnames not localhost
- Only necessary ports are exposed publicly
Complete API Reference for Your Project
Here is a summary of all available endpoints after completing this course:
Public endpoints — no token required:
POST /api/public/register Register new user
POST /api/public/login Login and receive JWT token
GET /api/product List all products
GET /api/product/:id Get one product
Protected endpoints — JWT token required in Authorization header:
GET /api/user/me Get authenticated user profile
PUT /api/user/:id Update user
POST /api/order Create new order
GET /api/order/my Get authenticated user orders
GET /api/order/:id Get one order
Admin endpoints — JWT token with admin role required:
GET /api/admin/user List all users
DELETE /api/admin/user/:id Delete user
POST /api/admin/product Create product
PUT /api/admin/product/:id Update product
DELETE /api/admin/product/:id Delete product
GET /api/admin/order List all orders
PUT /api/admin/order/:id/status Update order status
Summary of the Entire Course
This is everything you learned across all fourteen posts:
Post 1: What microservices are and why Moleculer exists. Post 2: Installation, project setup, first working service. Post 3: ServiceBroker in depth, moleculer.config.js, lifecycle hooks. Post 4: Services and Actions, validation, ctx.meta, error handling. Post 5: Events, fire and forget, emit vs broadcast, wildcard listeners. Post 6: Context object, requestID, caller, call chain tracing. Post 7: Transporters, TCP, NATS, Redis, service discovery, scaling. Post 8: Fault tolerance, timeout, retry, circuit breaker, bulkhead, fallback. Post 9: Caching, memory vs Redis, TTL, cache invalidation with events. Post 10: API Gateway, authentication, authorization, JWT flow, whitelist. Post 11: moleculer-db with MongoDB, free CRUD actions, custom actions, password hashing. Post 12: Logging, Metrics with Prometheus, Tracing with Jaeger. Post 13: Docker, Docker Compose, containerizing all services, Redis transporter. Post 14: Complete project review, best practices, security, performance, production checklist.
Up Next
Post 15 is the final post of this course. It covers what to learn next — testing Moleculer services, advanced patterns like sagas for distributed transactions, service mesh concepts, Kubernetes deployment, and the broader microservices ecosystem. It also covers where to find help, how to contribute to Moleculer, and recommended resources for going deeper.
Course Progress: 14 of 15 posts complete. One post remaining.
No comments:
Post a Comment