Persistencia: ORMs, N+1 y transacciones

Por: Artiko
nestjsbuenas-practicaspersistenciaormprismatransaccionesrendimiento

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

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[];
}

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
});

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.

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]
CriterioPrismaTypeORMDrizzleMikroORM
Type-safetyMuy alto (generado)MedioMuy alto (inferido)Alto
PatrónCliente/planoData Mapper + Active RecordQuery builderData Mapper + UoW
Control del SQLMedioAlto (QueryBuilder)Muy altoAlto
Unit of WorkNo nativoParcialNoSí (real)
Peso/arranquePesadoMedioLivianoMedio
Madurez en NestAltaMuy altaCrecienteAlta

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:

El repositorio del ORM (@InjectRepository de TypeORM, el cliente de Prisma directo) alcanza cuando:

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

Checklist

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 →.