🚨 ¡Nueva review! ¡Mi teclado ideal! ⌨️ Perfecto para programar, el Logitech MX Keys S . ¡Échale un ojo! 👀

TypeORM + PostgreSQL + Docker Compose en NestJS

Serie NestJS #7 — Conecta tu API a una base de datos real con TypeORM, PostgreSQL y Docker Compose

Escrito por domin el 29 de marzo de 2026

Vamos con el séptimo post de la serie NestJS, esto no se acaba!. De momento, nuestra app de NestJS guarda los datos en un array en memoria y cada vez que reiniciemos el servidor, todo se va a la puta, así que vamos a ponerle remedio a esta vaina.

Vamos a repasar nuestra base ya adquirida como en cada post con: Docker (post 1), controllers (post 2), DI (post 3), módulos (post 4), middleware (post 5) y validación con DTOs (post 6). Hoy vamos a conectar nuestra app con PostgreSQL usando TypeORM, y todo montado en Docker Compose para que no haya que instalar nada en la máquina y así evitamos problemillas varios con versiones e historias.

Cuando acabes este post vas a tener una base de datos PostgreSQL corriendo en Docker, un panel de pgAdmin para gestionarla visualmente, entidades tipadas con decoradores, y la configuración de conexión leyendo de variables de entorno como se hace en producción.

EA, al lío.

Diagrama de NestJS conectado a PostgreSQL mediante TypeORM dentro de un entorno Docker Compose.

1. ¿Por qué TypeORM?

es el ORM más usado en el ecosistema NestJS, el más usado no necesariamente tiene que ser el mejor, quiero dejar claro esto. Tiene integración oficial (@nestjs/typeorm), soporta decoradores TypeScript nativos y comparte la misma filosofía de NestJS, es decir todo tipado, con decoradores y con sus inyecciones.

¿Hay alternativas? Sí. Prisma , MikroORM , Drizzle … Todos son muy válidos pero vamos a user TypeORM porque:

🔌 Integración oficial

@nestjs/typeorm viene del equipo de NestJS. TypeOrmModule con forRoot, forFeature, InjectRepository... todo encaja con los patrones que ya conocemos.

🏷️ Decoradores nativos

Defines entidades con @Entity, @Column, @PrimaryGeneratedColumn. Misma filosofía que @Injectable, @Controller, @Module.

📐 Active Record y Data Mapper

Soporta ambos patrones. Usaremos Data Mapper (con Repository) porque es el que mejor encaja con la DI de NestJS.

🗄️ Multi-database

PostgreSQL, MySQL, MariaDB, SQLite, Oracle, SQL Server, MongoDB. Cambiar de base de datos es cambiar la config, no el código.


2. Docker Compose: PostgreSQL + pgAdmin

Lo primero es levantar la infraestructura, así que necesitamos PostgreSQL para la base de datos y pgAdmin para tener un panel visual donde ver las tablas, ejecutar queries y gestionar los datos.

Si vienes del post 1, ya tienes un docker-compose.yml con el servicio de la app NestJS. Vamos a añadir PostgreSQL y pgAdmin:

# docker-compose.yml
services:
    api:
        build:
            context: ./api
            dockerfile: Dockerfile
        ports:
            - '3000:3000'
        volumes:
            - ./api:/app
            - /app/node_modules
        depends_on:
            postgres:
                condition: service_healthy
        environment:
            - NODE_ENV=development
            - DB_HOST=postgres
            - DB_PORT=5432
            - DB_USERNAME=nestjs
            - DB_PASSWORD=nestjs_password
            - DB_DATABASE=nestjs_course
        command: npm run start:dev

    postgres:
        image: postgres:17-alpine
        ports:
            - '5432:5432'
        environment:
            POSTGRES_USER: nestjs
            POSTGRES_PASSWORD: nestjs_password
            POSTGRES_DB: nestjs_course
        volumes:
            - postgres_data:/var/lib/postgresql/data
        healthcheck:
            test: ['CMD-SHELL', 'pg_isready -U nestjs -d nestjs_course']
            interval: 5s
            timeout: 5s
            retries: 5

    pgadmin:
        image: dpage/pgadmin4:latest
        ports:
            - '5050:80'
        environment:
            PGADMIN_DEFAULT_EMAIL: admin@nestjs.com
            PGADMIN_DEFAULT_PASSWORD: admin
        volumes:
            - pgadmin_data:/var/lib/pgadmin
        depends_on:
            - postgres

