Saltar al contenido.
Blog de Enrique Stolar

Blog


Publicado el 27/07/2026 | Autor: Enrique Stolar

Separar rutas, controladores y modelos en Node.js

Separar rutas, controladores y modelos en Node.js

Serie: Desarrollo de Interfaces

Curso: Desarrollo de Interfaces 1

Capítulo 24: Separar rutas, controladores y modelos en Node.js

Capítulo anterior: Introducción a POO: objetos, clases y responsabilidades

Capítulo siguiente: Servicios y repositorios: separar reglas de negocio y acceso a datos

Cuando empezamos con Node.js y Express, es normal poner todo en un solo archivo. Primero una ruta, luego otra, después una consulta SQL, más abajo una validación, al final un middleware de error. El archivo crece sin hacer ruido. Un día funciona; al siguiente, nadie quiere tocarlo.

Ese momento es importante en el aprendizaje. No significa que el proyecto esté mal. Significa que ya pasó de ejercicio pequeño a aplicación. Y cuando una aplicación crece, necesita orden. En el capítulo anterior hablamos de objetos, clases y responsabilidades. Ahora llevaremos esa misma idea al backend: separar rutas, controladores y modelos.

La meta no es aplicar arquitectura compleja porque sí. La meta es que cada archivo tenga un motivo claro para existir.

Qué aprenderás hoy

  • Por qué un archivo server.js gigante se vuelve difícil de mantener.
  • Qué responsabilidad cumple una ruta.
  • Qué responsabilidad cumple un controlador.
  • Qué responsabilidad cumple un modelo.
  • Cómo organizar una API pequeña con Express.
  • Cómo dejar el proyecto listo para crecer sin perder claridad.
Idea clave

Separar archivos no es arquitectura por decoración. Es una forma de reducir confusión: cada pieza debe tener una responsabilidad reconocible.

El problema del server.js que hace todo

Un backend inicial puede verse así:

import express from 'express';
import { pool } from './db.js';

const app = express();
app.use(express.json());

app.get('/projects', async (request, response) => {
  const [rows] = await pool.execute(
    'SELECT id, title, status FROM projects ORDER BY created_at DESC'
  );

  response.json(rows);
});

app.post('/projects', async (request, response) => {
  const { title, status } = request.body;

  const [result] = await pool.execute(
    'INSERT INTO projects (title, status) VALUES (?, ?)',
    [title, status || 'draft']
  );

  response.status(201).json({
    id: result.insertId,
    title,
    status: status || 'draft'
  });
});

app.listen(3000);

Para aprender, está bien. Pero si agregamos validación, búsqueda, paginación, edición, eliminación, manejo de errores y más entidades, este archivo se vuelve una mezcla de responsabilidades.

Tres responsabilidades distintas

Una forma sencilla de ordenar una API es separar tres preguntas:

  • Ruta: ¿qué URL y método HTTP atiende esta parte de la aplicación?
  • Controlador: ¿qué debe ocurrir cuando llega esa solicitud?
  • Modelo: ¿cómo se conversa con los datos?

La ruta no debería saber SQL. El modelo no debería saber cómo responder con res.json(). El controlador queda al medio: recibe la solicitud, llama al modelo y arma la respuesta.

Buena práctica

Si un archivo cambia por muchas razones distintas, probablemente está mezclando responsabilidades. Separar rutas, controladores y modelos ayuda a que cada cambio tenga un lugar más natural.

Una estructura inicial

Para un CRUD de proyectos, podemos usar esta estructura:

project-api/
  src/
    app.js
    server.js
    db.js
    routes/
      project.routes.js
    controllers/
      project.controller.js
    models/
      project.model.js
    middlewares/
      error.middleware.js
  package.json
  .env

app.js configura Express. server.js solo levanta el servidor. Las rutas viven en routes, la lógica de solicitud en controllers, y el acceso a datos en models.

Configurar app.js

El archivo app.js se encarga de preparar la aplicación Express y conectar rutas principales.

import express from 'express';
import projectRoutes from './routes/project.routes.js';
import { errorMiddleware } from './middlewares/error.middleware.js';

export const app = express();

app.use(express.json());
app.use('/projects', projectRoutes);
app.use(errorMiddleware);

La línea app.use('/projects', projectRoutes) significa que todas las rutas del archivo project.routes.js empezarán con /projects.

Levantar el servidor en server.js

El archivo server.js queda pequeño. Su responsabilidad es iniciar la aplicación.

import { app } from './app.js';

const port = process.env.PORT || 3000;

app.listen(port, () => {
  console.log(`Servidor iniciado en http://localhost:${port}`);
});

Separar app.js y server.js también ayuda cuando más adelante queramos probar la app sin levantar un puerto real.

Crear las rutas

Express permite crear enrutadores con express.Router(). El archivo de rutas debe ser delgado: conecta métodos HTTP con funciones controladoras.

