FullStackJS Camp
Módulo 7·teoria·8h
Objetivos de aprendizaje
  • Separar responsabilidades en Modelo, Controlador y Rutas
  • Encapsular la conexión al Pool en una clase ConexionDB con campos privados
  • Implementar lógica de reintento al arrancar el servidor
  • Manejar errores enriquecidos con códigos de PostgreSQL
  • Configurar el middleware global de errores de Express 5

Arquitectura MVC con Express y PostgreSQL

¿Por qué MVC?

Cuando un servidor Express crece, poner toda la lógica en server.js se vuelve insostenible. El patrón MVC distribuye responsabilidades:

code
Petición HTTP


 [ Ruta ]           routes/tareas.routes.js
     │               Define el método y el path

 [ Controlador ]    controller/tareasController.js
     │               Valida input, llama al modelo, arma la respuesta

 [ Modelo ]         models/Tarea.js
                     Ejecuta el SQL, devuelve datos al controlador

Estructura del proyecto

code
proyecto/
├── .env
├── server.js                      ← arranque, middleware, error handler
├── config/
│   └── db.js                      ← clase ConexionDB (Pool encapsulado)
├── routes/
│   └── tareas.routes.js           ← definición de rutas
├── controller/
│   └── tareasController.js        ← lógica HTTP
├── models/
│   └── Tarea.js                   ← SQL (CRUD)
└── package.json

La clase ConexionDB

Encapsular el Pool en una clase tiene varias ventajas: campo #pool privado para evitar acceso accidental, lógica de reintento centralizada y un getter público de solo lectura.

javascript
// config/db.js
import "dotenv/config";
import pkg from "pg";
import chalk from "chalk";

const { Pool } = pkg;

class ConexionDB {
  #pool;
  #maxReintentos;
  #delayReintento;

  constructor() {
    this.#maxReintentos  = Number(process.env.DB_MAX_REINTENTOS)  || 5;
    this.#delayReintento = Number(process.env.DB_DELAY_REINTENTO) || 3000;

    this.#pool = new Pool({
      user:     process.env.DB_USER,
      host:     process.env.DB_HOST,
      password: process.env.DB_PASSWORD,
      database: process.env.DB_NAME,
      port:     Number(process.env.DB_PORT) || 5432,
      max:      Number(process.env.DB_POOL_MAX) || 10,
      idleTimeoutMillis:      Number(process.env.DB_POOL_IDLE_TIMEOUT)       || 30000,
      connectionTimeoutMillis: Number(process.env.DB_POOL_CONNECTION_TIMEOUT) || 2000,
    });

    // Listener de errores pasivos: clientes inactivos que pierden conexión
    this.#pool.on("error", (err) => {
      console.error(chalk.yellow("[Pool] Error en cliente inactivo:", err.message));
    });
  }

  // Getter público de solo lectura — lo usan los modelos
  get pool() {
    return this.#pool;
  }

  #esperar(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
  }

  // Verificación con reintentos al arrancar el servidor
  async verificarConexion() {
    let intentos = 0;
    while (intentos < this.#maxReintentos) {
      intentos++;
      let client;
      try {
        console.log(chalk.cyan(`[DB] Intento [${intentos}/${this.#maxReintentos}]...`));
        client = await this.#pool.connect();
        const { rows } = await client.query("SELECT NOW() AS hora_servidor");
        console.log(chalk.green(`[DB] Conectado. Hora servidor: ${rows[0].hora_servidor}`));
        return true;
      } catch (error) {
        console.error(chalk.red(`[DB] Fallo intento ${intentos}: ${error.message}`));
        if (intentos < this.#maxReintentos) {
          await this.#esperar(this.#delayReintento);
        }
      } finally {
        if (client) client.release();
      }
    }
    throw new Error("No se pudo conectar a la base de datos tras todos los intentos.");
  }

  async cerrarPool() {
    await this.#pool.end();
    console.log("[DB] Pool cerrado.");
  }
}