volumes:
    postgres_data:
    pgadmin_data:
Importante

Ojito aquí en el email del pgAdmin, que si pones un email no válido con una validación simple como puede ser el email patatas@bravas.local, no te va a levantar el servicio.

Uy uy uy ,aquí hay mucha palabra nueva, vamos a ver lo más importante:

El servicio postgres

El servicio pgadmin

depends_on con condición

depends_on:
    postgres:
        condition: service_healthy

Esto le dice a Docker Compose que no arranque la app hasta que el healthcheck de PostgreSQL pase. Sin la condición service_healthy, Docker solo espera a que el contenedor esté arrancado, no a que PostgreSQL esté listo, son cosas distintas.

Levantando la infraestructura 0 / 2
$
Pulsa para ejecutar el siguiente comando

Ahora ya tenemos PostgreSQL en localhost:5432 y pgAdmin en http://localhost:5050, así que vamos a seguir con la vaina del TypeORM.


3. Instalando las dependencias

Instalación de paquetes 0 / 1
$
Pulsa para ejecutar el siguiente comando
PaqueteQué es
@nestjs/typeormIntegración oficial de TypeORM con NestJS, proporciona TypeOrmModule
typeormEl ORM en sí con sus Decorators, Repository, QueryBuilder, migraciones…
pgDriver de PostgreSQL para Node.js, TypeORM lo usa por debajo
@nestjs/configMódulo oficial para gestionar variables de entorno con ConfigService

4. Configuración con variables de entorno: @nestjs/config

Antes de conectar TypeORM, vamos a montar la gestión de variables de entorno como se hace en producción, para trabajar medianamente bien y evitar hardcodear credenciales en el código:

4.1. Archivo .env

# .env
NODE_ENV=development
DB_HOST=postgres
DB_PORT=5432
DB_USERNAME=nestjs
DB_PASSWORD=nestjs_password
DB_DATABASE=nestjs_course

Añade .env a tu .gitignore si no lo tienes ya porque las credenciales nunca, jamás, se commitean.

4.2. ConfigModule global

El carga las variables del .env y las expone a toda la app a través de ConfigService:

// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';

@Module({
    imports: [
        ConfigModule.forRoot({
            isGlobal: true, // Disponible en todos los módulos sin importar ConfigModule
            envFilePath: '.env',
        }),
    ],
})
export class AppModule {}

Con isGlobal: true, cualquier service de cualquier módulo puede inyectar ConfigService sin tener que importar ConfigModule en su módulo. Es lo mismo que ponerle @Global() al módulo, pero la opción ya viene integrada.

4.3. Validación del .env con Joi (opcional pero recomendado)

Si quieres que la app no arranque si falta alguna variable de entorno, puedes validarlas con un esquema:

Instalar Joi 0 / 1
$
Pulsa para ejecutar el siguiente comando

Creamos un nuevo ficherito con la validación Joi:

// src/config/env.validation.ts
import * as Joi from 'joi';

export const envValidationSchema = Joi.object({
    NODE_ENV: Joi.string().valid('development', 'production', 'test').default('development'),
    DB_HOST: Joi.string().required(),
    DB_PORT: Joi.number().default(5432),
    DB_USERNAME: Joi.string().required(),
    DB_PASSWORD: Joi.string().required(),
    DB_DATABASE: Joi.string().required(),
});

Le endosamos la validación al import de la config e ya:

// src/app.module.ts
import { ConfigModule } from '@nestjs/config';
import { envValidationSchema } from './config/env.validation';

@Module({
    imports: [
        ConfigModule.forRoot({
            isGlobal: true,
            envFilePath: '.env',
            validationSchema: envValidationSchema,
            validationOptions: {
                abortEarly: true, // Para al primer error
            },
        }),
    ],
})
export class AppModule {}

Luego si arrancas la app sin la variable DB_PASSWORD, por ejemplo:

Validación de .env al arrancar 0 / 1
$
Pulsa para ejecutar el siguiente comando

De esta forma al arrancar si falta alguna configuración va a fallar rápido y de forma clara.


5. TypeOrmModule.forRootAsync(): la conexión a PostgreSQL

Ahora conectamos TypeORM con la base de datos. ¿Recuerdas el forRootAsync() del post 4? Ahora lo vamos a usar para que la configuración de TypeORM se construya con inyección de dependencias, leyendo las variables de ConfigService:

// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { envValidationSchema } from './config/env.validation';
import { UsersModule } from './users/users.module';

@Module({
    imports: [
        ConfigModule.forRoot({
            isGlobal: true,
            envFilePath: '.env',
            validationSchema: envValidationSchema,
        }),

        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: configService.getOrThrow<string>('NODE_ENV') === 'development',
                logging: configService.getOrThrow<string>('NODE_ENV') === 'development',
            }),
            inject: [ConfigService],
        }),

        UsersModule,
    ],
})
export class AppModule {}

Vamos a destripar cada opción:

getOrThrow vs get

configService.get('DB_HOST') devuelve undefined si la variable no existe. configService.getOrThrow('DB_HOST') lanza una excepción. Siempre usa getOrThrow para variables que son obligatorias porque es más seguro y el tipado es mejor (nunca devuelve undefined).

autoLoadEntities: true

Esto le dice a TypeORM que registre automáticamente todas las entidades que se importen con TypeOrmModule.forFeature() en cualquier módulo. Sin esto, tendrías que listar manualmente cada entidad en un array entities: [User, Product, Order, ...]. Con autoLoadEntities simplemente funciona.

synchronize: true (solo en desarrollo)

modifica la base de datos automáticamente para que coincida con tus entidades. Si añades una columna a una entidad, TypeORM la crea en la tabla al arrancar, esto es genial para desarrollo, el infierno en producción, porque puede borrar datos.

Úsalo cuando...
  • synchronize: true SOLO en desarrollo para iterar rápido mientras diseñas las entidades
  • Migraciones (que veremos en el post 9) para cualquier cambio en producción
Evítalo cuando...
  • synchronize: true en producción. NUNCA. Puede destruir datos, eliminar columnas y romper tu base de datos
  • Dejar synchronize: true hardcodeado sin depender del entorno. Usa NODE_ENV para controlarlo

logging: true

En desarrollo, activa el logging de SQL para ver las queries que TypeORM genera que muy útil para entender qué está pasando y detectar queries ineficientes. En producción mejor lo desactivas para no llenar los logs, y el disco de logs XD.


6. Tu primera entidad: @Entity

Una es una clase que mapea directamente a una tabla de la base de datos. Cada propiedad decorada es una columna, y chimpun.

// src/users/entities/user.entity.ts
import {
    Entity,
    PrimaryGeneratedColumn,
    Column,
    CreateDateColumn,
    UpdateDateColumn,
    Index,
} from 'typeorm';

@Entity('users') // Nombre de la tabla en la DB
export class User {
    @PrimaryGeneratedColumn('uuid')
    id: string;

    @Column({ type: 'varchar', length: 100 })
    name: string;

    @Index({ unique: true })
    @Column({ type: 'varchar', length: 255 })
    email: string;

    @Column({ type: 'varchar', length: 255, select: false })
    password: string;

    @Column({ type: 'boolean', default: true })
    active: boolean;

    @CreateDateColumn({ type: 'timestamptz' })
    createdAt: Date;

    @UpdateDateColumn({ type: 'timestamptz' })
    updatedAt: Date;
}

Vamos a ver propiedad por propiedad a ver qué nos cuentan:


7. @PrimaryGeneratedColumn: la clave primaria

TypeORM te da varias estrategias para generar IDs:

// Auto-incremento (1, 2, 3, ...)
@PrimaryGeneratedColumn()
id: number;

// UUID v4 (a3f2c8e1-7b4d-4e9a-b6f1-2d8e9c0a5b3f)
@PrimaryGeneratedColumn('uuid')
id: string;

// Identity (PostgreSQL 10+, recomendado sobre serial)
@PrimaryGeneratedColumn('identity')
id: number;
🔐 UUID

No predecible, no revela cuántos registros tienes, seguro para exponer en URLs. Ocupa más espacio y los índices son más lentos que con integers.

🔢 Auto-incremento

Más eficiente en índices y JOINs. Pero revela el orden de creación y cuántos registros hay. Predecible (si el último user es /users/42, probablemente existe /users/41).

Para APIs públicas, mejor UUID pero para tablas internas que no se exponen, auto-increment. Usamos UUID para users porque el ID se expone en la API.


8. @Column: tipos, opciones y mapeo con PostgreSQL

El decorador @Column() acepta un objeto de opciones que controla cómo se crea la columna en la base de datos:

