¿Cómo organizarías un proyecto Express en capas y por qué separar routes, controllers y services?
Ver respuesta — intenta responderla en voz alta primero
Se separa la aplicación en capas con responsabilidades claras: routes (definen los endpoints y a qué controlador van), controllers (manejan req y res, traducen entre HTTP y la lógica), services (contienen la lógica de negocio, sin saber de HTTP) y models/data (acceso a datos). Se separa así porque el código queda más testeable y mantenible: cada capa se prueba y se cambia de forma independiente.
Meter todo en un solo archivo funciona en un ejemplo pequeño, pero en un proyecto real se vuelve inmanejable. La solución es dividir por responsabilidad:
- routes (rutas). Solo declaran los endpoints: qué URL y qué verbo HTTP, y a qué función del controlador llaman. No tienen lógica.
- controllers (controladores). Reciben la petición (
req), extraen lo que necesitan (params, body, query), llaman al service correspondiente y devuelven la respuesta (res) con el código de estado adecuado. Son el puente entre HTTP y el negocio. - services (servicios). Aquí vive la lógica de negocio: reglas, cálculos, orquestación. No saben nada de
reqnires; reciben datos normales y devuelven datos normales. Eso los hace fáciles de reutilizar y de testear. - models/data. El acceso a los datos: consultas a la base de datos o la definición de los modelos.
Por qué separar
- Testeable. Puedes probar un service llamándolo con datos directamente, sin levantar un servidor HTTP ni simular
req/res. - Mantenible. Si cambia la base de datos, tocas la capa de datos sin afectar los controladores. Si cambia una regla de negocio, la tocas en el service.
- Legible. Cada archivo hace una cosa, así que es más fácil encontrar dónde vive cada responsabilidad.
Árbol de carpetas de ejemplo
proyecto/
├── src/
│ ├── routes/
│ │ └── usuarios.routes.js # define los endpoints de /usuarios
│ ├── controllers/
│ │ └── usuarios.controller.js # maneja req/res
│ ├── services/
│ │ └── usuarios.service.js # lógica de negocio
│ ├── models/
│ │ └── usuario.model.js # acceso a datos
│ └── app.js # arma la app y monta las rutas
├── package.json
└── .env
Flujo de una petición
Petición HTTP
-> routes (¿a qué endpoint corresponde?)
-> controller (lee req, llama al service, arma res)
-> service (aplica la lógica de negocio)
-> model/data (lee o escribe datos)
La respuesta vuelve por el mismo camino hasta el cliente.
// src/services/usuarios.service.js
// Lógica de negocio: NO sabe nada de HTTP (ni req ni res).
// "Base de datos" en memoria para el ejemplo
const usuarios = [{ id: 1, nombre: 'Ana' }];
function listarUsuarios() {
return usuarios;
}
function crearUsuario(nombre) {
// Regla de negocio: el nombre es obligatorio
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
// Maneja req/res y traduce entre HTTP y el 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) {
// El controller traduce el error de negocio a un código HTTP
res.status(400).json({ error: error.message });
}
}
module.exports = { getUsuarios, postUsuario };
// src/routes/usuarios.routes.js
// Solo define los endpoints y a qué controlador van.
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
// Arma la aplicación y monta las rutas bajo un prefijo.
const express = require('express');
const usuariosRoutes = require('./routes/usuarios.routes');
const app = express();
app.use(express.json());
// Todas las rutas de usuarios cuelgan de /usuarios
app.use('/usuarios', usuariosRoutes);
app.listen(3000, () => console.log('API en el puerto 3000'));
# Listar usuarios
curl http://localhost:3000/usuarios
# Crear un usuario
curl -X POST http://localhost:3000/usuarios \
-H "Content-Type: application/json" \
-d '{"nombre":"Luis"}'
Meter la lógica de negocio dentro del controlador (o directamente en la ruta), mezclando el acceso a datos, las reglas y el manejo de req/res en una sola función enorme. Eso hace que la lógica no se pueda reutilizar ni testear sin simular HTTP, y que cualquier cambio sea arriesgado. La lógica de negocio va en el service, que no debe conocer req ni res.
"Organizo el proyecto en capas con responsabilidades claras. Las rutas solo declaran los endpoints y a qué controlador llaman. Los controladores manejan req y res: leen lo que necesitan de la petición, llaman al service y arman la respuesta con el código de estado correcto. Los services tienen la lógica de negocio y no saben nada de HTTP, así que reciben y devuelven datos normales, lo que los hace fáciles de reutilizar y testear. Y la capa de datos o modelos se encarga del acceso a la base de datos. Separo así porque el código queda más mantenible y testeable: puedo probar un service con datos directos sin levantar un servidor, y si cambia la base de datos toco solo la capa de datos. El flujo típico es route, controller, service y model."
Este código mete todo en la ruta. ¿Qué parte moverías al service y por qué?
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);
});
Ver respuesta
Se mueve al service la lógica de negocio: la validación de la regla (total positivo), el cálculo del IVA y el guardado del pedido. En la ruta y el controller solo queda el manejo de HTTP.
// 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 });
}
}
Así el cálculo del IVA y la regla del total se pueden testear y reutilizar sin depender de req/res.