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.

1. ¿Por qué TypeORM?
💡 TypeORM ORM (Object-Relational Mapper) para TypeScript y JavaScript. Permite definir tablas como clases TypeScript decoradas, y hacer queries usando objetos en vez de SQL crudo. Soporta PostgreSQL, MySQL, SQLite, MongoDB y otros.
Más info →
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 Prisma ORM moderno con schema declarativo y generación automática de tipos. Alternativa popular a TypeORM con enfoque en la experiencia de desarrollo. , MikroORM MikroORM ORM basado en el patrón Data Mapper con Unit of Work. Alternativa a TypeORM con tipado más estricto y mejor soporte para relaciones complejas. , Drizzle Drizzle ORM ligero y type-safe que genera SQL explícito. Minimalista, con queries que parecen SQL pero con tipado completo. … Todos son muy válidos pero vamos a user TypeORM porque:
@nestjs/typeorm viene del equipo de NestJS. TypeOrmModule con forRoot, forFeature, InjectRepository... todo encaja con los patrones que ya conocemos.
Defines entidades con @Entity, @Column, @PrimaryGeneratedColumn. Misma filosofía que @Injectable, @Controller, @Module.
Soporta ambos patrones. Usaremos Data Mapper (con Repository) porque es el que mejor encaja con la DI de NestJS.
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:
ImportanteOjito 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
postgres:17-alpine— Imagen oficial de PostgreSQL 17 en Alpine que es la opción más ligera. Usa siempre una versión fija, nolatest, a poder ser la última versión LTS.POSTGRES_USER,POSTGRES_PASSWORD,POSTGRES_DB— Credenciales y nombre de la base de datos. PostgreSQL los crea automáticamente al arrancar por primera vez.volumes: postgres_data— Persiste los datos en un volumen de Docker. Si hacesdocker compose down, los datos sobreviven y solo se pierden condocker compose down -v, que borra volúmenes.healthcheck—pg_isreadyverifica que PostgreSQL está listo para aceptar conexiones. Esto es crítico porque PostgreSQL tarda unos segundos en arrancar, y sin healthcheck tu app intentaría conectarse antes de que estuviera listo.
El servicio pgadmin
- Puerto 5050 — Accedes a pgAdmin en
http://localhost:5050. SERVER_MODE: 'False'— Desactiva la autenticación de login de pgAdmin (modo escritorio). En desarrollo no necesitas login extra.
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.
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
| Paquete | Qué es |
|---|---|
@nestjs/typeorm | Integración oficial de TypeORM con NestJS, proporciona TypeOrmModule |
typeorm | El ORM en sí con sus Decorators, Repository, QueryBuilder, migraciones… |
pg | Driver de PostgreSQL para Node.js, TypeORM lo usa por debajo |
@nestjs/config | Mó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 💡 ConfigModule Módulo oficial de NestJS (@nestjs/config) que carga variables de entorno desde archivos .env y las expone a través de ConfigService. Internamente usa dotenv. Se registra una vez con forRoot() y se puede hacer global.
Más info →
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:
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:
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)
💡 synchronize Opción de TypeORM que sincroniza automáticamente el esquema de la base de datos con las entidades cada vez que arranca la aplicación. Crea tablas, añade columnas, ajusta tipos... TODO automáticamente. NUNCA activar en producción. Más info → 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.
- 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
- 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 💡 Entity Clase TypeScript decorada con @Entity() que representa una tabla en la base de datos. Cada instancia de la clase corresponde a una fila. Las propiedades decoradas con @Column() mapean a columnas. Es el modelo de datos de tu aplicación. Más info → 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;
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.
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
| TypeScript | PostgreSQL | Cuándo usarlo |
|---|---|---|
string | varchar(n) | Textos con longitud acotada: nombres, emails, slugs |
string | text | Textos largos sin límite: descripciones, biografías, contenido |
number | integer | Enteros: cantidades, contadores, edades |
number | decimal(p,s) | Decimales exactos: precios, porcentajes. Nunca float para dinero |
boolean | boolean | Flags: activo, verificado, eliminado |
Date | timestamptz | Fechas con hora y timezone. Siempre timestamptz, nunca timestamp |
Date | date | Solo fecha sin hora: cumpleaños, fechas de vencimiento |
object | jsonb | Datos semi-estructurados: configuraciones, metadata, preferencias |
enum | enum | Valores 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 soft delete Borrado lógico. En vez de eliminar la fila de la base de datos (DELETE), se marca con una fecha de eliminación. Los SELECT normales la ignoran, pero sigue existiendo para auditoría o recuperación. . 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 optimistic locking Patrón de concurrencia que usa un contador de versión para detectar si alguien más modificó el registro entre que tú lo leíste y lo guardas. Si la versión no coincide, la operación falla. . 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 💡 Index Estructura de datos que la base de datos mantiene para acelerar las búsquedas en columnas específicas. Sin índice, PostgreSQL tiene que recorrer toda la tabla (sequential scan). Con índice, salta directamente a los registros que busca. Más info → 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.
- Í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
- Í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:
- Registra la entidad
Userpara que TypeORM la conozca, que esto es necesario paraautoLoadEntities. - 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:
TypeOrmModule.forRoot()en elAppModule— Configura la conexión global.TypeOrmModule.forFeature([User])en cada feature module — Registra las entidades de ese módulo.
13. Verificar la conexión
Levanta todo y comprueba que funciona:
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
- Abre
http://localhost:5050en tu navegador. - Añade un nuevo servidor: nombre
NestJS Course, hostpostgres(nombre del servicio en Docker), puerto5432, usuarionestjs, passwordnestjs_password. - Navega a Databases →
nestjs_course→ Schemas → public → Tables. Ahí debería estar la tablausers.
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:
- ConfigModule primero — Todo lo demás depende de las variables de entorno.
- TypeOrmModule segundo — Se conecta a PostgreSQL usando
ConfigService. - 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:
synchronize: trueestá activo en desarrollo.- La entidad está registrada con
TypeOrmModule.forFeature([User]). autoLoadEntities: trueestá en la configuración deTypeOrmModule.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
PostgreSQL 17 + pgAdmin en contenedores. Healthcheck para asegurar que la DB está lista. Volúmenes para persistir datos.
Variables de entorno con ConfigService. isGlobal para acceso universal. Validación con Joi para fail fast.
forRootAsync() con ConfigService para la conexión. forFeature([Entity]) en cada módulo. autoLoadEntities.
Clase → tabla. @Column con tipos PostgreSQL explícitos. @PrimaryGeneratedColumn con UUID o incremento.
CreateDateColumn, UpdateDateColumn, DeleteDateColumn. select: false para passwords. Enums tipados.
Í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?