Parte IV · MikroORM

15. Entidades, relaciones, herencia y embeddables

Un ORM no es un traductor mágico de objetos a filas: es una capa que decide qué SQL emitir a partir de cómo has declarado tus entidades. Si no entiendes dónde vive una clave foránea, qué es el lado propietario o cuándo se inicializa una colección, aparecerán los síntomas clásicos: cambios que no se guardan, borrados que fallan por integridad referencial, respuestas JSON que desbordan la pila y consultas que se multiplican por mil. Este capítulo enseña a modelar relaciones con MikroORM v6 viendo siempre el SQL generado, que es la diferencia entre quien entiende el ORM y quien lo usa a ciegas.

CORE MikroORM Tiempo de lectura: ~120 min Prerrequisitos: capítulo 14 (Data Mapper, Identity Map, Unit of Work) y SQL básico

15.1 Qué vas a poder hacer al terminar

El dominio del libro Todo el capítulo trabaja sobre el mismo gestor de tareas de equipo: User, Team, Project, Task, Tag, Comment y Attachment. No son ejemplos de juguete distintos en cada apartado: es un único modelo que crece a lo largo del capítulo hasta quedar completo en la sección 15.16. Los capítulos 16 y 17 consultan y optimizan exactamente estas mismas entidades.

15.2 Del modelo conceptual al modelo de objetos

Antes de escribir un decorador hay que hacer un trabajo que ningún ORM puede hacer por ti: decidir qué cosas existen en el dominio, qué datos las describen y cómo se relacionan. Esto es análisis, no programación, y equivocarse aquí cuesta refactorizaciones caras.

15.2.1 Identificar entidades, atributos y relaciones

El procedimiento clásico sigue siendo el mejor: leer la descripción del negocio y marcar sustantivos y verbos. Los sustantivos son candidatos a entidad o a atributo; los verbos, a relación.

Analogía: el inventario de una oficina Una entidad tiene identidad propia y sigue siendo la misma aunque cambien todos sus datos: un empleado que se cambia el nombre sigue siendo ese empleado. Un atributo no tiene sentido fuera de su dueño: el color de una silla no es una cosa del inventario. Una relación es un hecho que conecta dos entidades. La pregunta que separa entidad de atributo es sencilla: ¿necesito referirme a esto desde otro sitio? Si la respuesta es sí, es una entidad.

Aplicado al enunciado del libro: «Los usuarios pertenecen a equipos con un rol. Cada equipo gestiona proyectos. Un proyecto contiene tareas. Una tarea puede estar asignada a un usuario, llevar varias etiquetas y acumular comentarios y adjuntos. Cada usuario puede tener un perfil ampliado.»

Concepto¿Entidad o atributo?Razón
UsuarioEntidad UserIdentidad propia; se referencia desde tareas y comentarios
EquipoEntidad TeamExiste con independencia de sus miembros
Rol dentro del equipoAtributo de la relaciónNo es del usuario ni del equipo: es del hecho «pertenece a»
Estado de la tareaAtributoValor de una lista cerrada; no se referencia desde fuera
EtiquetaEntidad TagSe comparte entre tareas y se renombra en un único sitio
Comentario y adjuntoEntidades dependientesTienen datos propios pero no viven sin su tarea (composición)
PerfilEntidad UserProfileSe separa por tamaño y frecuencia de uso, no por identidad

15.2.2 Cardinalidad y opcionalidad

Toda relación plantea dos preguntas independientes, y confundirlas origina la mitad de los errores de modelado. La cardinalidad (¿cuántos?) determina dónde va la clave foránea o si hace falta una tabla intermedia. La opcionalidad (¿obligatorio?) determina si la columna admite NULL, y en MikroORM se controla con nullable.

Relación del dominioCardinalidadOpcionalidadImplementación
Task → ProjectN:1Obligatoriaproject_id NOT NULL
Task → User (assignee)N:1Opcional (0..1)assignee_id NULL
Project → TeamN:1Obligatoriateam_id NOT NULL
User ↔ TeamM:N con atributosEntidad pivote TeamMembership
Task ↔ TagM:N puroTabla intermedia automática
Task → Comment / Attachment1:NFK en el hijo + orphanRemoval
User → UserProfile1:1OpcionalFK única y anulable en user

15.2.3 Diagrama entidad-relación del dominio

   ┌──────────────┐  1        N ┌────────────────────┐ N        1 ┌──────────────────┐
   │     Team     │────────────<│  TeamMembership    │>───────────│      User        │
   │ id, name     │             │ (pivote explícito) │            │ id, email UNIQUE │
   │ slug UNIQUE  │             │ role, joinedAt     │            │ name             │
   └──────┬───────┘             └────────────────────┘            └──┬────────────┬──┘
          │ 1                     M:N CON atributos                  │ 1          │ 0..1
          │ N                                                        │ N          ▼
   ┌──────┴───────┐                                                  │      ┌──────────────┐
   │   Project    │          ┌───────────────────────┐               │      │ UserProfile  │
   │ id, name     │──────────│        Task           │<──────────────┘      │ bio, avatar  │
   │ archived     │  1     N │ id, title, status     │  0..1 assignee       │ timezone     │
   └──────────────┘          │ dueDate, position     │                      └──────────────┘
                             └──┬─────────┬────────┬─┘                       1:1 opcional
                 1 ║            │       1 ║        │ M                       (FK en user)
                   ║ N          │         ║ N      │        N
          ┌────────╨──────┐     │  ┌──────╨───────┐│   ┌────┴─────────┐
          │   Comment     │     │  │  Attachment  ││   │     Tag      │
          │ body, author  │     │  │ filename     ││   │ id, name UQ  │
          └───────────────┘     │  └──────────────┘│   └──────────────┘
           composición          │   composición    │    M:N puro (tabla
           orphanRemoval        │   orphanRemoval  │    intermedia automática)
                                └── author: User ──┘  (Comment.author → User, N:1)

  Notación: 1 = «uno» · N/M = «muchos» · 0..1 = opcional · ║ = composición

15.2.4 Traducción a clases TypeScript

src/entities/project.entity.ts + task.entity.ts
import { Collection, Entity, Enum, ManyToMany, ManyToOne, OneToMany, PrimaryKey, Property, Ref } from '@mikro-orm/core';

@Entity()
export class Project {
  @PrimaryKey() id!: number;
  @Property({ length: 120 }) name!: string;
  @Property({ default: false }) archived = false;

  // Lado PROPIETARIO: aquí nace la columna team_id de la tabla "project".
  @ManyToOne(() => Team, { ref: true }) team!: Ref<Team>;

  // Lado INVERSO: no crea ninguna columna. Es un espejo navegable.
  @OneToMany(() => Task, (task) => task.project)
  tasks = new Collection<Task>(this);
}

export type TaskStatus = 'todo' | 'doing' | 'done' | 'blocked';

@Entity()
export class Task {
  @PrimaryKey() id!: number;
  @Property({ length: 200 }) title!: string;
  @Property({ nullable: true }) dueDate?: Date;
  @Enum({ items: () => ['todo', 'doing', 'done', 'blocked'], default: 'todo' })
  status: TaskStatus = 'todo';

  @ManyToOne(() => Project, { ref: true, deleteRule: 'cascade' })                 // obligatoria
  project!: Ref<Project>;
  @ManyToOne(() => User, { ref: true, nullable: true, deleteRule: 'set null' })   // opcional
  assignee?: Ref<User>;
  @ManyToMany(() => Tag, (tag) => tag.tasks, { owner: true })                     // M:N puro
  tags = new Collection<Tag>(this);
  @OneToMany(() => Comment, (c) => c.task, { orphanRemoval: true })               // composición
  comments = new Collection<Comment>(this);
  @OneToMany(() => Attachment, (a) => a.task, { orphanRemoval: true })
  attachments = new Collection<Attachment>(this);
}

15.2.5 El SQL que genera ese modelo

Con npx mikro-orm schema:create --dump obtenemos el DDL. Léelo con atención: cada decisión de modelado tiene una consecuencia física visible.

schema.sql · PostgreSQL
create table "project" (
  "id"       serial primary key,
  "name"     varchar(120) not null,
  "archived" boolean not null default false,
  "team_id"  int not null );             -- ← de @ManyToOne(() => Team)
alter table "project" add constraint "project_team_id_foreign"
  foreign key ("team_id") references "team" ("id") on update cascade;
create index "project_team_id_index" on "project" ("team_id");

create table "task" (
  "id"          serial primary key,
  "title"       varchar(200) not null,
  "status"      text check ("status" in ('todo','doing','done','blocked')) not null default 'todo',
  "due_date"    timestamptz null,
  "project_id"  int not null,            -- NOT NULL: relación obligatoria
  "assignee_id" int null );              -- NULL:     relación opcional
alter table "task" add constraint "task_project_id_foreign"
  foreign key ("project_id") references "project" ("id")
  on update cascade on delete cascade;   -- ← deleteRule: 'cascade'
alter table "task" add constraint "task_assignee_id_foreign"
  foreign key ("assignee_id") references "user" ("id")
  on update cascade on delete set null;  -- ← deleteRule: 'set null'

-- Y la tabla intermedia del M:N, que el ORM crea sin que exista clase alguna:
create table "task_tags" ( "task_id" int not null, "tag_id" int not null,
  constraint "task_tags_pkey" primary key ("task_id", "tag_id") );
Tres lecturas obligatorias de ese DDL

1. El lado inverso (Project.tasks) no ha generado nada: no hay ninguna columna en project que apunte a las tareas. Las relaciones a colección se materializan siempre en la tabla del lado «muchos».

2. nullable se traduce literalmente en NULL/NOT NULL. Es la restricción más barata y más eficaz que tienes.

3. MikroORM crea un índice sobre la clave foránea en los dialectos que no lo hacen solos. PostgreSQL no indexa automáticamente las FK, y una FK sin índice convierte cualquier DELETE del padre en un escaneo secuencial de la tabla hija.

15.3 Lado propietario y lado inverso

Lado propietario (owning side) es el extremo cuya tabla contiene la clave foránea. Es el único lado que el ORM consulta al construir el INSERT o el UPDATE. En una relación bidireccional siempre hay exactamente uno.

Lado inverso (inverse side) es el extremo declarado con mappedBy (el segundo argumento del decorador). No produce ninguna columna: es una vista de navegación que el ORM rellena leyendo la clave foránea del otro lado.

  LADO PROPIETARIO                                LADO INVERSO
  ┌────────────────────────────────┐              ┌───────────────────────────────────┐
  │ class Task                     │              │ class Project                     │
  │  @ManyToOne(() => Project)     │              │  @OneToMany(() => Task,           │
  │  project!: Ref<Project>;       │              │              t => t.project)      │
  │  ▸ Aquí vive la clave foránea  │              │  ▸ NO genera ninguna columna      │
  │  ▸ Cambiarlo genera SQL        │              │  ▸ Cambiarlo NO genera SQL por sí │
  └────────────────────────────────┘              └───────────────────────────────────┘
              │                                                    │
      tabla "task"                                          tabla "project"
  ┌────┬──────────┬──────────────┐                          ┌────┬──────────┐
  │ id │ title    │ project_id   │─── FK física ───────────>│ id │ name     │
  │ 1  │ Login KO │      7       │                          │ 7  │ Web      │
  │ 2  │ Export   │      7       │                          │ 8  │ Móvil    │
  └────┴──────────┴──────────────┘                          └────┴──────────┘

  REGLA: en @OneToMany/@ManyToOne el propietario es SIEMPRE el @ManyToOne.
         En @ManyToMany y @OneToOne lo eliges tú con owner: true.

La consecuencia es directa: si mueves una tarea añadiéndola a otroProyecto.tasks pero no tocas task.project, el Unit of Work no detecta ningún cambio en project_id y el flush() no emite ningún UPDATE. En memoria «parece» que funciona; al recargar, la tarea sigue donde estaba.

mover-tarea.service.tsINCORRECTO
async mover(taskId: number, destinoId: number) {
  const task = await this.em.findOneOrFail(Task, taskId);
  const destino = await this.em.findOneOrFail(
    Project, destinoId, { populate: ['tasks'] },
  );
  destino.tasks.add(task);   // solo el lado INVERSO
  await this.em.flush();
  // SQL emitido: NINGUNO sobre project_id.
  // La tarea sigue en su proyecto original.
}
mover-tarea.service.tsCORRECTO
async mover(taskId: number, destinoId: number) {
  const task = await this.em.findOneOrFail(Task, taskId);

  // Lado PROPIETARIO. Ni siquiera hace falta cargar
  // el proyecto destino: basta una referencia.
  task.project = this.em.getReference(Project, destinoId);
  await this.em.flush();
  // update "task" set "project_id" = 8 where "id" = 1;
}
Matiz: Collection.add() sí funciona… a veces MikroORM es más listo que la descripción anterior: cuando la colección inversa está inicializada y la relación es bidireccional, add() propaga el cambio al lado propietario por ti. El problema es que esa comodidad depende del estado de la colección y de que mappedBy esté bien puesto, así que produce código que funciona en un test y falla en producción con una colección no inicializada. Regla profesional: escribe siempre el lado propietario; actualiza el inverso solo si necesitas coherencia en memoria durante el resto de la petición.
El error clásico y su síntoma «He hecho proyecto.tasks.remove(task) y la tarea sigue ahí.» Quitar un elemento de una colección inversa no borra la fila: como mucho pondría la clave foránea a NULL, y solo si la columna lo permite. Si project_id es NOT NULL, el resultado es una excepción de integridad en el flush. Para borrar de verdad: orphanRemoval: true (sección 15.10) o em.remove(task) explícito.

