Controller / Service / Repository Layering
Keep HTTP concerns in controllers, business rules in services, and data access in repositories — so your logic is testable without a server.
The single most valuable architectural move in a growing Express app is to stop letting HTTP leak into your business logic. Draw three layers:
| Layer | Knows about | Never touches |
|---|---|---|
| Controller | req, res, status codes | SQL, business rules |
| Service | business rules, orchestration | req/res, the database driver |
| Repository | the database / external APIs | HTTP, business policy |
The controller's job shrinks to: read input off req, call one service method, map the result (or a thrown domain error) to a response. That is it.
export async function create(req, res) {
const order = await orderService.place(req.user.id, req.body);
res.status(201).json(order);
}The payoff is testing
Because the service takes plain arguments and returns plain values, you can test the entire business rulebook with fast unit tests — no HTTP, no supertest, no port. The thin controllers get a handful of integration tests and that is enough.
Example
// repositories/orderRepo.js — the only file that knows the DB exists.
export const orderRepo = {
async insert(order) { /* await db.query(...) */ return { id: 'o_1', ...order }; },
async findByUser(userId) { /* ... */ return []; },
};
// services/orderService.js — pure business rules, no req/res, no SQL.
import { orderRepo } from '../repositories/orderRepo.js';
export class OrderClosedError extends Error {}
export const orderService = {
async place(userId, input) {
if (input.items.length === 0) {
throw new OrderClosedError('Cannot place an empty order');
}
const total = input.items.reduce((sum, i) => sum + i.price * i.qty, 0);
return orderRepo.insert({ userId, total, status: 'pending' });
},
};
// controllers/orders.js — thin: HTTP in, HTTP out.
import { orderService } from '../services/orderService.js';
export async function create(req, res) {
// Express 5 auto-forwards a rejection to the error handler.
const order = await orderService.place(req.user.id, req.body);
res.status(201).json(order);
}
// The service is now unit-testable with zero HTTP:
// await orderService.place('u1', { items: [] }) -> throws OrderClosedErrorWhen to use it
- A developer separates product search logic into a ProductService so both the REST API controller and an internal cron job can call the same business function.
- A team's controller layer handles HTTP-specific concerns (status codes, headers, req/res) while the service layer never imports Express.
- Unit tests for the service layer run without an HTTP server, exercising business logic in isolation from Express routing.
More examples
Service layer module
A service module contains all database logic and exports pure async functions with no Express imports.
// services/userService.js
const db = require('../db');
exports.findById = async (id) => {
const user = await db.query('SELECT * FROM users WHERE id=$1', [id]);
return user.rows[0] || null;
};
exports.create = async ({ name, email }) => {
const res = await db.query(
'INSERT INTO users(name, email) VALUES($1,$2) RETURNING *',
[name, email]
);
return res.rows[0];
};Controller calling service
The controller handles HTTP concerns (status codes, req.body) and delegates all business logic to the service.
// controllers/users.js
const userService = require('../services/userService');
const catchAsync = fn => (req, res, next) => fn(req, res, next).catch(next);
exports.getById = catchAsync(async (req, res) => {
const user = await userService.findById(req.params.id);
if (!user) return res.status(404).json({ error: 'Not found' });
res.json(user);
});
exports.create = catchAsync(async (req, res) => {
const user = await userService.create(req.body);
res.status(201).json(user);
});Route wiring controller to service
The route file imports validation and controller functions, wiring them together with no business logic of its own.
// routes/users.js
const router = require('express').Router();
const ctrl = require('../controllers/users');
const { validateBody } = require('../middleware/validate');
const { createUserSchema } = require('../validations/user');
router.get('/:id', ctrl.getById);
router.post('/', validateBody(createUserSchema), ctrl.create);
module.exports = router;
Discussion