We’ll build a small e-commerce-like app with three modules:
- Users → registration & authentication
- Products → product catalog
- Orders → order placement
All modules reside within a single Node.js project, but each has distinct boundaries and communicates only through well-defined interfaces.
📂 Project Structure
modular-monolith/
├── package.json
├── src/
│ ├── app.js
│ ├── modules/
│ │ ├── users/
│ │ │ ├── user.controller.js
│ │ │ ├── user.service.js
│ │ │ └── user.model.js
│ │ ├── products/
│ │ │ ├── product.controller.js
│ │ │ ├── product.service.js
│ │ │ └── product.model.js
│ │ └── orders/
│ │ ├── order.controller.js
│ │ ├── order.service.js
│ │ └── order.model.js
│ ├── shared/
│ │ └── database.js
└── server.js
🔑 Shared Setup
src/shared/database.js
Here, we use a simple in-memory database for the demo. In production, each module should own its schema/tables.
// src/shared/database.js
export const db = {
users: [],
products: [],
orders: []
};
👤 Users Module
src/modules/users/user.model.js
export class User {
constructor({ id, name, email }) {
this.id = id;
this.name = name;
this.email = email;
}
}
src/modules/users/user.service.js
import { db } from "../../shared/database.js";
import { User } from "./user.model.js";
export class UserService {
createUser(data) {
const user = new User({ id: Date.now().toString(), ...data });
db.users.push(user);
return user;
}
getUserById(id) {
return db.users.find(u => u.id === id);
}
}
src/modules/users/user.controller.js
import express from "express";
import { UserService } from "./user.service.js";
const router = express.Router();
const userService = new UserService();
router.post("/", (req, res) => {
const user = userService.createUser(req.body);
res.status(201).json(user);
});
router.get("/:id", (req, res) => {
const user = userService.getUserById(req.params.id);
if (!user) return res.status(404).json({ message: "User not found" });
res.json(user);
});
export default router;
📦 Products Module
src/modules/products/product.model.js
export class Product {
constructor({ id, name, price }) {
this.id = id;
this.name = name;
this.price = price;
}
}
src/modules/products/product.service.js
import { db } from "../../shared/database.js";
import { Product } from "./product.model.js";
export class ProductService {
addProduct(data) {
const product = new Product({ id: Date.now().toString(), ...data });
db.products.push(product);
return product;
}
getProductById(id) {
return db.products.find(p => p.id === id);
}
}
src/modules/products/product.controller.js
import express from "express";
import { ProductService } from "./product.service.js";
const router = express.Router();
const productService = new ProductService();
router.post("/", (req, res) => {
const product = productService.addProduct(req.body);
res.status(201).json(product);
});
router.get("/:id", (req, res) => {
const product = productService.getProductById(req.params.id);
if (!product) return res.status(404).json({ message: "Product not found" });
res.json(product);
});
export default router;
🛒 Orders Module (interacts with Users & Products)
src/modules/orders/order.model.js
export class Order {
constructor({ id, userId, productId }) {
this.id = id;
this.userId = userId;
this.productId = productId;
this.createdAt = new Date();
}
}
src/modules/orders/order.service.js
import { db } from "../../shared/database.js";
import { Order } from "./order.model.js";
import { UserService } from "../users/user.service.js";
import { ProductService } from "../products/product.service.js";
export class OrderService {
constructor() {
this.userService = new UserService();
this.productService = new ProductService();
}
placeOrder(userId, productId) {
const user = this.userService.getUserById(userId);
const product = this.productService.getProductById(productId);
if (!user) throw new Error("User not found");
if (!product) throw new Error("Product not found");
const order = new Order({ id: Date.now().toString(), userId, productId });
db.orders.push(order);
return order;
}
listOrders() {
return db.orders;
}
}
src/modules/orders/order.controller.js
import express from "express";
import { OrderService } from "./order.service.js";
const router = express.Router();
const orderService = new OrderService();
router.post("/", (req, res) => {
try {
const { userId, productId } = req.body;
const order = orderService.placeOrder(userId, productId);
res.status(201).json(order);
} catch (err) {
res.status(400).json({ message: err.message });
}
});
router.get("/", (req, res) => {
res.json(orderService.listOrders());
});
export default router;
🚀 App Setup
src/app.js
import express from "express";
import userRoutes from "./modules/users/user.controller.js";
import productRoutes from "./modules/products/product.controller.js";
import orderRoutes from "./modules/orders/order.controller.js";
const app = express();
app.use(express.json());
// Module routes
app.use("/users", userRoutes);
app.use("/products", productRoutes);
app.use("/orders", orderRoutes);
export default app;
server.js
import app from "./src/app.js";
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`🚀 Modular Monolith running on http://localhost:${PORT}`);
});
✅ How This Reflects a Modular Monolith
- Single deployable unit → one Node.js process.
- Modules are isolated → Users, Products, Orders each have their own models, services, controllers.
- Explicit communication → Orders depends on services, not raw DB access from other modules.
- Independent ownership → Each module could eventually be extracted into its own microservice if scaling demands.
Top comments (1)
I also think modules are the way to go. I don't like all the unneeded layering of directories.
I even remove the modules directory. Everything is either a module or is in the root.
Of course for more complex projects extra layering can help the discovery.