15.4 @ManyToOne en profundidad

Es la relación fundamental y, junto con @OneToOne, la única que crea columnas: muchas filas de la tabla A apuntan a una fila de la tabla B.

task.entity.ts · todas las opciones relevantes
@ManyToOne(() => Project, {
  ref: true,                 // la propiedad será Ref<Project>, no Project
  nullable: false,           // columna NOT NULL (valor por defecto)
  deleteRule: 'cascade',     // ON DELETE CASCADE en la FK  (v5: onDelete)
  updateRule: 'cascade',     // ON UPDATE CASCADE en la FK  (v5: onUpdateIntegrity)
  fieldName: 'project_id',   // nombre físico de la columna
  index: true,               // crea índice sobre la FK
  eager: false,              // NO cargar siempre (valor por defecto)
  mapToPk: false,            // si true, la propiedad es number en lugar de entidad
})
project!: Ref<Project>;
OpciónQué haceEfecto en el SQL
() => ProjectReferencia diferida a la entidad objetivoNinguno directo; evita fallos por dependencias circulares
nullable: trueLa relación puede no existir"assignee_id" int null
deleteRuleQué hace la base de datos al borrar el padreon delete cascade | set null | restrict | no action
updateRuleQué hace al cambiar la PK del padreon update cascade (por defecto en MikroORM)
ref: trueEnvuelve el valor en Ref<T>Ninguno; evita consultas accidentales y mejora el tipado
mapToPk: trueLa propiedad es el identificador crudoNinguno; ahorra la creación del proxy
index: trueÍndice sobre la columna FKcreate index "task_project_id_index" …
eager: trueCarga la relación en toda consultaAñade un SELECT o un JOIN a cada find

15.4.1 Por qué la entidad objetivo va dentro de una función flecha

Los decoradores se evalúan al definir la clase, mientras los módulos aún se están cargando. Con @ManyToOne(Project) el valor se leería en ese instante, y con dos entidades que se importan mutuamente una de las dos valdrá undefined según el orden de resolución de módulos: el síntoma es un TypeError: Cannot read properties of undefined (reading 'name') en el arranque. La función flecha aplaza la lectura hasta que MikroORM procesa los metadatos, con todos los módulos ya cargados. Es el mismo motivo por el que NestJS ofrece forwardRef.

15.4.2 Cuándo la relación debe ser obligatoria

Una relación obligatoria es una invariante de negocio escrita en la base de datos. Hazla obligatoria siempre que la respuesta a «¿tiene sentido esta fila sin su padre?» sea no. Una tarea sin proyecto es basura: nadie la verá en la interfaz, ocupará espacio y falseará los informes; un NOT NULL lo impide para siempre, incluso frente a un script de migración mal escrito o a otro servicio que ataque la misma base de datos. Por eso nullable: true debe ser una decisión consciente que responda a un caso real («una tarea puede estar sin asignar»), nunca un atajo para no rellenar el campo en los tests: cada columna anulable obliga a comprobar el nulo en todos los sitios donde se lee, y ese coste es permanente.

15.4.3 Por qué eager: true suele ser mala idea

eager no significa «cargar rápido»: significa «cargar siempre, en toda consulta que devuelva esta entidad, la pida quien la pida». Es una decisión global tomada en el modelo que afecta a código que aún no has escrito.

efecto-domino.sql · lo que provoca un eager mal puesto
-- Modelo: Task.project es eager, Project.team es eager, Team.owner es eager.
-- El desarrollador solo quería los títulos:  em.find(Task, { status: 'todo' })
select "t0".* from "task" "t0" where "t0"."status" = 'todo';
select "p0".* from "project" "p0" where "p0"."id" in (7, 8, 11, 12, 15);
select "t0".* from "team"    "t0" where "t0"."id" in (1, 2, 3);
select "u0".* from "user"    "u0" where "u0"."id" in (4, 9, 21);
-- 4 consultas y tres tablas enteras en memoria para pintar una lista de títulos,
-- y ocurre en TODOS los endpoints que toquen Task, para siempre.

15.5 @OneToMany: el lado inverso y la colección

@OneToMany es siempre el espejo de un @ManyToOne: necesita saber qué propiedad del otro lado lo referencia, y eso es el segundo argumento, llamado históricamente mappedBy.

project.entity.ts
@OneToMany(() => Task, (task) => task.project, {
  orderBy: { position: 'asc' },   // orden por defecto al inicializar
  orphanRemoval: false,           // ver 15.10
})
tasks = new Collection<Task>(this);   // ← inicialización OBLIGATORIA

15.5.1 Cuándo NO modelar el lado inverso

Que se pueda declarar no significa que se deba. El lado inverso es una tentación de rendimiento: está ahí, se puede iterar, y alguien lo iterará. No lo declares si la cardinalidad no está acotada (eventos, logs, mensajes), si nunca necesitas «todos» los hijos porque la interfaz siempre pagina, o si solo necesitas el recuento.

El caso de las colecciones enormes Imagina User.events como @OneToMany hacia una tabla de auditoría con 500.000 filas por usuario. El día que alguien escriba await user.events.init(), o un inocente populate: ['events'], el proceso intentará materializar medio millón de objetos en memoria. No es un fallo del ORM: es que el modelo ofrecía esa operación como si fuese razonable.
audit.service.tsINCORRECTO
// Modelo con @OneToMany(() => Event, e => e.user)
const user = await em.findOneOrFail(User, id, {
  populate: ['events'],
});
const ultimos = user.events.getItems().slice(-20);
// select * from "event" where "user_id" = 42
// → 500.000 filas cargadas y 20 usadas.
audit.service.tsCORRECTO
// Sin lado inverso en User. Consulta explícita:
const ultimos = await em.find(Event, { user: id }, {
  orderBy: { createdAt: 'desc' },
  limit: 20,
});
// select * from "event" where "user_id" = 42
//   order by "created_at" desc limit 20  → 20 filas.

15.6 @ManyToMany: tabla intermedia y entidad pivote

Una relación M:N no se puede representar con una columna: necesita una tabla intermedia con dos claves foráneas. MikroORM la crea y la mantiene por ti si la relación es «pura», es decir, si el hecho de que A esté relacionado con B no tiene datos propios.

task.entity.ts / tag.entity.ts · M:N puro
@Entity()
export class Task {
  @ManyToMany(() => Tag, (tag) => tag.tasks, {
    owner: true,                    // ESTE lado define la tabla intermedia
    pivotTable: 'task_tags',        // nombre; por defecto "task_tags"
    joinColumn: 'task_id',          // FK hacia el propietario
    inverseJoinColumn: 'tag_id',    // FK hacia el otro lado
    fixedOrder: false,              // ver más abajo
  })
  tags = new Collection<Tag>(this);
}

@Entity()
export class Tag {
  @PrimaryKey() id!: number;
  @Property({ unique: true, length: 40 }) name!: string;
  // Sin owner y CON mappedBy: es el espejo. No define nada.
  @ManyToMany(() => Task, (task) => task.tags)
  tasks = new Collection<Task>(this);
}
pivot.sql · lo que genera y lo que emite
create table "task_tags" ( "task_id" int not null, "tag_id" int not null,
  constraint "task_tags_pkey" primary key ("task_id", "tag_id") );
alter table "task_tags" add constraint "task_tags_task_id_foreign"
  foreign key ("task_id") references "task" ("id") on update cascade on delete cascade;
-- (la restricción equivalente para "tag_id" se genera igual)

-- task.tags.add(tagUrgente); task.tags.remove(tagBug); await em.flush();
delete from "task_tags" where "task_id" = 1 and "tag_id" = 3;
insert into "task_tags" ("task_id", "tag_id") values (1, 7);

-- populate: ['tags'] con estrategia SELECT_IN:
select "t1".*, "t0"."task_id" as "fk__task_id"
  from "task_tags" as "t0"
  inner join "tag" as "t1" on "t0"."tag_id" = "t1"."id"
 where "t0"."task_id" in (1, 2, 3);
Olvidar owner: true Si declaras @ManyToMany en ambos lados sin marcar propietario, MikroORM falla en el arranque con un error de metadatos del estilo «Both Task.tags and Tag.tasks are defined as owning sides, use mappedBy on one of them». Si, al revés, ninguno tiene owner ni mappedBy, tendrás dos tablas intermedias distintas que no se hablan entre sí: añadir una etiqueta a la tarea no hará que la tarea aparezca en tag.tasks. Regla: propietario donde esté el dueño natural de la operación de negocio (aquí, la tarea es quien recibe etiquetas).

Si nunca vas a preguntar «¿qué tareas tienen esta etiqueta?» desde el objeto Tag, no declares el lado inverso: un M:N unidireccional (@ManyToMany(() => Tag) sin segundo argumento) es válido y ahorra una propiedad que alguien podría cargar por error. Y si el orden importa (una lista de pasos, prioridades manuales), fixedOrder: true añade a la tabla intermedia una columna order y un order by al leer, porque por defecto el orden de una M:N no está garantizado: depende del plan de ejecución.

15.6.1 Cuándo convertirla en entidad pivote explícita

La pregunta decisiva es: ¿la relación tiene atributos propios? Si el hecho «este usuario pertenece a este equipo» necesita guardar un rol, una fecha de alta o un estado, ya no es una relación pura: es una entidad con identidad conceptual propia (una «membresía»).

SeñalM:N automáticoEntidad pivote explícita
La relación tiene datos (rol, cantidad, fecha)ImposibleObligatorio
Necesitas consultar la relación por sí mismaSolo con SQL crudoNatural: es un repositorio más
Necesitas hooks, validación o auditoría al asociarNo hay entidad donde ponerlos
Simplicidad del códigoMáxima: collection.add()Más verboso: crear la entidad
Ejemplo del dominioTask ↔ TagUser ↔ Team con rol
Analogía: la lista de invitados y la entrada numerada Un M:N puro es una lista de invitados: solo dice quién está apuntado a qué fiesta. Una entidad pivote es una entrada numerada: además dice asiento, precio y hora de compra. En cuanto alguien pregunta «¿desde cuándo es administradora del equipo?», necesitas la entrada, no la lista.
team-membership.entity.ts · entidad pivote con clave compuesta
export type TeamRole = 'owner' | 'admin' | 'member' | 'guest';
@Entity()
export class TeamMembership {
  // Dos @ManyToOne marcados como primary forman una CLAVE PRIMARIA COMPUESTA:
  // la base de datos impide por sí sola dos membresías del mismo par.
  @ManyToOne(() => User, { primary: true, ref: true, deleteRule: 'cascade' })
  user!: Ref<User>;
  @ManyToOne(() => Team, { primary: true, ref: true, deleteRule: 'cascade' })
  team!: Ref<Team>;

  @Enum({ items: () => ['owner', 'admin', 'member', 'guest'], default: 'member' })
  role: TeamRole = 'member';       // ← el atributo que justifica toda la clase
  @Property() joinedAt: Date = new Date();
}

// Los lados inversos sustituyen a las colecciones M:N:
//   En User:  @OneToMany(() => TeamMembership, m => m.user)  memberships
//   En Team:  @OneToMany(() => TeamMembership, m => m.team)  members
// La relación M:N deja de existir: ahora son dos 1:N unidas por una entidad en
// medio. Es lo que hacía la base de datos, pero ahora la tabla del medio es TUYA.
pivote.sql
create table "team_membership" (
  "user_id"   int not null,
  "team_id"   int not null,
  "role"      text check ("role" in ('owner','admin','member','guest')) not null default 'member',
  "joined_at" timestamptz not null,
  constraint "team_membership_pkey" primary key ("user_id", "team_id") );
-- Ahora esto es trivial, e imposible con un M:N automático:
select "u".* from "team_membership" "m"
  join "user" "u" on "u"."id" = "m"."user_id"
 where "m"."team_id" = 3 and "m"."role" in ('owner', 'admin');
Migrar de M:N automático a pivote es doloroso Implica crear la tabla nueva, copiar los datos, recrear las claves foráneas y desplegar el código a la vez. Por eso conviene preguntarse al principio: ¿es concebible que algún día esta asociación necesite un dato propio? Para relaciones entre personas y organizaciones (usuario-equipo, alumno-curso) la respuesta casi siempre acaba siendo sí; para clasificaciones puras (tarea-etiqueta) casi siempre es no.

15.7 @OneToOne

Un uno a uno es, físicamente, un N:1 con una restricción UNIQUE sobre la clave foránea. Eso es literalmente lo que genera MikroORM.

