← Node & Express

REST APIs

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 req or res; 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."

Quick challenge

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.