Skip to content
Express.js ex patterns 4 min read

Controller & Service Layers

A common failure mode in Express applications is the “fat route handler”: a single function that parses the request, talks to the database, applies business rules, formats the response, and handles errors all at once. Such handlers are hard to test, impossible to reuse, and tightly coupled to HTTP. The controller and service split fixes this by giving each concern a home — controllers translate HTTP to and from plain data, while services hold the business logic that knows nothing about req or res.

The two responsibilities

Think of the boundary as a translation layer. The controller lives at the edge of your app and speaks HTTP: it reads req.params, req.query, and req.body, calls a service with ordinary arguments, then turns the result (or an error) into a status code and JSON. The service is pure application logic: it receives and returns plain values, enforces rules, and orchestrates data access. Because a service never touches req or res, you can call it from a route, a CLI script, a cron job, or a test with equal ease.

ConcernControllerService
Knows about HTTPYes (req/res)No
InputExpress requestPlain arguments
OutputStatus code + bodyDomain values / throws
Reused outside HTTPNoYes (jobs, CLI, tests)
Easy to unit-testHarder (needs mocks)Trivially

A thin controller

A controller method should be short. It extracts what the service needs, delegates, and shapes the response. Anything resembling a business rule belongs one layer down.

// controllers/user.controller.js
const userService = require("../services/user.service");

exports.getUser = async (req, res, next) => {
  try {
    const user = await userService.findById(req.params.id);
    res.json(user);
  } catch (err) {
    next(err);
  }
};

exports.createUser = async (req, res, next) => {
  try {
    const user = await userService.register(req.body);
    res.status(201).json(user);
  } catch (err) {
    next(err);
  }
};

Notice the controller does not validate emails, hash passwords, or check for duplicates — it simply forwards req.body and lets the service decide. Errors are passed to next(err) so a central error-handling middleware can map them to responses.

The service holding the logic

The service is where the real work lives. It can throw typed errors that the controller (or error middleware) understands, keeping HTTP status codes out of the business layer.

// services/user.service.js
const bcrypt = require("bcrypt");
const userRepo = require("../repositories/user.repository");

class NotFoundError extends Error {
  constructor(msg) { super(msg); this.status = 404; }
}
class ConflictError extends Error {
  constructor(msg) { super(msg); this.status = 409; }
}

async function findById(id) {
  const user = await userRepo.findById(id);
  if (!user) throw new NotFoundError("User not found");
  return user;
}

async function register({ email, password }) {
  if (await userRepo.findByEmail(email)) {
    throw new ConflictError("Email already in use");
  }
  const passwordHash = await bcrypt.hash(password, 10);
  return userRepo.create({ email, passwordHash });
}

module.exports = { findById, register, NotFoundError, ConflictError };

Wiring it into the router

Routers stay declarative: they map a method and path to a controller function and nothing more. This keeps the routing table readable at a glance.

// routes/user.routes.js
const express = require("express");
const router = express.Router();
const controller = require("../controllers/user.controller");

router.get("/:id", controller.getUser);
router.post("/", controller.createUser);

module.exports = router;
// app.js
const express = require("express");
const userRoutes = require("./routes/user.routes");

const app = express();
app.use(express.json());
app.use("/users", userRoutes);

// Central error handler maps service errors to HTTP responses
app.use((err, req, res, next) => {
  res.status(err.status || 500).json({ error: err.message });
});

app.listen(3000, () => console.log("Listening on http://localhost:3000"));

A POST /users with a duplicate email flows controller → service → thrown ConflictError → error middleware:

Output:

HTTP/1.1 409 Conflict
Content-Type: application/json

{ "error": "Email already in use" }

In Express 5, async handlers that reject are forwarded to the error middleware automatically, so the try/catch in controllers becomes optional. On Express 4 you still need the explicit catch (err) → next(err), or a wrapper like express-async-errors.

Why the split pays off

Because services take plain arguments, unit tests skip the HTTP machinery entirely:

// user.service.test.js
const userService = require("../services/user.service");
const userRepo = require("../repositories/user.repository");

jest.mock("../repositories/user.repository");

test("register rejects duplicate email", async () => {
  userRepo.findByEmail.mockResolvedValue({ id: 1 });
  await expect(userService.register({ email: "[email protected]", password: "x" }))
    .rejects.toThrow("Email already in use");
});

Keep services free of res. The moment a service calls res.json(), it can no longer be reused by a background job and becomes far harder to test.

Best Practices

  • Keep controllers thin: extract input, call one service method, shape one response. No business rules.
  • Never import req or res into a service — pass and return plain values only.
  • Throw typed/domain errors from services and let one central error middleware map them to status codes.
  • Group files by layer (controllers/, services/, repositories/) so each concern is easy to locate.
  • Have services depend on repositories rather than calling the database directly, so data access stays swappable.
  • Write fast unit tests against services and reserve slower end-to-end tests for the controller/HTTP boundary.
Last updated June 14, 2026
Was this helpful?