Skip to content

Node.js: De Cero a Profesional

Tutorial

35 min read

0. Introducción a JavaScript

Antes de entrar en Node.js, necesitas tener claros los fundamentos del lenguaje. Esta sección cubre el JavaScript moderno (ES2020+) que usarás a diario.

Variables: const, let y por qué olvidar var

// ❌ var tiene scope de función y se puede redeclarar — evítalo siempre
var nombre = 'Ana';
var nombre = 'Luis'; // No hay error... pero es un bug esperando ocurrir

// ✅ const: para valores que no se reasignan (úsalo por defecto)
const PI = 3.14;
const usuario = { nombre: 'Ana', edad: 28 }; // el objeto es mutable, la referencia no
usuario.edad = 29; // ✅ esto sí está permitido

// ✅ let: solo cuando necesitas reasignar
let contador = 0;
contador += 1; // OK

Regla de oro: usa siempre const. Solo cambia a let cuando el linter te avise que necesitas reasignar. Nunca uses var.

Tipos de datos

// Primitivos
const texto    = 'Hola mundo';       // string
const numero   = 42;                 // number
const decimal  = 3.14;               // number (no hay float separado)
const activo   = true;               // boolean
const vacio    = null;               // null — ausencia intencional de valor
const sinDef   = undefined;          // undefined — variable sin asignar
const id       = Symbol('id');       // symbol — único e irrepetible
const grande   = 9007199254740993n;  // bigint — enteros enormes

// Referencia
const array    = [1, 2, 3];
const objeto   = { clave: 'valor' };
const funcion  = () => {};

Template literals (strings modernos)

const nombre = 'María';
const edad = 30;

// ❌ Concatenación antigua
const saludo = 'Hola ' + nombre + ', tienes ' + edad + ' años.';

// ✅ Template literal
const saludo = `Hola ${nombre}, tienes ${edad} años.`;

// Multi-línea
const html = `
  <div>
    <h1>${nombre}</h1>
    <p>Edad: ${edad}</p>
  </div>
`;

// Expresiones dentro
const precio = 19.99;
console.log(`Total con IVA: ${(precio * 1.21).toFixed(2)}€`);

Desestructuración: la forma elegante de extraer datos

// Desestructuración de objetos
const usuario = { nombre: 'Carlos', edad: 25, ciudad: 'Madrid' };

const { nombre, ciudad } = usuario;
console.log(nombre); // 'Carlos'

// Con alias
const { nombre: nombreUsuario } = usuario;

// Con valor por defecto
const { rol = 'usuario' } = usuario; // 'usuario' si no existe

// Anidada
const { direccion: { calle } = {} } = usuario;

// ✅ Muy útil en parámetros de función
function mostrarUsuario({ nombre, edad, rol = 'usuario' }) {
  console.log(`${nombre} (${edad}) — ${rol}`);
}

// Desestructuración de arrays
const colores = ['rojo', 'verde', 'azul'];
const [primero, , tercero] = colores; // saltar 'verde'
console.log(primero, tercero); // 'rojo' 'azul'

// Intercambiar variables sin temporal
let a = 1, b = 2;
[a, b] = [b, a];

Spread y Rest operator (...)

// SPREAD — expandir elementos

// Copiar y combinar arrays
const frutas = ['manzana', 'pera'];
const masF   = [...frutas, 'uva', 'kiwi']; // ['manzana', 'pera', 'uva', 'kiwi']

// Copiar objetos (shallow copy)
const base    = { x: 1, y: 2 };
const copia   = { ...base, z: 3 };         // { x: 1, y: 2, z: 3 }
const override = { ...base, x: 99 };       // { x: 99, y: 2 }

// REST — recoger el resto
function sumar(primero, ...resto) {
  return primero + resto.reduce((acc, n) => acc + n, 0);
}
sumar(1, 2, 3, 4); // 10

// En desestructuración
const { nombre, ...otrosDatos } = usuario;
// otrosDatos tiene todo excepto nombre

Funciones: declaraciones, expresiones y arrow functions

// Declaración de función (hoisted — disponible antes de declararse)
function saludar(nombre) {
  return `Hola, ${nombre}`;
}

// Expresión de función
const despedir = function(nombre) {
  return `Adiós, ${nombre}`;
};

// Arrow function — sintaxis moderna, no tiene su propio 'this'
const multiplicar = (a, b) => a * b;

// Con cuerpo de bloque cuando hay más de una línea
const procesarUsuario = (usuario) => {
  const nombre = usuario.nombre.trim();
  const email  = usuario.email.toLowerCase();
  return { ...usuario, nombre, email };
};

// Parámetros por defecto
const saludarConRol = (nombre, rol = 'visitante') => {
  return `Hola ${nombre}, eres ${rol}`;
};