// Singleton — toda la app comparte la misma instancia
export default new ConexionDB();

El Modelo

El modelo ejecuta el SQL y devuelve datos limpios. Los errores se enriquecen con información de PostgreSQL antes de propagarse.

javascript
// models/Tarea.js
import db from "../config/db.js";

const pool = db.pool;

// Enriquece el error con metadatos de PostgreSQL para mejor debugging
const construirError = (error, operacion) => {
  const err = new Error(`[${operacion}] ${error.message}`);
  err.codigoPg  = error.code   ?? "N/A";  // ej: '23505' = unique_violation
  err.detallePg = error.detail ?? null;   // ej: 'Key (email)=(x) already exists'
  err.operacion = operacion;
  err.stack     = error.stack;

  // Traducir códigos PG a HTTP status codes semánticos
  if (error.code === "23503") {           // foreign_key_violation
    err.statusCode = 409;
    err.message = "No se puede eliminar: el registro está referenciado por otros datos.";
  }
  if (error.code === "23505") {           // unique_violation
    err.statusCode = 409;
    err.message = `Ya existe un registro con ese valor. ${error.detail ?? ""}`;
  }

  return err;
};

export const todasLasTareas = async () => {
  let client;
  try {
    client = await pool.connect();
    const { rows } = await client.query("SELECT * FROM tareas ORDER BY id DESC");
    return rows;
  } catch (error) {
    throw construirError(error, "todasLasTareas");
  } finally {
    if (client) client.release();
  }
};

export const nuevaTarea = async ({ titulo, descripcion }) => {
  let client;
  try {
    client = await pool.connect();
    const { rows } = await client.query(
      "INSERT INTO tareas (titulo, descripcion) VALUES ($1, $2) RETURNING *",
      [titulo, descripcion]
    );
    return rows[0];
  } catch (error) {
    throw construirError(error, "nuevaTarea");
  } finally {
    if (client) client.release();
  }
};

export const editarTarea = async (id, { titulo, descripcion }) => {
  let client;
  try {
    client = await pool.connect();
    const { rows } = await client.query(
      "UPDATE tareas SET titulo = $1, descripcion = $2 WHERE id = $3 RETURNING *",
      [titulo, descripcion, id]
    );
    return rows[0] ?? null; // null si el id no existe
  } catch (error) {
    throw construirError(error, "editarTarea");
  } finally {
    if (client) client.release();
  }
};

export const eliminarTarea = async (id) => {
  let client;
  try {
    client = await pool.connect();
    const { rows } = await client.query(
      "DELETE FROM tareas WHERE id = $1 RETURNING *",
      [id]
    );
    return rows[0] ?? null;
  } catch (error) {
    throw construirError(error, "eliminarTarea");
  } finally {
    if (client) client.release();
  }
};

El Controlador

El controlador valida el input HTTP, llama al modelo y construye la respuesta. No escribe SQL y no maneja conexiones.

javascript
// controller/tareasController.js
import { todasLasTareas, nuevaTarea, editarTarea, eliminarTarea } from "../models/Tarea.js";

const crearError = (mensaje, statusCode = 400) => {
  const err = new Error(mensaje);
  err.statusCode = statusCode;
  return err;
};

const validarId = (idTexto) => {
  const id = Number(idTexto);
  if (!Number.isInteger(id) || id <= 0) {
    throw crearError("El parámetro id debe ser un número entero positivo.", 400);
  }
  return id;
};

const normalizar = (valor) => String(valor ?? "").trim();

export const obtenerTareas = async (req, res, next) => {
  try {
    const tareas = await todasLasTareas();
    res.status(200).json(tareas);
  } catch (error) {
    next(error);
  }
};

