Buenas prácticas de organización del código en backends Node.js

Capítulo 11

Tiempo estimado de lectura: 9 minutos

+ Ejercicio

Patrones de organización que escalan

Cuando un backend crece, el problema más común no es “falta de features”, sino la dificultad para entender dónde está cada cosa y cómo cambiarla sin romper otras partes. Las buenas prácticas de organización buscan que el código sea predecible: que una persona nueva pueda ubicar rápidamente la lógica, que los cambios tengan un lugar natural y que las pruebas sean más simples.

Separación por capas (responsabilidades claras)

Una forma práctica de mantener orden es separar el backend en capas con responsabilidades distintas. La idea no es “hacer arquitectura por moda”, sino evitar mezclar validación, reglas de negocio y acceso a datos en el mismo archivo.

  • Rutas/Controladores (capa de entrada): reciben la request, validan/normalizan entrada, llaman a servicios y devuelven respuesta.
  • Servicios (negocio): contienen reglas de negocio y orquestan operaciones (por ejemplo, crear usuario, calcular totales, aplicar reglas).
  • Repositorios/DAO (datos): encapsulan el acceso a base de datos o APIs externas.
  • Config: centraliza configuración (por ejemplo, puertos, flags, timeouts) y la expone como módulo.
  • Utils: funciones pequeñas reutilizables (por ejemplo, formateo, parseo, helpers puros).

Regla práctica: si un archivo “hace de todo”, probablemente está en la capa equivocada.

Evitar lógica de negocio en rutas

Las rutas deberían ser delgadas. Un síntoma de mala organización es ver en una ruta: consultas a DB, cálculos, validaciones complejas y construcción de respuesta todo junto. En su lugar, la ruta debe delegar.

Ejemplo: ruta delgada + servicio

// routes/orders.routes.js
const express = require('express');
const router = express.Router();
const { createOrderController } = require('../controllers/orders.controller');

router.post('/', createOrderController);

module.exports = router;
// controllers/orders.controller.js
const { createOrder } = require('../services/orders.service');
const { validateCreateOrder } = require('../validators/orders.validator');
const { ok } = require('../http/response');

async function createOrderController(req, res, next) {
  try {
    const input = validateCreateOrder(req.body); // validación en entrada
    const result = await createOrder(input);
    return ok(res, result);
  } catch (err) {
    next(err);
  }
}

module.exports = { createOrderController };
// services/orders.service.js
const { ordersRepo } = require('../repositories/orders.repo');

async function createOrder({ customerId, items }) {
  // reglas de negocio aquí (ejemplo)
  if (items.length === 0) {
    const { AppError } = require('../errors/AppError');
    throw new AppError('ORDER_EMPTY', 'La orden debe tener al menos un item', 400);
  }

  const total = items.reduce((acc, it) => acc + it.price * it.qty, 0);
  const order = await ordersRepo.insert({ customerId, items, total });
  return { id: order.id, total };
}

module.exports = { createOrder };

Beneficios: el controlador se mantiene simple, el servicio es testeable sin Express, y el repositorio puede cambiar (MongoDB, SQL, mock) sin tocar la capa de entrada.

Continúa en nuestra aplicación.
  • Escuche el audio con la pantalla apagada.
  • Obtenga un certificado al finalizar.
  • ¡Más de 5000 cursos para que explores!
O continúa leyendo más abajo...
Download App

Descargar la aplicación

Servicios reutilizables y composición

Un servicio reutilizable es una función/módulo que resuelve una necesidad de negocio de forma independiente a HTTP. Para fomentar reutilización:

  • Evita que el servicio dependa de req/res.
  • Recibe datos ya validados y devuelve datos de dominio (no “respuestas HTTP”).
  • Divide servicios grandes en funciones pequeñas (composición).

Ejemplo: servicio compuesto

// services/billing.service.js
const { AppError } = require('../errors/AppError');

function computeSubtotal(items) {
  return items.reduce((acc, it) => acc + it.price * it.qty, 0);
}

function applyDiscount(subtotal, coupon) {
  if (!coupon) return subtotal;
  if (coupon.type === 'PERCENT' && coupon.value > 50) {
    throw new AppError('COUPON_INVALID', 'Cupón inválido', 400);
  }
  return coupon.type === 'PERCENT'
    ? subtotal * (1 - coupon.value / 100)
    : Math.max(0, subtotal - coupon.value);
}

module.exports = { computeSubtotal, applyDiscount };

Este tipo de funciones son fáciles de probar y de reutilizar desde distintos endpoints.

Módulos de configuración y utilidades (sin acoplar todo)