Arrays: los métodos que usarás todos los días

const productos = [
  { nombre: 'Laptop',  precio: 999,  activo: true  },
  { nombre: 'Mouse',   precio: 29,   activo: false },
  { nombre: 'Teclado', precio: 79,   activo: true  },
];

// map — transformar cada elemento → nuevo array
const nombres = productos.map(p => p.nombre);
// ['Laptop', 'Mouse', 'Teclado']

// filter — filtrar por condición → nuevo array
const activos = productos.filter(p => p.activo);
// [{ nombre: 'Laptop', ...}, { nombre: 'Teclado', ...}]

// find — encontrar el primero que cumpla
const laptop = productos.find(p => p.nombre === 'Laptop');

// reduce — acumular un valor
const total = productos.reduce((acc, p) => acc + p.precio, 0);
// 1107

// some / every
const hayCaros = productos.some(p => p.precio > 500);    // true
const todosCaro = productos.every(p => p.precio > 500);  // false

// Encadenado — el patrón más común en Node.js
const totalActivos = productos
  .filter(p => p.activo)
  .reduce((acc, p) => acc + p.precio, 0);
// 1078

// flat y flatMap (muy útiles con datos anidados)
const categorias = [['js', 'ts'], ['python', 'go']];
categorias.flat(); // ['js', 'ts', 'python', 'go']

Objetos modernos

// Shorthand properties
const nombre = 'Lucía';
const edad   = 27;
const obj    = { nombre, edad }; // En lugar de { nombre: nombre, edad: edad }

// Computed property names
const campo  = 'email';
const datos  = { [campo]: 'lucia@example.com' }; // { email: 'lucia@example.com' }

// Optional chaining — evita "Cannot read property of undefined"
const usuario = { perfil: { ciudad: 'Barcelona' } };
const ciudad  = usuario?.perfil?.ciudad;       // 'Barcelona'
const cp      = usuario?.perfil?.codigoPostal; // undefined (no lanza error)
const metodo  = usuario?.getPermisos?.();      // funciona con métodos también

// Nullish coalescing — valor por defecto solo si es null/undefined
const puerto = process.env.PORT ?? 3000;  // 3000 si PORT no existe
const cero   = 0 ?? 'default';            // 0 (no es null/undefined)
const falsy  = 0 || 'default';            // 'default' (|| también reacciona a 0, "")

Clases (ES2022+ con campos privados)

class Cuenta {
  // Campos privados con #
  #saldo;
  #titular;

  constructor(titular, saldoInicial = 0) {
    this.#titular  = titular;
    this.#saldo    = saldoInicial;
  }

  depositar(cantidad) {
    if (cantidad <= 0) throw new Error('La cantidad debe ser positiva');
    this.#saldo += cantidad;
    return this;  // Permite encadenamiento: cuenta.depositar(100).depositar(50)
  }

  retirar(cantidad) {
    if (cantidad > this.#saldo) throw new Error('Saldo insuficiente');
    this.#saldo -= cantidad;
    return this;
  }

  // Getter — accede como propiedad, no como método
  get saldo() {
    return this.#saldo;
  }

  get titular() {
    return this.#titular;
  }

  // Método estático — se llama en la clase, no en la instancia
  static crearCuentaNueva(titular) {
    return new Cuenta(titular, 0);
  }

  toString() {
    return `Cuenta de ${this.#titular}: ${this.#saldo}€`;
  }
}

const cuenta = Cuenta.crearCuentaNueva('Ana');
cuenta.depositar(500).depositar(200).retirar(100);
console.log(cuenta.saldo);    // 600
console.log(`${cuenta}`);     // 'Cuenta de Ana: 600€'
// cuenta.#saldo               // ❌ SyntaxError — campo privado

El Event Loop en 30 segundos

Este es el concepto más importante de JavaScript y Node.js. JavaScript es single-threaded — solo puede ejecutar una cosa a la vez. Entonces, ¿cómo maneja miles de peticiones simultáneas?

┌─────────────────────────┐
│        Call Stack        │  ← Tu código se ejecuta aquí, uno a uno
└────────────┬────────────┘
             │ cuando el stack está vacío...
┌────────────▼────────────┐
│      Event Loop          │  ← Revisa si hay callbacks listos
└────────────┬────────────┘
             │
┌────────────▼────────────┐
│     Callback Queue       │  ← Callbacks de timers, I/O, etc.
└─────────────────────────┘
console.log('1 — Síncrono');

setTimeout(() => console.log('2 — Timer (macro-task)'), 0);

Promise.resolve().then(() => console.log('3 — Microtask'));

console.log('4 — Síncrono');

// Output:
// 1 — Síncrono
// 4 — Síncrono
// 3 — Microtask     ← Las microtasks (Promises) van antes que los timers
// 2 — Timer (macro-task)

La clave: Las operaciones de I/O (leer archivos, consultas a BD, peticiones HTTP) son no bloqueantes. Node.js las delega al sistema operativo y continúa procesando otras peticiones. Cuando terminan, el callback entra a la cola. Por eso Node.js es tan eficiente para APIs.

El objeto this y por qué las arrow functions importan

class Temporizador {
  constructor() {
    this.segundos = 0;
  }