user.entity.ts + user-profile.entity.ts
@Entity()
export class User {
  // PROPIETARIO: la columna profile_id vivirá en la tabla "user".
  @OneToOne(() => UserProfile, (profile) => profile.user, {
    owner: true, ref: true,
    nullable: true,          // relación opcional: puede no tener perfil
    orphanRemoval: true,     // si se desvincula el perfil, se borra
  })
  profile?: Ref<UserProfile>;
}

@Entity()
export class UserProfile {
  @PrimaryKey() id!: number;
  @Property({ type: 'text', nullable: true }) bio?: string;
  @Property({ length: 40, default: 'Europe/Madrid' }) timezone = 'Europe/Madrid';
  // INVERSO: sin owner y con mappedBy. No genera columna.
  @OneToOne(() => User, (user) => user.profile)
  user!: User;
}
one-to-one.sql
create table "user_profile" ( "id" serial primary key, "bio" text null,
  "timezone" varchar(40) not null default 'Europe/Madrid' );

alter table "user" add column "profile_id" int null;
alter table "user" add constraint "user_profile_id_unique" unique ("profile_id");
--                                                        ^^^^^^ convierte un N:1
--                          en un 1:1, porque la FK no se puede repetir.
alter table "user" add constraint "user_profile_id_foreign"
  foreign key ("profile_id") references "user_profile" ("id")
  on update cascade on delete set null;
¿En qué lado pongo el propietario? En el lado que hace la pregunta más a menudo y, sobre todo, en el lado opcional. Si user.profile_id es anulable, un usuario sin perfil es una fila normal. Ponerlo al revés (user_profile.user_id NOT NULL UNIQUE) también funciona y evita nulos, pero entonces cargar un usuario con su perfil obliga a consultar la otra tabla, porque el usuario no sabe si lo tiene. Ambas son defendibles: decídelo conscientemente y documenta el porqué.

15.8 La clase Collection a fondo

Collection<T> no es un array: es un objeto que conoce a su dueño, sabe si sus elementos están cargados y registra qué se ha añadido o quitado para que el Unit of Work genere el SQL correcto. Confundirla con un array es la fuente de la mitad de los errores de este capítulo.

MétodoQué hace¿Necesita estar inicializada?
add(...items)Añade elementos y propaga al lado propietarioSí para 1:N; en M:N puede diferirse
remove(...items)Quita elementos (borra fila pivote o pone la FK a NULL)
set(items)Reemplaza el contenido calculando la diferencia
removeAll()Vacía la colección
contains(item)Comprueba pertenenciaSí (si no, mira solo lo cargado)
count()Número de elementos ya cargados en memoria
loadCount()Lanza un SELECT COUNT(*) a la base de datosNo
getItems()Devuelve el array de entidadesSí (lanza error si no)
getIdentifiers()Devuelve solo las claves primarias
isInitialized()Indica si está cargadaNo
init(options?)Carga la colección desde la base de datosNo (es lo que la inicializa)
loadItems(options?)init() y devuelve el arrayNo
matching(options)Consulta filtrada y paginada sobre la colecciónNo

15.8.1 Colecciones no inicializadas: el error más frecuente

no-inicializada.ts · y las cuatro formas de cargarla
const project = await em.findOne(Project, 7);
// select "p0".* from "project" "p0" where "p0"."id" = 7 limit 1;  ← no toca "task"
project.tasks.isInitialized();   // false
project.tasks.count();           // ValidationError: Collection<Task> of entity
                                 // Project[7] not initialized
for (const t of project.tasks) { /* … */ }   // itera sobre NADA

// 1) populate en la consulta original: lo más habitual y lo más eficiente
const p1 = await em.findOne(Project, 7, { populate: ['tasks'] });
// 2) init() con opciones propias
await p1.tasks.init({ where: { status: { $ne: 'done' } }, orderBy: { dueDate: 'asc' },
                      populate: ['assignee'] });
// 3) loadItems(): init() + devuelve el array tipado
const tareas = await p1.tasks.loadItems({ populate: ['tags'] });
// 4) em.populate() sobre entidades ya cargadas: una sola consulta adicional
const proyectos = await em.find(Project, {});
await em.populate(proyectos, ['tasks']);
informe.service.tsINCORRECTO
const proyectos = await em.find(Project, {});
for (const p of proyectos) {
  await p.tasks.init();      // una consulta por iteración
  console.log(p.name, p.tasks.count());
}
// 1 + N consultas: con 300 proyectos, 301 viajes.
informe.service.tsCORRECTO
const proyectos = await em.find(Project, {}, { populate: ['tasks'] });
for (const p of proyectos) {
  console.log(p.name, p.tasks.count());
}
// 2 consultas fijas: los proyectos y
// select * from "task" where "project_id" in (…)

15.8.2 count(), loadCount() y matching()

Si lo único que necesitas es un número (el típico «12 comentarios»), cargar la colección entera es un despilfarro que crece con los datos. Y si necesitas una página de resultados, matching() aplica WHERE, ORDER BY, LIMIT y OFFSET en la base de datos.

contar-y-paginar.ts
await task.comments.init(); task.comments.count();
// select "c0".* from "comment" "c0" where "c0"."task_id" = 1;
// Transfiere todas las filas y construye todos los objetos: O(n) en red, memoria
// y CPU. Con 5.000 comentarios es un desastre.
await task.comments.loadCount();
// select count(*) as "count" from "comment" "c0" where "c0"."task_id" = 1;
// Una fila, un entero. El índice sobre task_id lo resuelve sin tocar la tabla.

const pagina = await task.comments.matching({
  where: { deletedAt: null }, orderBy: { createdAt: 'desc' },
  limit: 20, offset: 40, populate: ['author'],
  store: false,        // true = guarda el resultado dentro de la colección
});
// select "c0".* from "comment" "c0" where "c0"."task_id" = 1
//  and "c0"."deleted_at" is null order by "c0"."created_at" desc limit 20 offset 40;
task.comments.isInitialized();   // false: con store: false no la inicializa
Dos trampas de rendimiento

No llames a loadCount() en un bucle: eso es un N+1 de manual. Si necesitas los recuentos de muchos padres, usa una consulta agregada con group by (capítulo 16) o una propiedad @Formula.

Cuidado con store: true en matching(): marca la colección como inicializada aunque solo contenga una página. Después, count() devolverá 20 y alguien creerá que es el total; removeAll() borraría solo esos 20.

15.9 Referencias: Ref<T> y em.getReference()

Cuando cargas una tarea, MikroORM no carga su proyecto: pone en task.project un objeto no inicializado que solo conoce la clave primaria. El problema histórico es que, sin ayuda del tipado, ese objeto parece un Project completo y leer task.project.name devuelve undefined en silencio. Ref<T> resuelve el problema en tiempo de compilación: la propiedad pasa a ser un envoltorio que obliga a declarar tu intención antes de leer los datos.

sin-ref.tsINCORRECTO
@ManyToOne(() => Project)
project!: Project;

const task = await em.findOneOrFail(Task, 1);
console.log(task.project.name);
// Compila perfectamente.
// En ejecución: undefined, porque el proxy no
// está inicializado. Error silencioso.
con-ref.tsCORRECTO
@ManyToOne(() => Project, { ref: true })
project!: Ref<Project>;

const task = await em.findOneOrFail(Task, 1);
console.log(task.project.id);        // OK: la PK siempre está
// console.log(task.project.name);   // ERROR de compilación
const project = await task.project.load();
console.log(project.name);           // OK y explícito
APIQué hace¿Consulta la base de datos?
ref.load()Inicializa y devuelve la entidadSí, si no estaba cargada
ref.load('name')Carga y devuelve una sola propiedadSí, si no estaba cargada
ref.unwrap()Devuelve la entidad envuelta sin cargarlaNo
ref.$Acceso directo asumiendo que ya está cargadaNo (falla si no lo está)
wrap(entidad).init()Inicializa un proxy sin Ref
em.getReference(E, id)Crea una referencia a partir de un idNo
ref(entidad) / rel(...)Helpers de construcción y de tipadoNo

15.9.1 em.getReference(): asignar sin consultar

Es una de las herramientas más infravaloradas del ORM. Para grabar una clave foránea no necesitas los datos del padre: solo su identificador. em.getReference() construye un objeto gestionado por el Identity Map que representa esa fila sin leerla.

crear-tarea.service.tsINCORRECTO
async crear(dto: CreateTaskDto) {
  // Dos consultas solo para tener los objetos…
  const project = await this.em.findOneOrFail(Project, dto.projectId);
  const assignee = await this.em.findOneOrFail(User, dto.assigneeId);

  const task = this.em.create(Task, {
    title: dto.title, project, assignee,
  });
  await this.em.flush();
  return task;
}
// select * from "project" where "id" = 7 limit 1;
// select * from "user"    where "id" = 4 limit 1;
// insert into "task" (…) values (…);
crear-tarea.service.tsCORRECTO
async crear(dto: CreateTaskDto) {
  // Cero consultas: la integridad la garantiza la FK.
  const task = this.em.create(Task, {
    title: dto.title,
    project: this.em.getReference(Project, dto.projectId),
    assignee: this.em.getReference(User, dto.assigneeId),
  });
  await this.em.flush();
  return task;
}
// insert into "task" (…) values (…);
// Si el proyecto no existe, la base de datos rechaza
// el INSERT por violación de clave foránea: 1 viaje.
Cuándo compensa cargar de verdad Usa getReference() cuando solo vayas a escribir la relación. Carga la entidad cuando necesites sus datos para validar reglas de negocio («no se pueden crear tareas en un proyecto archivado»): ahí la consulta no es un desperdicio, es la comprobación que quieres hacer. Lo que nunca tiene sentido es cargar la entidad completa solo para usarla como valor de una clave foránea.
variantes.ts · mapToPk, rel(), ref() y wrap()
// (a) mapToPk: la propiedad ES el identificador. Ni proxy ni Ref.
@ManyToOne(() => Project, { mapToPk: true })
project!: number;
task.project = 8;   // útil en importaciones masivas o entidades de solo escritura;
                    // el precio es perder la navegación (no hay .load()).

// (b) rel(): tipado cómodo cuando NO usas ref: true; ref(): construye un Ref
import { rel, ref, wrap, type Rel } from '@mikro-orm/core';
@ManyToOne(() => Project) project!: Rel<Project>;   // evita tipos circulares en TS
user.profile = ref(em.create(UserProfile, { timezone: 'Europe/Madrid' }));
// (c) wrap(): utilidad genérica que funciona con cualquier entidad
await wrap(task.assignee).init();
const plano = wrap(task).toObject();
Diferencias con MikroORM v5 En la v5 el tipo se llamaba IdentifiedReference<T> y la opción del decorador era wrappedReference: true; en la v6 son Ref<T> y ref: true. Igualmente, onDelete y onUpdateIntegrity pasan a llamarse deleteRule y updateRule, y populate: true se sustituye por populate: ['*']. Los nombres antiguos siguen apareciendo en muchísimos tutoriales: si copias código y el compilador no reconoce el tipo, estás mezclando versiones.

15.10 Cascadas y borrado

«Cascada» significa que una operación sobre una entidad se propaga a sus relacionadas. El problema es que hay dos mecanismos distintos con el mismo nombre: la cascada del ORM (opción cascade) y la de la base de datos (opción deleteRule). Se configuran en el mismo decorador, se parecen mucho y hacen cosas diferentes.

Valor de cascadeQué propagaPor defecto
Cascade.PERSISTAl persistir el padre, persiste los hijos nuevos que cuelguen de la relaciónActivo
Cascade.MERGEAl fusionar el padre en el contexto, fusiona los hijosActivo
Cascade.REMOVEAl borrar el padre, borra los hijos con un DELETE por entidadInactivo
Cascade.SCHEDULE_ORPHAN_REMOVALLo que activa internamente orphanRemoval: trueInactivo
Cascade.ALLTodo lo anteriorInactivo
cascade-persist.ts · lo que ya funciona sin configurar nada
const project = em.create(Project, { name: 'Rediseño', team: teamRef });
const t1 = em.create(Task, { title: 'Wireframes', project: ref(project) });
const t2 = em.create(Task, { title: 'Prototipo',  project: ref(project) });
await em.persistAndFlush(project);   // PERSIST está activo por defecto
// insert into "project" ("name","team_id") values ('Rediseño', 3) returning "id";
// insert into "task" ("title","project_id") values ('Wireframes', 12), ('Prototipo', 12);
// Nunca hizo falta persistir t1 ni t2 explícitamente.

15.10.1 orphanRemoval frente a Cascade.REMOVE

La diferencia cabe en una frase: Cascade.REMOVE borra los hijos cuando se borra el padre; orphanRemoval borra además los hijos que dejan de estar en la colección, aunque el padre siga vivo. orphanRemoval: true implica Cascade.REMOVE, así que es estrictamente más fuerte.

Con cascade: [Cascade.REMOVE]
@OneToMany(() => Attachment, (a) => a.task,
           { cascade: [Cascade.REMOVE] })
attachments = new Collection<Attachment>(this);

