Noveno post de la serie NestJS y último del bloque de Validación y Base de Datos, ya se me está atragantando el tema. Ya tenemos PostgreSQL en Docker (post 7) y un CRUD completo con paginación, filtros y soft delete (post 8). Pero nuestro modelo de datos es es bobo una sola tabla users solitaria sin conexión con nada.
En la vida real los usuarios tienen posts, los posts tienen tags, los pedidos tienen productos. Las relaciones entre entidades es lo más normal en las bases de datos relacionales. Hoy vamos a conectar entidades entre ellas y de paso nos quitamos de encima el synchronize: true que nos ha dado vergüenza desde el post 7.
Y de sobremesa veremos los seeders, que son scripts tipados para llenar la base de datos con datos de prueba automáticamente, tipo las fixtures de Symfony.
EA, amo al lio.

1. Tipos de relaciones en bases de datos
Antes de meter la zarpa en los decorators vamos a ver los tres tipos de relación, con sus ejemplos:
Un usuario tiene MUCHOS posts. Cada post pertenece a UN usuario. La FK (userId) va en la tabla del "many" (posts).
Un usuario tiene UN perfil. Un perfil pertenece a UN usuario. FK en cualquiera de las dos tablas.
Un post tiene MUCHOS tags. Un tag pertenece a MUCHOS posts. Se necesita una tabla intermedia (pivot table).
Vamos a implementar las tres en nuestro proyecto del curso, a modo de ejemplo, y gratis!
2. @ManyToOne y @OneToMany: la relación más común
Un usuario tiene muchos posts. Cada post pertenece a un usuario. Esta es la relación que más vas a usar en tu bida tt.
2.1. La entidad Post
// src/posts/entities/post.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
@Entity('posts')
export class Post {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 255 })
title: string;
@Index({ unique: true })
@Column({ type: 'varchar', length: 255 })
slug: string;
@Column({ type: 'text' })
content: string;
@Column({ type: 'boolean', default: false })
published: boolean;
@ManyToOne(() => User, (user) => user.posts, {
nullable: false,
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'userId' })
author: User;
@Column({ type: 'uuid' })
userId: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
}
2.2. El lado inverso en User
// src/users/entities/user.entity.ts
import { OneToMany } from 'typeorm';
import { Post } from '../../posts/entities/post.entity';
@Entity('users')
export class User {
// ... columnas existentes del post 7 ...
@OneToMany(() => Post, (post) => post.author)
posts: Post[];
}
Vamos a destripar cada pieza:
El decorador @ManyToOne
@ManyToOne(() => User, (user) => user.posts, {
nullable: false,
onDelete: 'CASCADE',
})
() => User— La entidad relacionada. Se usa una función tipo arrow function en lugar de la referencia directa para evitar problemas de circular dependency circular dependency Cuando dos módulos se importan mutuamente. En TypeScript, si Post importa User y User importa Post, puede haber problemas con el orden de inicialización. Las arrow functions lo resuelven porque se evalúan en runtime, no en import. .(user) => user.posts— La propiedad inversa de la relación en la otra entidad. Le dice a TypeORM cómo conectar ambos lados.nullable: false— Todo post debe tener un autor. Sin esto, TypeORM permiteuserId = NULL.onDelete: 'CASCADE'— Si se borra el usuario, se borran sus posts. En la siguiente tabla vemos otras opciones:
| onDelete | Qué hace | Cuándo usarlo |
|---|---|---|
CASCADE | Borra los registros hijos automáticamente | Posts de un usuario, items de un pedido |
SET NULL | Pone la FK a NULL (requiere nullable: true) | Comentarios de un usuario borrado (se quedan como “anónimo”) |
RESTRICT | Impide borrar el padre si tiene hijos | No puedes borrar un usuario si tiene pedidos activos |
NO ACTION | Similar a RESTRICT pero evaluado al final de la transacción | Default de PostgreSQL |
@JoinColumn y la columna FK explícita
@JoinColumn({ name: 'userId' })
author: User;
@Column({ type: 'uuid' })
userId: string;
Dos escopetas tengo:
@JoinColumn({ name: 'userId' })— Le dice a TypeORM cómo se llama la columna FK en la tabla. Sin esto, crearíaauthorIdtal cuál.userId: stringcomo columna explícita. Al declarar la FK también como@Column, puedes acceder al ID sin cargar toda la relación:post.userIden vez depost.author.id. Evita JOINs innecesarios cuando solo necesitas el ID.
@OneToMany: el lado inverso
@OneToMany(() => Post, (post) => post.author)
posts: Post[];
El lado “one” no tiene @JoinColumn porque la FK está en el lado “many” (la tabla posts). @OneToMany es un decorador virtual: no crea ninguna columna en la tabla users. Solo le dice a TypeORM que existe una relación inversa.
3. @OneToOne: relación uno a uno
Un usuario tiene un perfil y un perfil pertenece a un solo usuario:
// src/profiles/entities/profile.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
OneToOne,
JoinColumn,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
@Entity('profiles')
export class Profile {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'text', nullable: true })
bio: string | null;
@Column({ type: 'varchar', length: 500, nullable: true })
avatarUrl: string | null;
@Column({ type: 'varchar', length: 255, nullable: true })
website: string | null;
@OneToOne(() => User, (user) => user.profile, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'userId' })
user: User;
@Column({ type: 'uuid', unique: true })
userId: string;
}
Y en la entidad User:
@OneToOne(() => Profile, (profile) => profile.user)
profile: Profile;
La diferencia con @ManyToOne es que @JoinColumn solo va en uno de los dos lados (el que tiene la FK). En @OneToOne, tú decides dónde va. Normalmente en la entidad “dependiente” (el perfil depende del usuario, no al revés).
4. @ManyToMany: la tabla pivote
Un post puede tener muchos tags y un tag puede pertenecer a muchos posts. Esta relación necesita una 💡 tabla pivote Tabla intermedia (junction table) que conecta dos entidades en una relación Many-to-Many. Contiene solo las FKs de ambas tablas. En TypeORM se crea automáticamente con @JoinTable(). Más info → :
4.1. La entidad Tag
// src/tags/entities/tag.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, ManyToMany, Index } from 'typeorm';
import { Post } from '../../posts/entities/post.entity';
@Entity('tags')
export class Tag {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index({ unique: true })
@Column({ type: 'varchar', length: 50 })
name: string;
@Column({ type: 'varchar', length: 50, unique: true })
slug: string;
@ManyToMany(() => Post, (post) => post.tags)
posts: Post[];
}
4.2. El lado dueño en Post
// src/posts/entities/post.entity.ts
import { ManyToMany, JoinTable } from 'typeorm';
import { Tag } from '../../tags/entities/tag.entity';
@Entity('posts')
export class Post {
// ... otras columnas ...
@ManyToMany(() => Tag, (tag) => tag.posts, {
eager: false,
})
@JoinTable({
name: 'posts_tags',
joinColumn: { name: 'postId', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'tagId', referencedColumnName: 'id' },
})
tags: Tag[];
}
@JoinTable: configurando la tabla pivote
@JoinTable({
name: 'posts_tags', // Nombre de la tabla pivote
joinColumn: { name: 'postId' }, // FK hacia esta entidad (Post)
inverseJoinColumn: { name: 'tagId' }, // FK hacia la otra entidad (Tag)
})
@JoinTable() solo va en un lado de la relación (el “dueño”). TypeORM crea la tabla posts_tags con dos columnas: postId y tagId y no será necesario crear esta tabla a mano.
Sin las opciones, TypeORM crearía la tabla con nombres automáticos (post_tags_tag), que suelen ser mu feos. Así que siempre configura @JoinTable explícitamente.
5. Cargar relaciones: eager vs lazy vs explicit
¿Cuándo carga TypeORM los datos relacionados?
5.1. Eager loading: carga automática
@ManyToOne(() => User, (user) => user.posts, {
eager: true, // Se carga SIEMPRE que haces find/findOne
})
author: User;
Con eager: true cada vez que buscas un post, TypeORM hace un JOIN automático para cargar el autor. Parece genial, pero…
- Eager loading SOLO para relaciones que SIEMPRE necesitas. Ejemplo: un post siempre necesita su autor para mostrar el nombre
- Eager en relaciones One-to-Many grandes. Si un usuario tiene 10.000 posts, un findOne del usuario carga los 10.000 posts automáticamente
- Eager en cadena: si Post tiene eager author y Author tiene eager posts... loop infinito de JOINs
- Eager "por comodidad". Es la causa #1 de queries N+1 y APIs lentas
5.2. Explicit loading: tú decides (recomendado)
No pongas eager: true y carga las relaciones solo cuando las necesitas con la opción relations:
// Solo el post, sin relaciones
const post = await this.postsRepository.findOne({
where: { id },
});
// Post con su autor
const postWithAuthor = await this.postsRepository.findOne({
where: { id },
relations: { author: true },
});
// Post con autor y tags
const postComplete = await this.postsRepository.findOne({
where: { id },
relations: {
author: true,
tags: true,
},
});
// Relaciones anidadas: post → autor → perfil
const postDeep = await this.postsRepository.findOne({
where: { id },
relations: {
author: {
profile: true,
},
tags: true,
},
});
La opción relations acepta un objeto tipado y TypeScript sabe qué relaciones existen en la entidad y autocompleta. Si escribes relations: { pepito: true }, error de compilación.
5.3. Lazy loading: carga bajo demanda
@OneToMany(() => Post, (post) => post.author)
posts: Promise<Post[]>; // Nota el Promise<>
Con lazy loading, la propiedad es una Promise. Se ejecuta la query solo cuando haces await user.posts. Requiere declarar el tipo como Promise<>.
En la práctica el lazy loading en TypeORM tiene problemas porque funciona mal con serialización JSON, el debugger no lo muestra correctamente, y es fácil crear N+1 sin darte cuenta. Usa explicit loading siempre.
6. Cascade: operaciones en cadena
El 💡 cascade Opción de TypeORM que permite propagar operaciones (insert, update, remove) automáticamente a las entidades relacionadas cuando guardas la entidad padre. Si guardas un Post con tags nuevos y cascade está activo, TypeORM inserta los tags automáticamente. Más info → permite que al guardar una entidad, las relaciones se guarden automáticamente:
@OneToMany(() => Post, (post) => post.author, {
cascade: true, // INSERT y UPDATE se propagan a los posts
})
posts: Post[];
Ejemplo:
const user = this.usersRepository.create({
name: 'Domin',
email: 'domin@domin.es',
password: hashedPassword,
posts: [
{ title: 'Mi primer post', slug: 'mi-primer-post', content: '...' },
{ title: 'Segundo post', slug: 'segundo-post', content: '...' },
],
});
// Un solo save() crea el usuario Y los dos posts
await this.usersRepository.save(user);
Puedes ser más fino con las opciones de cascade:
@OneToMany(() => Post, (post) => post.author, {
cascade: ['insert'], // Solo propaga inserts, no updates ni removes
})
posts: Post[];
- cascade: ["insert"] para relaciones padre-hijo donde creas ambos a la vez (pedido + items)
- cascade: true cuando genuinamente quieres propagar todas las operaciones
- cascade: true en ManyToMany. Si guardas un post con tags existentes, puede duplicar los tags
- cascade: ["remove"] sin pensarlo. Borrar un usuario puede borrar en cascada posts, comentarios, likes...
- cascade como sustituto de transacciones. Las cascadas no son atómicas si falla a mitad
7. Queries con relaciones: el día a día
7.1. Filtrar por relación
Buscar posts de un usuario determinado:
const userPosts = await this.postsRepository.find({
where: { userId: userId }, // Usamos la FK directa
relations: { tags: true },
order: { createdAt: 'DESC' },
});
Usamos userId (la columna FK que declaramos explícitamente) en vez de author: { id: userId }. Es más eficiente porque no necesita JOIN.
7.2. Filtrar por una propiedad de la relación
Buscar posts de usuarios con rol admin:
const adminPosts = await this.postsRepository.find({
where: {
author: { role: UserRole.ADMIN },
},
relations: { author: true },
});
Aquí sí que vamos a necesitar el JOIN. TypeORM lo genera automáticamente porque el where referencia una propiedad de la relación.
7.3. Manejar relaciones ManyToMany
Asignar tags a un post:
async addTagsToPost(postId: string, tagIds: string[]): Promise<Post> {
const post = await this.postsRepository.findOne({
where: { id: postId },
relations: { tags: true },
});
if (!post) {
throw new NotFoundException(`Post con id ${postId} no encontrado`);
}
const tags = await this.tagsRepository.find({
where: { id: In(tagIds) },
});
post.tags = [...post.tags, ...tags];
return this.postsRepository.save(post);
}
Para eliminar un tag de un post:
async removeTagFromPost(postId: string, tagId: string): Promise<Post> {
const post = await this.postsRepository.findOne({
where: { id: postId },
relations: { tags: true },
});
if (!post) {
throw new NotFoundException(`Post con id ${postId} no encontrado`);
}
post.tags = post.tags.filter((tag) => tag.id !== tagId);
return this.postsRepository.save(post);
}
TypeORM se encarga de insertar y borrar las filas de la tabla pivote posts_tags, así tú solo manipulas el array tags y guardas.
8. Migraciones: adiós synchronize
Hasta ahora hemos usado synchronize: true para que TypeORM cree las tablas automáticamente. En producción, eso es una fokin locura. Las 💡 migraciones Archivos versionados que describen cambios en el esquema de la base de datos (crear tablas, añadir columnas, modificar tipos...). Se ejecutan de forma secuencial e irreversible. Son el control de versiones de tu base de datos.
Más info →
son la solución profesional.
8.1. DataSource config: un archivo separado
TypeORM CLI necesita una configuración de conexión separada (no usa la de NestJS). Creamos un archivo de config que funciona tanto con el CLI como con la app:
// src/config/data-source.ts
import { DataSource, type DataSourceOptions } from 'typeorm';
import * as dotenv from 'dotenv';
dotenv.config();
export const dataSourceOptions: DataSourceOptions = {
type: 'postgres',
host: process.env.DB_HOST ?? 'localhost',
port: Number(process.env.DB_PORT) ?? 5432,
username: process.env.DB_USERNAME ?? 'nestjs',
password: process.env.DB_PASSWORD ?? 'nestjs_password',
database: process.env.DB_DATABASE ?? 'nestjs_course',
entities: ['dist/**/*.entity.js'],
migrations: ['dist/migrations/*.js'],
};
const dataSource = new DataSource(dataSourceOptions);
export default dataSource;
Puntos clave:
entities: ['dist/**/*.entity.js']: Apunta a los archivos compilados, no a los.ts. El CLI ejecuta JavaScript.migrations: ['dist/migrations/*.js']: Dónde buscar los archivos de migración compilados.export default dataSource: El CLI necesita un export default de unDataSource.
8.2. Scripts en package.json
{
"scripts": {
"typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d src/config/data-source.ts",
"migration:generate": "npm run typeorm -- migration:generate",
"migration:run": "npm run typeorm -- migration:run",
"migration:revert": "npm run typeorm -- migration:revert",
"migration:show": "npm run typeorm -- migration:show"
}
}
8.3. Generar una migración
El comando migration:generate compara tus entidades con el estado actual de la base de datos y genera un archivo con los cambios necesarios:
El archivo generado:
// src/migrations/1711000000000-CreateUsersTable.ts
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateUsersTable1711000000000 implements MigrationInterface {
name = 'CreateUsersTable1711000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TYPE "users_role_enum" AS ENUM('user', 'admin', 'moderator')
`);
await queryRunner.query(`
CREATE TABLE "users" (
"id" uuid DEFAULT uuid_generate_v4() NOT NULL,
"name" varchar(100) NOT NULL,
"email" varchar(255) NOT NULL,
"password" varchar(255) NOT NULL,
"role" "users_role_enum" DEFAULT 'user' NOT NULL,
"active" boolean DEFAULT true NOT NULL,
"createdAt" TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
"updatedAt" TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
"deletedAt" TIMESTAMP WITH TIME ZONE,
CONSTRAINT "PK_users" PRIMARY KEY ("id")
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX "IDX_users_email" ON "users" ("email")
WHERE "deletedAt" IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "IDX_users_email"`);
await queryRunner.query(`DROP TABLE "users"`);
await queryRunner.query(`DROP TYPE "users_role_enum"`);
}
}
Cada migración tiene dos métodos:
up()— Aplica el cambio (crear tabla, añadir columna, crear índice…).down()— Revierte el cambio (borrar tabla, quitar columna…). Para poder hacer rollback.
8.4. Ejecutar migraciones
TypeORM crea una tabla migrations en tu base de datos que registra qué migraciones se han ejecutado. Así sabe cuáles son nuevas y cuáles ya están aplicadas.
8.5. Actualizar la config de NestJS
Ahora que usamos migraciones, desactivamos synchronize y apuntamos a las migraciones:
// src/app.module.ts
TypeOrmModule.forRootAsync({
useFactory: (configService: ConfigService) => ({
type: 'postgres',
host: configService.getOrThrow<string>('DB_HOST'),
port: configService.getOrThrow<number>('DB_PORT'),
username: configService.getOrThrow<string>('DB_USERNAME'),
password: configService.getOrThrow<string>('DB_PASSWORD'),
database: configService.getOrThrow<string>('DB_DATABASE'),
autoLoadEntities: true,
synchronize: false, // 👈 Adiós synchronize
migrationsRun: true, // 👈 Ejecuta migraciones pendientes al arrancar
logging: configService.getOrThrow<string>('NODE_ENV') === 'development',
}),
inject: [ConfigService],
}),
Con migrationsRun: true, la app ejecuta las migraciones pendientes automáticamente al arrancar. Una alternativa es ejecutar npm run migration:run en el Dockerfile o en el pipeline de CI/CD antes de arrancar la app.
8.6. El flujo de trabajo con migraciones
1. Modificas una entidad (añades columna, cambias tipo, nueva relación...)
2. npm run build (compilar los .ts a .js)
3. npm run migration:generate -- src/migrations/NombreDescriptivo
4. Revisas el archivo generado (SIEMPRE revísalo, a veces genera cosas raras)
5. npm run migration:run
6. Commit del archivo de migración al repositorio
Las migraciones se commitean al repo porque son parte del código. Cuando otro developer hace pull, las migraciones pendientes se ejecutan automáticamente (con migrationsRun: true) o las ejecuta manualmente.
9. Seeders: datos de prueba tipados
Los 💡 seeders Scripts que insertan datos iniciales o de prueba en la base de datos. Útiles para desarrollo (tener datos con los que trabajar), demos y testing. Se ejecutan manualmente después de las migraciones. son scripts que llenan la base de datos con datos de prueba, como las fixtures en Symfony, así se pueden probar cosas con datos. TypeORM no tiene un sistema de seeders integrado, así que lo montamos nosotros.
9.1. El script de seed
// src/seeds/seed.ts
import { DataSource } from 'typeorm';
import { dataSourceOptions } from '../config/data-source';
import { User } from '../users/entities/user.entity';
import { UserRole } from '../users/enums/user-role.enum';
import { Post } from '../posts/entities/post.entity';
import { Tag } from '../tags/entities/tag.entity';
import * as bcrypt from 'bcrypt';
async function seed(): Promise<void> {
const dataSource = new DataSource({
...dataSourceOptions,
entities: [User, Post, Tag],
});
await dataSource.initialize();
console.warn('DataSource initialized for seeding...');
const queryRunner = dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const usersRepository = dataSource.getRepository(User);
const postsRepository = dataSource.getRepository(Post);
const tagsRepository = dataSource.getRepository(Tag);
// Limpiar datos existentes (orden inverso por FKs)
await postsRepository.delete({});
await tagsRepository.delete({});
await usersRepository.delete({});
// Crear usuarios
const hashedPassword = await bcrypt.hash('Password123!', 10);
const admin = usersRepository.create({
name: 'Admin',
email: 'admin@nestjs.local',
password: hashedPassword,
role: UserRole.ADMIN,
active: true,
});
const domin = usersRepository.create({
name: 'Domin',
email: 'domin@domin.es',
password: hashedPassword,
role: UserRole.USER,
active: true,
});
const users = await usersRepository.save([admin, domin]);
// Crear tags
const tags = await tagsRepository.save(
['typescript', 'nestjs', 'docker', 'postgresql', 'testing'].map((name) =>
tagsRepository.create({ name, slug: name }),
),
);
// Crear posts
const posts = [
{
title: 'Introducción a NestJS',
slug: 'introduccion-nestjs',
content: 'NestJS es un framework progresivo...',
published: true,
userId: users[0].id,
tags: [tags[0], tags[1]],
},
{
title: 'Docker para developers',
slug: 'docker-para-developers',
content: 'Docker simplifica el despliegue...',
published: true,
userId: users[1].id,
tags: [tags[2], tags[3]],
},
{
title: 'Testing en NestJS (borrador)',
slug: 'testing-nestjs-borrador',
content: 'Los tests son fundamentales...',
published: false,
userId: users[0].id,
tags: [tags[1], tags[4]],
},
];
for (const postData of posts) {
const { tags: postTags, ...rest } = postData;
const post = postsRepository.create(rest);
post.tags = postTags;
await postsRepository.save(post);
}
await queryRunner.commitTransaction();
console.warn(`Seed completed: ${users.length} users, ${tags.length} tags, ${posts.length} posts`);
} catch (error) {
await queryRunner.rollbackTransaction();
console.error('Seed failed:', error);
throw error;
} finally {
await queryRunner.release();
await dataSource.destroy();
}
}
seed();
9.2. Script en package.json
{
"scripts": {
"seed": "ts-node -r tsconfig-paths/register src/seeds/seed.ts"
}
}
9.3. Buenas prácticas de seeders
- Seeders idempotentes: que puedan ejecutarse múltiples veces sin duplicar datos (borra primero, inserta después)
- Transacciones: si el seed falla a mitad, no dejar datos parciales
- Datos realistas: nombres, emails y contenido que se parezcan a datos reales para detectar problemas en el UI
- Seeders que dependen de IDs hardcodeados. Los UUIDs cambian en cada ejecución
- Ejecutar seeders en producción. Son solo para desarrollo y testing
- Passwords en texto plano en los seeders. Siempre hashea aunque sean datos de prueba
10. Estructura completa del proyecto
Después de este post, la estructura de tu proyecto debería verse así:
src/
├── config/
│ ├── data-source.ts # Config para TypeORM CLI
│ └── env.validation.ts # Validación de .env con Joi
├── common/
│ ├── dto/
│ │ └── pagination.dto.ts
│ └── interfaces/
│ └── paginated-result.interface.ts
├── migrations/
│ ├── 1711000000000-CreateUsersTable.ts
│ ├── 1711000000001-CreatePostsTable.ts
│ └── 1711000000002-CreateTagsAndPivot.ts
├── seeds/
│ └── seed.ts
├── users/
│ ├── dto/
│ ├── entities/
│ │ └── user.entity.ts
│ ├── enums/
│ │ └── user-role.enum.ts
│ ├── users.controller.ts
│ ├── users.module.ts
│ └── users.service.ts
├── posts/
│ ├── dto/
│ ├── entities/
│ │ └── post.entity.ts
│ ├── posts.controller.ts
│ ├── posts.module.ts
│ └── posts.service.ts
├── tags/
│ ├── entities/
│ │ └── tag.entity.ts
│ ├── tags.module.ts
│ └── tags.service.ts
├── profiles/
│ ├── entities/
│ │ └── profile.entity.ts
│ └── profiles.module.ts
├── app.module.ts
└── main.ts
Cada feature module tiene su propia carpeta con entidades, DTOs, controller, service y module. Las migraciones y seeds están en la raíz de src/ porque son de ámbito general.
12. Recapitulando
La relación más común. FK en el lado "many". @JoinColumn en el @ManyToOne. @OneToMany es virtual.
Tabla pivote con @JoinTable. Solo en un lado. Manipulas el array y save() gestiona la pivot.
Cargar relaciones con relations: { author: true }. NO eager. Tú decides qué cargar en cada query.
migration:generate compara entidades vs DB. migration:run aplica. migration:revert deshace. Adiós synchronize.
Propaga insert/update/remove a relaciones. Úsalo con cuidado. cascade: ["insert"] es más seguro que true.
Scripts tipados para datos de prueba. Transaccional, idempotente, con passwords hasheados. Solo para desarrollo.
Con este post cerramos el Bloque 2: Validación y Base de Datos. Ahora tenemos una API conectada a PostgreSQL con CRUD validado, relaciones entre entidades, migraciones versionadas y datos seed y todo en Docker Compose.
En el próximo bloque nos metemos con Seguridad y veremos autenticación con Passport y JWT, autorización con Guards y RBAC, y protección de la API con Helmet, CORS y Rate Limiting.
EA, nos vemos en los bares!! 🍺
Pon a prueba lo aprendido
1. ¿En qué lado de una relación @ManyToOne/@OneToMany va la Foreign Key?
2. ¿Por qué NO se recomienda eager: true en relaciones One-to-Many grandes?
3. ¿Qué decorador se usa para crear la tabla pivote en una relación Many-to-Many?
4. ¿Qué hace el comando migration:generate de TypeORM?
5. ¿Cuál es la forma recomendada de cargar relaciones en queries?