  // ❌ MALO — 'this' dentro del callback apunta a undefined (o global)
  iniciarMal() {
    setInterval(function() {
      this.segundos++; // TypeError: Cannot set properties of undefined
    }, 1000);
  }

  // ✅ BUENO — Arrow function hereda 'this' del contexto padre
  iniciar() {
    setInterval(() => {
      this.segundos++; // 'this' es el Temporizador
    }, 1000);
  }
}

Manejo de errores síncronos

// try/catch/finally
function dividir(a, b) {
  if (b === 0) throw new Error('División por cero');
  return a / b;
}

try {
  const resultado = dividir(10, 0);
} catch (error) {
  console.error(error.message); // 'División por cero'
} finally {
  // Se ejecuta SIEMPRE, haya error o no
  console.log('Operación completada');
}

// Tipos de error nativos
throw new TypeError('Se esperaba un número');
throw new RangeError('El valor debe estar entre 0 y 100');
throw new SyntaxError('JSON malformado');

Con estos fundamentos de JavaScript bien asentados, ya estás listo para aprovechar Node.js al máximo. Todo lo que viene a continuación los usará constantemente.


1. Fundamentos y Ecosistema Moderno

¿Qué es Node.js en 2025?

Node.js es un runtime de JavaScript construido sobre el motor V8 de Chrome. Hoy en día, Node.js 22+ viene con soporte nativo para TypeScript (con el flag --experimental-strip-types), ES Modules por defecto, y una API de fetch global sin necesidad de paquetes externos.

Instalación recomendada: fnm (Fast Node Manager)

Nunca instales Node.js directamente en tu sistema. Usa un gestor de versiones.

# Instalar fnm
curl -fsSL https://fnm.vercel.app/install | bash

# Instalar la última LTS
fnm install --lts
fnm use lts-latest
fnm default lts-latest

# Verificar
node --version  # v22.x.x
npm --version

Inicializar un proyecto moderno

mkdir mi-api && cd mi-api

# Crea package.json interactivo
npm init -y

Edita tu package.json para habilitarlo como módulo ES:

{
  "name": "mi-api",
  "version": "1.0.0",
  "type": "module",
  "engines": {
    "node": ">=22.0.0"
  },
  "scripts": {
    "dev": "node --watch src/index.js",
    "start": "node src/index.js",
    "test": "node --test",
    "lint": "eslint ."
  }
}

Tip Pro: "type": "module" activa ES Modules en todo el proyecto. node --watch es el hot-reload nativo de Node (no necesitas nodemon).


2. Módulos ES Modules vs CommonJS

ES Modules (la forma correcta en 2025)

// utils/math.js
export function sumar(a, b) {
  return a + b;
}

export const PI = 3.14159;

// Default export
export default function calcular(operacion, a, b) {
  const ops = { sumar, restar: (a, b) => a - b };
  return ops[operacion]?.(a, b) ?? null;
}
// main.js
import calcular, { sumar, PI } from './utils/math.js'; // ← la extensión .js es OBLIGATORIA

console.log(sumar(2, 3));       // 5
console.log(PI);                // 3.14159
console.log(calcular('sumar', 10, 5)); // 15

Importaciones dinámicas (lazy loading)

// Solo carga el módulo cuando realmente lo necesitas
async function procesarArchivo(tipo) {
  if (tipo === 'csv') {
    const { parsearCSV } = await import('./parsers/csv.js');
    return parsearCSV;
  }
  const { parsearJSON } = await import('./parsers/json.js');
  return parsearJSON;
}

Importar JSON nativo (Node 22+)

import config from './config.json' with { type: 'json' };

console.log(config.puerto); // Sin necesidad de fs.readFileSync

¿Cuándo usar CommonJS?

Solo cuando trabajas con librerías legacy que no tienen soporte ESM. En ese caso, usa la extensión .cjs:

// legacy.cjs  ← nota la extensión
const express = require('express');
module.exports = { app: express() };

3. Manejo Asíncrono Moderno

El problema con los callbacks (nunca hagas esto)

// ❌ MALO - Callback hell
fs.readFile('archivo.txt', (err, data) => {
  if (err) {
    db.query('INSERT INTO errores VALUES (?)', [err.message], (err2) => {
      // ...más anidamiento infinito
    });
  }
});

Async/Await: la forma correcta

import { readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';

// ✅ BUENO - Limpio y legible
async function procesarArchivo(nombreArchivo) {
  const ruta = join(import.meta.dirname, 'datos', nombreArchivo);
  
  const contenido = await readFile(ruta, 'utf-8');
  const procesado = contenido.toUpperCase();
  
  await writeFile(ruta.replace('.txt', '_procesado.txt'), procesado);
  
  return procesado;
}

Tip: Usa node:fs/promises (con el prefijo node:) en lugar de fs/promises. El prefijo node: deja claro que es un módulo nativo y mejora la carga.

Manejo de errores con async/await

// Patrón: wrapper para evitar try/catch repetitivo
async function intentar(promesa) {
  try {
    const resultado = await promesa;
    return [null, resultado];
  } catch (error) {
    return [error, null];
  }
}

// Uso limpio
async function obtenerUsuario(id) {
  const [error, usuario] = await intentar(db.findById(id));
  
  if (error) {
    console.error('Error al obtener usuario:', error.message);
    return null;
  }
  
  return usuario;
}

Operaciones en paralelo

// ❌ LENTO - Secuencial (espera cada uno)
const usuario = await obtenerUsuario(id);
const pedidos = await obtenerPedidos(id);
const facturas = await obtenerFacturas(id);

// ✅ RÁPIDO - Paralelo (todos al mismo tiempo)
const [usuario, pedidos, facturas] = await Promise.all([
  obtenerUsuario(id),
  obtenerPedidos(id),
  obtenerFacturas(id),
]);

// Con manejo individual de errores
const resultados = await Promise.allSettled([
  obtenerUsuario(id),
  obtenerPedidos(id),
]);

resultados.forEach(({ status, value, reason }) => {
  if (status === 'fulfilled') console.log(value);
  else console.error('Falló:', reason);
});

Streams: procesando datos grandes eficientemente

import { createReadStream, createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
import { createGzip } from 'node:zlib';

// Comprimir un archivo gigante SIN cargar todo en memoria
async function comprimirArchivo(entrada, salida) {
  await pipeline(
    createReadStream(entrada),
    createGzip(),
    createWriteStream(salida)
  );
  console.log('Archivo comprimido exitosamente');
}

await comprimirArchivo('datos-grandes.csv', 'datos-grandes.csv.gz');

4. Arquitectura Modular y Estructura de Proyecto

Una buena arquitectura es la diferencia entre un proyecto mantenible y uno que se vuelve inmanejable.

Estructura recomendada (Feature-based)

mi-api/
├── src/
│   ├── index.js              ← Punto de entrada
│   ├── app.js                ← Configuración Express
│   ├── config/
│   │   ├── index.js          ← Configuración central
│   │   └── database.js       ← Config de base de datos
│   ├── features/             ← Módulos por funcionalidad
│   │   ├── usuarios/
│   │   │   ├── usuarios.router.js
│   │   │   ├── usuarios.controller.js
│   │   │   ├── usuarios.service.js
│   │   │   ├── usuarios.repository.js
│   │   │   └── usuarios.schema.js
│   │   └── productos/
│   │       ├── productos.router.js
│   │       ├── productos.controller.js
│   │       ├── productos.service.js
│   │       └── productos.schema.js
│   ├── middleware/
│   │   ├── auth.middleware.js
│   │   ├── error.middleware.js
│   │   └── validate.middleware.js
│   ├── lib/
│   │   ├── database.js       ← Conexión DB
│   │   ├── logger.js         ← Sistema de logs
│   │   └── errors.js         ← Clases de error personalizadas
│   └── utils/
│       └── helpers.js
├── tests/
│   ├── unit/
│   └── integration/
├── .env
├── .env.example
├── .gitignore
└── package.json

Separación de responsabilidades (capas)

Petición HTTP
     ↓
  Router      ← Define rutas y métodos HTTP
     ↓
Controller    ← Maneja req/res, llama al servicio
     ↓
  Service     ← Lógica de negocio pura
     ↓
Repository    ← Acceso a datos (DB, APIs externas)
     ↓
  Base de Datos

Ejemplo: Módulo de Usuarios completo

src/features/usuarios/usuarios.repository.js

// Solo habla con la base de datos
import { prisma } from '../../lib/database.js';

export class UsuariosRepository {
  async findById(id) {
    return prisma.usuario.findUnique({ where: { id } });
  }

  async findByEmail(email) {
    return prisma.usuario.findUnique({ where: { email } });
  }

  async create(datos) {
    return prisma.usuario.create({ data: datos });
  }

  async update(id, datos) {
    return prisma.usuario.update({ where: { id }, data: datos });
  }

  async delete(id) {
    return prisma.usuario.delete({ where: { id } });
  }
}

src/features/usuarios/usuarios.service.js

// Solo lógica de negocio, sin saber nada de HTTP
import bcrypt from 'bcrypt';
import { UsuariosRepository } from './usuarios.repository.js';
import { NotFoundError, ConflictError } from '../../lib/errors.js';

const repo = new UsuariosRepository();

export class UsuariosService {
  async obtenerPorId(id) {
    const usuario = await repo.findById(id);
    if (!usuario) throw new NotFoundError(`Usuario ${id} no encontrado`);
    return usuario;
  }

  async crear(datos) {
    const existe = await repo.findByEmail(datos.email);
    if (existe) throw new ConflictError('El email ya está registrado');

    const hash = await bcrypt.hash(datos.password, 12);
    
    const { password, ...usuario } = await repo.create({
      ...datos,
      password: hash,
    });

    return usuario; // Nunca devuelves el password
  }
}

src/features/usuarios/usuarios.controller.js

// Solo maneja req/res, delega al servicio
import { UsuariosService } from './usuarios.service.js';

const service = new UsuariosService();

export async function obtenerUsuario(req, res, next) {
  try {
    const usuario = await service.obtenerPorId(req.params.id);
    res.json({ data: usuario });
  } catch (error) {
    next(error); // Delega al middleware de errores
  }
}

export async function crearUsuario(req, res, next) {
  try {
    const usuario = await service.crear(req.body);
    res.status(201).json({ data: usuario });
  } catch (error) {
    next(error);
  }
}

src/features/usuarios/usuarios.router.js

import { Router } from 'express';
import { validate } from '../../middleware/validate.middleware.js';
import { authMiddleware } from '../../middleware/auth.middleware.js';
import { crearUsuarioSchema } from './usuarios.schema.js';
import { obtenerUsuario, crearUsuario } from './usuarios.controller.js';

export const usuariosRouter = Router();

usuariosRouter.get('/:id', authMiddleware, obtenerUsuario);
usuariosRouter.post('/', validate(crearUsuarioSchema), crearUsuario);

5. APIs REST con Express y mejores prácticas

Instalación

npm install express
npm install -D @types/express  # Solo si usas TypeScript

src/app.js — Configuración central de Express

import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import rateLimit from 'express-rate-limit';
import { usuariosRouter } from './features/usuarios/usuarios.router.js';
import { productosRouter } from './features/productos/productos.router.js';
import { errorMiddleware } from './middleware/error.middleware.js';
import { logger } from './lib/logger.js';

export function crearApp() {
  const app = express();

  // Seguridad
  app.use(helmet());
  app.use(cors({ origin: process.env.CORS_ORIGIN || '*' }));

  // Rate limiting
  const limiter = rateLimit({
    windowMs: 15 * 60 * 1000, // 15 minutos
    max: 100,
    message: { error: 'Demasiadas peticiones, intenta más tarde' },
  });
  app.use('/api', limiter);

  // Body parsing
  app.use(express.json({ limit: '10kb' })); // Limitar tamaño para evitar ataques

  // Request logging
  app.use((req, _res, next) => {
    logger.info({ method: req.method, url: req.url, ip: req.ip });
    next();
  });

  // Rutas
  app.use('/api/v1/usuarios', usuariosRouter);
  app.use('/api/v1/productos', productosRouter);

  // Health check
  app.get('/health', (_req, res) => {
    res.json({ status: 'ok', timestamp: new Date().toISOString() });
  });

  // 404
  app.use((_req, res) => {
    res.status(404).json({ error: 'Ruta no encontrada' });
  });

  // Error handler (siempre al final)
  app.use(errorMiddleware);

  return app;
}

src/index.js — Punto de entrada

import { crearApp } from './app.js';
import { conectarDB } from './lib/database.js';
import { config } from './config/index.js';
import { logger } from './lib/logger.js';

async function iniciar() {
  await conectarDB();

  const app = crearApp();

  const servidor = app.listen(config.puerto, () => {
    logger.info(`🚀 Servidor corriendo en http://localhost:${config.puerto}`);
  });

  // Cierre graceful: terminar conexiones activas antes de apagar
  const cerrarGracefully = async (señal) => {
    logger.info(`Recibida señal ${señal}, cerrando servidor...`);
    servidor.close(async () => {
      await prisma.$disconnect();
      logger.info('Servidor cerrado correctamente');
      process.exit(0);
    });
  };

  process.on('SIGTERM', cerrarGracefully);
  process.on('SIGINT', cerrarGracefully);
}

iniciar().catch((error) => {
  console.error('Error al iniciar:', error);
  process.exit(1);
});

6. Validación, Errores y Seguridad

Errores personalizados

src/lib/errors.js

export class AppError extends Error {
  constructor(message, statusCode = 500, code = 'INTERNAL_ERROR') {
    super(message);
    this.statusCode = statusCode;
    this.code = code;
    this.isOperational = true; // Errores que conocemos vs bugs reales
    Error.captureStackTrace(this, this.constructor);
  }
}

export class NotFoundError extends AppError {
  constructor(message = 'Recurso no encontrado') {
    super(message, 404, 'NOT_FOUND');
  }
}

export class ValidationError extends AppError {
  constructor(message, details) {
    super(message, 400, 'VALIDATION_ERROR');
    this.details = details;
  }
}

export class ConflictError extends AppError {
  constructor(message) {
    super(message, 409, 'CONFLICT');
  }
}

export class UnauthorizedError extends AppError {
  constructor(message = 'No autorizado') {
    super(message, 401, 'UNAUTHORIZED');
  }
}

Middleware de errores centralizado

src/middleware/error.middleware.js

import { AppError } from '../lib/errors.js';
import { logger } from '../lib/logger.js';

export function errorMiddleware(error, req, res, next) {
  // Error operacional conocido
  if (error instanceof AppError) {
    logger.warn({
      error: error.message,
      code: error.code,
      url: req.url,
      method: req.method,
    });

    return res.status(error.statusCode).json({
      error: error.message,
      code: error.code,
      ...(error.details && { details: error.details }),
    });
  }

  // Error inesperado (bug real)
  logger.error({ error: error.message, stack: error.stack });

  res.status(500).json({
    error: 'Error interno del servidor',
    code: 'INTERNAL_ERROR',
  });
}

Validación con Zod (la mejor librería de validación)

npm install zod

src/features/usuarios/usuarios.schema.js

import { z } from 'zod';

export const crearUsuarioSchema = z.object({
  nombre: z.string().min(2).max(100).trim(),
  email: z.string().email().toLowerCase(),
  password: z
    .string()
    .min(8)
    .regex(/[A-Z]/, 'Debe contener al menos una mayúscula')
    .regex(/[0-9]/, 'Debe contener al menos un número'),
  rol: z.enum(['admin', 'usuario']).default('usuario'),
});

export const actualizarUsuarioSchema = crearUsuarioSchema.partial().omit({
  password: true,
});

src/middleware/validate.middleware.js

import { ZodError } from 'zod';
import { ValidationError } from '../lib/errors.js';

export function validate(schema) {
  return (req, _res, next) => {
    const resultado = schema.safeParse(req.body);

    if (!resultado.success) {
      const detalles = resultado.error.issues.map((issue) => ({
        campo: issue.path.join('.'),
        mensaje: issue.message,
      }));

      return next(new ValidationError('Datos inválidos', detalles));
    }

    req.body = resultado.data; // Datos limpios y transformados
    next();
  };
}

7. Base de Datos con ORM moderno (Prisma)

npm install prisma @prisma/client
npx prisma init

prisma/schema.prisma

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model Usuario {
  id        String    @id @default(cuid())
  nombre    String
  email     String    @unique
  password  String
  rol       Rol       @default(USUARIO)
  creadoEn  DateTime  @default(now()) @map("creado_en")
  pedidos   Pedido[]

  @@map("usuarios")
}

model Pedido {
  id         String   @id @default(cuid())
  total      Decimal  @db.Decimal(10, 2)
  estado     Estado   @default(PENDIENTE)
  usuario    Usuario  @relation(fields: [usuarioId], references: [id])
  usuarioId  String   @map("usuario_id")
  creadoEn   DateTime @default(now()) @map("creado_en")

  @@map("pedidos")
}

enum Rol {
  ADMIN
  USUARIO
}

enum Estado {
  PENDIENTE
  PROCESANDO
  COMPLETADO
  CANCELADO
}

src/lib/database.js

import { PrismaClient } from '@prisma/client';
import { logger } from './logger.js';

// Singleton: una sola instancia en toda la app
const globalForPrisma = globalThis;

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    log: [
      { emit: 'event', level: 'query' },
      { emit: 'event', level: 'error' },
    ],
  });

prisma.$on('error', (e) => logger.error({ msg: 'DB Error', error: e.message }));

if (process.env.NODE_ENV !== 'production') {
  globalForPrisma.prisma = prisma;
}

export async function conectarDB() {
  await prisma.$connect();
  logger.info('✅ Conectado a la base de datos');
}

Migraciones

# Crear una migración
npx prisma migrate dev --name agregar-campo-telefono

# Aplicar en producción
npx prisma migrate deploy

# Ver el estado
npx prisma migrate status

8. Testing Profesional

Node.js 22 incluye un test runner nativo. No necesitas Jest para la mayoría de los casos.

# El test runner nativo
node --test

# Con cobertura
node --test --experimental-test-coverage

Tests unitarios

tests/unit/usuarios.service.test.js

import { describe, it, before, after, mock } from 'node:test';
import assert from 'node:assert/strict';

// Mock del repositorio para no tocar la DB
const mockRepo = {
  findByEmail: mock.fn(),
  create: mock.fn(),
};

// Inyección de dependencia para tests
describe('UsuariosService', () => {
  describe('crear()', () => {
    it('debe crear un usuario exitosamente', async () => {
      mockRepo.findByEmail.mock.mockImplementationOnce(() => null);
      mockRepo.create.mock.mockImplementationOnce((datos) => ({
        id: '123',
        ...datos,
      }));

      const service = new UsuariosServiceTestable(mockRepo);
      const usuario = await service.crear({
        nombre: 'Ana García',
        email: 'ana@example.com',
        password: 'Segura123',
      });

      assert.equal(usuario.email, 'ana@example.com');
      assert.equal(usuario.password, undefined); // No devuelve el password
    });

    it('debe lanzar ConflictError si el email ya existe', async () => {
      mockRepo.findByEmail.mock.mockImplementationOnce(() => ({ id: '456' }));

      const service = new UsuariosServiceTestable(mockRepo);

      await assert.rejects(
        () => service.crear({ email: 'duplicado@example.com', password: 'Test123' }),
        { name: 'ConflictError' }
      );
    });
  });
});

Tests de integración con supertest

npm install -D supertest

tests/integration/usuarios.test.js

import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import { crearApp } from '../../src/app.js';
import { prisma } from '../../src/lib/database.js';

const app = crearApp();

describe('POST /api/v1/usuarios', () => {
  after(async () => {
    await prisma.usuario.deleteMany({ where: { email: { contains: 'test' } } });
    await prisma.$disconnect();
  });

  it('debe crear un usuario y devolver 201', async () => {
    const res = await request(app)
      .post('/api/v1/usuarios')
      .send({
        nombre: 'Test User',
        email: 'test@example.com',
        password: 'Segura123',
      });

    assert.equal(res.status, 201);
    assert.ok(res.body.data.id);
    assert.equal(res.body.data.email, 'test@example.com');
    assert.equal(res.body.data.password, undefined);
  });

  it('debe devolver 400 con datos inválidos', async () => {
    const res = await request(app)
      .post('/api/v1/usuarios')
      .send({ email: 'no-es-email' });

    assert.equal(res.status, 400);
    assert.equal(res.body.code, 'VALIDATION_ERROR');
  });
});

9. Variables de Entorno y Configuración

.env

NODE_ENV=development
PORT=3000
DATABASE_URL=postgresql://user:pass@localhost:5432/midb
JWT_SECRET=un-secreto-muy-largo-y-seguro-de-al-menos-32-chars
CORS_ORIGIN=http://localhost:5173

.env.example (commitear esto, nunca el .env)

NODE_ENV=development
PORT=3000
DATABASE_URL=postgresql://USER:PASS@HOST:PORT/DBNAME
JWT_SECRET=CHANGE_ME
CORS_ORIGIN=http://localhost:5173

src/config/index.js — Validar variables de entorno al iniciar

import { z } from 'zod';

const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
  PORT: z.coerce.number().default(3000),
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32, 'JWT_SECRET debe tener al menos 32 caracteres'),
  CORS_ORIGIN: z.string().default('*'),
});