// CASO A · se borra la tarea
em.remove(task); await em.flush();
//   delete from "attachment" where "id" in (…);
//   delete from "task" where "id" = 1;          ✔
// CASO B · se quita un adjunto de la colección
task.attachments.remove(adjunto); await em.flush();
//   update "attachment" set "task_id" = null
//     where "id" = 5;
//   ✘ La fila SIGUE EXISTIENDO, huérfana. Y si
//   task_id es NOT NULL → error de integridad.
Con orphanRemoval: true
@OneToMany(() => Attachment, (a) => a.task,
           { orphanRemoval: true })
attachments = new Collection<Attachment>(this);

// CASO A · se borra la tarea (igual que antes)
em.remove(task); await em.flush();
//   delete from "attachment" where "id" in (…);
//   delete from "task" where "id" = 1;          ✔
// CASO B · se quita un adjunto de la colección
task.attachments.remove(adjunto); await em.flush();
//   delete from "attachment" where "id" = 5;    ✔
//   El hijo desvinculado se considera basura y
//   se elimina. Esto es COMPOSICIÓN.
La prueba definitiva para decidir «Si este hijo se desvincula de su padre, ¿tiene sentido que siga existiendo?». Un adjunto sin tarea es un fichero fantasma que nadie verá: composición, orphanRemoval: true. Una tarea sin asignado sigue siendo una tarea válida: agregación, nada de cascadas. Una etiqueta que ya no está en ninguna tarea sigue siendo reutilizable: agregación.

15.10.2 Cascada del ORM frente a cascada de la base de datos

deleteRuleSQLComportamiento al borrar el padreCuándo usarlo
'cascade'on delete cascadeLa base de datos borra las filas hijasComposición estricta: comentarios, adjuntos, líneas de pedido
'set null'on delete set nullPone la FK a NULL (la columna debe ser anulable)Relaciones opcionales: el asignado de una tarea
'restrict'on delete restrictImpide el borrado si hay hijos, comprobado de inmediatoDatos maestros e históricos que no deben desaparecer
'no action'on delete no actionComo restrict, pero la comprobación puede diferirse al final de la transacciónValor por defecto del estándar SQL
  ¿QUIÉN BORRA A LOS HIJOS?      em.remove(project); await em.flush();
  ─────────────────────────────────────────────────────────────────────────────
   ┌─────────────────────────────────────────────────────────────────────┐
   │ 1. CASCADA DEL ORM     @OneToMany(…, { cascade: [Cascade.REMOVE] }) │
   │                        u orphanRemoval: true                        │
   │    · CARGA la colección de hijos (SELECT extra si hace falta)       │
   │    · Emite un DELETE por entidad                                    │
   │    · DISPARA @BeforeDelete / @AfterDelete y los subscribers         │
   │    · Actualiza el Identity Map: la memoria queda coherente          │
   │    · Coste: varias consultas. Más lento.                            │
   └─────────────────────────────────────────────────────────────────────┘
   ┌─────────────────────────────────────────────────────────────────────┐
   │ 2. CASCADA DE LA BD    @ManyToOne(…, { deleteRule: 'cascade' })     │
   │    · UNA sentencia: delete from "project" where "id" = 7            │
   │    · El motor borra las filas hijas internamente                    │
   │    · NO dispara ningún hook ni subscriber del ORM                   │
   │    · El Identity Map NO se entera: quedan objetos zombis en memoria │
   │    · Coste: mínimo. Con diferencia, lo más rápido.                  │
   └─────────────────────────────────────────────────────────────────────┘

   SI ACTIVAS LAS DOS: gana la del ORM porque actúa antes (ya ha borrado los hijos
   cuando llega el DELETE del padre). La de la BD queda como red de seguridad para
   escrituras que no pasen por el ORM: combinación defendible, pero DELIBERADA.
Por qué mezclarlas sin saberlo duele

Hooks que no se ejecutan. Si confías en un @BeforeDelete del adjunto para borrar el fichero de S3 y la base de datos borra la fila por su cuenta, el fichero queda huérfano en el bucket para siempre: la cascada de base de datos no ejecuta código TypeScript.

Objetos zombis. Tras un DELETE en cascada del motor, el Identity Map sigue guardando los hijos como si existieran; en la misma petición podrías modificarlos y emitir un UPDATE sobre filas inexistentes que no afecta a nada y pasa desapercibido.

Borrados masivos accidentales. Un on delete cascade encadenado a tres niveles convierte «borro un equipo» en «borro proyectos, tareas, comentarios y adjuntos de media empresa» con una sola sentencia. Para datos importantes, prefiere restrict y borrado lógico.

CriterioComposición (el hijo no vive sin el padre)Agregación (el hijo es independiente)
Ejemplo del dominioTask → Comment, Task → AttachmentTask → User (assignee), Task ↔ Tag
nullable en la FKfalseNormalmente true
orphanRemovaltruefalse
deleteRule'cascade''set null' o 'restrict'
¿Se accede al hijo sin el padre?No: su repositorio casi no se usaSí: tiene endpoints propios
Notación UMLRombo rellenoRombo vacío

15.11 Estrategias de carga

EstrategiaCómo se activaVentajaRiesgo
Perezosa (por defecto)No hacer nadaConsultas mínimas; solo traes lo que pidesSi accedes sin cargar, obtienes una referencia vacía
Ansiosaeager: true en el decoradorNunca falta el datoGlobal e irrevocable; infla todas las consultas (15.4.3)
Explícitapopulate en la consultaLocal, visible y ajustable por endpointHay que acordarse de escribirlo
Norma profesional Carga perezosa en el modelo y populate explícito en cada caso de uso. Así el coste de cada endpoint está escrito en el propio endpoint, se revisa en un pull request y se cambia sin miedo a romper otros.

15.11.1 SELECT_IN frente a JOINED

estrategias.sql · la misma consulta de dos maneras
-- em.find(Project, { archived: false }, { populate: ['tasks', 'tasks.assignee'] })
-- (A) LoadStrategy.SELECT_IN — valor por defecto en la v6
select "p0".* from "project" "p0" where "p0"."archived" = false;      -- 3 proyectos
select "t0".* from "task" "t0" where "t0"."project_id" in (7, 8, 11); -- 40 tareas
select "u0".* from "user" "u0" where "u0"."id" in (4, 9, 21);         -- 3 usuarios
-- 3 consultas; cada fila aparece UNA vez y el ORM las cose con el Identity Map.

-- (B) LoadStrategy.JOINED — añadiendo strategy: 'joined'
select "p0"."id", "p0"."name", "t1"."id" as "t1__id", "t1"."title" as "t1__title",
       "u2"."id" as "u2__id", "u2"."email" as "u2__email"
  from "project" "p0"
  left join "task" "t1" on "t1"."project_id" = "p0"."id"
  left join "user" "u2" on "u2"."id" = "t1"."assignee_id" where "p0"."archived" = false;
-- 1 sola consulta… pero los datos del proyecto se REPITEN una vez por tarea:
-- 40 filas arrastrando el nombre del proyecto. Producto cartesiano parcial.
AspectoSELECT_INJOINED
Viajes a la base de datos1 por nivel de relación1 en total
Volumen transferidoMínimo, sin duplicadosMultiplicado por la cardinalidad de las colecciones
Relaciones a-unoCorrectoMejor: no duplica nada y ahorra un viaje
Relaciones a-muchosMejor en generalPeligroso: dos colecciones en el mismo join multiplican filas
limit / offsetSe aplica limpiamente al padreNecesita subconsulta; más complejo y lento
Filtrar por un campo de la relaciónRequiere condición aparteDirecto: ya está en el JOIN
Latencia de red altaPenaliza (varios viajes)Gana
El multiplicador oculto Con JOINED y dos colecciones al mismo nivel (tasks.comments y tasks.tags), el número de filas es el producto: una tarea con 20 comentarios y 5 etiquetas devuelve 100 filas para 25 objetos distintos. Con tres colecciones, la explosión es cúbica. Por eso select-in es el valor por defecto en MikroORM v6.
configurar-estrategia.ts
import { LoadStrategy } from '@mikro-orm/core';
// (a) Global, en la configuración del ORM
export default defineConfig({ loadStrategy: LoadStrategy.SELECT_IN, /* … */ });
// (b) Por relación, en el decorador
@ManyToOne(() => Project, { ref: true, strategy: LoadStrategy.JOINED })
project!: Ref<Project>;
// (c) Por consulta: la más útil, porque es la más informada
await em.find(Project, { archived: false },
              { populate: ['tasks'], strategy: LoadStrategy.JOINED });

15.11.2 populate: rutas anidadas, comodines y filtros

populate.ts
// Rutas anidadas separadas por punto; el tipado las valida (escribir
// 'project.ownr' es un error de compilación).
await em.find(Task, {}, { populate: ['project.team', 'assignee', 'tags'] });
// Todo lo que cuelgue de la entidad (v6; en v5 era populate: true)
await em.find(Task, {}, { populate: ['*'] });
// Carga parcial: solo estas columnas. Menos ancho de banda y menos memoria.
await em.find(Task, {}, { fields: ['title', 'status', 'project.name'] });

// populateWhere: condiciones aplicadas a las relaciones cargadas
await em.find(Project, { archived: false }, {
  populate: ['tasks'], populateWhere: { tasks: { status: { $ne: 'done' } } },
});
// select "t0".* from "task" "t0"
//  where "t0"."project_id" in (7, 8) and "t0"."status" != 'done';
// PopulateHint.INFER: aplica a las relaciones la MISMA condición del where principal
import { PopulateHint } from '@mikro-orm/core';
await em.find(Project, { tasks: { status: 'blocked' } }, {
  populate: ['tasks'], populateWhere: PopulateHint.INFER,   // solo las bloqueadas
});
// Por defecto es PopulateHint.ALL: filtra los PROYECTOS por tener alguna tarea
// bloqueada, pero luego carga TODAS las tareas de esos proyectos.

// populateOrderBy: ordenar las colecciones cargadas
await em.find(Project, {}, { populate: ['tasks'],
                             populateOrderBy: { tasks: { dueDate: 'asc' } } });
populate: ['*'] no es un atajo inocente Trae todas las relaciones, incluidas las colecciones que quizá tengan miles de filas. Es aceptable en un script puntual o en un test; en un endpoint de producción es una bomba de relojería que explota el día que un cliente grande usa la aplicación.

15.11.3 El problema N+1

  CÓDIGO INOCENTE                         SQL REALMENTE EJECUTADO
  ───────────────────────────────────     ────────────────────────────────────────
  const tasks = await em.find(Task, {});  select * from "task";                 (1)
                                                                             ─────
  for (const t of tasks) {                select * from "project" where id = 7;  (2)
    const p = await t.project.load();     select * from "project" where id = 8;  (3)
    console.log(t.title, p.name);         select * from "project" where id = 11; (4)
  }                                       …                                       …
                                          select * from "project" where id = 26; (N+1)

  Con 500 tareas: 501 consultas. Cada una con su latencia (1-3 ms en local, 20-50 ms
  contra una base de datos remota): 501 × 30 ms = 15 segundos. El Identity Map evita
  repetir los proyectos YA cargados, pero no los viajes de los que aún no lo están.
listado.service.tsINCORRECTO
const tasks = await this.em.find(Task, { status: 'todo' });
return Promise.all(tasks.map(async (t) => ({
  title: t.title,
  project: (await t.project.load()).name,      // 1 por tarea
  assignee: t.assignee ? (await t.assignee.load()).email : null,
})));
// 1 + N + N consultas.
listado.service.tsCORRECTO
const tasks = await this.em.find(Task, { status: 'todo' },
  { populate: ['project', 'assignee'] });
return tasks.map((t) => ({
  title: t.title,
  project: t.project.$.name,               // ya cargado
  assignee: t.assignee?.$.email ?? null,
}));
// 3 consultas fijas, sea cual sea el número de tareas.

Las tres formas de resolverlo, de más a menos recomendable: populate explícito en la consulta que ya haces, que cubre el 90 % de los casos; em.populate(entidades, [...]) a posteriori, cuando no controlas la consulta original; y una consulta agregada con QueryBuilder cuando lo que necesitas son recuentos o sumas por padre, donde un group by sustituye a mil colecciones cargadas. Para detectarlo, activa debug: true en desarrollo: si al pintar una lista ves una ráfaga de consultas idénticas que solo cambian en el identificador, tienes un N+1. El capítulo 16 profundiza en el diagnóstico y en QueryBuilder.

15.12 Herencia de entidades

15.12.1 Single Table Inheritance