import { Router } from 'express';
import {
  listProjects,
  getProjectById,
  createProject
} from '../controllers/project.controller.js';

const router = Router();

router.get('/', listProjects);
router.get('/:id', getProjectById);
router.post('/', createProject);

export default router;

Este archivo no contiene consultas SQL ni reglas largas. Solo dice: cuando llegue esta ruta, llama a este controlador.

Crear el controlador

El controlador interpreta la solicitud. Puede leer parámetros, validar datos simples, llamar al modelo y responder.

import {
  findAllProjects,
  findProjectById,
  insertProject
} from '../models/project.model.js';

export async function listProjects(request, response, next) {
  try {
    const projects = await findAllProjects();
    response.json(projects);
  } catch (error) {
    next(error);
  }
}

export async function getProjectById(request, response, next) {
  try {
    const id = Number(request.params.id);

    if (!Number.isInteger(id) || id <= 0) {
      return response.status(400).json({ error: 'Identificador no valido.' });
    }

    const project = await findProjectById(id);

    if (!project) {
      return response.status(404).json({ error: 'Proyecto no encontrado.' });
    }

    response.json(project);
  } catch (error) {
    next(error);
  }
}

export async function createProject(request, response, next) {
  try {
    const { title, status = 'draft' } = request.body;
    const project = await insertProject({ title, status });

    response.status(201).json(project);
  } catch (error) {
    next(error);
  }
}

El controlador no construye directamente la consulta SQL. Delegar eso al modelo hace que el código sea más fácil de leer y probar.

Crear el modelo

El modelo concentra el acceso a datos. Si estamos usando MySQL, aquí viven las consultas preparadas.

import { pool } from '../db.js';

export async function findAllProjects() {
  const [rows] = await pool.execute(
    `SELECT id, title, status
     FROM projects
     ORDER BY created_at DESC`
  );

  return rows;
}

export async function findProjectById(id) {
  const [rows] = await pool.execute(
    `SELECT id, title, status
     FROM projects
     WHERE id = ?`,
    [id]
  );

  return rows[0] || null;
}

export async function insertProject(project) {
  const [result] = await pool.execute(
    `INSERT INTO projects (title, status)
     VALUES (?, ?)`,
    [project.title, project.status]
  );

  return {
    id: result.insertId,
    title: project.title,
    status: project.status
  };
}

Si mañana cambiamos una consulta, sabemos dónde buscar. Si mañana agregamos filtros, probablemente empezaremos aquí y luego ajustaremos el controlador.

Middleware de error

Para no repetir respuestas de error inesperado en todos los controladores, podemos centralizar el manejo de errores.

export function errorMiddleware(error, request, response, next) {
  console.error(error);

  response.status(500).json({
    error: 'Ocurrio un problema en el servidor.'
  });
}

En Express, cuando una función llama a next(error), el error puede llegar a este middleware. Así evitamos exponer detalles internos al usuario y mantenemos un comportamiento consistente.

Qué gana la interfaz

Aunque esta separación ocurre en backend, la interfaz también gana. Si la API responde de forma consistente, el frontend puede consumirla mejor. Si hay errores ordenados, formularios y mensajes se vuelven más claros. Si las rutas mantienen el mismo contrato, podemos cambiar el interior del backend sin rehacer la pantalla.

Una interfaz profesional no depende solo del HTML, CSS o React. También depende de APIs previsibles.

Errores comunes

  • Crear demasiadas carpetas antes de entender el problema.
  • Poner SQL en las rutas.
  • Poner response.json() dentro del modelo.
  • Duplicar validaciones en varios controladores sin abstraerlas cuando crecen.
  • Llamar “modelo” a cualquier archivo que tenga funciones mezcladas.
  • Creer que separar archivos por sí solo mejora el código. La separación debe responder a responsabilidades reales.
Cuidado

No conviertas una API pequeña en una arquitectura pesada. Primero separa lo evidente: rutas, controladores y acceso a datos. Luego mejora cuando el proyecto lo pida.

Reto práctico

Reto

Toma el CRUD con MySQL de capítulos anteriores y reorganízalo en routes, controllers y models. Mantén las mismas URLs y respuestas JSON para comprobar que la interfaz sigue funcionando.

Variante extra: agrega una carpeta validators para mover allí la validación de datos del proyecto, pero solo si ya la estás repitiendo en más de un controlador.

Conclusión

Separar rutas, controladores y modelos es uno de esos pasos que marcan la transición entre “mi código funciona” y “mi código se puede mantener”. No hace falta complicarlo. Basta con ordenar responsabilidades: las rutas conectan URLs, los controladores coordinan la solicitud y los modelos hablan con los datos.

En el siguiente capítulo afinaremos aún más esta idea: veremos servicios y repositorios para separar reglas de negocio y acceso a datos cuando una API empieza a crecer.

Referencias consultadas

Compartir este artículo: