Node.js: De Cero a Profesional
Tutorial
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 aletcuando el linter te avise que necesitas reasignar. Nunca usesvar.
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 --watches el hot-reload nativo de Node (no necesitasnodemon).
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 prefijonode:) en lugar defs/promises. El prefijonode: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
uncaughtExceptionyunhandledRejection - Dockerfile multi-stage con usuario no-root
-
.env.examplecommiteado,.enven.gitignore
Recursos para Seguir Aprendiendo
- 📖 Documentación oficial Node.js
- 📖 Prisma Docs
- 📖 Zod Documentation
- 📖 Node.js Best Practices (GitHub)
- 🎥 Node.js 22 What's New
Tutorial actualizado para Node.js 22 LTS · 2025