Persistencia: ORMs, N+1 y transacciones
Persistencia: ORMs, N+1 y transacciones
La capa de datos es donde la mayoría de los backends NestJS se caen en producción. No por sintaxis del framework, sino por decisiones de persistencia: un ORM elegido por moda, un findMany que dispara doscientas queries sin que nadie lo note, una transacción que se olvidó de envolver dos escrituras que debían ser atómicas, o un dominio tan pegado a las entidades del ORM que cambiar de motor es reescribir la mitad del código.
Este capítulo ataca cuatro problemas de calidad concretos: elegir la herramienta correcta con criterio técnico, detectar y eliminar el N+1, garantizar atomicidad con transacciones bien propagadas, y desacoplar el dominio del ORM con el patrón repositorio. Todo con conteo de queries y complejidad medibles, no con recomendaciones vagas.
Este capítulo asume que ya leíste SOLID en NestJS (para el DIP que sostiene el patrón repositorio) y se apoya en el ciclo de vida del request para las transacciones por request.
Panorama de ORMs y query builders
En Nest 11 conviven cuatro opciones serias. No hay una “correcta”; hay trade-offs que dependen de tu equipo y tu carga.
Prisma
Un cliente generado a partir de un schema declarativo (schema.prisma). No es un ORM clásico: no hay clases de entidad con métodos, hay un cliente tipado que devuelve objetos planos. El type-safety es el más fuerte del ecosistema porque los tipos se generan desde tu schema real, no se infieren de decoradores.
// schema.prisma declara el modelo; Prisma genera el cliente tipado
const users = await prisma.user.findMany({
where: { active: true },
include: { posts: true },
});
// users: (User & { posts: Post[] })[] ← tipo exacto, sin any
- A favor: DX excelente, migraciones declarativas (
prisma migrate), tipos exactos,relationLoadStrategy: "join"para resolver relaciones en una sola query SQL. - En contra: menos control sobre el SQL fino, el cliente es un singleton pesado, y el modelo de “objetos planos” no encaja con quien quiere entidades ricas de dominio.
TypeORM
El ORM histórico de Nest, con integración de primera clase (@nestjs/typeorm). Soporta Data Mapper (repositorios) y Active Record (métodos en la entidad). Muy maduro, muchísimas features (relaciones, migraciones, query builder, listeners).
@Entity()
export class User {
@PrimaryGeneratedColumn() id: number;
@Column() email: string;
@OneToMany(() => Post, (post) => post.author) posts: Post[];
}
- A favor: madurez, ecosistema enorme, flexibilidad de patrones, integración nativa con
@InjectRepository. - En contra: el type-safety es más débil (los decoradores no garantizan que la columna exista en runtime), tiene rincones sorprendentes con relaciones lazy y
save()en cascada, y su rendimiento en queries complejas obliga a bajar alQueryBuilder.
Drizzle
Un query builder tipado más que un ORM: escribís algo muy parecido a SQL, pero con tipos de TypeScript inferidos del schema. Cero capa de “magia”, bundle chico, y una Relational Queries API que garantiza una sola sentencia SQL para relaciones anidadas.
const result = await db.query.users.findMany({
columns: { id: true, name: true }, // partial select en la query
with: { posts: true }, // 1 sola sentencia SQL, sin N+1
});
- A favor: control total sobre el SQL, rendimiento predecible, type-safety fuerte por inferencia, ideal para serverless (arranque frío mínimo).
- En contra: más joven, menos features “automáticas”, tenés que pensar en SQL (lo cual para algunos es ventaja).
MikroORM
ORM con Unit of Work e Identity Map de verdad (estilo Doctrine/Hibernate). Rastrea los cambios de las entidades y hace flush() de todo junto en una transacción. El más cercano al patrón “dominio rico” con persistencia transparente.
- A favor: Unit of Work e Identity Map reales, buen type-safety, filosofía DDD-friendly.
- En contra: curva de aprendizaje del ciclo de vida de entidades (managed/detached), menos difundido que Prisma o TypeORM.
Cómo decidir
flowchart TD
A[¿Qué priorizás?] --> B{Type-safety y DX<br/>por encima de todo}
A --> C{Control fino del SQL<br/>y serverless}
A --> D{Dominio rico con<br/>Unit of Work real}
A --> E{Madurez y features<br/>del ecosistema Nest}
B --> P[Prisma]
C --> DR[Drizzle]
D --> M[MikroORM]
E --> T[TypeORM]
| Criterio | Prisma | TypeORM | Drizzle | MikroORM |
|---|---|---|---|---|
| Type-safety | Muy alto (generado) | Medio | Muy alto (inferido) | Alto |
| Patrón | Cliente/plano | Data Mapper + Active Record | Query builder | Data Mapper + UoW |
| Control del SQL | Medio | Alto (QueryBuilder) | Muy alto | Alto |
| Unit of Work | No nativo | Parcial | No | Sí (real) |
| Peso/arranque | Pesado | Medio | Liviano | Medio |
| Madurez en Nest | Alta | Muy alta | Creciente | Alta |
Data Mapper vs Active Record: en Active Record la entidad sabe persistirse (
user.save()), lo que acopla el dominio a la base. En Data Mapper un objeto aparte (el repositorio) traduce entre dominio y base. Para arquitectura hexagonal (cap 2) querés Data Mapper: la entidad no debería saber que existe una base de datos.
El problema N+1
Es el bug de rendimiento más común y más silencioso de la capa de datos. Ocurre cuando hacés 1 query para traer una lista de N elementos y luego N queries adicionales, una por elemento, para traer sus relaciones. Total: N+1 queries para algo que debería resolverse en 1 o 2.
Cómo se ve
// ANTIPATRÓN: N+1 silencioso
const posts = await this.postRepo.find(); // 1 query: SELECT * FROM post
for (const post of posts) {
post.author = await this.userRepo.findOneBy({ // N queries: 1 por cada post
id: post.authorId,
});
}
// 100 posts → 101 queries. Cada una es un round-trip a la base.
El costo no es la CPU: es la latencia de red acumulada. Si cada round-trip a la base son 2 ms, 101 queries son 202 ms sólo en ir y volver, cuando la respuesta correcta tardaría 4 ms. Y escala linealmente con los datos: mañana hay 10.000 posts y el endpoint tarda 20 segundos.
El N+1 también se esconde detrás de relaciones lazy de TypeORM o de resolvers de GraphQL: cada acceso a post.author dispara una query sin que veas el for.
Cómo detectarlo
Prendé el log de queries y contá. En Prisma:
const prisma = new PrismaClient({ log: ['query'] });
// En consola verás una línea SELECT por cada round-trip.
En TypeORM, logging: true en la config del DataSource. En Drizzle, pasá un logger: true al inicializar. Regla operativa: abrí un endpoint, mirá cuántas queries emite y comparalo con lo que esperabas. Si el número crece con la cantidad de filas, tenés un N+1. Herramientas como Prisma Optimize o el APM (cap 12) lo señalan en producción.
Cómo resolverlo
La solución es traer las relaciones de forma agrupada: en 1 o 2 queries en lugar de N+1.
// MEJOR: Prisma con include → 2 queries (una de posts, una de authors agrupada)
const posts = await this.prisma.post.findMany({
include: { author: true },
});
// AÚN MEJOR: relationLoadStrategy join → 1 sola query SQL con JOIN
const posts = await this.prisma.post.findMany({
relationLoadStrategy: 'join',
include: { author: true },
});
// TypeORM: relations en find, o QueryBuilder con leftJoinAndSelect → 1 query con JOIN
const posts = await this.postRepo.find({ relations: { author: true } });
const posts2 = await this.postRepo
.createQueryBuilder('post')
.leftJoinAndSelect('post.author', 'author')
.getMany();
// Drizzle: with → 1 sola sentencia SQL garantizada
const posts = await db.query.posts.findMany({
with: { author: true },
});
El siguiente diagrama muestra la diferencia de round-trips:
sequenceDiagram
participant S as Servicio
participant DB as Base de datos
Note over S,DB: N+1 (101 round-trips para 100 posts)
S->>DB: SELECT * FROM post
DB-->>S: 100 posts
loop por cada post (x100)
S->>DB: SELECT * FROM "user" WHERE id = ?
DB-->>S: 1 author
end
Note over S,DB: Con JOIN / include (1-2 round-trips)
S->>DB: SELECT ... FROM post LEFT JOIN "user" ...
DB-->>S: 100 posts + authors
Cuando la cardinalidad de la relación es alta (un post con miles de comentarios), un JOIN infla filas (producto cartesiano). Ahí conviene la estrategia de dos queries agrupadas (traer los posts, luego WHERE authorId IN (...)), que es exactamente lo que hace Prisma sin join y lo que resuelve un DataLoader en GraphQL: acumula los IDs de un tick y dispara una query IN (...) en lugar de N.
Seleccionar sólo lo necesario
SELECT * trae todas las columnas aunque uses tres. Eso cuesta ancho de banda, memoria y, sobre todo, impide que un índice cubra la query (un covering index sólo sirve si pedís columnas que el índice ya contiene).
// ANTIPATRÓN: trae la entidad completa (incluye campos pesados como bio, avatar)
const users = await this.prisma.user.findMany();
// MEJOR: sólo las columnas que la respuesta necesita
const users = await this.prisma.user.findMany({
select: { id: true, email: true },
});
Regla: la query devuelve exactamente lo que la respuesta usa, ni una columna más. Esto además reduce el acoplamiento accidental (nadie empieza a depender de un campo que “venía de yapa”).
Índices y su relación con el N+1
Resolver el N+1 con un JOIN no sirve de nada si la columna de la FK no está indexada: el JOIN degenera en un full table scan por cada fila. Cada columna por la que filtrás (WHERE), ordenás (ORDER BY) o unís (JOIN ... ON) es candidata a índice.
// Prisma: índice compuesto para el patrón de acceso "posts de un autor, recientes"
model Post {
id Int @id @default(autoincrement())
authorId Int
createdAt DateTime @default(now())
@@index([authorId, createdAt])
}
El orden de las columnas en un índice compuesto importa: [authorId, createdAt] sirve para filtrar por autor y ordenar por fecha; [createdAt, authorId] no sirve para “posts de este autor”. Sin índice, un WHERE authorId = ? es O(n) sobre la tabla; con índice, es O(log n).
Paginación: offset vs keyset
La paginación por offset (LIMIT 20 OFFSET 10000) obliga a la base a contar y descartar las primeras 10.000 filas antes de devolver 20. Es O(offset): la página 1 es instantánea, la página 500 es lentísima. Además es inconsistente si se insertan filas entre página y página.
La paginación por keyset (o cursor) usa la última fila vista como ancla:
// ANTIPATRÓN: offset. Costo crece con el número de página.
const page = await this.prisma.post.findMany({
orderBy: { id: 'desc' },
take: 20,
skip: 10000, // la base descarta 10.000 filas
});
// MEJOR: keyset. Costo constante; usa el índice para saltar directo.
const page = await this.prisma.post.findMany({
orderBy: { id: 'desc' },
take: 20,
cursor: { id: lastSeenId },
skip: 1,
});
El keyset es O(log n) para llegar al ancla (usa el índice) y devuelve las siguientes 20 sin descartar nada. El trade-off: no podés saltar a “página 47” arbitraria, sólo avanzar/retroceder. Para scroll infinito y APIs es ideal; para una tabla con paginador numerado, offset con límites razonables alcanza.
Transacciones
Una transacción garantiza atomicidad: un conjunto de operaciones se aplica entero o nada. El caso clásico es la transferencia: descontar de una cuenta y acreditar en otra. Si falla la segunda escritura sin transacción, el dinero desaparece.
En cada ORM
// Prisma: interactive transaction. tx es un cliente con la transacción abierta.
await this.prisma.$transaction(async (tx) => {
const sender = await tx.account.update({
where: { id: fromId },
data: { balance: { decrement: amount } },
});
if (sender.balance < 0) throw new Error('Fondos insuficientes'); // rollback automático
await tx.account.update({
where: { id: toId },
data: { balance: { increment: amount } },
});
});
// TypeORM: DataSource.transaction. manager es el EntityManager transaccional.
await this.dataSource.transaction(async (manager) => {
await manager.decrement(Account, { id: fromId }, 'balance', amount);
await manager.increment(Account, { id: toId }, 'balance', amount);
});
// O con QueryRunner cuando necesitás control explícito de commit/rollback:
const runner = this.dataSource.createQueryRunner();
await runner.connect();
await runner.startTransaction();
try {
await runner.manager.save(/* ... */);
await runner.commitTransaction();
} catch (e) {
await runner.rollbackTransaction();
throw e;
} finally {
await runner.release(); // SIEMPRE liberar la conexión al pool
}
// Drizzle: db.transaction. tx es la conexión transaccional.
await db.transaction(async (tx) => {
await tx.update(accounts).set({ balance: sql`${accounts.balance} - ${amount}` }).where(eq(accounts.id, fromId));
await tx.update(accounts).set({ balance: sql`${accounts.balance} + ${amount}` }).where(eq(accounts.id, toId));
});
Lo común a los cuatro: si el callback lanza, la transacción hace rollback; si retorna, hace commit. La clave es que todas las operaciones usen el mismo objeto transaccional (tx/manager). Si dentro del callback usás this.prisma en vez de tx, esa query corre fuera de la transacción: un bug de atomicidad silencioso.
El problema real: transacciones que cruzan servicios
El ejemplo de arriba es fácil porque todo pasa en un método. En una app real, la lógica se reparte: OrderService crea la orden, InventoryService descuenta stock, PaymentService registra el cobro. Los tres tienen que estar en la misma transacción, pero ninguno debería recibir un tx como parámetro (eso contamina todas las firmas y acopla el dominio a la mecánica del ORM).
// ANTIPATRÓN: propagar tx a mano por toda la cadena de llamadas
async createOrder(dto: Dto, tx: PrismaClient) { // firma contaminada
await this.inventory.reserve(dto.items, tx); // tx pasa a todos
await this.payment.charge(dto.total, tx); // ...y a todos
}
Esto es el patrón Unit of Work mal implementado. El Unit of Work agrupa todas las escrituras de una unidad de trabajo (típicamente un request) y las commitea juntas. La forma limpia de propagarlo en Nest es con AsyncLocalStorage: guardar el objeto transaccional en el contexto del request y que los repositorios lo lean solos.
Transacción por request con AsyncLocalStorage
AsyncLocalStorage (de node:async_hooks) mantiene un contexto que “viaja” con la cadena de await sin pasarlo por parámetro. La comunidad estandarizó esto en @nestjs-cls/transactional, que expone un decorador @Transactional() y un TransactionHost con adaptadores para Prisma, TypeORM, Drizzle y otros.
// El servicio de dominio no conoce tx: sólo declara que el método es transaccional.
@Injectable()
export class OrderService {
constructor(
private readonly inventory: InventoryService,
private readonly payment: PaymentService,
private readonly orders: OrderRepository,
) {}
@Transactional() // abre la transacción y la guarda en el AsyncLocalStorage
async create(dto: CreateOrderDto) {
const order = await this.orders.save(dto); // usa el tx del contexto
await this.inventory.reserve(dto.items); // mismo tx, sin pasarlo
await this.payment.charge(dto.total); // mismo tx
return order;
}
}
flowchart LR
R[Request] --> T["@Transactional()<br/>abre tx"]
T --> ALS[(AsyncLocalStorage<br/>guarda tx)]
ALS -.lee.-> R1[OrderRepository]
ALS -.lee.-> R2[InventoryRepository]
ALS -.lee.-> R3[PaymentRepository]
R1 & R2 & R3 --> C{¿excepción?}
C -->|no| CM[commit]
C -->|sí| RB[rollback]
Ventaja: los servicios y repositorios quedan agnósticos de la transacción. La firma no cambia, y anidar @Transactional() respeta la propagación (por defecto se une a la transacción existente, estilo REQUIRED; hay modos como REQUIRES_NEW para forzar una nueva). Esto es el Unit of Work bien hecho: una transacción por request, invisible para el dominio.
Ojo con las transacciones largas: una transacción mantiene una conexión del pool tomada y locks abiertos durante toda su vida. No metas llamadas HTTP externas, envíos de mail ni trabajos pesados dentro de una transacción: hacé eso después del commit, idealmente publicando un evento o encolando un job (cap 9).
Connection pooling
Cada conexión a la base es un recurso caro y limitado (Postgres aguanta ~100 conexiones por defecto). Nunca abrís una conexión por request: usás un pool que reutiliza un puñado de conexiones. El tamaño del pool debe ser acotado: demasiado chico y los requests hacen cola esperando conexión; demasiado grande y saturás la base.
// Prisma: el pool se configura en la connection string
// postgresql://...?connection_limit=10&pool_timeout=20
En serverless (funciones que escalan a cientos de instancias) cada instancia con su propio pool agota la base al instante; ahí se usa un pooler externo (PgBouncer, Prisma Accelerate, Neon pooler) en modo transacción. Regla: el total de conexiones = instancias × tamaño de pool debe caber en el límite del motor.
Seguridad: nunca concatenar SQL
El acoplamiento entre persistencia y seguridad es directo: toda query armada con concatenación de strings es una inyección SQL esperando pasar. Los ORMs parametrizan por vos, pero el peligro aparece cuando bajás a SQL crudo.
// ANTIPATRÓN: SQL injection. email viene del usuario, se concatena crudo.
const users = await this.prisma.$queryRawUnsafe(
`SELECT * FROM "user" WHERE email = '${email}'`, // email = "' OR '1'='1"
);
// MEJOR: query parametrizada. El valor NUNCA se interpola como texto SQL.
const users = await this.prisma.$queryRaw`
SELECT * FROM "user" WHERE email = ${email}
`; // Prisma envía email como parámetro, no como parte de la sentencia
Lo mismo en TypeORM: usá query('... WHERE id = $1', [id]) o el QueryBuilder con :param, nunca `WHERE id = ${id}`. En Drizzle, el helper sql parametriza los ${valores} interpolados. La regla es absoluta: los datos del usuario siempre viajan como parámetros, jamás como parte de la cadena SQL. Esto se profundiza en Seguridad.
Patrón repositorio: desacoplar el dominio del ORM
El patrón repositorio abstrae el acceso a datos detrás de un puerto (una interface) para que el dominio no dependa del ORM. Aplica el Principio de Inversión de Dependencias (cap 3): el servicio de dominio depende de una abstracción, y el adaptador concreto (Prisma/TypeORM) la implementa.
// Puerto: vive en el dominio, no conoce Prisma ni TypeORM
export interface UserRepository {
findById(id: string): Promise<User | null>;
save(user: User): Promise<void>;
}
// Token de inyección (interfaces no existen en runtime, necesitás un token)
export const USER_REPOSITORY = Symbol('UserRepository');
// Adaptador: vive en infraestructura, traduce entre dominio y Prisma
@Injectable()
export class PrismaUserRepository implements UserRepository {
constructor(private readonly prisma: PrismaService) {}
async findById(id: string): Promise<User | null> {
const row = await this.prisma.user.findUnique({ where: { id } });
return row ? this.toDomain(row) : null; // mapea fila → entidad de dominio
}
async save(user: User): Promise<void> {
await this.prisma.user.upsert({ /* ... */ });
}
private toDomain(row: PrismaUser): User { /* ... */ }
}
// Wiring en el módulo: el servicio recibe la abstracción, no el adaptador
@Module({
providers: [
{ provide: USER_REPOSITORY, useClass: PrismaUserRepository },
UserService,
],
})
export class UserModule {}
// En el servicio de dominio:
constructor(@Inject(USER_REPOSITORY) private readonly users: UserRepository) {}
classDiagram
class UserService {
+register(dto)
}
class UserRepository {
<<interface / puerto>>
+findById(id) User
+save(user) void
}
class PrismaUserRepository {
+findById(id) User
+save(user) void
}
UserService ..> UserRepository : depende de la abstracción
PrismaUserRepository ..|> UserRepository : implementa el puerto
PrismaUserRepository ..> PrismaService : usa el ORM
El pago: podés cambiar de Prisma a Drizzle tocando sólo el adaptador, y podés testear UserService con un repositorio en memoria sin levantar una base (cap 13).
Cuándo el repositorio custom aporta y cuándo no
No siempre vale la pena. El repositorio custom aporta cuando:
- Tenés lógica de dominio real que querés aislar del ORM (arquitectura hexagonal).
- Querés testear la lógica sin base de datos.
- Prevés cambiar de ORM o tenés múltiples fuentes de datos.
El repositorio del ORM (@InjectRepository de TypeORM, el cliente de Prisma directo) alcanza cuando:
- Es un CRUD delgado sin lógica de dominio: agregar una capa que sólo reenvía llamadas es indirección sin beneficio.
- El equipo es chico y el ORM ya no va a cambiar.
Meter un repositorio custom que sólo hace return this.prisma.user.findMany() es ceremonia vacía. Meter uno cuando hay dominio que proteger es lo que hace mantenible el sistema. Criterio, no dogma.
Antipatrones a erradicar
- Lógica de negocio en entidades acopladas al ORM: reglas de dominio dentro de una
@Entity()de TypeORM. El dominio queda preso del framework de persistencia y no se puede testear sin base. Movela a servicios de dominio o a entidades puras. - N+1 silencioso: el más caro y el más invisible. Aparece con relaciones lazy, resolvers GraphQL y loops con
awaitadentro. Contá queries en cada endpoint. - Cargar relaciones que no se usan:
include/relationsde todo “por las dudas”. Cada relación cargada es un JOIN o una query extra y más memoria. Traé sólo lo que la respuesta usa. - Transacción olvidada: dos escrituras que debían ser atómicas corriendo sueltas. Si la segunda falla, quedás con estado inconsistente. Identificá las unidades de trabajo y envolvelas.
txfugado: dentro de un$transaction, usar el cliente global (this.prisma) en vez deltx. Esa query queda fuera de la transacción.- Repositorio god: una interface con 40 métodos (
findByEmailAndStatusAndRole...). Viola el ISP (cap 3). Segmentá por caso de uso o exponé un query builder acotado. SELECT *por defecto: traer entidades completas cuando la respuesta usa tres campos. Desperdicia ancho de banda e impide índices covering.- Paginación offset a ciegas en tablas grandes: la página N tarda O(N). Usá keyset donde el volumen lo justifique.
Checklist
- Elegiste el ORM con criterio técnico (type-safety, control de SQL, Unit of Work, madurez), no por moda.
- El dominio no depende del ORM: usás Data Mapper / patrón repositorio donde hay lógica de negocio que proteger.
- Prendiste el log de queries y contaste las de cada endpoint clave; ninguna crece linealmente con las filas (sin N+1).
- Las relaciones se cargan con
include/relations/witho JOIN, no con loops de queries. - Usás
selectcon las columnas necesarias, noSELECT *por defecto. - Las columnas de FK,
WHERE,ORDER BYyJOINestán indexadas; los índices compuestos siguen el orden de acceso real. - Las unidades de trabajo con múltiples escrituras están dentro de una transacción; todas usan el mismo objeto transaccional.
- Las transacciones que cruzan servicios se propagan con AsyncLocalStorage (
@nestjs-cls/transactional), no pasandotxpor parámetro. - No hay llamadas externas ni trabajos pesados dentro de transacciones (se hacen después del commit).
- El pool de conexiones está acotado y dimensionado (instancias × pool ≤ límite del motor); usás un pooler en serverless.
- Cero concatenación de strings en SQL: todo dato de usuario viaja como parámetro.
- La paginación usa keyset donde el volumen lo justifica; el offset queda para tablas chicas.
- No hay repositorios god ni repositorios vacíos que sólo reenvían llamadas.
Con la persistencia bajo control, el siguiente cuello de botella es todo lo que no debería bloquear el request: trabajos en background, colas y eventos. Seguimos en Async, colas y eventos →.