@Column({
    type: 'varchar',       // Tipo de columna en PostgreSQL
    length: 255,           // Longitud máxima (para varchar/char)
    nullable: false,       // ¿Puede ser NULL? (default: false)
    unique: true,          // ¿Valor único en toda la tabla?
    default: 'active',     // Valor por defecto en la DB
    select: false,         // ¿Se incluye en los SELECT por defecto? (ojo con passwords)
    name: 'user_email',    // Nombre de la columna en la DB (si difiere del nombre de la propiedad)
    comment: 'Email del usuario', // Comentario en la DB
})
email: string;

Mapeo de tipos TypeScript → PostgreSQL

TypeScriptPostgreSQLCuándo usarlo
stringvarchar(n)Textos con longitud acotada: nombres, emails, slugs
stringtextTextos largos sin límite: descripciones, biografías, contenido
numberintegerEnteros: cantidades, contadores, edades
numberdecimal(p,s)Decimales exactos: precios, porcentajes. Nunca float para dinero
booleanbooleanFlags: activo, verificado, eliminado
DatetimestamptzFechas con hora y timezone. Siempre timestamptz, nunca timestamp
DatedateSolo fecha sin hora: cumpleaños, fechas de vencimiento
objectjsonbDatos semi-estructurados: configuraciones, metadata, preferencias
enumenumValores limitados: roles, estados, categorías

select: false — El truco para passwords

@Column({ type: 'varchar', length: 255, select: false })
password: string;

Con select: false, TypeORM no incluye esta columna en los SELECT normales. Si haces userRepository.find(), el campo password no aparece en el resultado, por defecto, si no que tendrás que pedirlo explicitamente en la query select:

const user = await this.usersRepository.findOne({
    where: { email },
    select: ['id', 'email', 'password'], // Pides explícitamente el password
});

Esto evita que el hash del password se filtre accidentalmente en respuestas de la API, teniendo así una buena capa de seguridad by default.

Enums tipados

Los enums de TypeScript se mapean directamente a enums de PostgreSQL:

// src/users/enums/user-role.enum.ts
export enum UserRole {
    USER = 'user',
    ADMIN = 'admin',
    MODERATOR = 'moderator',
}
@Column({
    type: 'enum',
    enum: UserRole,
    default: UserRole.USER,
})
role: UserRole;

TypeORM crea un tipo enum en PostgreSQL (CREATE TYPE "user_role_enum" AS ENUM ('user', 'admin', 'moderator')) y valida que solo se inserten valores válidos del enum.

Columnas JSONB

PostgreSQL tiene soporte nativo para JSON con jsonb, que permite queries e índices sobre campos JSON:

@Column({ type: 'jsonb', default: {} })
preferences: Record<string, unknown>;

Pero podemos tiparlo mejor:

// src/users/interfaces/user-preferences.interface.ts
export interface UserPreferences {
    theme: 'light' | 'dark';
    language: string;
    notifications: {
        email: boolean;
        push: boolean;
    };
}
@Column({ type: 'jsonb', default: { theme: 'light', language: 'es', notifications: { email: true, push: false } } })
preferences: UserPreferences;

Tipado en TypeScript aunque PostgreSQL solo vea un JSONB.


9. Columnas especiales: timestamps automáticos

TypeORM tiene decoradores específicos para columnas que se gestionan automáticamente:

// Se establece automáticamente al crear el registro
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;

// Se actualiza automáticamente cada vez que se guarda el registro
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;

// Se establece cuando se hace soft delete (borrado lógico)
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt: Date | null;

// Se incrementa en 1 automáticamente cada vez que se guarda
@VersionColumn()
version: number;

@CreateDateColumn y @UpdateDateColumn son imprescindibles, así que ponlos en todas tus entidades. Siempre querrás saber cuándo se creó y cuándo se modificó un registro.

@DeleteDateColumn es para soft delete . En vez de borrar la fila, se marca con la fecha. Lo veremos en detalle en el próximo post.

@VersionColumn es para optimistic locking . Lo veremos cuando hablemos de transacciones.

Usa siempre timestamptz (timestamp with timezone) en vez de timestamp. Sin timezone, las fechas cambian de significado según dónde esté tu servidor, pero con timezone son absolutas.


10. @Index: índices para queries rápidas

Los son fundamentales para el rendimiento. Si haces queries frecuentes por una columna, necesita un índice:

// Índice simple
@Index()
@Column({ type: 'varchar', length: 100 })
name: string;

