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:

LayerKnows aboutNever touches
Controllerreq, res, status codesSQL, business rules
Servicebusiness rules, orchestrationreq/res, the database driver
Repositorythe database / external APIsHTTP, 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

Example · javascript
// 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 OrderClosedError

When 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.

Example · js
// 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.

Example · js
// 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.

Example · js
// 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

  • Be the first to comment on this lesson.