MikroORM implementa la herencia de tabla única: toda la jerarquía se guarda en una sola tabla y una columna discriminadora indica de qué clase es cada fila.

                  ┌───────────────────────────────────────┐
                  │  abstract class BaseTask              │  discriminatorColumn: 'type'
                  │  id, title, project, createdAt        │
                  └───────────────────┬───────────────────┘
            ┌─────────────────────────┼─────────────────────────┐
   ┌────────┴─────────┐     ┌─────────┴─────────┐     ┌─────────┴─────────┐
   │ Bug              │     │ Feature           │     │ Chore             │
   │ severity         │     │ storyPoints       │     │ (sin extras)      │
   │ stepsToReproduce │     │ acceptanceCriteria│     │                   │
   └──────────────────┘     └───────────────────┘     └───────────────────┘

   UNA SOLA TABLA "base_task"
   ┌────┬─────────┬────────────┬──────────┬──────────────┬─────────────────┐
   │ id │ type    │ title      │ severity │ story_points │ steps_to_reprod │
   ├────┼─────────┼────────────┼──────────┼──────────────┼─────────────────┤
   │  1 │ bug     │ Login KO   │ high     │ NULL         │ 1. Abrir…       │
   │  2 │ feature │ Exportar   │ NULL     │ 8            │ NULL            │
   │  3 │ chore   │ Actualizar │ NULL     │ NULL         │ NULL            │
   └────┴─────────┴────────────┴──────────┴──────────────┴─────────────────┘
     select … where "type" = 'bug' → así se filtra por tipo; las columnas NULL
     son inevitables, porque ninguna subclase usa las de las demás.
base-task.entity.ts · jerarquía STI completa
@Entity({
  discriminatorColumn: 'type',
  discriminatorMap: { bug: 'Bug', feature: 'Feature', chore: 'Chore' },
  abstract: true,          // no se instancia directamente
})
export abstract class BaseTask {
  @PrimaryKey() id!: number;
  @Property({ length: 200 }) title!: string;
  // La propiedad discriminadora se declara para poder leerla; la rellena el ORM.
  @Enum({ items: () => ['bug', 'feature', 'chore'] })
  type!: 'bug' | 'feature' | 'chore';
}

@Entity({ discriminatorValue: 'bug' })
export class Bug extends BaseTask {
  @Enum({ items: () => ['low', 'medium', 'high', 'critical'] })
  severity: 'low' | 'medium' | 'high' | 'critical' = 'medium';
  @Property({ type: 'text', nullable: true }) stepsToReproduce?: string;
}
@Entity({ discriminatorValue: 'feature' })
export class Feature extends BaseTask {
  @Property({ nullable: true }) storyPoints?: number;
}

@Entity({ discriminatorValue: 'chore' }) export class Chore extends BaseTask {}
sti.sql · consultas y polimorfismo
-- em.find(Bug, { severity: 'critical' })
select "b0".* from "base_task" "b0"
 where "b0"."type" = 'bug' and "b0"."severity" = 'critical';
--     ^^^^^^^^^^^^^^^^^^ el ORM añade el discriminador automáticamente
-- em.find(BaseTask, {})  → devuelve Bug, Feature y Chore mezclados
select "b0".* from "base_task" "b0";
-- El ORM lee la columna "type" y construye la clase correcta de cada fila:
-- polimorfismo real, con una consulta, sin joins y sin UNION.
Ventajas de STIInconvenientes de STI
Una sola consulta para toda la jerarquía, sin JOIN ni UNIONColumnas nulas por diseño: pierdes el NOT NULL justo donde más falta hace
Polimorfismo natural: las relaciones apuntan a la base y aceptan cualquier subclaseRestricciones difíciles: «severity obligatorio en los bugs» exige un CHECK condicional a mano
Las claves foráneas funcionan: hay una sola tabla a la que apuntarLa tabla engorda con cada subclase; con diez muy distintas es inmanejable
Cambiar el tipo de una fila es un UPDATE de una columnaÍndices menos eficientes por la dispersión de valores NULL

15.12.2 Mapped superclass: compartir campos sin crear tabla

El uso más frecuente de la herencia no es el polimorfismo, sino evitar repetir el mismo bloque de campos en veinte entidades. Para eso existe la superclase abstracta sin discriminador: sus propiedades se copian en cada entidad que la extienda y no se crea ninguna tabla base.

base.entity.ts
// abstract: true SIN discriminatorColumn → no genera tabla propia.
@Entity({ abstract: true })
export abstract class BaseEntity {
  [OptionalProps]?: 'createdAt' | 'updatedAt';
  @PrimaryKey() id!: number;
  @Property() createdAt: Date = new Date();
  @Property({ onUpdate: () => new Date() }) updatedAt: Date = new Date();
}

@Entity()
export class Tag extends BaseEntity {
  @Property({ unique: true, length: 40 }) name!: string;
}
// create table "tag" ("id" serial primary key, "created_at" timestamptz not null,
//   "updated_at" timestamptz not null, "name" varchar(40) not null);
// Esas tres columnas se repiten en cada entidad que herede.

15.12.3 Qué NO soporta MikroORM y cuándo no usar herencia

EstrategiaDescripción¿MikroORM?Alternativa
Single TableUna tabla para toda la jerarquía + discriminador
Mapped superclassCampos comunes replicados, sin tabla base (abstract: true)
Joined tableTabla base + una tabla por subclase unidas por JOINNoModelarlo a mano: entidad base + @OneToOne a la específica
Table per concrete classUna tabla completa e independiente por subclaseNoEntidades separadas que comparten una mapped superclass
Cuándo la herencia es mala idea en el modelo de datos La herencia expresa «es un» y es permanente: una fila no puede cambiar de clase sin trucos. Si en tu dominio un elemento puede ser dos cosas a la vez (una tarea que es bug y deuda técnica) o cambiar de naturaleza con el tiempo, la herencia es el modelo equivocado. La alternativa es la composición: un campo kind más uno o varios embeddables o entidades satélite opcionales con los datos específicos. Es más flexible, admite combinaciones y no obliga a migrar el esquema cada vez que aparece un tipo nuevo. La regla de Clean Code se aplica igual en el modelo de datos que en el código: prefiere composición a herencia, y reserva la herencia para jerarquías cerradas, estables y realmente excluyentes.

15.13 Embeddables y value objects

No todo lo que agrupa datos merece ser una entidad. Una dirección postal no tiene identidad propia: dos direcciones con los mismos campos son la misma dirección. Eso es un value object, y se modela con @Embeddable: una clase con comportamiento que se guarda dentro de la tabla de su dueño, sin fila ni identificador propios.

address.embeddable.ts + team.entity.ts
@Embeddable()
export class Address {
  @Property({ length: 120 }) street!: string;
  @Property({ length: 60 })  city!: string;
  @Property({ length: 10 })  postalCode!: string;
  @Property({ length: 2, default: 'ES' }) country = 'ES';
  constructor(street: string, city: string, postalCode: string, country = 'ES') {
    this.street = street; this.city = city;
    this.postalCode = postalCode; this.country = country;
  }
  // Comportamiento propio: esto lo convierte en un value object de verdad.
  format(): string { return `${this.street}, ${this.postalCode} ${this.city}`; }
}

@Entity()
export class Team {
  @Embedded(() => Address, { prefix: 'billing_' })             // (a) en línea con prefijo
  billingAddress!: Address;
  @Embedded(() => Address, { prefix: false, nullable: true })  // (b) sin prefijo
  address?: Address;
  @Embedded(() => Address, { object: true, nullable: true })   // (c) columna JSON
  shippingAddress?: Address;
  @Embedded(() => Address, { array: true })                    // (d) array: siempre JSON
  previousAddresses: Address[] = [];
}
embeddables.sql
create table "team" (
  "id"                  serial primary key,
  "billing_street"      varchar(120) not null,   -- ┐
  "billing_city"        varchar(60)  not null,   -- │ (a) prefix: 'billing_'
  "billing_postal_code" varchar(10)  not null,   -- ┘  (+ billing_country)
  "street"              varchar(120) null,       -- ┐ (b) prefix: false
  "city"                varchar(60)  null,       -- ┘  (+ postal_code, country)
  "shipping_address"    jsonb null,              --   (c) object: true
  "previous_addresses"  jsonb not null default '[]' );  -- (d) array: true

-- En modo EN LÍNEA se indexa y se consulta como cualquier columna:
create index "team_billing_city_index" on "team" ("billing_city");
select * from "team" where "billing_city" = 'Bilbao';
-- En modo OBJETO hacen falta operadores JSON y es más difícil de indexar:
select * from "team" where "shipping_address"->>'city' = 'Bilbao';
CriterioEn línea (columnas)Objeto (JSON)
Consultas y filtrosSQL normal, rápidoOperadores JSON, más lento y verboso
ÍndicesNormalesSolo GIN o funcionales
Restricciones (NOT NULL, CHECK)No: el motor no valida el contenido
Cambios de estructuraRequieren migraciónNo la requieren… pero conviven versiones distintas
Arrays de embebidosNo es posibleObligatorio

15.13.1 Value objects: por qué mejoran el diseño

obsesion-por-primitivos.tsINCORRECTO
@Entity()
class Invoice {
  @Property() amountCents!: number;
  @Property({ length: 3 }) currency!: string;
  @Property() startDate!: Date;
  @Property() endDate!: Date;
}
// Nada impide esto:
invoice.amountCents = -50;
invoice.currency = 'euros';                  // ¿ISO? ¿nombre?
invoice.endDate = new Date('2020-01-01');
invoice.startDate = new Date('2024-01-01');  // fin antes que inicio

// Y sumar es responsabilidad del que llama:
const total = a.amountCents + b.amountCents; // ¿misma moneda?
value-objects.tsCORRECTO
@Embeddable()
export class Money {
  @Property() cents!: number;
  @Property({ length: 3 }) currency!: string;
  constructor(cents: number, currency: string) {
    if (!Number.isInteger(cents) || cents < 0) throw new Error('Importe inválido');
    if (!/^[A-Z]{3}$/.test(currency)) throw new Error('Moneda no ISO 4217');
    this.cents = cents; this.currency = currency;
  }
  add(other: Money): Money {
    if (this.currency !== other.currency) throw new Error('Monedas distintas');
    return new Money(this.cents + other.cents, this.currency);
  }
}

@Entity()
class Invoice {
  @Embedded(() => Money, { prefix: 'total_' }) total!: Money;
  @Embedded(() => DateRange, { prefix: 'period_' }) period!: DateRange;
  // DateRange valida en su constructor que fin >= inicio
}

Las reglas dejan de estar repartidas por los servicios y viven en un único sitio: una vez construido, es imposible que exista un valor inválido. Es el principio de responsabilidad única de SOLID aplicado a los datos y la base del diseño táctico de DDD que se desarrolla en el capítulo 20.

15.13.2 Tipos personalizados con Type<JSType, DBType>

Un embeddable ocupa varias columnas. Cuando lo que quieres es una única columna con conversión entre la representación de la base de datos y una clase de TypeScript, la herramienta es Type.

email.type.ts · tipo personalizado completo
import { Type, Platform, ValidationError, EntityProperty } from '@mikro-orm/core';
export class Email {
  private constructor(public readonly value: string) {}
  static create(raw: string): Email {
    const limpio = raw.trim().toLowerCase();
    if (!/^[^@\s]+@[^@\s]+\.[a-z]{2,}$/i.test(limpio)) {
      throw new ValidationError(`Email inválido: ${raw}`);
    }
    return new Email(limpio);
  }
}

// Type<TipoEnJS, TipoEnBD>
export class EmailType extends Type<Email | undefined, string | undefined> {
  // Objeto de TypeScript → valor que se manda a la base de datos
  convertToDatabaseValue(value: Email | string | undefined): string | undefined {
    if (value == null) return undefined;
    return value instanceof Email ? value.value : Email.create(value).value;
  }
  // Valor leído de la base de datos → objeto de TypeScript
  convertToJSValue(value: string | undefined): Email | undefined {
    return value == null ? undefined : Email.create(value);
  }
  getColumnType(prop: EntityProperty, platform: Platform): string {
    return platform.getVarcharTypeDeclarationSQL({ length: 254 });
  }
  compareAsType(): string { return 'string'; }   // cómo detectar cambios
}

// Uso: @Property({ type: EmailType, unique: true })  email!: Email;
// em.create(User, { email: Email.create('  ANA@Empresa.COM ') })
//   → insert into "user" ("email") values ('ana@empresa.com');

15.13.3 Columnas JSON y JSONB: cuándo son una trampa

Úsalas cuando…Evítalas cuando…
El contenido es realmente libre: campos personalizados de un formulario configurableVas a filtrar u ordenar por su contenido: los operadores JSON no usan los índices normales
Guardas la carga original de un webhook para auditoría, sin consultarlaNecesitas integridad: dentro del JSON no hay NOT NULL, ni UNIQUE, ni claves foráneas
Son preferencias que solo se leen enteras y nunca se filtranLa estructura va a evolucionar: acabarás con cinco versiones del mismo objeto en la tabla
Necesitas un array de value objects (array: true lo exige)Es una relación disfrazada: {"tagIds": [1,2,3]} reinventa una tabla intermedia sin integridad
JSON frente a JSONB en PostgreSQL json guarda el texto tal cual (escritura rápida, lectura lenta); jsonb guarda una representación binaria normalizada que admite índices GIN y operadores de contención, y es lo que quieres en el 99 % de los casos. MikroORM usa jsonb por defecto en PostgreSQL.

15.14 Relaciones especiales

15.14.1 Auto-referencia: árboles de categorías y comentarios anidados

Una entidad puede relacionarse consigo misma: jerarquías de categorías, organigramas o comentarios con respuestas. El modelo más simple es la lista de adyacencia, en la que cada nodo guarda un puntero a su padre.

