NUCBA
27 de enero de 2026
programación

5 patrones para estructurar un backend Node.js que escale

Carpetas, rutas y middlewares que evitan el caos cuando pasás de 10 a 100 endpoints sin tener que refactorizar todo desde cero.

Equipo NUCBA

Equipo NUCBA

8 min de lectura

5 patrones para estructurar un backend Node.js que escale

Arrancás con tres endpoints en server.js, todo funciona perfecto. Seis meses después tenés 80 rutas, middlewares duplicados por todos lados, y cada vez que tocás algo se rompe otra cosa. El proyecto creció, pero la arquitectura no.

La diferencia entre un backend que escala y uno que se convierte en deuda técnica no es la tecnología que usás. Es cómo organizás las carpetas, cómo manejás las rutas y cómo estructurás los middlewares desde el principio. Este artículo te muestra cinco patrones concretos que podés aplicar hoy para evitar el caos cuando tu proyecto Node.js pasa de 10 a 100 endpoints.

1. Estructura de carpetas por dominio, no por tipo técnico

El error más común es organizar por capas técnicas: una carpeta controllers/, otra models/, otra routes/. Esto funciona en un proyecto de 5 archivos. Cuando tenés 40, buscás la lógica de "usuarios" y tenés que abrir 6 carpetas diferentes.

La alternativa es agrupar por dominio de negocio:

src/
├── users/
│   ├── user.controller.js
│   ├── user.service.js
│   ├── user.model.js
│   ├── user.routes.js
│   └── user.validation.js
├── products/
│   ├── product.controller.js
│   ├── product.service.js
│   ├── product.model.js
│   └── product.routes.js
├── orders/
│   └── ...
└── shared/
    ├── middlewares/
    ├── utils/
    └── config/

Todo lo relacionado con usuarios vive en users/. Si tenés que cambiar cómo funciona el login, sabés exactamente dónde buscar. Si borrás el módulo de productos, eliminás la carpeta completa sin tocar nada más.

Criterio para decidir qué va en cada carpeta:

  • Si el archivo se usa solo en UN dominio → va dentro de esa carpeta
  • Si se usa en dos o más dominios → va en shared/
  • Si es configuración global → shared/config/

Este patrón es el que te permite escalar de 10 a 100 endpoints sin que el proyecto se vuelva innavegable.

2. Rutas centralizadas con un index que las registra automáticamente

Cada módulo exporta sus rutas, pero el server.js no tiene que conocer cada archivo. Usás un index.js de rutas que las registra automáticamente:

// src/routes/index.js
const fs = require('fs');
const path = require('path');

function registerRoutes(app) {
  const modules = ['users', 'products', 'orders'];
  
  modules.forEach(module => {
    const routePath = path.join(__dirname, '..', module, `${module}.routes.js`);
    
    if (fs.existsSync(routePath)) {
      const routes = require(routePath);
      app.use(`/api/${module}`, routes);
    }
  });
}

module.exports = registerRoutes;
// src/users/user.routes.js
const express = require('express');
const router = express.Router();
const userController = require('./user.controller');
const { validateUser } = require('./user.validation');

router.post('/', validateUser, userController.create);
router.get('/:id', userController.getById);
router.put('/:id', validateUser, userController.update);

module.exports = router;
// server.js
const express = require('express');
const registerRoutes = require('./src/routes');

const app = express();
registerRoutes(app);

app.listen(3000);

Ventajas concretas:

  • Agregás un nuevo módulo y las rutas ya están disponibles sin tocar server.js
  • Cada módulo es independiente y testeable por separado
  • Podés desactivar módulos completos cambiando el array modules

3. Middlewares en tres niveles: global, por ruta y por endpoint

El caos de middlewares aparece cuando mezclás validación, autenticación y manejo de errores sin un criterio claro. La solución es aplicarlos en tres niveles bien diferenciados:

Nivel 1: Middlewares globales (todos los endpoints)

// server.js
app.use(express.json());
app.use(cors());
app.use(helmet());
app.use(logger); // tu propio logger

Nivel 2: Middlewares por módulo/ruta (grupo de endpoints)

// src/users/user.routes.js
const { authenticate } = require('../shared/middlewares/auth');

router.use(authenticate); // Aplica a TODAS las rutas de users

router.post('/', validateUser, userController.create);
router.get('/:id', userController.getById);

Nivel 3: Middlewares específicos (endpoint individual)

// src/orders/order.routes.js
const { isAdmin } = require('../shared/middlewares/roles');

router.get('/', orderController.getAll); // sin middleware extra
router.delete('/:id', isAdmin, orderController.delete); // solo admins

Regla práctica:

  • ¿Se usa en el 100% de las rutas? → Global
  • ¿Se usa en el 80% de un módulo? → Por módulo
  • ¿Se usa en 1-2 endpoints? → Por endpoint

Esta separación hace que sea obvio dónde va cada middleware nuevo y evita duplicar lógica.

4. Separación real entre controllers, services y repositories