// Índice único (como unique: true en @Column, pero más explícito)
@Index({ unique: true })
@Column({ type: 'varchar', length: 255 })
email: string;

Índices compuestos

Para queries que filtran por varias columnas a la vez, un índice compuesto es más eficiente que dos índices simples:

@Entity('products')
@Index(['category', 'active']) // Índice compuesto
export class Product {
    @PrimaryGeneratedColumn('uuid')
    id: string;

    @Column({ type: 'varchar', length: 100 })
    category: string;

    @Column({ type: 'boolean', default: true })
    active: boolean;

    @Column({ type: 'varchar', length: 200 })
    name: string;
}

Si tu query más frecuente es WHERE category = 'electronics' AND active = true, este índice compuesto es perfecto. El orden importa porque el primer campo del índice es el más selectivo.

Úsalo cuando...
  • Índices en columnas que aparecen en WHERE, JOIN y ORDER BY frecuentemente
  • Índice único en campos que deben ser únicos: email, slug, username
  • Índices compuestos cuando siempre filtras por múltiples columnas juntas
Evítalo cuando...
  • Índice en cada columna "por si acaso". Los índices ocupan espacio y ralentizan los INSERT/UPDATE
  • Índices en columnas con muy pocos valores distintos (boolean con 50% true/false — no es selectivo)
  • Ignorar los índices y quejarte de que "PostgreSQL es lento". Es tu schema, no PostgreSQL

11. Entidad completa: User con todo lo aprendido

Juntamos todo en una entidad, como esta:

// src/users/entities/user.entity.ts
import {
    Entity,
    PrimaryGeneratedColumn,
    Column,
    CreateDateColumn,
    UpdateDateColumn,
    DeleteDateColumn,
    Index,
} from 'typeorm';
import { UserRole } from '../enums/user-role.enum';

@Entity('users')
export class User {
    @PrimaryGeneratedColumn('uuid')
    id: string;

    @Column({ type: 'varchar', length: 100 })
    name: string;

    @Index({ unique: true })
    @Column({ type: 'varchar', length: 255 })
    email: string;

    @Column({ type: 'varchar', length: 255, select: false })
    password: string;

    @Column({ type: 'enum', enum: UserRole, default: UserRole.USER })
    role: UserRole;

    @Column({ type: 'boolean', default: true })
    active: boolean;

    @CreateDateColumn({ type: 'timestamptz' })
    createdAt: Date;

    @UpdateDateColumn({ type: 'timestamptz' })
    updatedAt: Date;

    @DeleteDateColumn({ type: 'timestamptz' })
    deletedAt: Date | null;
}
// src/users/enums/user-role.enum.ts
export enum UserRole {
    USER = 'user',
    ADMIN = 'admin',
    MODERATOR = 'moderator',
}

Esto genera la siguiente tabla en PostgreSQL:

CREATE TABLE "users" (
    "id"         uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
    "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"  timestamptz DEFAULT now() NOT NULL,
    "updatedAt"  timestamptz DEFAULT now() NOT NULL,
    "deletedAt"  timestamptz
);

CREATE UNIQUE INDEX "IDX_users_email" ON "users" ("email");

Todo desde una clase TypeScript con cero unidades de SQL manual para el schema.


12. Registrar la entidad en el módulo: forFeature()

Para que NestJS y TypeORM conozcan tu entidad, la registras en el feature module con TypeOrmModule.forFeature():

// src/users/users.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';

@Module({
    imports: [TypeOrmModule.forFeature([User])], // 👈 Registra la entidad
    controllers: [UsersController],
    providers: [UsersService],
    exports: [UsersService],
})
export class UsersModule {}

forFeature() hace dos cosas:

  1. Registra la entidad User para que TypeORM la conozca, que esto es necesario para autoLoadEntities.
  2. Crea un Repository<User> que se puede inyectar en los services del módulo con @InjectRepository(User).

¿Recuerdas el patrón forRoot() / forFeature() del post 4? Aquí vemos un ejemplo más de uso:


13. Verificar la conexión

Levanta todo y comprueba que funciona:

Verificar conexión 0 / 1
$
Pulsa para ejecutar el siguiente comando

Si ves TypeOrmModule dependencies initialized y las queries de creación de tabla, la conexión funciona. Con logging: true vas a ver cada SQL que TypeORM ejecuta. En los logs de arriba, TypeORM ha creado la tabla users automáticamente porque synchronize: true en desarrollo.