const resultado = envSchema.safeParse(process.env);

if (!resultado.success) {
  console.error('❌ Variables de entorno inválidas:');
  console.error(resultado.error.format());
  process.exit(1); // Fallar rápido y fuerte
}

export const config = Object.freeze(resultado.data);

Por qué esto importa: Si falta una variable de entorno crítica, tu app fallará al arrancar con un mensaje claro, en lugar de fallar silenciosamente horas después en producción.


10. Logging y Observabilidad

Pino: el logger más rápido para Node.js

npm install pino pino-pretty

src/lib/logger.js

import pino from 'pino';
import { config } from '../config/index.js';

export const logger = pino({
  level: config.NODE_ENV === 'production' ? 'info' : 'debug',
  
  // En desarrollo: logs bonitos y legibles
  // En producción: JSON estructurado para herramientas como Datadog/Loki
  transport: config.NODE_ENV !== 'production'
    ? { target: 'pino-pretty', options: { colorize: true } }
    : undefined,

  // Campos base en todos los logs
  base: {
    env: config.NODE_ENV,
    pid: process.pid,
  },

  // Ocultar datos sensibles
  redact: ['req.headers.authorization', 'body.password', 'body.token'],
});

Uso

import { logger } from './lib/logger.js';

// ✅ Log estructurado (searchable en producción)
logger.info({ userId: '123', action: 'crear_pedido' }, 'Pedido creado');

