Lección 1 de 312 min
ORM, ODM y query builders
Del SQL a mano a los objetos: Active Record, Data Mapper, migraciones y el problema N+1.
Tu backend necesita leer y guardar datos en PostgreSQL. La base de datos habla SQL y piensa en tablas y filas; tu código habla JavaScript y piensa en objetos. Alguien tiene que traducir entre ambos mundos: escribir la consulta, pasarle los valores sin abrir agujeros de seguridad y convertir cada fila en un objeto con el que trabajar.
Hay tres formas de hacerlo, de más a menos control: escribir SQL a mano con parámetros, usar un query builder que construye el SQL con funciones, o usar un ORM que te deja trabajar con clases y objetos. En esta lección verás qué ofrece cada una, los dos grandes patrones de diseño de los ORM, las migraciones y el problema de rendimiento más típico: el N+1.
Tres formas de hablar con la base de datos
SQL a mano con parámetros
Escribir tú la consulta y pasar los valores aparte, nunca pegados al texto.
- Marcadores:
- $1 (PostgreSQL), ? (MySQL)
La opción más directa es usar el driver de la base de datos (como pg para PostgreSQL o mysql2 para MySQL) y escribir el SQL tú mismo. Tienes control total: puedes usar cualquier función de tu base de datos y sabes exactamente qué consulta se ejecuta.
La regla de oro es usar consultas parametrizadas: en el texto escribes un marcador ($1 en PostgreSQL, ? en MySQL) y pasas los valores en una lista aparte. El driver los envía separados del SQL, así que un valor nunca puede convertirse en código. Si en cambio juntas textos, un usuario malicioso puede colar SQL propio: es la inyección SQL.
Úsalo cuando
Para consultas complejas o muy optimizadas (informes, agregaciones, funciones específicas de tu base de datos), y en proyectos pequeños donde un ORM sería demasiado.
Ten en cuenta
Tú conviertes las filas en objetos y no tienes ayuda de tipos ni autocompletado. Y basta una sola consulta construida concatenando texto para abrir una vulnerabilidad: lo verás a fondo en el módulo de seguridad.
import pg from "pg";
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
// Mal: concatenar abre la puerta a la inyección SQL
// pool.query(`SELECT * FROM usuarios WHERE email = '${email}'`);
// Bien: el valor viaja aparte, como parámetro
const { rows } = await pool.query(
"SELECT id, nombre FROM usuarios WHERE email = $1",
[email],
);
console.log(rows[0]?.nombre);Query builders
Construir el SQL encadenando funciones, con los parámetros puestos por ti.
- Ejemplos:
- Knex, Kysely
Un query builder te deja escribir la consulta con funciones de JavaScript: .select(), .where(), .orderBy(). Por debajo genera el SQL y siempre usa parámetros, así que la inyección deja de ser un riesgo en el uso normal. Como cada función devuelve el constructor, es fácil añadir condiciones según lo que pida el usuario.
Sigue muy cerca del SQL: piensas en tablas y columnas, no en objetos, y obtienes filas como objetos planos. Además adapta la sintaxis a cada base de datos (comillas, LIMIT, tipos). Los más conocidos son Knex, que verás más adelante en este módulo, y Kysely, con tipos de TypeScript muy precisos.
Úsalo cuando
Cuando quieres el control del SQL sin escribirlo a mano, sobre todo si los filtros cambian según la petición, como en un buscador con varios criterios opcionales.
Ten en cuenta
No te libra de saber SQL: si no entiendes qué JOIN está generando, tampoco entenderás por qué es lento. Muchos ofrecen .toSQL() o .toString() para ver la consulta resultante.
let consulta = knex("recetas")
.select("id", "nombre", "minutos")
.where("minutos", "<", 30)
.orderBy("nombre")
.limit(10);
// Condiciones opcionales según los filtros del usuario
if (soloVeganas) consulta = consulta.andWhere({ vegana: true });
const recetas = await consulta;
// SQL generado (con parámetros):
// select "id", "nombre", "minutos" from "recetas"
// where "minutos" < ? and "vegana" = ? order by "nombre" asc limit ?ORM
Trabajar con clases y objetos; el ORM traduce a tablas, filas y SQL.
- Sigla:
- Object-Relational Mapping
- Ejemplos:
- Prisma, TypeORM, Sequelize
Un ORM (Object-Relational Mapping, «mapeo objeto-relacional») hace corresponder cada clase con una tabla y cada objeto con una fila. Cargas un Autor, cambias su nombre y llamas a save(): el ORM genera el UPDATE. También gestiona las relaciones: autor.libros te da los libros de ese autor sin escribir el JOIN.
Resuelve el llamado desajuste objeto-relacional: los objetos tienen referencias entre sí, listas y herencia; las tablas tienen filas planas unidas por claves foráneas. Los más usados en el ecosistema de Node son Prisma, TypeORM, Sequelize y Drizzle; TypeORM y Sequelize tienen su propia lección en este módulo.
Úsalo cuando
En aplicaciones con muchas operaciones de crear, leer, actualizar y borrar sobre entidades relacionadas, donde quieres tipos, validación y relaciones sin repetir SQL.
Ten en cuenta
La comodidad tiene un precio: es fácil generar consultas ineficientes sin darte cuenta, como el problema N+1 que verás más abajo. Activa el registro de consultas en desarrollo para ver qué SQL produce el ORM.
// El ORM traduce objetos a SQL y filas a objetos
const autor = await autores.findOne({
where: { id: 7 },
relations: { libros: true },
});
console.log(autor.libros.length); // sin escribir el JOIN
autor.nombre = "Ana María";
await autores.save(autor);
// UPDATE "autores" SET "nombre" = $1 WHERE "id" = $2ODM: Mongoose
La misma idea que un ORM, pero para bases de datos de documentos como MongoDB.
- Sigla:
- Object-Document Mapping
- Base de datos:
- MongoDB
Un ODM (Object-Document Mapping) hace corresponder objetos con documentos en lugar de filas. El más usado es Mongoose, para MongoDB: defines un esquema con los campos, sus tipos y sus validaciones, y a partir de él un modelo con métodos como create, find o updateOne.
MongoDB no exige un esquema, así que cada documento podría tener una forma distinta. Mongoose añade esa estructura en tu aplicación: valida antes de guardar, pone valores por defecto y, con populate, carga los documentos relacionados a partir de su identificador.
Úsalo cuando
Cuando tu base de datos es MongoDB y quieres validación y estructura en los documentos, que es lo habitual en una aplicación.
Ten en cuenta
El esquema solo existe en tu código: si otra aplicación escribe en la misma colección sin Mongoose, nada impide que guarde datos con otra forma. Y cada populate lanza una consulta adicional: con muchos niveles anidados, o si lo llamas documento a documento dentro de un bucle, se nota.
import mongoose from "mongoose";
const recetaSchema = new mongoose.Schema({
nombre: { type: String, required: true },
minutos: { type: Number, min: 1 },
ingredientes: [String],
autor: { type: mongoose.Schema.Types.ObjectId, ref: "Autor" },
});
const Receta = mongoose.model("Receta", recetaSchema);
await Receta.create({ nombre: "Gazpacho", minutos: 15, ingredientes: ["tomate"] });
// populate trae el autor de cada receta a partir de su ObjectId
const rapidas = await Receta.find({ minutos: { $lt: 30 } }).populate("autor");Patrones y migraciones
Los ORM siguen sobre todo uno de dos patrones de diseño, con nombres que verás en cualquier documentación. Y todos, igual que los query builders, ofrecen migraciones para cambiar el esquema de forma controlada.
Active Record
Cada objeto sabe guardarse, borrarse y buscarse a sí mismo.
- Lo usan:
- Rails, Eloquent, Sequelize
En el patrón Active Record, la clase del modelo representa una tabla y además contiene los métodos para acceder a ella: Receta.findAll(), receta.save(), receta.destroy(). Los datos y la forma de guardarlos viven juntos en el mismo objeto.
Es el patrón de Ruby on Rails, de Laravel con Eloquent y de Sequelize. Es muy rápido de aprender y de escribir: para un CRUD sencillo, apenas necesitas código.
Úsalo cuando
En aplicaciones donde cada modelo se parece mucho a su tabla y la lógica de negocio es sencilla.
Ten en cuenta
Como cualquier objeto puede guardarse a sí mismo, el acceso a la base de datos acaba repartido por todo el código. Eso complica los tests (necesitas una base de datos para casi todo) y la separación de responsabilidades en aplicaciones grandes.
// El modelo hereda los métodos para hablar con la base de datos
const receta = new Receta();
receta.nombre = "Gazpacho";
receta.minutos = 15;
await receta.save(); // INSERT
const rapidas = await Receta.findAll({ where: { minutos: 15 } });
await rapidas[0].destroy(); // DELETEData Mapper
Los objetos son solo datos; un repositorio aparte se encarga de guardarlos.
- Lo usan:
- Doctrine, Hibernate, TypeORM
En el patrón Data Mapper, las entidades son objetos sencillos que no saben nada de la base de datos. Un objeto aparte, el mapper o repositorio, se encarga de cargarlos y guardarlos: recetas.save(receta) en lugar de receta.save().
Así la lógica de negocio queda separada de la persistencia: puedes probar una entidad sin base de datos y cambiar cómo se guarda sin tocarla. Es el patrón de Doctrine en PHP, de Hibernate en Java y de los repositorios de TypeORM, que también admite Active Record.
Úsalo cuando
En aplicaciones con lógica de negocio compleja o arquitecturas por capas, como las que verás en el módulo de arquitectura.
Ten en cuenta
Requiere más código y más conceptos (entidades, repositorios, a veces una «unidad de trabajo»). Para un CRUD pequeño, puede ser más ceremonia de la necesaria.
// La entidad es solo datos
const receta = new Receta();
receta.nombre = "Gazpacho";
receta.minutos = 15;
// El repositorio sabe cómo guardarla
const recetas = dataSource.getRepository(Receta);
await recetas.save(receta); // INSERT
const rapidas = await recetas.findBy({ minutos: 15 });
await recetas.remove(rapidas[0]); // DELETEMigraciones
Los cambios del esquema como archivos versionados que se aplican en orden.
- Se guardan en:
- Git, con el código
Una migración es un archivo que describe un cambio en el esquema: crear una tabla, añadir una columna, crear un índice. Cada una suele tener una función para aplicarlo (up) y otra para deshacerlo (down). Se guardan en Git junto al código y la herramienta las aplica en orden.
La herramienta anota en una tabla especial qué migraciones ya se aplicaron en cada base de datos. Así, al desplegar, solo ejecuta las pendientes, y tu equipo, el CI y producción tienen siempre el mismo esquema.
Úsalo cuando
En cualquier proyecto con base de datos relacional que vaya a producción. Es la única forma segura de cambiar el esquema cuando ya hay datos reales.
Ten en cuenta
No edites una migración que ya se aplicó en otro entorno: crea una nueva. Y cuidado con las que borran o renombran columnas, porque pueden perder datos o romper la versión anterior del código que aún se está ejecutando durante el despliegue.
001_crear_recetas.sql (aplicada)
002_crear_autores.sql (aplicada)
003_minutos_en_recetas.sql (pendiente)
-- 003_minutos_en_recetas.sql
-- up
ALTER TABLE recetas ADD COLUMN minutos INTEGER;
CREATE INDEX recetas_minutos_idx ON recetas (minutos);
-- down
DROP INDEX recetas_minutos_idx;
ALTER TABLE recetas DROP COLUMN minutos;El problema N+1
N+1: carga perezosa y anticipada
Una consulta para la lista y otra por cada elemento: el fallo de rendimiento más típico.
- Consultas (perezosa):
- 1 + N
- Consultas (por lotes):
- 2
Quieres mostrar 10 autores con sus libros. Con carga perezosa (lazy loading), el ORM pide los autores en una consulta y, cada vez que tocas autor.libros, lanza otra para ese autor. Total: 1 + 10 consultas, el problema N+1. Con 10 no se nota; con 500 y la base de datos en otra máquina, cada viaje suma milisegundos y la página tarda segundos.
La solución es la carga anticipada (eager loading): decirle al ORM de antemano qué relaciones vas a necesitar. Puede traerlo todo en una consulta con JOIN, o en dos: los autores y luego todos sus libros a la vez con WHERE autor_id IN (...). El código apenas cambia (include en Sequelize, relations en TypeORM), pero el número de consultas deja de crecer con los datos.
Úsalo cuando
Cada vez que recorres una lista y accedes a una relación de cada elemento. En GraphQL es tan común que existe una herramienta específica, DataLoader, que agrupa esas cargas en lotes.
Ten en cuenta
Cargar con JOIN tampoco es gratis: con varias relaciones de uno a muchos a la vez, las filas se multiplican y se repiten datos. Y no cargues relaciones «por si acaso»: pide solo las que vas a usar.
// Perezosa: 1 consulta + 1 por cada autor
const autores = await Autor.findAll({ limit: 10 });
for (const autor of autores) {
const libros = await autor.getLibros(); // SELECT … WHERE autor_id = ?
}
// Anticipada: los libros llegan con los autores
const conLibros = await Autor.findAll({
limit: 10,
include: { model: Libro, separate: true }, // … WHERE autor_id IN (…)
});Pruébalo
En el simulador, deja la carga perezosa con 50 autores y sube la latencia a 50 ms, como si la base de datos estuviera en otra región. Después cambia a «Por lotes con IN» y compara.
Interactivo
Mostrar autores con sus libros
Elige cómo carga el ORM la relación y cuántos autores mostrar, y cuenta las consultas que llegan a la base de datos.
const autores = await Autor.findAll({ limit: n });
for (const autor of autores) {
autor.libros = await autor.getLibros(); // una consulta por autor
}SQL que llega a la base de datos
1SELECT id, nombre FROM autores ORDER BY id LIMIT 10;
2SELECT id, titulo FROM libros WHERE autor_id = 1;
3SELECT id, titulo FROM libros WHERE autor_id = 2;
… 7 consultas más, iguales salvo el autor_id …
11SELECT id, titulo FROM libros WHERE autor_id = 10;
Las tres estrategias, con 10 autores
- Carga perezosa11 · 55 ms
- Anticipada con JOIN1 · 5 ms
- Por lotes con IN2 · 10 ms
11 consultas × 5 ms ≈ 55 ms esperando a la red · 40 filas recibidas
10 autores, 11 consultas: 1 para los autores y 10 más, una por cada autor. Es el problema N+1: el número de viajes a la base de datos crece con los datos.
La estimación solo cuenta la latencia de ida y vuelta de cada consulta, no lo que tarda la base de datos en ejecutarla.
Interactivo
SQL a mano, query builder u ORM
Activa o desactiva opciones para compararlas cara a cara.
| Característica | SQL con parámetros | Query builder | ORM |
|---|---|---|---|
| Qué escribes | SQL, en texto | Funciones encadenadas que generan SQL | Clases, objetos y métodos |
| Control sobre el SQL | Total | Alto | Menor; con escape a SQL |
| Protección frente a inyección | Solo si usas siempre parámetros | Parámetros automáticos | Parámetros automáticos |
| Qué obtienes | Filas como objetos planos | Filas como objetos planos | Instancias de tus clases, con relaciones |
| Curva de aprendizaje | La de SQL | SQL y la API del builder | La más alta: el ORM tiene sus propias reglas |
| Ejemplos | pg, mysql2 | Knex, Kysely | Prisma, TypeORM, Sequelize |
Ponlo en práctica
Saber qué es cada cosa es la mitad del trabajo. La otra mitad es reconocer cuándo usarla.
Interactivo
¿Qué usarías?
Lee cada situación y elige la opción que mejor la resuelve. Fallar aquí no cuenta: es para practicar.
La página de un blog tarda tres segundos. Los registros muestran 201 consultas: una para los 200 artículos y una más por cada artículo para cargar su autor.
Para recordar
- SQL a mano da control total, pero siempre con consultas parametrizadas: nunca concatenes valores en el SQL.
- Un query builder construye el SQL con funciones y parámetros; un ORM te deja trabajar con objetos y relaciones; un ODM como Mongoose hace lo mismo con documentos.
- Active Record junta datos y persistencia en el mismo objeto; Data Mapper los separa con repositorios.
- Las migraciones son la forma segura de cambiar el esquema: archivos versionados que se aplican una vez y en orden.
- El problema N+1 aparece al cargar una relación elemento a elemento; la carga anticipada lo reduce a una o dos consultas.
Comprueba lo aprendido
Cuestionario
Responde las 5 preguntas para completar la lección.
1.¿Por qué una consulta parametrizada protege frente a la inyección SQL?
2.Muestras 100 pedidos y, para cada uno, accedes a
pedido.clientecon carga perezosa. ¿Cuántas consultas se lanzan?3.¿Qué diferencia hay entre Active Record y Data Mapper?
4.Una migración ya se aplicó en producción y descubres que le falta un índice. ¿Qué haces?
5.¿Qué hace
populate("autor")en una consulta de Mongoose?