La configuración debe ser un módulo único que el resto del proyecto consuma. Evita leer variables de entorno dispersas por el código, porque complica el mantenimiento y las pruebas.

Ejemplo: módulo de configuración

// config/index.js
function required(name, value) {
  if (!value) throw new Error(`Missing env: ${name}`);
  return value;
}

const config = {
  env: process.env.NODE_ENV || 'development',
  port: Number(process.env.PORT || 3000),
  dbUri: required('DB_URI', process.env.DB_URI),
  requestTimeoutMs: Number(process.env.REQUEST_TIMEOUT_MS || 10000)
};

module.exports = { config };

Las utilidades (utils) deberían ser funciones puras y pequeñas. Si una “utilidad” empieza a tener estado, dependencias o reglas de negocio, probablemente debe ser un servicio.

Validación en la capa de entrada

Valida lo antes posible: en el controlador o en un validador dedicado. Esto evita que la capa de negocio tenga que lidiar con tipos incorrectos o campos faltantes. Además, la validación define el “contrato” del endpoint.

Guía práctica paso a paso para validación

  • Paso 1: define un validador por caso de uso (por ejemplo, validateCreateOrder).
  • Paso 2: valida presencia, tipos y rangos; rechaza campos inesperados si aplica.
  • Paso 3: normaliza entrada (por ejemplo, convertir strings numéricos a número) solo si es seguro y explícito.
  • Paso 4: devuelve un objeto “limpio” para el servicio.
// validators/orders.validator.js
const { AppError } = require('../errors/AppError');

function validateCreateOrder(body) {
  if (!body || typeof body !== 'object') {
    throw new AppError('VALIDATION_ERROR', 'Body inválido', 400);
  }

  const { customerId, items } = body;

  if (typeof customerId !== 'string' || customerId.trim() === '') {
    throw new AppError('VALIDATION_ERROR', 'customerId es requerido', 400);
  }

  if (!Array.isArray(items)) {
    throw new AppError('VALIDATION_ERROR', 'items debe ser un array', 400);
  }

  const normalizedItems = items.map((it, idx) => {
    if (!it || typeof it !== 'object') {
      throw new AppError('VALIDATION_ERROR', `item[${idx}] inválido`, 400);
    }
    const price = Number(it.price);
    const qty = Number(it.qty);
    if (!Number.isFinite(price) || price < 0) {
      throw new AppError('VALIDATION_ERROR', `item[${idx}].price inválido`, 400);
    }
    if (!Number.isInteger(qty) || qty <= 0) {
      throw new AppError('VALIDATION_ERROR', `item[${idx}].qty inválido`, 400);
    }
    return { price, qty };
  });

  return { customerId: customerId.trim(), items: normalizedItems };
}

module.exports = { validateCreateOrder };

Nota: la validación no debe “consultar la base de datos” (por ejemplo, verificar si existe un usuario). Eso suele ser parte de reglas de negocio (servicio) o de integridad (repositorio), no de validación sintáctica.

Normalización de respuestas (contrato consistente)

Un backend mantenible responde de forma consistente. Esto reduce lógica en el frontend y facilita debugging. Define un formato estándar para éxito y error.

Ejemplo: helpers de respuesta

// http/response.js
function ok(res, data) {
  return res.status(200).json({ ok: true, data });
}

function created(res, data) {
  return res.status(201).json({ ok: true, data });
}

module.exports = { ok, created };

Recomendación: evita mezclar formatos por endpoint (por ejemplo, a veces devolver un array “pelado” y otras veces un objeto con metadata). Si necesitas paginación, define un shape estándar (por ejemplo, { items, page, pageSize, total }).

Async/await consistente (y sin “try/catch” repetitivo)

La consistencia con async/await reduce errores sutiles. Pautas:

  • No mezcles await con .then() en el mismo flujo.
  • Siempre await promesas relevantes (especialmente escrituras).
  • Centraliza el manejo de errores asíncronos para no repetir try/catch en cada controlador.

Patrón: wrapper para controladores async