category.entity.ts · adjacency list
@Entity()
export class Category {
  @PrimaryKey() id!: number;
  @Property({ length: 80 }) name!: string;

  // Auto-referencia: la raíz tiene parent = null
  @ManyToOne(() => Category, { nullable: true, ref: true, deleteRule: 'cascade' })
  parent?: Ref<Category>;

  @OneToMany(() => Category, (c) => c.parent)
  children = new Collection<Category>(this);
}
  ÁRBOL                        TABLA (adjacency list)     ¿Cómo obtengo TODA la rama?
  ─────────────────────        ──────────────────────     ───────────────────────────
  Backend (1)                  ┌────┬──────────┬────────┐  Con N consultas (una por
   ├─ API (2)                  │ id │ name     │ parent │  nivel) o con SQL recursivo:
   │   ├─ REST (4)             ├────┼──────────┼────────┤
   │   └─ GraphQL (5)          │  1 │ Backend  │  NULL  │  with recursive t as (
   └─ Datos (3)                │  2 │ API      │    1   │    select * from category
       └─ SQL (6)              │  3 │ Datos    │    1   │     where id = 1
                               │  4 │ REST     │    2   │    union all
                               │  5 │ GraphQL  │    2   │    select c.* from category c
                               │  6 │ SQL      │    3   │      join t on c.parent_id = t.id
                               └────┴──────────┴────────┘  ) select * from t;
ModeloCómo se guardaLeer una ramaMover un nodoCuándo elegirlo
Lista de adyacenciaparent_idConsulta recursiva (CTE) o N consultasTrivial: un UPDATEPor defecto. Árboles poco profundos o motor con CTE recursivas
Path materializadoColumna path tipo '/1/2/4/'Una consulta: path LIKE '/1/2/%'Reescribir el path de todo el subárbolMuchas lecturas de ramas, movimientos raros: menús, catálogos
Nested setColumnas lft y rgtMuy rápida: lft between … and …Recalcular medio árbol; requiere bloqueoÁrboles enormes casi inmutables con lecturas masivas
path.sql · el modelo de path materializado en acción
-- Entidad: @Property({ length: 255, index: true }) path!: string;   ('/1/2/')
-- Todos los descendientes de "API" (id 2), a cualquier profundidad:
select * from "category" where "path" like '/1/2/%' order by "path";
-- Una sola consulta que usa el índice de prefijo: imbatible en lectura.
-- Los ancestros se extraen del propio path en memoria, sin consultar: [1, 2]
-- El precio: mover "API" bajo "Datos" obliga a reescribir el subárbol entero
update "category" set "path" = replace("path", '/1/2/', '/1/3/2/')
 where "path" like '/1/2/%';
Recomendación práctica Empieza siempre con lista de adyacencia: es la más simple y la que mejor mantiene la integridad. Si al medir detectas que las lecturas de ramas completas son el cuello de botella, añade una columna path derivada (mantenida por un hook o un trigger) sin eliminar parent_id. Nested set solo se justifica en catálogos gigantes y estáticos.

15.14.2 Relaciones polimórficas

Una relación polimórfica es aquella cuya clave foránea puede apuntar a tablas distintas según un campo de tipo: «un comentario puede colgar de una tarea, de un proyecto o de un documento». Es habitual en frameworks dinámicos y una fuente constante de problemas en SQL.

polimorfico.tsINCORRECTO
@Entity()
class Comment {
  @Property() commentableType!: string; // 'task' | 'project'
  @Property() commentableId!: number;   // ← sin FK posible
}
// · Ninguna clave foránea: la base de datos NO puede
//   garantizar que el id exista. Habrá huérfanos.
// · Los JOIN necesitan un CASE o varias UNION.
// · Borrar una tarea deja comentarios apuntando a la nada.
// · El ORM no puede navegar: comment.commentable no existe.
alternativas.tsCORRECTO
// OPCIÓN A · Claves foráneas anulables excluyentes
@Entity()
class Comment {
  @ManyToOne(() => Task, { nullable: true, ref: true })
  task?: Ref<Task>;
  @ManyToOne(() => Project, { nullable: true, ref: true })
  project?: Ref<Project>;
  // + CHECK: exactamente una debe ser NOT NULL
}

// OPCIÓN B · Entidad base común con STI
//   Commentable (STI) ← Task, Project
//   Comment.commentable → Commentable   (FK real)

// OPCIÓN C · Una tabla de comentarios por tipo
//   task_comment, project_comment
//   Duplica esquema, pero es la más rápida y segura.
Qué ofrece MikroORM MikroORM no implementa relaciones polimórficas al estilo de Rails o Laravel. Lo que sí tiene son embeddables polimórficos: una jerarquía de clases @Embeddable con discriminador, útil para guardar variantes de un value object dentro de la misma entidad. Para relaciones, la opción A es la habitual con dos o tres tipos; con más de cuatro, la opción B es más limpia.

15.14.3 Claves compuestas en relaciones

Cuando la clave primaria está formada por varias columnas (como TeamMembership), cualquier relación que apunte a ella arrastra todas esas columnas.

compuestas.ts
@ManyToOne(() => TeamMembership, { ref: true })
membership!: Ref<TeamMembership>;

// Buscar por clave compuesta: objeto con todas las partes, o tupla
const m  = await em.findOne(TeamMembership, { user: 4, team: 3 });
const m2 = await em.findOne(TeamMembership, [4, 3]);

/*  create table "membership_note" ( "id" serial primary key,
      "membership_user_id" int not null,   -- ┐ una sola FK lógica
      "membership_team_id" int not null,   -- ┘ repartida en dos columnas
      "note"               text not null );
    alter table "membership_note" add constraint "membership_note_foreign"
      foreign key ("membership_user_id", "membership_team_id")
      references "team_membership" ("user_id", "team_id") on update cascade;  */
El coste de las claves compuestas Son correctas desde el punto de vista relacional y expresan la unicidad de forma natural, pero se propagan: cada tabla que las referencie hereda todas sus columnas, los índices engordan y las URLs de la API se complican (/memberships/4-3). Alternativa pragmática: clave primaria artificial más una restricción UNIQUE (user_id, team_id). Obtienes la misma garantía con relaciones de una sola columna, y es lo recomendable cuando la entidad pivote va a ser referenciada por otras.

15.15 Serialización de entidades con relaciones

