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.
15.1 Qué vas a poder hacer al terminar
- Traducir un modelo conceptual de negocio a entidades TypeScript y predecir, sin ejecutar nada, el DDL exacto que generará el generador de esquema.
- Explicar qué es el lado propietario, dónde vive la clave foránea y por qué modificar solo el lado inverso no produce ningún
UPDATE. - Elegir con criterio entre
@ManyToOne,@OneToMany,@ManyToMany,@OneToOney una entidad pivote explícita, y justificar la decisión. - Manejar
CollectionyRef<T>con soltura: inicialización perezosa,init,matching,loadCountyem.getReference()para asignar relaciones sin tocar la base de datos. - Distinguir la cascada del ORM de la de la base de datos, y saber cuál dispara los hooks y cuál es más rápida.
- Configurar estrategias de carga (
SELECT_INfrente aJOINED) y diagnosticar un N+1 leyendo el log de SQL. - Aplicar herencia de tabla única y superclases abstractas, y saber cuándo la herencia es peor que la composición.
- Modelar value objects con
@Embeddabley tipos personalizados conType<JSType, DBType>. - Serializar entidades con relaciones sin ciclos infinitos y explicar por qué la solución correcta es un DTO de respuesta explícito.
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.
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 |
|---|---|---|
| Usuario | Entidad User | Identidad propia; se referencia desde tareas y comentarios |
| Equipo | Entidad Team | Existe con independencia de sus miembros |
| Rol dentro del equipo | Atributo de la relación | No es del usuario ni del equipo: es del hecho «pertenece a» |
| Estado de la tarea | Atributo | Valor de una lista cerrada; no se referencia desde fuera |
| Etiqueta | Entidad Tag | Se comparte entre tareas y se renombra en un único sitio |
| Comentario y adjunto | Entidades dependientes | Tienen datos propios pero no viven sin su tarea (composición) |
| Perfil | Entidad UserProfile | Se 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 dominio | Cardinalidad | Opcionalidad | Implementación |
|---|---|---|---|
| Task → Project | N:1 | Obligatoria | project_id NOT NULL |
| Task → User (assignee) | N:1 | Opcional (0..1) | assignee_id NULL |
| Project → Team | N:1 | Obligatoria | team_id NOT NULL |
| User ↔ Team | M:N con atributos | — | Entidad pivote TeamMembership |
| Task ↔ Tag | M:N puro | — | Tabla intermedia automática |
| Task → Comment / Attachment | 1:N | — | FK en el hijo + orphanRemoval |
| User → UserProfile | 1:1 | Opcional | FK ú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
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.
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") );
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.
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.
}
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;
}
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.
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.
@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ón | Qué hace | Efecto en el SQL |
|---|---|---|
() => Project | Referencia diferida a la entidad objetivo | Ninguno directo; evita fallos por dependencias circulares |
nullable: true | La relación puede no existir | "assignee_id" int null |
deleteRule | Qué hace la base de datos al borrar el padre | on delete cascade | set null | restrict | no action |
updateRule | Qué hace al cambiar la PK del padre | on update cascade (por defecto en MikroORM) |
ref: true | Envuelve el valor en Ref<T> | Ninguno; evita consultas accidentales y mejora el tipado |
mapToPk: true | La propiedad es el identificador crudo | Ninguno; ahorra la creación del proxy |
index: true | Índice sobre la columna FK | create index "task_project_id_index" … |
eager: true | Carga la relación en toda consulta | Añ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.
-- 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.
- Rompe el principio de mínimo privilegio de datos. Cada endpoint debería pedir lo que necesita:
populatees explícito y local;eageres implícito y global. - Impide optimizar. Cuando un endpoint se vuelva lento no podrás quitarle el eager sin romper otros veinte.
- Se propaga en cascada: una relación eager que apunta a otra eager arrastra medio esquema.
- Excepción razonable: una N:1 a una tabla pequeñísima e inmutable que se necesita literalmente siempre (la moneda o el idioma de un registro). Aun así, prefiere
populateexplícito.
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.
@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
- La colección se inicializa en la declaración con
new Collection<Task>(this). Elthises imprescindible: la colección necesita conocer a su dueño para propagar cambios al lado propietario. Si la declaras contasks!: Collection<Task>y no la construyes, el primeradd()falla conCannot read properties of undefined. - No genera columnas. En la tabla
projectno hay ni rastro de las tareas; por eso el lado inverso es gratis en disco, pero no en consultas. - Empieza sin inicializar. Al cargar un proyecto,
project.tasksexiste como objeto pero está vacía y marcada como no inicializada.
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.
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.
// 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.
// 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.
@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);
}
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);
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ñal | M:N automático | Entidad pivote explícita |
|---|---|---|
| La relación tiene datos (rol, cantidad, fecha) | Imposible | Obligatorio |
| Necesitas consultar la relación por sí misma | Solo con SQL crudo | Natural: es un repositorio más |
| Necesitas hooks, validación o auditoría al asociar | No hay entidad donde ponerlos | Sí |
| Simplicidad del código | Máxima: collection.add() | Más verboso: crear la entidad |
| Ejemplo del dominio | Task ↔ Tag | User ↔ Team con rol |
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.
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');
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.
@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;
}
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;
- Dividir una tabla ancha (partición vertical). Si
usertiene 8 columnas que se leen en cada petición y 15 que solo se ven en la pantalla de perfil, separarlas reduce el tamaño de fila y acelera losSELECTfrecuentes. Es la razón número uno para usar 1:1. - Datos opcionales voluminosos o sensibles. Un
UserCredentialscon el hash de la contraseña y los secretos de segundo factor en otra tabla permite restringir permisos a nivel de base de datos. - Extender una entidad de un módulo que no controlas sin tocar su tabla, o modelar una relación realmente opcional.
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étodo | Qué hace | ¿Necesita estar inicializada? |
|---|---|---|
add(...items) | Añade elementos y propaga al lado propietario | Sí para 1:N; en M:N puede diferirse |
remove(...items) | Quita elementos (borra fila pivote o pone la FK a NULL) | Sí |
set(items) | Reemplaza el contenido calculando la diferencia | Sí |
removeAll() | Vacía la colección | Sí |
contains(item) | Comprueba pertenencia | Sí (si no, mira solo lo cargado) |
count() | Número de elementos ya cargados en memoria | Sí |
loadCount() | Lanza un SELECT COUNT(*) a la base de datos | No |
getItems() | Devuelve el array de entidades | Sí (lanza error si no) |
getIdentifiers() | Devuelve solo las claves primarias | Sí |
isInitialized() | Indica si está cargada | No |
init(options?) | Carga la colección desde la base de datos | No (es lo que la inicializa) |
loadItems(options?) | init() y devuelve el array | No |
matching(options) | Consulta filtrada y paginada sobre la colección | No |
15.8.1 Colecciones no inicializadas: el error más frecuente
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']);
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.
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.
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
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.
@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.
@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
| API | Qué hace | ¿Consulta la base de datos? |
|---|---|---|
ref.load() | Inicializa y devuelve la entidad | Sí, si no estaba cargada |
ref.load('name') | Carga y devuelve una sola propiedad | Sí, si no estaba cargada |
ref.unwrap() | Devuelve la entidad envuelta sin cargarla | No |
ref.$ | Acceso directo asumiendo que ya está cargada | No (falla si no lo está) |
wrap(entidad).init() | Inicializa un proxy sin Ref | Sí |
em.getReference(E, id) | Crea una referencia a partir de un id | No |
ref(entidad) / rel(...) | Helpers de construcción y de tipado | No |
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.
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 (…);
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.
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.
// (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();
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 cascade | Qué propaga | Por defecto |
|---|---|---|
Cascade.PERSIST | Al persistir el padre, persiste los hijos nuevos que cuelguen de la relación | Activo |
Cascade.MERGE | Al fusionar el padre en el contexto, fusiona los hijos | Activo |
Cascade.REMOVE | Al borrar el padre, borra los hijos con un DELETE por entidad | Inactivo |
Cascade.SCHEDULE_ORPHAN_REMOVAL | Lo que activa internamente orphanRemoval: true | Inactivo |
Cascade.ALL | Todo lo anterior | Inactivo |
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.
@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.
@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.
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
deleteRule | SQL | Comportamiento al borrar el padre | Cuándo usarlo |
|---|---|---|---|
'cascade' | on delete cascade | La base de datos borra las filas hijas | Composición estricta: comentarios, adjuntos, líneas de pedido |
'set null' | on delete set null | Pone la FK a NULL (la columna debe ser anulable) | Relaciones opcionales: el asignado de una tarea |
'restrict' | on delete restrict | Impide el borrado si hay hijos, comprobado de inmediato | Datos maestros e históricos que no deben desaparecer |
'no action' | on delete no action | Como restrict, pero la comprobación puede diferirse al final de la transacción | Valor 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.
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.
| Criterio | Composición (el hijo no vive sin el padre) | Agregación (el hijo es independiente) |
|---|---|---|
| Ejemplo del dominio | Task → Comment, Task → Attachment | Task → User (assignee), Task ↔ Tag |
nullable en la FK | false | Normalmente true |
orphanRemoval | true | false |
deleteRule | 'cascade' | 'set null' o 'restrict' |
| ¿Se accede al hijo sin el padre? | No: su repositorio casi no se usa | Sí: tiene endpoints propios |
| Notación UML | Rombo relleno | Rombo vacío |
15.11 Estrategias de carga
| Estrategia | Cómo se activa | Ventaja | Riesgo |
|---|---|---|---|
| Perezosa (por defecto) | No hacer nada | Consultas mínimas; solo traes lo que pides | Si accedes sin cargar, obtienes una referencia vacía |
| Ansiosa | eager: true en el decorador | Nunca falta el dato | Global e irrevocable; infla todas las consultas (15.4.3) |
| Explícita | populate en la consulta | Local, visible y ajustable por endpoint | Hay que acordarse de escribirlo |
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
-- 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.
| Aspecto | SELECT_IN | JOINED |
|---|---|---|
| Viajes a la base de datos | 1 por nivel de relación | 1 en total |
| Volumen transferido | Mínimo, sin duplicados | Multiplicado por la cardinalidad de las colecciones |
| Relaciones a-uno | Correcto | Mejor: no duplica nada y ahorra un viaje |
| Relaciones a-muchos | Mejor en general | Peligroso: dos colecciones en el mismo join multiplican filas |
limit / offset | Se aplica limpiamente al padre | Necesita subconsulta; más complejo y lento |
| Filtrar por un campo de la relación | Requiere condición aparte | Directo: ya está en el JOIN |
| Latencia de red alta | Penaliza (varios viajes) | Gana |
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.
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
// 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.
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.
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.
@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 {}
-- 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 STI | Inconvenientes de STI |
|---|---|
Una sola consulta para toda la jerarquía, sin JOIN ni UNION | Columnas 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 subclase | Restricciones 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 apuntar | La 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.
// 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
| Estrategia | Descripción | ¿MikroORM? | Alternativa |
|---|---|---|---|
| Single Table | Una tabla para toda la jerarquía + discriminador | Sí | — |
| Mapped superclass | Campos comunes replicados, sin tabla base | Sí (abstract: true) | — |
| Joined table | Tabla base + una tabla por subclase unidas por JOIN | No | Modelarlo a mano: entidad base + @OneToOne a la específica |
| Table per concrete class | Una tabla completa e independiente por subclase | No | Entidades separadas que comparten una mapped superclass |
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.
@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[] = [];
}
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';
| Criterio | En línea (columnas) | Objeto (JSON) |
|---|---|---|
| Consultas y filtros | SQL normal, rápido | Operadores JSON, más lento y verboso |
| Índices | Normales | Solo GIN o funcionales |
Restricciones (NOT NULL, CHECK) | Sí | No: el motor no valida el contenido |
| Cambios de estructura | Requieren migración | No la requieren… pero conviven versiones distintas |
| Arrays de embebidos | No es posible | Obligatorio |
15.13.1 Value objects: por qué mejoran el diseño
@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?
@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.
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 configurable | Vas 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 consultarla | Necesitas integridad: dentro del JSON no hay NOT NULL, ni UNIQUE, ni claves foráneas |
| Son preferencias que solo se leen enteras y nunca se filtran | La 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 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.
@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;
| Modelo | Cómo se guarda | Leer una rama | Mover un nodo | Cuándo elegirlo |
|---|---|---|---|---|
| Lista de adyacencia | parent_id | Consulta recursiva (CTE) o N consultas | Trivial: un UPDATE | Por defecto. Árboles poco profundos o motor con CTE recursivas |
| Path materializado | Columna path tipo '/1/2/4/' | Una consulta: path LIKE '/1/2/%' | Reescribir el path de todo el subárbol | Muchas lecturas de ramas, movimientos raros: menús, catálogos |
| Nested set | Columnas lft y rgt | Muy rápida: lft between … and … | Recalcular medio árbol; requiere bloqueo | Árboles enormes casi inmutables con lecturas masivas |
-- 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/%';
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.
@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.
// 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.
@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.
@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; */
/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.
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.
@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.
@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.
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)
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)
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
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
@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;
}
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;
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íntoma | Causa real | Solución |
|---|---|---|
Collection<Task> of entity Project[7] not initialized | Se 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 collection | Se llama a add() sobre una colección 1:N sin inicializar | Cargarla antes o, mejor, asignar el lado propietario y no tocar la colección |
| Los cambios en la colección no se guardan | Se modificó solo el lado inverso | Escribir siempre el lado propietario (el @ManyToOne) |
Cannot read properties of undefined (reading 'add') | Falta = new Collection<T>(this) en la declaración | Inicializar la colección en la propia propiedad |
update or delete on table "project" violates foreign key constraint | Se borra un padre con hijos y la FK es restrict | Decidir la semántica: deleteRule: 'cascade', orphanRemoval o borrar los hijos antes |
null value in column "project_id" violates not-null constraint | collection.remove() sobre una relación obligatoria: intenta poner la FK a NULL | orphanRemoval: true o em.remove(hijo) explícito |
RangeError: Maximum call stack size exceeded al responder | Ciclo en la serialización de una relación bidireccional | DTO de respuesta; en su defecto serialize() con exclude o hidden: true |
Una consulta simple lanza decenas de SELECT | Relaciones eager: true encadenadas | Quitar eager del modelo y usar populate por caso de uso |
| Ráfaga de consultas idénticas salvo el id | N+1 por carga perezosa dentro de un bucle | populate, em.populate() o una consulta agregada |
Both Task.tags and Tag.tasks are defined as owning sides | Falta el mappedBy en uno de los dos lados del M:N | owner: true en uno y la función mappedBy en el otro |
| Resultados duplicados al filtrar por una colección | JOIN con una relación a-muchos: una fila del padre por cada hijo | Estrategia SELECT_IN, distinct o una subconsulta de existencia |
Entity of type Project expects an instance, got object | Se asigna un objeto plano donde se espera una entidad o una Ref | em.getReference(), ref() o em.create() |
Un hook @BeforeDelete no se ejecuta | El borrado lo hizo la base de datos por on delete cascade | Usar la cascada del ORM para todo lo que necesite efectos secundarios |
ValidationError: Value for Task.project is required | Se creó la entidad sin la relación obligatoria | Pasarla en em.create(), o hacerla nullable si el negocio lo permite |
El DELETE del padre tarda segundos | Clave foránea sin índice en la tabla hija | index: 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: truepor defecto en las relaciones a-uno: convierte en error de compilación lo que si no sería unundefinedsilencioso.- 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.populateexplícito en cada consulta, adaptado al caso de uso.nullable: falsepor defecto: opcionales solo las relaciones que el negocio permite que falten.orphanRemovalen las composiciones,deleteRulecoherente 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: trueen 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: truecomo solución a unundefined: 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: usaloadCount().- 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 + idsin clave foránea real. - Devolver entidades desde el controlador confiando en
hidden: truecomo ú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?
@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?
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?
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?
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?
¿Qué estrategia de carga elijo, SELECT_IN o JOINED?
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?
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?
@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?
@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?
¿Por qué la entidad objetivo se pasa como función flecha?
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?
hidden: true en los campos sensibles, pero eso es una lista negra; el DTO es una lista blanca.15.20 Ejercicios
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();
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.
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: trueydeleteRule: 'cascade'.Loan → CopyyLoan → Member: N:1 obligatorias y agregación. AquídeleteRuledebe ser'restrict': el historial de préstamos es un registro contable que no puede desaparecer porque alguien borre un socio.returnedAtanulable distingue un préstamo abierto de uno cerrado. Una restricciónUNIQUEparcial sobrecopy_id WHERE returned_at IS NULLimpide 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
INSERTyUPDATE; el inverso es una vista de navegación que no crea columnas. @ManyToOnees la relación fundamental;@OneToManyes su espejo,@ManyToManyañade una tabla intermedia y@OneToOnees un@ManyToOneconUNIQUE.- En cuanto una asociación tiene datos propios deja de ser un M:N y pasa a ser una entidad pivote, como
TeamMembershipcon su rol. Collectionno es un array: puede estar sin inicializar, sabe qué ha cambiado y ofreceloadCount()ymatching()para no cargar de más.Ref<T>convierte en error de compilación lo que de otro modo sería unundefinedsilencioso, yem.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.
orphanRemovales la traducción de la composición. SELECT_INpara colecciones,JOINEDpara 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
- MikroORM · Modeling entity relationships — referencia oficial de los cuatro tipos de relación con todas sus opciones.
- MikroORM · Collections — API completa de
Collection, inicialización,matchingyloadCount. - MikroORM · Entity references and Ref — referencias envueltas,
getReferenceymapToPk. - MikroORM · Cascading — cascadas del ORM,
orphanRemovaly su relación con las reglas de la base de datos. - MikroORM · Loading strategies —
select-infrente ajoinedcon ejemplos de SQL. - MikroORM · Inheritance mapping — herencia de tabla única y superclases abstractas.
- MikroORM · Embeddables — modo en línea, modo objeto, arrays y embeddables polimórficos; y Custom types, con la clase
Typey sus métodos de conversión. - MikroORM · Serializing —
hidden,serializer,serialize()y control de ciclos. - MikroORM · Upgrading from v5 to v6 — lista completa de cambios, incluidos
RefydeleteRule. - PostgreSQL · Constraints — claves foráneas, acciones referenciales y restricciones
CHECK. - Patterns of Enterprise Application Architecture — catálogo de Martin Fowler: Data Mapper, Foreign Key Mapping, Single Table Inheritance y Embedded Value.
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.