How would you organize an Express project into layers, and why separate routes, controllers and services?
Show answer β try answering out loud first
You split the application into layers with clear responsibilities: routes (define the endpoints and which controller they go to), controllers (handle req and res, translate between HTTP and the logic), services (contain the business logic, without knowing about HTTP) and models/data (data access). You separate it this way because the code becomes more testable and maintainable: each layer is tested and changed independently.
Putting everything in a single file works in a small example, but in a real project it becomes unmanageable. The solution is to divide by responsibility:
- routes. They only declare the endpoints: which URL and which HTTP verb, and which controller function they call. They have no logic.
- controllers. They receive the request (
req), extract what they need (params, body, query), call the corresponding service and return the response (res) with the appropriate status code. They are the bridge between HTTP and the business. - services. This is where the business logic lives: rules, calculations, orchestration. They know nothing about
reqorres; they receive plain data and return plain data. That makes them easy to reuse and test. - models/data. Data access: database queries or the definition of the models.
Why separate
- Testable. You can test a service by calling it with data directly, without spinning up an HTTP server or mocking
req/res. - Maintainable. If the database changes, you touch the data layer without affecting the controllers. If a business rule changes, you touch it in the service.
- Readable. Each file does one thing, so it's easier to find where each responsibility lives.
Example folder tree
proyecto/
βββ src/
β βββ routes/
β β βββ usuarios.routes.js # defines the /usuarios endpoints
β βββ controllers/
β β βββ usuarios.controller.js # handles req/res
β βββ services/
β β βββ usuarios.service.js # business logic
β βββ models/
β β βββ usuario.model.js # data access
β βββ app.js # builds the app and mounts the routes
βββ package.json
βββ .env
Flow of a request
HTTP request
-> routes (which endpoint does it match?)
-> controller (reads req, calls the service, builds res)
-> service (applies the business logic)
-> model/data (reads or writes data)
The response travels back along the same path to the client.
// src/services/usuarios.service.js
// Business logic: it knows NOTHING about HTTP (neither req nor res).
// In-memory "database" for the example
const usuarios = [{ id: 1, nombre: 'Ana' }];
function listarUsuarios() {
return usuarios;
}
function crearUsuario(nombre) {
// Business rule: the name is required
if (!nombre || nombre.trim() === '') {
throw new Error('El nombre es obligatorio');
}
const nuevo = { id: usuarios.length + 1, nombre: nombre.trim() };
usuarios.push(nuevo);
return nuevo;
}
module.exports = { listarUsuarios, crearUsuario };
// src/controllers/usuarios.controller.js
// Handles req/res and translates between HTTP and the service.
const service = require('../services/usuarios.service');
function getUsuarios(req, res) {
const usuarios = service.listarUsuarios();
res.status(200).json(usuarios);
}
function postUsuario(req, res) {
try {
const nuevo = service.crearUsuario(req.body.nombre);
res.status(201).json(nuevo);
} catch (error) {
// The controller translates the business error into an HTTP code
res.status(400).json({ error: error.message });
}
}
module.exports = { getUsuarios, postUsuario };
// src/routes/usuarios.routes.js
// Only defines the endpoints and which controller they go to.
const express = require('express');
const router = express.Router();
const controller = require('../controllers/usuarios.controller');
router.get('/', controller.getUsuarios);
router.post('/', controller.postUsuario);
module.exports = router;
// src/app.js
// Builds the application and mounts the routes under a prefix.
const express = require('express');
const usuariosRoutes = require('./routes/usuarios.routes');
const app = express();
app.use(express.json());
// All the user routes hang off /usuarios
app.use('/usuarios', usuariosRoutes);
app.listen(3000, () => console.log('API en el puerto 3000'));
# List users
curl http://localhost:3000/usuarios
# Create a user
curl -X POST http://localhost:3000/usuarios \
-H "Content-Type: application/json" \
-d '{"nombre":"Luis"}'
Putting the business logic inside the controller (or directly in the route), mixing data access, rules and req/res handling into one huge function. This makes the logic impossible to reuse or test without mocking HTTP, and makes any change risky. The business logic goes in the service, which should not know about req or res.
"I organize the project into layers with clear responsibilities. The routes only declare the endpoints and which controller they call. The controllers handle req and res: they read what they need from the request, call the service and build the response with the correct status code. The services hold the business logic and know nothing about HTTP, so they receive and return plain data, which makes them easy to reuse and test. And the data or models layer takes care of database access. I separate it this way because the code becomes more maintainable and testable: I can test a service with direct data without spinning up a server, and if the database changes I only touch the data layer. The typical flow is route, controller, service and model."
This code puts everything in the route. Which part would you move to the service and why?
router.post('/pedidos', (req, res) => {
if (!req.body.total || req.body.total <= 0) {
return res.status(400).json({ error: 'Total invΓ‘lido' });
}
const conIva = req.body.total * 1.16;
const pedido = { id: Date.now(), total: conIva };
pedidos.push(pedido);
res.status(201).json(pedido);
});
See answer
You move the business logic to the service: validating the rule (positive total), calculating the VAT and saving the order. Only the HTTP handling stays in the route and the controller.
// service
function crearPedido(total) {
if (!total || total <= 0) {
throw new Error('Total invΓ‘lido');
}
const conIva = total * 1.16;
const pedido = { id: Date.now(), total: conIva };
pedidos.push(pedido);
return pedido;
}
// controller
function postPedido(req, res) {
try {
const pedido = service.crearPedido(req.body.total);
res.status(201).json(pedido);
} catch (error) {
res.status(400).json({ error: error.message });
}
}
This way the VAT calculation and the total rule can be tested and reused without depending on req/res.