DEV Community

Cover image for How to structure a Modular Monolith
Agbo, Daniel Onuoha
Agbo, Daniel Onuoha

Posted on

How to structure a Modular Monolith

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 
Enter fullscreen mode Exit fullscreen mode

🔑 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: [] }; 
Enter fullscreen mode Exit fullscreen mode

👤 Users Module

src/modules/users/user.model.js

export class User { constructor({ id, name, email }) { this.id = id; this.name = name; this.email = email; } } 
Enter fullscreen mode Exit fullscreen mode

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); } } 
Enter fullscreen mode Exit fullscreen mode

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; 
Enter fullscreen mode Exit fullscreen mode

📦 Products Module

src/modules/products/product.model.js

export class Product { constructor({ id, name, price }) { this.id = id; this.name = name; this.price = price; } } 
Enter fullscreen mode Exit fullscreen mode

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); } } 
Enter fullscreen mode Exit fullscreen mode

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; 
Enter fullscreen mode Exit fullscreen mode

🛒 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(); } } 
Enter fullscreen mode Exit fullscreen mode

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; } } 
Enter fullscreen mode Exit fullscreen mode

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; 
Enter fullscreen mode Exit fullscreen mode

🚀 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; 
Enter fullscreen mode Exit fullscreen mode

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}`); }); 
Enter fullscreen mode Exit fullscreen mode

✅ 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)

Collapse
 
xwero profile image
david duymelinck

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.