Glue Between HTTP and Your Domain Logic
Controllers
A controller reads input, calls a service, sends a response. Nothing more.
What you'll learn
- Author thin controllers
- Keep HTTP concerns separate from domain
- Recognize controller smells
A controller is glue. Its job: parse the request, call a service, send a response. Anything more is misplaced.
A Thin Controller
// src/controllers/users.js
import * as userService from "../services/users.js";
export async function list(req, res) {
const { page, limit } = req.validQuery;
const users = await userService.list({ page, limit });
res.json({ data: users });
}
export async function get(req, res) {
const user = await userService.findById(req.params.id);
if (!user) {
return res.status(404).json({ error: { code: "user_not_found" } });
}
res.json({ data: user });
}
export async function create(req, res) {
const user = await userService.create(req.validBody);
res.status(201).json({ data: user });
}
export async function update(req, res) {
const user = await userService.update(req.params.id, req.validBody);
res.json({ data: user });
}
export async function remove(req, res) {
await userService.remove(req.params.id);
res.status(204).end();
} Each is 3-5 lines. Read input → call service → respond.
What Controllers Should NOT Do
❌ Direct database queries:
// bad — controller has database logic
export async function get(req, res) {
const user = await db.query("SELECT * FROM users WHERE id = $1", [req.params.id]);
if (!user.rows[0]) return res.status(404).end();
// ... business logic here
res.json(user.rows[0]);
} ❌ Complex business rules:
// bad — controller computes pricing rules
export async function checkout(req, res) {
let total = 0;
for (const item of req.body.items) {
total += item.price * item.qty;
}
if (total > 100) total *= 0.9; // discount
// ...
} Both belong in services or domain modules.
What Controllers SHOULD Do
✅ Coerce / validate input:
const id = Number(req.params.id);
if (!Number.isInteger(id) || id < 1) {
return res.status(400).json({ error: { message: "bad id" } });
} (Better: do this in a middleware like validate(schema).)
✅ Pick the right status code
✅ Shape the response
✅ Translate domain errors into HTTP
export async function create(req, res, next) {
try {
const user = await userService.create(req.validBody);
res.status(201).json({ data: user });
} catch (err) {
if (err.code === "USER_EMAIL_TAKEN") {
return res.status(409).json({ error: { code: "email_taken" } });
}
next(err);
}
} Naming
Each route maps to a verb-style action on the controller:
GET /users→listGET /users/:id→getorshowPOST /users→createPATCH /users/:id→updateDELETE /users/:id→removeordestroy
Whatever convention you pick, stay consistent across files.
Services →