// ✅ Error con contexto
logger.error({ error: err.message, stack: err.stack }, 'Fallo al procesar pago');

// ❌ Nunca en producción
console.log('algo pasó');

11. Performance y Buenas Prácticas Avanzadas

Worker Threads: CPU-intensive tasks

import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';
import { fileURLToPath } from 'node:url';

// Si estamos en el worker
if (!isMainThread) {
  const resultado = calcularPrimos(workerData.limite);
  parentPort.postMessage(resultado);
}

// En el hilo principal
export function calcularEnWorker(limite) {
  return new Promise((resolve, reject) => {
    const worker = new Worker(fileURLToPath(import.meta.url), {
      workerData: { limite },
    });
    worker.on('message', resolve);
    worker.on('error', reject);
  });
}

function calcularPrimos(limite) {
  // Operación pesada de CPU que no bloquea el event loop
  const primos = [];
  for (let i = 2; i <= limite; i++) {
    if (esPrimo(i)) primos.push(i);
  }
  return primos;
}

Caching con Map (en memoria, simple y efectivo)

export class Cache {
  #store = new Map();
  #ttls = new Map();

  set(key, value, ttlMs = 60_000) {
    this.#store.set(key, value);
    const timeout = setTimeout(() => this.delete(key), ttlMs);
    timeout.unref(); // No impide que Node.js cierre
    this.#ttls.set(key, timeout);
  }