// http/asyncHandler.js
function asyncHandler(fn) {
  return function (req, res, next) {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
}

module.exports = { asyncHandler };
// controllers/orders.controller.js
const { asyncHandler } = require('../http/asyncHandler');
const { createOrder } = require('../services/orders.service');
const { validateCreateOrder } = require('../validators/orders.validator');
const { created } = require('../http/response');

const createOrderController = asyncHandler(async (req, res) => {
  const input = validateCreateOrder(req.body);
  const result = await createOrder(input);
  return created(res, result);
});

module.exports = { createOrderController };

Esto mantiene controladores limpios y asegura que cualquier error asíncrono llegue al middleware de errores.

Estructura de errores (tipos, códigos y contexto)

Una estructura de errores clara permite responder con el status correcto y mensajes útiles sin filtrar detalles internos. Define un error de aplicación con:

  • code: identificador estable (útil para frontend y logs).
  • message: mensaje para el cliente (sin detalles sensibles).
  • status: HTTP status.
  • details: opcional, para validación o debugging controlado.

Ejemplo: AppError

// errors/AppError.js
class AppError extends Error {
  constructor(code, message, status = 500, details) {
    super(message);
    this.code = code;
    this.status = status;
    this.details = details;
  }
}

module.exports = { AppError };

Regla práctica: lanza AppError para errores esperables (validación, no encontrado, conflicto). Para errores inesperados (bugs, caídas externas), deja que se propaguen como error genérico y se transformen en una respuesta segura.

Estructura de carpetas sugerida (mantenible y fácil de navegar)

Una estructura simple y común para proyectos Express:

src/
  app.js
  routes/
    index.js
    orders.routes.js
  controllers/
    orders.controller.js
  services/
    orders.service.js
    billing.service.js
  repositories/
    orders.repo.js
  validators/
    orders.validator.js
  http/
    response.js
    asyncHandler.js
  errors/
    AppError.js
  config/
    index.js
  utils/
    dates.js
    strings.js

Pautas:

  • Evita carpetas “misc” o “helpers” gigantes: si crecen, subdivide por dominio (por ejemplo, orders, users).
  • Prefiere nombres explícitos: orders.service.js es más claro que service.js.
  • Un archivo por responsabilidad principal (si un archivo supera ~300–500 líneas, evalúa dividir).

Límites de responsabilidad (reglas rápidas para decidir “dónde va”)

Si el código...Entonces va en...
Traduce HTTP a llamadas internas (lee params/body, arma respuesta)Controller
Aplica reglas de negocio (cálculos, decisiones, orquestación)Service
Lee/escribe datos (DB, cache, API externa)Repository/Client
Valida forma y tipos de entradaValidator (entrada)
Es una función genérica y pura (sin dependencias del dominio)Utils
Define valores de entorno y defaultsConfig

Consistencia de estilo (para reducir fricción)

La mantenibilidad mejora cuando el equipo escribe “parecido”. Pautas simples:

  • Usa una convención de nombres consistente: camelCase para variables/funciones, PascalCase para clases, archivos con sufijo por rol (.controller, .service).
  • Evita duplicación: si copias/pegas lógica entre controladores, probablemente falta un servicio o util.
  • Mantén imports ordenados por capas (por ejemplo: libs externas, luego módulos internos).
  • Define reglas mínimas de lint/format (aunque sea básico) para evitar discusiones de estilo en PRs.

Documentación mínima del API (README útil)

No hace falta una documentación extensa para empezar, pero sí un README que permita usar el backend sin leer el código. Contenido mínimo recomendado:

  • Cómo ejecutar el proyecto (comandos y variables requeridas).
  • Convención de respuestas (shape de éxito/error).
  • Lista de endpoints con método, ruta, descripción, body/params y ejemplos.

Ejemplo de sección de endpoints en README

## Endpoints

### POST /orders
Crea una orden.

Body:
{
  "customerId": "abc123",
  "items": [{ "price": 10, "qty": 2 }]
}

Respuesta 201:
{
  "ok": true,
  "data": { "id": "ord_1", "total": 20 }
}

Errores:
- 400 VALIDATION_ERROR
- 400 ORDER_EMPTY

Si el proyecto crece, puedes migrar a una especificación formal (por ejemplo, OpenAPI), pero un README consistente ya aporta mucho valor para mantenimiento y colaboración.

Ahora responde el ejercicio sobre el contenido:

¿Cuál es el objetivo principal de mantener las rutas/controladores “delgados” en un backend con Express?

¡Tienes razón! Felicitaciones, ahora pasa a la página siguiente.

¡Tú error! Inténtalo de nuevo.

Las rutas/controladores deben manejar la entrada/salida HTTP (validar/normalizar y responder) y delegar las reglas de negocio a servicios. Esto mejora la mantenibilidad, facilita pruebas sin Express y permite cambiar repositorios sin tocar la capa de entrada.

Siguiente capítulo

Despliegue básico de una API Node.js con Express

Arrow Right Icon
Portada de libro electrónico gratuitaNode.js para principiantes: crea un backend simple con Express
92%

Node.js para principiantes: crea un backend simple con Express

Nuevo curso

12 páginas

Descarga la aplicación para obtener una certificación gratuita y escuchar cursos en segundo plano, incluso con la pantalla apagada.