Separar en archivos no es suficiente. La mayoría pone todo el código en el controller y el service queda vacío. La separación real es por responsabilidad:

Controller: recibe request, valida entrada, llama al service, devuelve response

// user.controller.js
async function create(req, res, next) {
  try {
    const userData = req.body;
    const user = await userService.createUser(userData);
    
    res.status(201).json({ 
      success: true, 
      data: user 
    });
  } catch (error) {
    next(error);
  }
}

Service: lógica de negocio, orquesta diferentes operaciones, no toca request/response

// user.service.js
async function createUser(userData) {
  // Validar reglas de negocio
  const existingUser = await userRepository.findByEmail(userData.email);
  
  if (existingUser) {
    throw new Error('Email ya registrado');
  }
  
  // Hash password
  const hashedPassword = await bcrypt.hash(userData.password, 10);
  
  // Crear usuario
  const user = await userRepository.create({
    ...userData,
    password: hashedPassword
  });
  
  // Enviar email de bienvenida
  await emailService.sendWelcome(user.email);
  
  return user;
}

Repository: acceso a datos, queries, no conoce lógica de negocio

// user.repository.js
async function create(userData) {
  return await User.create(userData);
}

async function findByEmail(email) {
  return await User.findOne({ where: { email } });
}

Por qué importa:

  • Podés cambiar de base de datos (Postgres → MongoDB) tocando solo repositories
  • Podés testear la lógica de negocio sin mockear toda la DB
  • Podés reutilizar services desde diferentes controllers (API REST, GraphQL, cron jobs)

5. Manejo de errores centralizado con clases de error personalizadas

El típico try/catch en cada endpoint termina con código duplicado y mensajes de error inconsistentes. La solución es un middleware global + clases de error:

// shared/errors/AppError.js
class AppError extends Error {
  constructor(message, statusCode, code) {
    super(message);
    this.statusCode = statusCode;
    this.code = code;
    this.isOperational = true;
  }
}

class NotFoundError extends AppError {
  constructor(resource) {
    super(`${resource} no encontrado`, 404, 'NOT_FOUND');
  }
}

class ValidationError extends AppError {
  constructor(message) {
    super(message, 400, 'VALIDATION_ERROR');
  }
}

module.exports = { AppError, NotFoundError, ValidationError };
// shared/middlewares/errorHandler.js
function errorHandler(err, req, res, next) {
  // Errores operacionales (esperados)
  if (err.isOperational) {
    return res.status(err.statusCode).json({
      success: false,
      code: err.code,
      message: err.message
    });
  }
  
  // Errores de programación (no esperados)
  console.error('ERROR NO MANEJADO:', err);
  
  res.status(500).json({
    success: false,
    code: 'INTERNAL_ERROR',
    message: 'Algo salió mal'
  });
}

module.exports = errorHandler;

Usarlo en cualquier parte del código:

// user.service.js
const { NotFoundError } = require('../shared/errors/AppError');

async function getUserById(id) {
  const user = await userRepository.findById(id);
  
  if (!user) {
    throw new NotFoundError('Usuario');
  }
  
  return user;
}
// server.js (al final, después de todas las rutas)
const errorHandler = require('./shared/middlewares/errorHandler');
app.use(errorHandler);

Esto te da errores consistentes, tipados, fáciles de testear y con códigos de status correctos sin escribir res.status(404).json() en 50 lugares.

Checklist: aplicá estos patrones hoy

  • Reorganzá al menos UN módulo por dominio (empezá con el más grande)
  • Creá un routes/index.js que registre rutas automáticamente
  • Identificá middlewares duplicados y unificá en los tres niveles
  • Movê lógica de negocio de controllers a services
  • Implementá clases de error y el middleware global
  • Documentá la estructura en el README para el equipo
  • Aplicá el mismo patrón al próximo feature antes de escribir código

Preguntas frecuentes

¿Qué hago si mi proyecto ya tiene 50 endpoints en la estructura vieja?

No refactorices todo de una. Aplicá los patrones solo a features nuevos y andá migrando módulos de a uno por sprint. Empezá con el módulo que más cambia (generalmente users o el core del negocio). En 2-3 meses vas a tener la mayoría migrado sin frenar desarrollo.

¿Esta estructura funciona con Express, Fastify o cualquier framework?

Sí. Los cinco patrones son independientes del framework. Cambián los detalles de sintaxis (cómo registrás rutas, cómo usás middlewares), pero la separación por dominio, services/repositories y manejo de errores aplican igual.

¿Cuándo está bien NO usar esta estructura?

Si tu backend tiene menos de 10 endpoints y no va a crecer (un MVP para validar, un proyecto interno simple, un microservicio con UN solo propósito). Ahí podés vivir con todo en server.js. Pero si tenés planeado agregar features, aplicá al menos patrones 1, 3 y 5 desde el arranque.

¿Te gustó este artículo?

Descubre nuestros cursos y carreras para llevar tus habilidades al siguiente nivel.