Verificar en pgAdmin

  1. Abre http://localhost:5050 en tu navegador.
  2. Añade un nuevo servidor: nombre NestJS Course, host postgres (nombre del servicio en Docker), puerto 5432, usuario nestjs, password nestjs_password.
  3. Navega a Databases → nestjs_course → Schemas → public → Tables. Ahí debería estar la tabla users.

14. Anatomía de la configuración completa

Vamos a ver cómo queda el AppModule con todo montado:

// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { envValidationSchema } from './config/env.validation';
import { UsersModule } from './users/users.module';

@Module({
    imports: [
        // 1. Config — se carga primero, las demás lo necesitan
        ConfigModule.forRoot({
            isGlobal: true,
            envFilePath: '.env',
            validationSchema: envValidationSchema,
        }),

        // 2. TypeORM — conexión a PostgreSQL con config del entorno
        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: configService.getOrThrow<string>('NODE_ENV') === 'development',
                logging: configService.getOrThrow<string>('NODE_ENV') === 'development',
            }),
            inject: [ConfigService],
        }),

        // 3. Feature modules
        UsersModule,
    ],
})
export class AppModule {}

Fíjate en el orden y la separación:

  1. ConfigModule primero — Todo lo demás depende de las variables de entorno.
  2. TypeOrmModule segundo — Se conecta a PostgreSQL usando ConfigService.
  3. Feature modules al final — Cada uno registra sus entidades con forFeature().

Esta estructura escala sin problemas. Cuando añadas módulos de Products, Orders o Auth, solo tienes que crear el feature module con su forFeature() y añadirlo al array de imports.


15. Errores comunes y debugging

”Connection refused” al arrancar

Error: connect ECONNREFUSED 127.0.0.1:5432

Tu app intenta conectarse a localhost en vez de al servicio Docker. Comprueba que DB_HOST=postgres (el nombre del servicio en docker-compose.yml), no localhost. Dentro de Docker, los servicios se comunican por nombre de servicio.

”relation users does not exist”

La tabla no se ha creado, así que miramos lo siguiente:

  1. synchronize: true está activo en desarrollo.
  2. La entidad está registrada con TypeOrmModule.forFeature([User]).
  3. autoLoadEntities: true está en la configuración de TypeOrmModule.forRootAsync().

PostgreSQL no arranca a tiempo

Si la app arranca antes de que PostgreSQL esté listo, añade el healthcheck y el depends_on con condition: service_healthy como mostramos en la sección 2.

Las queries son lentas

Activa logging: true y mira qué SQL genera TypeORM. A veces un find() inocente genera un JOIN de 5 tablas porque tienes relaciones eager. En el post 9 veremos cómo controlar las relaciones.


16. Recapitulando

🐳 Docker Compose

PostgreSQL 17 + pgAdmin en contenedores. Healthcheck para asegurar que la DB está lista. Volúmenes para persistir datos.

⚙️ @nestjs/config

Variables de entorno con ConfigService. isGlobal para acceso universal. Validación con Joi para fail fast.

🔌 TypeOrmModule

forRootAsync() con ConfigService para la conexión. forFeature([Entity]) en cada módulo. autoLoadEntities.

📋 @Entity

Clase → tabla. @Column con tipos PostgreSQL explícitos. @PrimaryGeneratedColumn con UUID o incremento.

🏷️ Columnas especiales

CreateDateColumn, UpdateDateColumn, DeleteDateColumn. select: false para passwords. Enums tipados.

⚡ @Index

Índices simples, únicos y compuestos. Imprescindibles para rendimiento. No abuses: solo en columnas que filtras.

En el próximo post meteremos la zarpa en el barro con CRUD tipado completo con Repository Pattern. Vamos a usar Repository<User>, @InjectRepository, queries tipadas, paginación, filtros dinámicos, soft delete y transacciones con QueryRunner. De tener datos en un array en memoria a tener un CRUD de producción.

EA, nos vemos en los bares!! 🍺


Pon a prueba lo aprendido

1. ¿Qué opción de TypeORM sincroniza automáticamente el schema de la DB con las entidades?

2. ¿Por qué NUNCA debes usar synchronize: true en producción?

3. ¿Qué hace la opción select: false en un @Column?

4. ¿Cuál es la diferencia entre TypeOrmModule.forRoot() y TypeOrmModule.forFeature()?

5. ¿Qué ventaja tiene usar getOrThrow() en vez de get() del ConfigService?