Structure your API into single-responsibility layers and wire them together with lightweight injection to make it testable.
Open this lesson in KodokonA 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.
import { Router } from 'express';
export function createUserRouter(controller) {
const router = Router();
router.get('/', controller.list);
router.post('/', controller.create);
return router;
}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.
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);
}
},
};
}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.
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);
},
};
}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.
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;
}createApp start listening on the network itself?