How to structure a Node.js backend: routes, services and data access
Mosaddik Billah 5 min read

On this page
A backend usually starts as one file with a few routes, which is fine. Then the routes grow: each one checks input, runs SQL, applies business rules, calls an email service and formats the response, all mixed together. Changing a rule means hunting through route handlers, and testing anything means starting a server and a database.
The fix is to give each kind of code one home. This guide shows a structure that works well for Node.js and Express apps of most sizes. Every file below was tested with Express 5 and Node’s built-in SQLite.
Three layers, one job each#
HTTP request
│
▼
Routes read the request, call a service, send the response
│
▼
Services business rules: what's allowed, what to calculate, what happens next
│
▼
Repositories data access: the only place that knows about SQL or the databaseThe point is that each layer only talks to the one below it:
- Routes know about HTTP (
req,res, status codes) but not SQL. - Services know the business rules but not HTTP or SQL. They could be called from a route, a background job or a script.
- Repositories know how data is stored, and nothing about the business.
Organise folders by feature#
Group files by what they’re about, not by what kind of file they are:
src/
orders/
orders.routes.js
orders.service.js
orders.repository.js
orders.service.test.js
customers/
...
shared/
errors.js
app.js
server.jsWith folders by type (routes/, services/, repositories/), one change to orders touches three distant folders. By feature, everything about orders is in one place, and each folder is a natural module, the basis of a modular monolith.
The repository: data access only#
// orders/orders.repository.js
export function createOrdersRepository(db) {
return {
findById(id) {
return db.prepare("SELECT * FROM orders WHERE id = ?").get(id);
},
insert({ customerId, totalCents }) {
const { lastInsertRowid } = db
.prepare("INSERT INTO orders (customer_id, total_cents) VALUES (?, ?)")
.run(customerId, totalCents);
return this.findById(lastInsertRowid);
},
};
}If you later switch from raw SQL to an ORM, or add a cache, this is the only file that changes.
The service: business rules only#
// orders/orders.service.js
import { AppError } from "../shared/errors.js";
export function createOrdersService(ordersRepo) {
return {
placeOrder({ customerId, items }) {
if (!Array.isArray(items) || items.length === 0) {
throw new AppError(400, "An order needs at least one item.");
}
const totalCents = items.reduce((sum, i) => sum + i.priceCents * i.quantity, 0);
return ordersRepo.insert({ customerId, totalCents });
},
getOrder(id) {
const order = ordersRepo.findById(id);
if (!order) throw new AppError(404, "Order not found.");
return order;
},
};
}The service receives its repository as an argument instead of importing a database connection. This is dependency injection, and it’s what makes the service easy to test, as you’ll see below.
AppError is a small error class that carries an HTTP status:
// shared/errors.js
export class AppError extends Error {
constructor(status, message) {
super(message);
this.status = status;
}
}The routes: HTTP only#
// orders/orders.routes.js
import { Router } from "express";
export function ordersRoutes(ordersService) {
const router = Router();
router.post("/", (req, res) => {
const order = ordersService.placeOrder({
customerId: req.body.customerId,
items: req.body.items,
});
res.status(201).json(order);
});
router.get("/:id", (req, res) => {
res.json(ordersService.getOrder(Number(req.params.id)));
});
return router;
}Each handler is a few lines: take what’s needed from the request, call the service, send the result. Notice there’s no try/catch. Express 5 passes errors thrown in handlers, including from async ones, to the error handler.
Wire it up in one place, with one error handler#
// app.js
import express from "express";
import { createOrdersRepository } from "./orders/orders.repository.js";
import { createOrdersService } from "./orders/orders.service.js";
import { ordersRoutes } from "./orders/orders.routes.js";
export function createApp(db) {
const ordersService = createOrdersService(createOrdersRepository(db));
const app = express();
app.use(express.json());
app.use("/orders", ordersRoutes(ordersService));
app.use((err, req, res, next) => {
const status = err.status ?? 500;
if (status === 500) console.error(err);
res.status(status).json({ error: status === 500 ? "Something went wrong." : err.message });
});
return app;
}Now an empty order returns 400 with a clear message, an unknown ID returns 404, and an unexpected bug returns a plain 500 while the details go to your logs, never to the client. Keep createApp separate from server.js (which reads the port and calls listen) so tests can create the app without starting a server.
Why this pays off: testing#
Because the service takes its repository as an argument, you can test the business rules with a fake one, with no database and no server:
// orders/orders.service.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { createOrdersService } from "./orders.service.js";
test("placeOrder adds up the items", () => {
const fakeRepo = { insert: (order) => ({ id: 1, ...order }) };
const service = createOrdersService(fakeRepo);
const order = service.placeOrder({
customerId: 7,
items: [{ priceCents: 1200, quantity: 2 }, { priceCents: 800, quantity: 1 }],
});
assert.equal(order.totalCents, 3200);
});
test("placeOrder rejects an empty order", () => {
const service = createOrdersService({ insert: () => assert.fail("should not save") });
assert.throws(() => service.placeOrder({ customerId: 7, items: [] }), { status: 400 });
});Run them with node --test. Tests like these run in milliseconds, so you can have hundreds; the testing pyramid explains how they fit with slower tests.
A few more habits#
- Validate input at the edge. Check the shape of
req.bodyin the route (a library such as Zod helps), and keep the business rules in the service. - Read configuration once, in a
config.jsthat checks every required value at startup. See environment variables in Node.js. - Don’t over-layer small apps. For three routes, one file is fine. Introduce layers when a file starts to mix concerns, not before.
- Keep shared code small. A huge
utils/folder is usually code that belongs to a feature.
New to Express? Start with how to build a simple REST API with Node.js and Express, then reshape it this way as it grows. If you need a backend built or an existing one untangled, I do that as part of Wix apps with the Wix CLI and custom API integration.
Comments