  get(key) {
    return this.#store.get(key);
  }

  delete(key) {
    clearTimeout(this.#ttls.get(key));
    this.#ttls.delete(key);
    this.#store.delete(key);
  }

  has(key) {
    return this.#store.has(key);
  }
}

// Uso en el service
const cache = new Cache();

async function obtenerProductos() {
  const KEY = 'productos:todos';

  if (cache.has(KEY)) {
    return cache.get(KEY);
  }

  const productos = await repo.findAll();
  cache.set(KEY, productos, 5 * 60_000); // 5 minutos
  return productos;
}

Private class fields (ES2022+)

// ✅ Usa # para encapsular estado interno
class ServicioAutenticacion {
  #secreto;
  #intentosFallidos = new Map();

  constructor(secreto) {
    this.#secreto = secreto; // No accesible desde fuera
  }

  async verificarToken(token) {
    // ...
  }
}

Evitar errores no capturados

// En src/index.js — siempre añadir estos handlers
process.on('uncaughtException', (error) => {
  logger.fatal({ error: error.message, stack: error.stack }, 'Error no capturado');
  process.exit(1); // Siempre salir — el proceso está en estado incierto
});

process.on('unhandledRejection', (reason) => {
  logger.fatal({ reason }, 'Promise rechazada sin manejar');
  process.exit(1);
});

12. Contenedores y Deployment

Dockerfile optimizado para Node.js

# Etapa 1: Dependencias
FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force

# Etapa 2: Build (si usas TypeScript o similar)
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build 2>/dev/null || true  # No falla si no hay build

# Etapa 3: Imagen final (mínima y segura)
FROM node:22-alpine AS runner
WORKDIR /app

# Usuario no-root para seguridad
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

COPY --from=deps --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:appgroup /app/src ./src
COPY --chown=appuser:appgroup package.json ./

ENV NODE_ENV=production
EXPOSE 3000

# Usa dumb-init para manejar señales correctamente
ENTRYPOINT ["node"]
CMD ["src/index.js"]

docker-compose.yml para desarrollo local

version: '3.9'

services:
  app:
    build: .
    ports:
      - '3000:3000'
    environment:
      - NODE_ENV=development
      - DATABASE_URL=postgresql://postgres:postgres@db:5432/miapp
    volumes:
      - ./src:/app/src  # Hot reload en desarrollo
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: miapp
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U postgres']
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  postgres_data:

.gitignore esencial

node_modules/
.env
.env.local
dist/
build/
*.log
coverage/
.DS_Store

Checklist Final: ¿Tu API está lista para producción?

  • Variables de entorno validadas al arrancar
  • Manejo centralizado de errores
  • Logging estructurado (sin console.log)
  • Rate limiting habilitado
  • Helmet.js para headers de seguridad
  • Validación de inputs con Zod
  • Passwords hasheados con bcrypt (rounds >= 12)
  • JWT con expiración corta
  • Tests unitarios e integración
  • Health check endpoint
  • Cierre graceful del servidor
  • Handlers para uncaughtException y unhandledRejection
  • Dockerfile multi-stage con usuario no-root
  • .env.example commiteado, .env en .gitignore

Recursos para Seguir Aprendiendo


Tutorial actualizado para Node.js 22 LTS · 2025