Controllers

Glue Between HTTP and Your Domain Logic

Controllers

A controller reads input, calls a service, sends a response. Nothing more.

4 min read Level 1/5 #express#controllers#architecture
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 /userslist
  • GET /users/:idget or show
  • POST /userscreate
  • PATCH /users/:idupdate
  • DELETE /users/:idremove or destroy

Whatever convention you pick, stay consistent across files.

Services →