Convertir entidades en JSON es donde estallan a la vez todos los problemas de modelado: ciclos infinitos, datos sensibles filtrados y consultas disparadas por accidente.

   task.toJSON()
      └─ project: project.toJSON()
            └─ tasks: [ task.toJSON()
                  └─ project: project.toJSON()
                        └─ … RangeError: Maximum call stack size exceeded

   Toda relación BIDIRECCIONAL es un ciclo en potencia. MikroORM corta los ciclos
   en su propio serializador (toObject/toJSON) registrando las entidades ya
   visitadas; un JSON.stringify() sobre estructuras planas ya clonadas, NO.
Dónde ocurre de verdad en NestJS Si un controlador devuelve una entidad, Nest la pasa por JSON.stringify. Y si además transformas con class-transformer o con un interceptor de serialización, el objeto recorrido puede no ser el proxy del ORM sino un clon plano, donde no hay protección contra ciclos. El síntoma es un RangeError en producción con una traza de miles de líneas.
serializacion.ts · las herramientas del ORM
@Entity()
export class User {
  @Property({ hidden: true })                       // (1) nunca aparece en la salida
  passwordHash!: string;
  @Property({ serializer: (v: Date) => v.toISOString().slice(0, 10) })  // (2)
  birthDate!: Date;
  @Property({ serializedName: 'displayName' })      // (3) cambia la clave del JSON
  name!: string;
  @Property({ persist: false })                     // (4) getter calculado
  get initials(): string { return this.name.split(' ').map((p) => p[0]).join(''); }
}

const plano = wrap(user).toObject();   // sigue las relaciones YA cargadas y corta ciclos
const json  = wrap(user).toJSON();     // lo que usa JSON.stringify; delega en toObject()
const dto = serialize(user, {          // control fino, la opción más flexible
  populate: ['memberships.team'],      // qué relaciones incluir
  exclude: ['memberships.user'],       // qué cortar (evita el ciclo)
  forceObject: true,                   // relaciones no cargadas como { id }, no como 1
});

15.15.1 Por qué la solución correcta es un DTO explícito

Todo lo anterior son parches sobre el mismo problema de fondo: la entidad es un modelo de persistencia, no un contrato de API. Mezclarlos acopla el esquema de la base de datos a lo que ven tus clientes: añades una columna interna y aparece en la respuesta pública; renombras una columna en una migración y rompes a todos los clientes; y serializar relaciones puede disparar cargas perezosas fuera del contexto del EntityManager.

tasks.controller.tsINCORRECTO
@Get(':id')
async findOne(@Param('id') id: number) {
  // Devuelve la ENTIDAD tal cual.
  return this.em.findOneOrFail(Task, id,
    { populate: ['project', 'comments', 'assignee'] });
}
// · Expone lo que haya en la entidad, hoy y mañana.
// · Riesgo de ciclo task → project → tasks.
// · La forma del JSON cambia sola al tocar el modelo.
// · Imposible de documentar bien con Swagger.
tasks.controller.tsCORRECTO
export class TaskResponseDto {
  id!: number;
  title!: string;
  project!: { id: number; name: string };
  assignee!: { id: number; email: string } | null;
  commentCount!: number;
  static from(task: Task): TaskResponseDto {
    return {
      id: task.id, title: task.title,
      project: { id: task.project.$.id, name: task.project.$.name },
      assignee: task.assignee
        ? { id: task.assignee.$.id, email: task.assignee.$.email } : null,
      commentCount: task.comments.count(),
    };
  }
}

@Get(':id')
async findOne(@Param('id') id: number): Promise<TaskResponseDto> {
  const task = await this.em.findOneOrFail(Task, id,
    { populate: ['project', 'assignee', 'comments'] });
  return TaskResponseDto.from(task);
}

El contrato queda escrito, es tipado, se documenta solo con Swagger y ninguna migración puede romperlo por accidente. Un DTO es una lista blanca; hidden: true es una lista negra, y las listas negras se olvidan. Esta idea, junto con la validación de entrada, se desarrolla en el capítulo 10.

15.16 Casos de uso reales completos

15.16.1 Permisos de usuario en un equipo (entidad pivote con rol)

memberships.service.ts
async invitar(teamId: number, userId: number, role: TeamRole) {
  // La PK compuesta impide duplicados en la base de datos, pero comprobarlo
  // antes permite dar un mensaje de error decente.
  const existe = await this.em.findOne(TeamMembership, { team: teamId, user: userId });
  if (existe) throw new ConflictException('El usuario ya pertenece al equipo');
  const membership = this.em.create(TeamMembership, {
    team: this.em.getReference(Team, teamId),
    user: this.em.getReference(User, userId),
    role, joinedAt: new Date(),
  });
  await this.em.flush();
  return membership;
}
async cambiarRol(teamId: number, userId: number, role: TeamRole) {
  const m = await this.em.findOneOrFail(TeamMembership, { team: teamId, user: userId });

  // Invariante de negocio: siempre debe quedar al menos un propietario.
  if (m.role === 'owner' && role !== 'owner') {
    const owners = await this.em.count(TeamMembership, { team: teamId, role: 'owner' });
    if (owners <= 1) throw new BadRequestException('El equipo necesita un propietario');
  }
  m.role = role;
  await this.em.flush();   // update "team_membership" set "role" = 'admin' where …
}

// Consultar la propia relación: imposible con un M:N automático.
async administradores(teamId: number): Promise<User[]> {
  const ms = await this.em.find(TeamMembership,
    { team: teamId, role: { $in: ['owner', 'admin'] } }, { populate: ['user'] });
  return ms.map((m) => m.user.$);
}

15.16.2 Etiquetas compartidas entre tareas (M:N puro)

tags.service.ts
async sincronizarEtiquetas(taskId: number, nombres: string[]) {
  const task = await this.em.findOneOrFail(Task, taskId, { populate: ['tags'] });
  // upsertMany crea las que falten y devuelve todas: una sola ida y vuelta.
  const tags = await this.em.upsertMany(Tag, nombres.map((name) => ({ name })),
    { onConflictFields: ['name'] });
  task.tags.set(tags);   // calcula la diferencia: inserta las nuevas, borra las que sobran
  await this.em.flush();
}

// SQL para pasar de {bug, urgente} a {bug, backend}:
//   insert into "tag" ("name") values ('backend')
//     on conflict ("name") do update set "name" = excluded."name" returning "id";
//   delete from "task_tags" where "task_id" = 1 and "tag_id" = 9;  -- urgente
//   insert into "task_tags" ("task_id","tag_id") values (1, 12);   -- backend
Borrar una etiqueta no debe borrar tareas En la tabla intermedia sí queremos on delete cascade: si desaparece la etiqueta, sus filas de asociación se van con ella. Lo que nunca debe ocurrir es que se borre la tarea. Por eso el M:N es agregación: los dos extremos son independientes y solo muere la asociación.

15.16.3 Adjuntos de una tarea (1:N con orphanRemoval) y perfil 1:1

attachment.entity.ts + attachments.service.ts
@Entity()
export class Attachment {
  @PrimaryKey() id!: number;
  @Property({ length: 255 }) filename!: string;
  @Property({ length: 512 }) storageKey!: string;   // clave en S3 o en el disco

  // NOT NULL + cascade: un adjunto sin tarea no tiene sentido.
  @ManyToOne(() => Task, { ref: true, deleteRule: 'cascade' })
  task!: Ref<Task>;
  // El hook borra el fichero físico y solo se ejecuta si borra el ORM:
  // por eso aquí la cascada del ORM SÍ importa.
  @BeforeDelete()
  async borrarFichero() { await storage.delete(this.storageKey); }
}

// En Task: @OneToMany(() => Attachment, a => a.task, { orphanRemoval: true })
async eliminar(taskId: number, attachmentId: number) {
  const task = await this.em.findOneOrFail(Task, taskId, { populate: ['attachments'] });
  const adjunto = task.attachments.getItems().find((a) => a.id === attachmentId);
  if (!adjunto) throw new NotFoundException();
  task.attachments.remove(adjunto);   // orphanRemoval hace el resto
  await this.em.flush();
  // 1) @BeforeDelete borra el fichero de S3;  2) delete from "attachment" where "id" = 5;
}
profile.service.ts · 1:1 opcional
async guardarPerfil(userId: number, dto: UpdateProfileDto) {
  const user = await this.em.findOneOrFail(User, userId, { populate: ['profile'] });
  if (!user.profile) {
    // Crear y enlazar: cascade PERSIST lo guarda junto con el usuario.
    user.profile = ref(this.em.create(UserProfile, { ...dto, user }));
  } else {
    this.em.assign(user.profile.$, dto);
  }
  await this.em.flush();
  return user.profile.$;
}

// Primera llamada:  insert into "user_profile" (…) values (…) returning "id";
//                   update "user" set "profile_id" = 31 where "id" = 4;
// Siguientes:       update "user_profile" set "bio" = '…' where "id" = 31;
El modelo completo, de un vistazo Con estos cuatro casos el dominio queda cerrado: una entidad pivote para la relación con atributos (TeamMembership), un M:N puro para las clasificaciones (Task ↔ Tag), composición con orphanRemoval para lo que no vive sin su padre (Comment, Attachment), agregación con FK anulable para lo independiente (Task.assignee) y un 1:1 opcional para partir una tabla ancha (User.profile). Casi cualquier modelo relacional se construye combinando estos cinco patrones.

15.17 Errores comunes y cómo solucionarlos

Error o síntomaCausa realSolución
Collection<Task> of entity Project[7] not initializedSe accede a los elementos de una colección que nunca se cargópopulate, await col.init() o col.loadItems(); para contar, loadCount()
Cannot add entity to a not initialized collectionSe llama a add() sobre una colección 1:N sin inicializarCargarla antes o, mejor, asignar el lado propietario y no tocar la colección
Los cambios en la colección no se guardanSe modificó solo el lado inversoEscribir siempre el lado propietario (el @ManyToOne)
Cannot read properties of undefined (reading 'add')Falta = new Collection<T>(this) en la declaraciónInicializar la colección en la propia propiedad
update or delete on table "project" violates foreign key constraintSe borra un padre con hijos y la FK es restrictDecidir la semántica: deleteRule: 'cascade', orphanRemoval o borrar los hijos antes
null value in column "project_id" violates not-null constraintcollection.remove() sobre una relación obligatoria: intenta poner la FK a NULLorphanRemoval: true o em.remove(hijo) explícito
RangeError: Maximum call stack size exceeded al responderCiclo en la serialización de una relación bidireccionalDTO de respuesta; en su defecto serialize() con exclude o hidden: true
Una consulta simple lanza decenas de SELECTRelaciones eager: true encadenadasQuitar eager del modelo y usar populate por caso de uso
Ráfaga de consultas idénticas salvo el idN+1 por carga perezosa dentro de un buclepopulate, em.populate() o una consulta agregada
Both Task.tags and Tag.tasks are defined as owning sidesFalta el mappedBy en uno de los dos lados del M:Nowner: true en uno y la función mappedBy en el otro
Resultados duplicados al filtrar por una colecciónJOIN con una relación a-muchos: una fila del padre por cada hijoEstrategia SELECT_IN, distinct o una subconsulta de existencia
Entity of type Project expects an instance, got objectSe asigna un objeto plano donde se espera una entidad o una Refem.getReference(), ref() o em.create()
Un hook @BeforeDelete no se ejecutaEl borrado lo hizo la base de datos por on delete cascadeUsar la cascada del ORM para todo lo que necesite efectos secundarios
ValidationError: Value for Task.project is requiredSe creó la entidad sin la relación obligatoriaPasarla en em.create(), o hacerla nullable si el negocio lo permite
El DELETE del padre tarda segundosClave foránea sin índice en la tabla hijaindex: true en el @ManyToOne y revisar el plan de ejecución

15.18 Buenas y malas prácticas

Haz esto

  • Escribe siempre el lado propietario. Es el único que genera SQL.
  • ref: true por defecto en las relaciones a-uno: convierte en error de compilación lo que si no sería un undefined silencioso.
  • Inicializa las colecciones con = new Collection<T>(this) en la declaración.
  • em.getReference() cuando solo tienes el id y no necesitas los datos del padre.
  • populate explícito en cada consulta, adaptado al caso de uso.
  • nullable: false por defecto: opcionales solo las relaciones que el negocio permite que falten.
  • orphanRemoval en las composiciones, deleteRule coherente con ella e índices en las claves foráneas que se usen para filtrar o para borrar en cascada.
  • Entidad pivote explícita en cuanto la asociación tenga un solo atributo propio.
  • DTOs de respuesta en la frontera HTTP; nunca devuelvas entidades.
  • debug: true en desarrollo y lee el SQL que genera tu código.
  • Value objects para los conceptos con reglas: dinero, email, rangos de fechas.

Evita esto

  • eager: true como solución a un undefined: pagas esa carga en todo el sistema, para siempre.
  • Modelar el lado inverso «por si acaso», sobre todo en colecciones sin techo.
  • count() tras cargar la colección cuando solo necesitas el número: usa loadCount().
  • Cargar el padre entero solo para asignarlo como clave foránea.
  • Mezclar cascada del ORM y de la base de datos sin decidirlo conscientemente.
  • populate: ['*'] en endpoints de producción.
  • Cargar relaciones dentro de un bucle: es la receta exacta del N+1.
  • Herencia para modelar cosas que cambian de tipo o que son varias a la vez.
  • Columnas JSON para datos que vas a consultar o filtrar, y relaciones polimórficas con tipo + id sin clave foránea real.
  • Devolver entidades desde el controlador confiando en hidden: true como única barrera.
  • Claves primarias compuestas en entidades que van a ser referenciadas por muchas otras.

15.19 Preguntas frecuentes

¿Cómo sé cuál es el lado propietario de una relación?
En una pareja @ManyToOne/@OneToMany el propietario es siempre el @ManyToOne, porque su tabla contiene la clave foránea; no hay elección posible. En @ManyToMany y @OneToOne lo eliges tú con owner: true, y el otro lado se marca pasando la función mappedBy. Truco mnemotécnico: si el decorador lleva mappedBy, es el lado inverso. Y una regla física infalible: el propietario es el lado cuya tabla tiene la columna.
¿Por qué mi collection.add() a veces funciona y otras no?
Porque MikroORM propaga los cambios al lado propietario cuando la relación es bidireccional y la colección está inicializada. Si no está cargada, si la relación es unidireccional o si mappedBy no apunta a la propiedad correcta, la propagación no ocurre y el Unit of Work no ve ningún cambio en la clave foránea. Depender de ese comportamiento produce código que funciona en un test con datos recién creados y falla en producción. Asignar el lado propietario es una ruta que siempre funciona.
¿Ref<T> o entidad directa? ¿Merece la pena la verbosidad?
Merece mucho la pena. Sin ref: true, task.project se declara como Project, así que el compilador te deja escribir task.project.name aunque el objeto sea un proxy no inicializado: obtienes undefined sin ningún aviso. Con Ref<Project> solo tienes acceso directo a la clave primaria; para lo demás debes llamar a load() o usar $ tras haber hecho populate. El tipo te obliga a saber si el dato está o no, que es justo la información que necesitas para no cometer un N+1.
¿Qué diferencia hay entre Cascade.REMOVE y orphanRemoval?
Cascade.REMOVE solo actúa al borrar el padre: entonces borra los hijos. orphanRemoval hace eso y además borra los hijos que se quitan de la colección o se reemplazan mientras el padre sigue vivo. Es la traducción del concepto de composición: el hijo existe únicamente como parte del padre, así que desvincularlo equivale a destruirlo. orphanRemoval: true ya implica Cascade.REMOVE: no hace falta poner los dos.
¿Uso la cascada del ORM o la de la base de datos?
Depende de si necesitas que se ejecute código. Si el borrado del hijo tiene efectos secundarios (borrar un fichero de S3, emitir un evento, escribir auditoría), necesitas la del ORM, que es la única que dispara hooks y subscribers. Si solo hay que limpiar filas y el volumen es grande, la de la base de datos es órdenes de magnitud más rápida: una sola sentencia. Una combinación razonable es usar la del ORM en la aplicación y dejar deleteRule: 'cascade' como red de seguridad para escrituras que no pasen por ella. Lo que no puedes es tenerlas ambas por accidente y sorprenderte cuando un hook no se ejecuta.
¿Cuándo debo convertir un @ManyToMany en una entidad pivote?
En cuanto la asociación necesite un dato propio: rol, fecha de alta, cantidad, estado, orden o quién la creó. También si necesitas consultarla por sí misma («todas las membresías creadas este mes»), aplicarle validación, hooks o borrado lógico. La conversión posterior es cara —tabla nueva, migración de datos y cambio de código coordinado—, así que ante la duda, en asociaciones entre personas y organizaciones, empieza ya con la entidad pivote. Para clasificaciones puras el M:N automático es perfecto.
¿Qué estrategia de carga elijo, SELECT_IN o JOINED?
Regla práctica: JOINED para relaciones a-uno, porque no duplica filas y ahorra un viaje; SELECT_IN para colecciones, porque el join multiplica las filas del padre por el número de hijos y con dos colecciones el crecimiento es multiplicativo. SELECT_IN es el valor por defecto en la v6 precisamente por eso. La excepción es una base de datos remota con latencia alta y colecciones pequeñas y acotadas, donde ahorrar viajes puede compensar el volumen extra. Mídelo antes de cambiarlo.
¿Por qué me salen filas duplicadas al filtrar por una colección?
Porque el JOIN con una relación a-muchos devuelve una fila del padre por cada hijo que cumpla la condición: un proyecto con tres tareas bloqueadas aparece tres veces. Soluciones por orden de preferencia: usar la estrategia SELECT_IN, que no necesita join para filtrar; expresar la condición como subconsulta de existencia; o aplicar distinct. Ojo con combinar distinct y limit: el límite se aplica a las filas del join y no a los padres distintos, así que los resultados pueden ser incorrectos.
¿Puedo tener una relación sin lado inverso? ¿Y cómo cuento los hijos sin cargarlos?
Sí: un @ManyToOne sin su @OneToMany es válido y genera exactamente el mismo esquema. Ganas que nadie pueda cargar por accidente una colección de un millón de filas y pierdes la navegación desde el padre, que se suple con una consulta paginada en el repositorio; declara el lado inverso solo cuando vayas a usarlo y la colección tenga un tamaño acotado. Para contar sin cargar, usa await coleccion.loadCount() (un SELECT COUNT(*)) o em.count(Task, { project: id }) si no tienes el padre a mano. Para muchos padres a la vez no llames a loadCount() en un bucle —eso es un N+1—: haz una consulta agregada con QueryBuilder agrupando por la clave foránea, o define una propiedad con @Formula.
¿Qué estrategias de herencia soporta MikroORM?
Herencia de tabla única (STI) con columna discriminadora, y superclases abstractas (@Entity({ abstract: true })) que replican sus columnas en cada entidad hija sin crear tabla propia. No soporta joined table inheritance ni table per concrete class. Si necesitas algo parecido a la herencia por tablas unidas, modélalo a mano: una entidad base más una relación @OneToOne a la entidad con los campos específicos. En la mayoría de los casos, sin embargo, la respuesta correcta es no usar herencia y componer.
¿Embeddable o entidad relacionada?
Embeddable si el objeto no tiene identidad propia y solo existe dentro de su dueño: una dirección, un importe con moneda, un rango de fechas. Entidad si necesitas referenciarlo desde otro sitio, compartirlo, consultarlo por sí mismo o darle su propio ciclo de vida. La prueba: si dos objetos con exactamente los mismos valores son intercambiables, es un value object; si necesitas distinguirlos aunque tengan los mismos datos, es una entidad.
¿Por qué la entidad objetivo se pasa como función flecha?
Porque los decoradores se ejecutan durante la carga de módulos, cuando las importaciones circulares aún no se han resuelto. Si dos entidades se referencian mutuamente, una de las dos valdría undefined en el momento de evaluar el decorador. Al envolverla en una función flecha, la referencia se resuelve más tarde, cuando MikroORM procesa los metadatos y todos los módulos están cargados. Es el mismo motivo por el que NestJS ofrece forwardRef.
¿Puedo devolver entidades directamente desde un controlador de NestJS?
Técnicamente sí, y para un prototipo puede valer. En un proyecto serio, no: acoplas el contrato público al esquema de la base de datos, arriesgas ciclos de serialización, puedes filtrar campos internos al añadir una columna y provocas cargas perezosas inesperadas al serializar. Un DTO de respuesta explícito es tipado, documentable con Swagger y a prueba de migraciones. Lo mínimo imprescindible es hidden: true en los campos sensibles, pero eso es una lista negra; el DTO es una lista blanca.

15.20 Ejercicios

Nivel 1 · básico

15.1 Dado este enunciado, identifica entidades, atributos y relaciones y dibuja el diagrama E-R en ASCII: «Una biblioteca presta ejemplares de libros a socios. Cada libro tiene varios ejemplares. Un préstamo registra qué socio se llevó qué ejemplar, en qué fecha y cuándo lo devolvió. Un libro tiene uno o varios autores.» Indica cardinalidad y opcionalidad de cada relación y señala cuáles son composición y cuáles agregación.

15.2 Escribe las entidades Comment y Task con su relación bidireccional completa (@ManyToOne con ref: true y @OneToMany con orphanRemoval). Escribe a mano el DDL que esperas y compruébalo con npx mikro-orm schema:create --dump.

15.3 Dado un Project cargado sin populate, escribe tres formas distintas de obtener sus tareas y explica cuántas consultas emite cada una.

15.4 Explica por qué este código no guarda nada y corrígelo:

const project = await em.findOneOrFail(Project, 7, { populate: ['tasks'] });
const task = await em.findOneOrFail(Task, 42);
project.tasks.add(task);
await em.flush();
Nivel 2 · intermedio

15.5 Convierte la relación M:N User ↔ Team en una entidad pivote TeamMembership con role y joinedAt. Escribe la entidad, los dos lados inversos y la migración SQL que copiaría los datos de la tabla intermedia antigua sin perder información.

15.6 Modela Attachment de forma que al quitar un adjunto de task.attachments se borre la fila y el fichero del almacenamiento. Explica qué ocurriría si en lugar de la cascada del ORM usaras solo deleteRule: 'cascade'.

15.7 Escribe una consulta que devuelva los 20 proyectos más recientes con el número de tareas pendientes de cada uno, sin cargar ni una sola tarea. Compara el SQL con el de la versión ingenua que hace populate: ['tasks'] y cuenta en memoria.

15.8 Implementa el value object DateRange como @Embeddable con start y end, que valide en el constructor que end no es anterior a start y ofrezca days() y overlaps(other). Úsalo en una entidad Sprint en modo en línea con prefijo.

15.9 Dado un modelo con Task.project marcado como eager: true, enumera todos los sitios donde eso cambia el SQL y propón el plan para eliminarlo sin romper los endpoints existentes.

15.10 Crea una jerarquía STI Notification con EmailNotification (campo subject) y PushNotification (campo deviceToken). Escribe el DDL resultante y una consulta que recupere todas las notificaciones no leídas de un usuario, del tipo que sean.

Nivel 3 · avanzado

15.11 Detecta y resuelve un N+1: escribe un endpoint que devuelva las tareas de un proyecto con el nombre del proyecto, el email del asignado, sus etiquetas y el número de comentarios. Hazlo primero de la forma ingenua, mide las consultas con debug: true y después optimízalo hasta dejarlo en un número fijo, independiente del número de tareas.

15.12 Implementa un tipo personalizado para importes monetarios que guarde el valor en numeric(12,2) y la moneda en otra columna. Pista: un Type mapea una sola columna, así que tendrás que decidir entre dos Type o un @Embeddable; justifica la elección.

15.13 Modela un árbol de comentarios anidados con lista de adyacencia y añade una columna path derivada mantenida por hooks. Escribe la consulta que recupera un hilo completo en una sola sentencia y la que mueve una rama entera bajo otro padre.

15.14 Diseña el borrado de un Team completo (proyectos, tareas, comentarios, adjuntos, membresías) de tres formas: cascada del ORM, cascada de la base de datos y borrado lógico. Compara número de sentencias, ejecución de hooks, tiempo y reversibilidad.

15.15 Escribe la capa de serialización de la API de tareas con DTOs explícitos y un mapper tipado, de forma que el compilador falle si alguien añade un campo a la entidad y olvida decidir si va o no en la respuesta.

Solución comentada · 15.1 (modelar un dominio dado)

Entidades: Book, Copy (ejemplar), Member (socio), Loan (préstamo) y Author. La fecha de devolución no es una entidad: es un atributo del préstamo. El préstamo sí lo es, porque tiene datos propios y se consulta por sí mismo: es el caso de libro de texto de una asociación con atributos.

  ┌──────────┐ M      N ┌──────────┐ 1      N ┌──────────┐
  │  Author  │─────────<│   Book   │>─────────│   Copy   │
  │ id, name │  M:N     │ id,title │  1:N     │ id, code │
  └──────────┘  puro    │ isbn UQ  │  comp.   │ state    │
                        └──────────┘          └────┬─────┘
  ┌──────────┐ 1                        N     ┌────┴─────────────┐
  │  Member  │───────────────────────────────<│      Loan        │
  │ id, name │                                │ loanedAt, dueAt  │
  │ email UQ │                                │ returnedAt? NULL │
  └──────────┘                                └──────────────────┘
  • Book ↔ Author: M:N puro (la autoría no tiene datos) y agregación: un autor existe aunque se retire el libro del catálogo.
  • Book → Copy: 1:N obligatoria y composición: un ejemplar sin libro no significa nada. orphanRemoval: true y deleteRule: 'cascade'.
  • Loan → Copy y Loan → Member: N:1 obligatorias y agregación. Aquí deleteRule debe ser 'restrict': el historial de préstamos es un registro contable que no puede desaparecer porque alguien borre un socio.
  • returnedAt anulable distingue un préstamo abierto de uno cerrado. Una restricción UNIQUE parcial sobre copy_id WHERE returned_at IS NULL impide prestar dos veces el mismo ejemplar: la integridad, en la base de datos.
Solución comentada · 15.5 (convertir un M:N en entidad pivote)

Partimos de User.teams como @ManyToMany propietario con tabla user_teams. El objetivo es TeamMembership con role y joinedAt.

@Entity()
export class TeamMembership {
  @ManyToOne(() => User, { primary: true, ref: true, deleteRule: 'cascade' })
  user!: Ref<User>;
  @ManyToOne(() => Team, { primary: true, ref: true, deleteRule: 'cascade' })
  team!: Ref<Team>;
  @Enum({ items: () => ['owner', 'admin', 'member', 'guest'], default: 'member' })
  role: TeamRole = 'member';
  @Property() joinedAt: Date = new Date();
}

// Lados inversos: User.memberships y Team.members (@OneToMany).
// Un getter conserva la comodidad perdida:
get teams(): Team[] { return this.memberships.getItems().map((m) => m.team.$); }

La migración debe conservar los datos. El orden importa: primero crear la tabla, luego copiar y solo al final eliminar la antigua, para poder revertir.

create table "team_membership" (
  "user_id"   int not null references "user" ("id") on delete cascade,
  "team_id"   int not null references "team" ("id") on delete cascade,
  "role"      varchar(20) not null default 'member',
  "joined_at" timestamptz not null default now(),
  constraint "team_membership_pkey" primary key ("user_id", "team_id") );

-- No hay rol histórico: valor por defecto y fecha aproximada a partir del alta.
insert into "team_membership" ("user_id", "team_id", "role", "joined_at")
select ut."user_id", ut."team_id", 'member', coalesce(u."created_at", now())
  from "user_teams" ut join "user" u on u."id" = ut."user_id";
update "team_membership" m set "role" = 'owner'   -- el dueño recupera su rol real
  from "team" t where t."id" = m."team_id" and t."owner_id" = m."user_id";
drop table "user_teams";

En sistemas con tráfico real esto se despliega en dos pasos: primero se crea y rellena la tabla nueva mientras el código escribe en las dos, y la antigua se elimina en un despliegue posterior, para que nunca convivan el código viejo y el esquema nuevo.

Solución comentada · 15.11 (resolver un N+1)

Versión ingenua. Parece razonable y es un desastre:

const tasks = await em.find(Task, { project: projectId });
for (const t of tasks) {
  salida.push({
    title: t.title,
    project: (await t.project.load()).name,                         // 1 por tarea
    assignee: t.assignee ? (await t.assignee.load()).email : null,  // 1 más
    tags: (await t.tags.loadItems()).map((x) => x.name),            // 1 más
    comments: await t.comments.loadCount(),                         // 1 más
  });
}
// Con 200 tareas: 1 + 200 + 200 + 200 + 200 = 801 consultas.

Paso 1: medir. Con debug: true, la consola muestra la ráfaga de sentencias idénticas salvo el identificador. Ese patrón es la firma inequívoca de un N+1.

Paso 2: populate. Sustituye las cargas perezosas por una carga por lotes:

const tasks = await em.find(Task, { project: projectId },
                            { populate: ['project', 'assignee', 'tags'] });
// select * from "task"    where "project_id" = 7;
// select * from "project" where "id" in (7);
// select * from "user"    where "id" in (4, 9, 21);
// select t.*, tt.task_id from "task_tags" tt join "tag" t on … where tt.task_id in (…);
// → 4 consultas, sea cual sea el número de tareas.

Paso 3: el recuento. Un loadCount() por tarea seguiría siendo un N+1. La solución es una propiedad calculada con @Formula, que el ORM incrusta como subconsulta en el mismo SELECT:

@Formula((alias) => `(select count(*) from "comment" c where c."task_id" = ${alias}.id)`)
commentCount!: number;
// select "t0".*, (select count(*) from "comment" c where c."task_id" = "t0".id)
//   as "comment_count" from "task" "t0" where "t0"."project_id" = 7;

// Alternativa sin @Formula: una sola consulta agregada y un Map en memoria.
const filas = await em.createQueryBuilder(Comment, 'c')
  .select(['c.task_id', 'count(*) as total'])
  .where({ task: { $in: tasks.map((t) => t.id) } })
  .groupBy('c.task_id').execute<{ task_id: number; total: string }[]>();
const porTarea = new Map(filas.map((f) => [f.task_id, Number(f.total)]));
// 5 consultas en total. De 801 a 5: dos órdenes de magnitud.

Conclusión. El número de consultas debe depender del número de tipos de datos que pides, nunca del número de filas. Si al duplicar los datos se duplican las consultas, hay un N+1. El capítulo 16 amplía esto con QueryBuilder, paginación y caché.

15.21 Resumen del capítulo

  • Modelar es decidir, no decorar. Antes de escribir un decorador hay que fijar entidades, atributos, cardinalidad y opcionalidad; cada decisión tiene una traducción física visible en el DDL.
  • El lado propietario es el que manda. Es donde vive la clave foránea y el único que genera INSERT y UPDATE; el inverso es una vista de navegación que no crea columnas.
  • @ManyToOne es la relación fundamental; @OneToMany es su espejo, @ManyToMany añade una tabla intermedia y @OneToOne es un @ManyToOne con UNIQUE.
  • En cuanto una asociación tiene datos propios deja de ser un M:N y pasa a ser una entidad pivote, como TeamMembership con su rol.
  • Collection no es un array: puede estar sin inicializar, sabe qué ha cambiado y ofrece loadCount() y matching() para no cargar de más.
  • Ref<T> convierte en error de compilación lo que de otro modo sería un undefined silencioso, y em.getReference() escribe claves foráneas sin ninguna consulta.
  • Hay dos cascadas distintas. La del ORM borra entidad por entidad y dispara hooks; la de la base de datos es una sola sentencia, mucho más rápida y ciega. orphanRemoval es la traducción de la composición.
  • SELECT_IN para colecciones, JOINED para relaciones a-uno. El join con colecciones multiplica filas, y varias colecciones lo hacen de forma exponencial.
  • El N+1 se reconoce a simple vista en el log: una ráfaga de consultas iguales salvo el id. Se resuelve con populate, em.populate() o una consulta agregada. MikroORM solo soporta herencia de tabla única y superclases abstractas; la herencia es permanente y excluyente, así que si el dominio no lo es, compón.
  • Los embeddables y los tipos personalizados llevan las reglas de negocio al propio dato: un value object válido por construcción elimina categorías enteras de errores.
  • Nunca devuelvas entidades desde un controlador. Un DTO explícito evita ciclos, filtraciones y rupturas de contrato, y es una lista blanca en lugar de una lista negra.

15.22 Recursos adicionales

Siguiente paso Ya tienes el modelo. El capítulo 16 lo pone a trabajar: el QueryBuilder, los operadores de filtrado, la paginación eficiente sobre relaciones, el diagnóstico sistemático del N+1 y las técnicas para que una consulta compleja siga siendo rápida con un millón de filas.