export const crearTarea = async (req, res, next) => {
  try {
    const titulo      = normalizar(req.body.titulo);
    const descripcion = normalizar(req.body.descripcion);

    if (!titulo || !descripcion) {
      throw crearError('Los campos "titulo" y "descripcion" son obligatorios.');
    }

    const tarea = await nuevaTarea({ titulo, descripcion });
    res.status(201).json(tarea);
  } catch (error) {
    next(error);
  }
};

export const actualizarTarea = async (req, res, next) => {
  try {
    const id          = validarId(req.params.id);
    const titulo      = normalizar(req.body.titulo);
    const descripcion = normalizar(req.body.descripcion);

    if (!titulo || !descripcion) {
      throw crearError('Los campos "titulo" y "descripcion" son obligatorios.');
    }

    const tarea = await editarTarea(id, { titulo, descripcion });
    if (!tarea) throw crearError("Tarea no encontrada.", 404);
    res.status(200).json(tarea);
  } catch (error) {
    next(error);
  }
};

export const borrarTarea = async (req, res, next) => {
  try {
    const id    = validarId(req.params.id);
    const tarea = await eliminarTarea(id);
    if (!tarea) throw crearError("Tarea no encontrada.", 404);
    res.status(200).json({ mensaje: "Tarea eliminada.", tarea });
  } catch (error) {
    next(error);
  }
};

Las Rutas

javascript
// routes/tareas.routes.js
import { Router } from "express";
import {
  obtenerTareas,
  crearTarea,
  actualizarTarea,
  borrarTarea,
} from "../controller/tareasController.js";

const router = Router();

router.get("/",        obtenerTareas);
router.post("/",       crearTarea);
router.put("/:id",     actualizarTarea);
router.delete("/:id",  borrarTarea);

export default router;

El Servidor

javascript
// server.js
import "dotenv/config";
import express from "express";
import chalk from "chalk";
import db from "./config/db.js";
import tareasRouter from "./routes/tareas.routes.js";

const app  = express();
const PORT = process.env.PORT || 3000;

app.use(express.json());
app.use("/api/tareas", tareasRouter);

// ── Middleware global de errores (Express 5) ──────────────────────────────────
// Recibe 4 parámetros: (err, req, res, next)
// Se ejecuta cuando cualquier controlador llama a next(error)
app.use((err, req, res, next) => {
  const status = err.statusCode ?? 500;
  const body = {
    ok:        false,
    mensaje:   err.message,
    operacion: err.operacion  ?? undefined,
    codigoPg:  err.codigoPg   ?? undefined,
    detallePg: err.detallePg  ?? undefined,
  };

  // Solo incluimos el stack en desarrollo
  if (process.env.NODE_ENV !== "production") {
    body.stack = err.stack;
  }

  res.status(status).json(body);
});

// ── Manejadores globales del proceso ─────────────────────────────────────────
process.on("uncaughtException", (error) => {
  console.error(chalk.bgRed.white("[PROCESO] uncaughtException:", error.message));
  db.cerrarPool().finally(() => process.exit(1));
});

process.on("unhandledRejection", (reason) => {
  console.error(chalk.bgRed.white("[PROCESO] unhandledRejection:", reason));
});

// ── Arranque con verificación de BD ──────────────────────────────────────────
const iniciar = async () => {
  await db.verificarConexion(); // Lanza error si no puede conectar
  app.listen(PORT, () => {
    console.log(chalk.green(`[SERVER] Escuchando en http://localhost:${PORT}`));
  });
};

iniciar().catch((err) => {
  console.error(chalk.red("[SERVER] No se pudo iniciar:", err.message));
  process.exit(1);
});

Códigos de error de PostgreSQL más comunes

CódigoNombreSignificadoHTTP sugerido
23505unique_violationValor duplicado (UNIQUE constraint)409
23503foreign_key_violationFK no existe o no se puede eliminar409
23502not_null_violationCampo NOT NULL con valor nulo400
22P02invalid_text_representationTipo de dato inválido (ej: texto en INT)400
42P01undefined_tableLa tabla no existe500
ECONNREFUSEDPostgreSQL no está corriendo503