Node.js and Express course menu
How to Structure an Express.js Project (Routes, Controllers, Services)
Organise a growing Express API: a folder structure with routes, controllers, services and data access, separating the app from the server for testing, configuration in one module, shared middleware and errors, and how requests flow through the layers.
On this page
A single server.js is fine for small examples. As an API grows to dozens of routes, everything in one file becomes hard to read, test and change. A common structure splits the code by responsibility.
A typical layout#
my-api/
├── package.json
├── .env secrets and settings (not committed)
└── src/
├── server.js starts the HTTP server (listen)
├── app.js creates and configures the Express app
├── config.js reads environment variables in one place
├── db.js opens the database
├── routes/
│ └── products.routes.js URL → controller mapping
├── controllers/
│ └── products.controller.js reads the request, sends the response
├── services/
│ └── products.service.js business rules, independent of HTTP
├── middleware/
│ └── error-handler.js
└── utils/
└── http-error.jsEach layer has one job:
A request flows down and the result flows back up:
request → route → (middleware) → controller → service → database
response ← controller ← service ← databaseBecause services know nothing about HTTP, the same placeOrder logic can be reused by a REST route, a background job or a command-line script, and tested without starting a server.
The files#
export class HttpError extends Error {
constructor(status, message) {
super(message);
this.status = status;
}
}import { HttpError } from "../utils/http-error.js";
const products = [
{ id: 1, name: "Notebook", price: 60, stock: 10 },
{ id: 2, name: "Gel pen", price: 25, stock: 0 },
];
export function listProducts({ inStock } = {}) {
return inStock ? products.filter((p) => p.stock > 0) : products;
}
export function getProduct(id) {
const product = products.find((p) => p.id === id);
if (!product) throw new HttpError(404, "Product not found");
return product;
}
export function buyProduct(id, quantity) {
const product = getProduct(id);
if (quantity > product.stock) throw new HttpError(409, `Only ${product.stock} in stock`);
product.stock -= quantity;
return { product: product.name, quantity, total: product.price * quantity };
}import * as service from "../services/products.service.js";
export function list(req, res) {
res.json(service.listProducts({ inStock: req.query.inStock === "true" }));
}
export function show(req, res) {
res.json(service.getProduct(Number(req.params.id)));
}
export function buy(req, res) {
const quantity = Number(req.body.quantity ?? 1);
res.status(201).json(service.buyProduct(Number(req.params.id), quantity));
}import { Router } from "express";
import * as controller from "../controllers/products.controller.js";
const router = Router();
router.get("/", controller.list);
router.get("/:id", controller.show);
router.post("/:id/buy", controller.buy);
export default router;export function notFound(req, res) {
res.status(404).json({ error: `No route for ${req.method} ${req.path}` });
}
export function errorHandler(err, req, res, next) {
const status = err.status ?? 500;
if (status >= 500) console.error(err);
res.status(status).json({ error: status >= 500 ? "Internal server error" : err.message });
}export const config = {
port: Number(process.env.PORT ?? 3000),
env: process.env.NODE_ENV ?? "development",
};import express from "express";
import productsRouter from "./routes/products.routes.js";
import { errorHandler, notFound } from "./middleware/error-handler.js";
export function createApp() {
const app = express();
app.use(express.json());
app.use("/api/products", productsRouter);
app.use(notFound);
app.use(errorHandler);
return app;
}import { createApp } from "./app.js";
import { config } from "./config.js";
createApp().listen(config.port, () => console.log(`API (${config.env}) listening on port ${config.port}`));node src/server.jsAPI (development) listening on port 3000curl -s "localhost:3000/api/products?inStock=true"
curl -s -w " [%{http_code}]\n" -X POST localhost:3000/api/products/1/buy -H "Content-Type: application/json" -d '{"quantity":3}'
curl -s -w " [%{http_code}]\n" -X POST localhost:3000/api/products/2/buy -H "Content-Type: application/json" -d '{"quantity":1}'
curl -s -w " [%{http_code}]\n" localhost:3000/api/products/9
curl -s -w " [%{http_code}]\n" localhost:3000/api/carts[{"id":1,"name":"Notebook","price":60,"stock":10}]
{"product":"Notebook","quantity":3,"total":180} [201]
{"error":"Only 0 in stock"} [409]
{"error":"Product not found"} [404]
{"error":"No route for GET /api/carts"} [404]Why separate app.js and server.js?#
app.js builds the app without starting it; server.js only calls listen. Tests can then import createApp() and send requests to it directly, without opening a real port, as the testing lesson shows. A factory function (createApp) also lets tests create a fresh app, for example with a test database.
Guidelines#
- Keep controllers thin: no business rules or SQL, just translating between HTTP and services.
- Read
process.envin one config module, not throughout the code. - Group by feature when a project grows large (
src/products/{routes,controller,service}.js) rather than by type; both are common. - Don't over-engineer small projects: three files can be the right structure for a small API.
Common mistakes#
Practice#
Practice exercise
Following the same pattern, add a GET /api/products/:id/price-with-gst endpoint: put the calculation (price × 1.18, rounded to 2 decimals) in the service, a thin controller, and the route. Which file should know that GST is 18%?
Show hint
Add priceWithGst(id) to the service, a priceWithGst controller that calls it, and router.get("/:id/price-with-gst", controller.priceWithGst).
Show solution
The GST rate is a business rule, so it belongs in the service (or a config module the service reads), never in the route or controller:
// products.service.js
const GST_RATE = 0.18;
export function priceWithGst(id) {
const product = getProduct(id);
return { product: product.name, price: product.price, withGst: Math.round(product.price * (1 + GST_RATE) * 100) / 100 };
}
// products.controller.js
export function priceWithGst(req, res) {
res.json(service.priceWithGst(Number(req.params.id)));
}
// products.routes.js
router.get("/:id/price-with-gst", controller.priceWithGst);For product 1 (₹60) the endpoint returns {"product":"Notebook","price":60,"withGst":70.8}.
Key takeaway#
Finished this lesson?