Structuring a Real-World API
Small examples fit in one app.js file. Real APIs do not. After a few months, a shop API has orders, products, users, payments and reports, and a single file of 2,000 lines becomes hard to read, hard to test and risky to change. When you join a company, the first thing you will notice in their Node.js code is how it is organised.
There is no single official layout, but most professional Express projects follow the same few ideas. This lesson teaches those ideas with a small orders API: a checked configuration module, three layers with clear jobs, factory functions that connect the layers, and fast tests with supertest.
A folder layout that scales
Group files by feature (orders, products, users), and inside each feature split the code by job:
orders-api/
package.json "type": "module", "scripts": { "test": "node --test" }
.env.example names of every setting, with fake values
src/
config.js reads and checks environment variables
errors.js AppError class
app.js createApp(): middleware, routers, error handler
server.js the only file that calls listen()
orders/
orders.routes.js HTTP: read the request, send the response
orders.service.js business rules
orders.repository.js storage: SQL, MongoDB or memory
test/
orders.test.jsAdding a feature means adding a folder, not editing a giant file. A new team member who is asked to change the delivery fee knows to open orders.service.js.
Configuration in one place
Scattering process.env.SOMETHING across 40 files causes typos, missing values and strings where numbers were expected. Read and check every setting once, at startup, and export a clean object. Here the Zod library does the checking:
// src/config.js: read and check every setting once, at startup
import { z } from "zod";
const schema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
DATABASE_URL: z.string().min(1),
JWT_SECRET: z.string().min(32, "JWT_SECRET must be at least 32 characters"),
CORS_ORIGINS: z.string().default("http://localhost:5173")
.transform((s) => s.split(",").map((o) => o.trim())),
});
export function loadConfig(env = process.env) {
const result = schema.safeParse(env);
if (!result.success) {
const problems = result.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`);
throw new Error(`Invalid configuration:\n${problems.join("\n")}`);
}
return Object.freeze(result.data);
}import { loadConfig } from "./src/config.js";
try {
loadConfig({ PORT: "abc", JWT_SECRET: "short" });
} catch (err) {
console.log(err.message);
}
const config = loadConfig({
PORT: "8080",
DATABASE_URL: "mysql://shop@localhost/shop",
JWT_SECRET: "x".repeat(40),
CORS_ORIGINS: "https://shop.example.in, https://admin.example.in",
});
console.log(config.PORT, typeof config.PORT, config.NODE_ENV);
console.log(config.CORS_ORIGINS);Invalid configuration: PORT: Invalid input: expected number, received NaN DATABASE_URL: Invalid input: expected string, received undefined JWT_SECRET: JWT_SECRET must be at least 32 characters 8080 number development [ 'https://shop.example.in', 'https://admin.example.in' ]
Every problem is listed at once, so the person deploying fixes them all in one go instead of discovering them one crash at a time. z.coerce.number() turns the text "8080" into the number 8080, and Object.freeze() stops code from changing settings by accident. The function takes env as an argument, which makes it easy to test.
Commit a .env.example file that lists every setting name with fake values, so new developers know what to set. Never commit the real .env.
Three layers, three jobs
Each layer has one job and talks only to the layer below it:
| Layer | Job | Knows about HTTP? | Knows about the database? |
|---|---|---|---|
| Routes (controller) | Read and validate the request, call the service, send the response | Yes | No |
| Service | Business rules: prices, GST, limits, who may do what | No | No |
| Repository | Save and load data | No | Yes |
The repository is the only code that knows how orders are stored. This one keeps them in memory; a MySQL version would have the same two functions with SQL inside:
// src/orders/orders.repository.js: the only file that knows how orders are stored
export function createMemoryOrdersRepository() {
const rows = new Map();
let nextId = 1;
return {
async findById(id) {
return rows.get(id) ?? null;
},
async insert(order) {
const saved = { id: nextId++, ...order, createdAt: new Date().toISOString() };
rows.set(saved.id, saved);
return saved;
},
};
}The service holds the rules your business cares about. Notice that it receives the repository as an argument instead of importing it:
// src/orders/orders.service.js: business rules, no HTTP and no SQL here
import { AppError } from "../errors.js";
const GST_RATE = 0.18;
const FREE_DELIVERY_FROM = 499;
export function createOrdersService({ ordersRepo }) {
return {
async placeOrder({ customer, items }) {
const subtotal = items.reduce((sum, i) => sum + i.price * i.qty, 0);
if (subtotal > 200_000) throw new AppError(422, "Orders above ₹2,00,000 need manual approval");
const delivery = subtotal >= FREE_DELIVERY_FROM ? 0 : 40;
const total = Math.round(subtotal * (1 + GST_RATE)) + delivery;
return ordersRepo.insert({ customer, items, subtotal, delivery, total });
},
async getOrder(id) {
const order = await ordersRepo.findById(id);
if (!order) throw new AppError(404, `Order ${id} not found`);
return order;
},
};
}AppError (in src/errors.js) is a small Error subclass with a status field, like the one in Error Handling. The routes do the HTTP work only:
// src/orders/orders.routes.js: HTTP in, HTTP out
import { Router } from "express";
import { z } from "zod";
const newOrder = z.object({
customer: z.string().trim().min(2).max(60),
items: z.array(z.object({
sku: z.string().min(1),
price: z.number().int().positive(),
qty: z.number().int().min(1).max(20),
})).min(1),
});
export function ordersRouter({ ordersService }) {
const router = Router();
router.post("/", async (req, res) => {
const input = newOrder.parse(req.body);
const order = await ordersService.placeOrder(input);
res.status(201).location(`/api/orders/${order.id}`).json(order);
});
router.get("/:id", async (req, res) => {
const id = z.coerce.number().int().positive().parse(req.params.id);
res.json(await ordersService.getOrder(id));
});
return router;
}In Express 5, an error thrown in an async handler (a ZodError or an AppError) goes straight to the error-handling middleware, so the routes need no try...catch. (In a real shop, the price would come from the products table, never from the client. The client sends it here only to keep the example short.)
Wiring it together: dependency injection
Dependency injection is a big name for a simple idea: a piece of code receives the things it needs (its dependencies) as arguments instead of creating or importing them itself. createApp() receives the service, and the service receives the repository:
// src/app.js: build the app from its parts; no listen() here
import express from "express";
import { ZodError } from "zod";
import { AppError } from "./errors.js";
import { ordersRouter } from "./orders/orders.routes.js";
export function createApp({ ordersService }) {
const app = express();
app.use(express.json({ limit: "100kb" }));
app.get("/api/health", (req, res) => res.json({ status: "ok" }));
app.use("/api/orders", ordersRouter({ ordersService }));
app.use((req, res) => res.status(404).json({ error: "Not found" }));
app.use((err, req, res, next) => {
if (err instanceof ZodError) {
return res.status(400).json({ error: "Invalid input", details: err.issues.map((i) => i.message) });
}
if (err instanceof AppError) return res.status(err.status).json({ error: err.message });
console.error(err);
res.status(500).json({ error: "Something went wrong" });
});
return app;
}// src/server.js: the only file that wires real things together and listens
import { loadConfig } from "./config.js";
import { createApp } from "./app.js";
import { createMemoryOrdersRepository } from "./orders/orders.repository.js";
import { createOrdersService } from "./orders/orders.service.js";
const config = loadConfig();
const ordersRepo = createMemoryOrdersRepository(); // later: a MySQL repository
const ordersService = createOrdersService({ ordersRepo });
const app = createApp({ ordersService });
const server = app.listen(config.PORT, () => {
console.log(`Orders API (${config.NODE_ENV}) on http://localhost:${config.PORT}`);
});
process.on("SIGTERM", () => server.close(() => process.exit(0)));Orders API (development) on http://localhost:4100
server.js is the only place that decides which real implementations are used. To move from memory to MySQL, you write createMysqlOrdersRepository(pool) with the same functions and change one line here. Routes and services do not change at all.
Testing with supertest
Because createApp() does not listen, tests can build the app with a fresh in-memory repository each time. The supertest package (npm install --save-dev supertest) sends requests straight to an Express app without you choosing a port, and its .expect() calls check the status, headers and body:
// test/orders.test.js
import { describe, it, beforeEach } from "node:test";
import assert from "node:assert/strict";
import request from "supertest";
import { createApp } from "../src/app.js";
import { createMemoryOrdersRepository } from "../src/orders/orders.repository.js";
import { createOrdersService } from "../src/orders/orders.service.js";
describe("orders API", () => {
let app;
beforeEach(() => {
// a fresh, empty store for every test
const ordersService = createOrdersService({ ordersRepo: createMemoryOrdersRepository() });
app = createApp({ ordersService });
});
it("places an order and adds GST", async () => {
const res = await request(app)
.post("/api/orders")
.send({ customer: "Ananya", items: [{ sku: "KURTA-M", price: 899, qty: 1 }] })
.expect(201)
.expect("Location", "/api/orders/1");
assert.equal(res.body.total, 1061); // 899 x 1.18 = 1060.82, no delivery fee
});
it("charges delivery on small orders", async () => {
const res = await request(app)
.post("/api/orders")
.send({ customer: "Farhan", items: [{ sku: "PEN-BLUE", price: 20, qty: 5 }] })
.expect(201);
assert.equal(res.body.total, 158); // 100 x 1.18 = 118, plus 40 delivery
});
it("rejects an empty cart", async () => {
const res = await request(app).post("/api/orders").send({ customer: "Isha", items: [] }).expect(400);
assert.equal(res.body.error, "Invalid input");
});
it("returns 404 for an unknown order", async () => {
await request(app).get("/api/orders/99").expect(404, { error: "Order 99 not found" });
});
});▶ orders API ✔ places an order and adds GST (941.0478ms) ✔ charges delivery on small orders (32.0833ms) ✔ rejects an empty cart (39.1932ms) ✔ returns 404 for an unknown order (26.2248ms) ✔ orders API (1044.5735ms)
You can also test the service alone, without HTTP, by calling createOrdersService({ ordersRepo: fakeRepo }) directly. Tests at this level are fast enough to run on every save. For a full test against a real database, run the same tests with a MySQL repository pointed at a separate test database.
Common mistakes
- Putting SQL or business rules inside route handlers. They cannot be reused or tested without HTTP. Move them to the repository and service.
- Reading
process.envall over the code. Read it once inconfig.jsand check it. - Importing the database connection directly into services. Then tests need a real database. Pass the repository in.
- Calling
listen()inapp.js. Tests and scripts that import the app then start a server. Keeplisten()inserver.js. - Over-engineering a tiny project. A 100-line API does not need ten folders. Add layers when the code starts to grow.
Key points
- Group code by feature, and inside each feature split routes, service and repository.
- Routes handle HTTP, services hold business rules, repositories handle storage.
- Load and validate configuration once at startup, failing fast with every problem listed.
- Pass dependencies in through factory functions, so
server.jschooses real parts and tests choose fakes. - Export
createApp()withoutlisten(), and test it with supertest andnode:test.
Exercise
Add a cancelOrder(id) function to the orders service. It should throw AppError(404, ...) if the order does not exist and AppError(409, "Order already cancelled") if its status is already "cancelled"; otherwise it sets the status to "cancelled" through a new repository function updateStatus(id, status). Add the route POST /api/orders/:id/cancel and one supertest test for the 409 case.
Show solution
// orders.repository.js (inside the returned object)
async updateStatus(id, status) {
const order = rows.get(id);
order.status = status;
return order;
},
// orders.service.js (inside the returned object)
async cancelOrder(id) {
const order = await this.getOrder(id); // throws 404 if missing
if (order.status === "cancelled") throw new AppError(409, "Order already cancelled");
return ordersRepo.updateStatus(id, "cancelled");
},
// orders.routes.js
router.post("/:id/cancel", async (req, res) => {
const id = z.coerce.number().int().positive().parse(req.params.id);
res.json(await ordersService.cancelOrder(id));
});
// test
it("refuses to cancel twice", async () => {
await request(app).post("/api/orders")
.send({ customer: "Kabir", items: [{ sku: "BAT", price: 1299, qty: 1 }] }).expect(201);
await request(app).post("/api/orders/1/cancel").expect(200);
await request(app).post("/api/orders/1/cancel").expect(409, { error: "Order already cancelled" });
});The rule lives in the service, the storage change lives in the repository, and the route only translates HTTP. this.getOrder works because the service methods are called on the service object.
Quick Quiz
1. Where should the rule "free delivery from ₹499" live?
Business rules belong in the service, which knows nothing about HTTP or SQL.
2. Why does createApp() receive ordersService as an argument?
This is dependency injection: the caller chooses the parts.
3. What is the benefit of validating configuration at startup?
Failing fast is better than crashing later inside a request.
4. What does supertest's request(app) do?
It is a popular helper for API integration tests.
Learn Node.js with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
