🚨 ¡Nueva review! 🔇 Los mejores cascos con ANC del mercado: los Sony WH-1000XM4 . ¡Échale un ojo! 👀

Relaciones, Migraciones y Seeders en NestJS con TypeORM

Serie NestJS #9 — @OneToMany, @ManyToMany, eager/lazy loading, TypeORM CLI migrations y seed scripts tipados

Escrito por domin el 3 de abril de 2026

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.

Diagrama de entidades conectadas por relaciones con flechas, representando OneToMany, ManyToOne y ManyToMany en TypeORM.

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:

1️⃣ One-to-Many / Many-to-One

Un usuario tiene MUCHOS posts. Cada post pertenece a UN usuario. La FK (userId) va en la tabla del "many" (posts).

🔗 One-to-One

Un usuario tiene UN perfil. Un perfil pertenece a UN usuario. FK en cualquiera de las dos tablas.

🔀 Many-to-Many

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',
})
onDeleteQué haceCuándo usarlo
CASCADEBorra los registros hijos automáticamentePosts de un usuario, items de un pedido
SET NULLPone la FK a NULL (requiere nullable: true)Comentarios de un usuario borrado (se quedan como “anónimo”)
RESTRICTImpide borrar el padre si tiene hijosNo puedes borrar un usuario si tiene pedidos activos
NO ACTIONSimilar a RESTRICT pero evaluado al final de la transacciónDefault de PostgreSQL

@JoinColumn y la columna FK explícita

@JoinColumn({ name: 'userId' })
author: User;

@Column({ type: 'uuid' })
userId: string;

Dos escopetas tengo:

  1. @JoinColumn({ name: 'userId' }) — Le dice a TypeORM cómo se llama la columna FK en la tabla. Sin esto, crearía authorId tal cuál.
  2. userId: string como columna explícita. Al declarar la FK también como @Column, puedes acceder al ID sin cargar toda la relación: post.userId en vez de post.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 :

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…

Úsalo cuando...
  • Eager loading SOLO para relaciones que SIEMPRE necesitas. Ejemplo: un post siempre necesita su autor para mostrar el nombre
Evítalo cuando...
  • 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 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[];
Úsalo cuando...
  • cascade: ["insert"] para relaciones padre-hijo donde creas ambos a la vez (pedido + items)
  • cascade: true cuando genuinamente quieres propagar todas las operaciones
Evítalo cuando...
  • 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 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:

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"
    }
}
Instalación de dependencias para migraciones 0 / 1
$
Pulsa para ejecutar el siguiente comando

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:

Generar migración 0 / 1
$
Pulsa para ejecutar el siguiente comando

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:

8.4. Ejecutar migraciones

Ejecutar y gestionar migraciones 0 / 3
$
Pulsa para ejecutar el siguiente comando

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 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"
    }
}
Ejecutar seed 0 / 1
$
Pulsa para ejecutar el siguiente comando

9.3. Buenas prácticas de seeders

Úsalo cuando...
  • 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
Evítalo cuando...
  • 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

🔗 @ManyToOne / @OneToMany

La relación más común. FK en el lado "many". @JoinColumn en el @ManyToOne. @OneToMany es virtual.

🔀 @ManyToMany

Tabla pivote con @JoinTable. Solo en un lado. Manipulas el array y save() gestiona la pivot.

🎯 Explicit loading

Cargar relaciones con relations: { author: true }. NO eager. Tú decides qué cargar en cada query.

📋 Migraciones

migration:generate compara entidades vs DB. migration:run aplica. migration:revert deshace. Adiós synchronize.

⚡ Cascade

Propaga insert/update/remove a relaciones. Úsalo con cuidado. cascade: ["insert"] es más seguro que true.

🌱 Seeders

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?