Kodokon kodokon.com

Architecting an API: routes, controllers, services

Structure your API into single-responsibility layers and wire them together with lightweight injection to make it testable.

10 min · 3 questions

Open this lesson in Kodokon

A single file that mixes routing, business logic, and data access always ends up costly: you can't test the business rules without spinning up a server, and you can't swap infrastructure without rewriting everything. Splitting into three layers solves both problems. Routes declare the URLs and delegate. Controllers translate HTTP into business calls: read the request, pick the status code, serialize the response. Services hold the business rules and know nothing at all about Express. The dependency rule is strict: each layer knows only the one below it, never the other way around.

JAVASCRIPT
import { Router } from 'express';

export function createUserRouter(controller) {
  const router = Router();
  router.get('/', controller.list);
  router.post('/', controller.create);
  return router;
}
The route declares, the controller acts: no logic here.

The controller is a factory that receives the service as an argument. It holds no business rule: its job is limited to extracting data from req, calling the service, and turning the result into an HTTP response. Any error is sent to next to be handled by the central error middleware - never a res.status(500) scattered across controllers.

JAVASCRIPT
export function createUserController(service) {
  return {
    async list(req, res, next) {
      try {
        const users = await service.listUsers();
        res.json(users);
      } catch (err) {
        next(err);
      }
    },
    async create(req, res, next) {
      try {
        const user = await service.createUser(req.body);
        res.status(201).json(user);
      } catch (err) {
        next(err);
      }
    },
  };
}
Pure HTTP translation: status codes, serialization, delegation.

The service concentrates the decisions: email uniqueness, creation rules. It throws business errors - at most enriched with a status field - while knowing nothing about Express. It too receives its dependency, the data repository, as an argument. This is lightweight injection: plain factory functions called once at startup replace heavy injection containers, and make every layer swappable in tests.

JAVASCRIPT
export function createUserService(repository) {
  return {
    listUsers() {
      return repository.findAll();
    },
    createUser(input) {
      const found = repository.findByEmail(input.email);
      if (found) {
        const err = new Error('Email already used');
        err.status = 409;
        throw err;
      }
      return repository.insert(input);
    },
  };
}
The business logic lives here, with no req, res, or SQL.

That leaves the composition root: the single place where the factories fit together. createApp receives the infrastructure dependencies (here the database, covered in the next lesson) and returns a complete Express application that is not yet listening. Separating construction from listening looks trivial; it's exactly what will let tests mount the API on an ephemeral port while the production server stays three lines: create the database, create the app, listen.

JAVASCRIPT
import express from 'express';
import { createUserRepository } from './db/users.js';
import { createUserService } from './services/users.js';
import {
  createUserController,
} from './controllers/users.js';
import { createUserRouter } from './routes/users.js';

export function createApp({ db }) {
  const app = express();
  app.use(express.json());
  const repository = createUserRepository(db);
  const service = createUserService(repository);
  const controller = createUserController(service);
  app.use('/api/users', createUserRouter(controller));
  return app;
}
app.js: the only part of the application that knows everyone.

Knowledge check

Make sure you remember the key points of this lesson.

  1. In this layering, where should the rule "an email can only be used once" live?
    • In the route, as close to the URL as possible
    • In the controller, which has access to req.body
    • In the service, which holds the business rules
    • In a global Express middleware
  2. What is the main benefit of passing dependencies as factory arguments rather than importing them directly into each module?
    • Factories speed up module loading
    • You can swap a dependency (in-memory database, fake repository) without touching the module that uses it
    • Express requires this style for its middlewares
    • It avoids writing separate files
  3. Why doesn't createApp start listening on the network itself?
    • Because Express forbids calling listen inside a function
    • So tests can instantiate the application without opening a fixed port, keeping the server entry point trivial
    • Because listen is asynchronous and would block the factory
    • To reduce memory usage at startup