Skip to main content

How does NestJS integrate with TypeORM?

NestJS + TypeORM integration - connects the database layer to NestJS's module system through the @nestjs/typeorm package, giving each feature module its own typed repository.

Theory

TL;DR

  • Install @nestjs/typeorm typeorm pg, configure once with TypeOrmModule.forRoot()
  • Each feature module registers its entities via TypeOrmModule.forFeature([Entity])
  • Inject Repository<Entity> into services using the @InjectRepository() decorator
  • synchronize: true rewrites database schema automatically - fine in development, destructive in production
  • For transactions, inject DataSource directly, not the repository

Module setup

typescript
// app.module.ts TypeOrmModule.forRoot({ type: 'postgres', host: process.env.DB_HOST, port: +process.env.DB_PORT, username: process.env.DB_USER, password: process.env.DB_PASS, database: process.env.DB_NAME, autoLoadEntities: true, // picks up all forFeature() entities automatically synchronize: process.env.NODE_ENV !== 'production', // ⚠️ dev only })

autoLoadEntities: true means you never have to list entities in forRoot(). Each TypeOrmModule.forFeature([User]) call registers the entity without touching the root config.

Defining an entity

Entities are plain TypeScript classes with TypeORM decorators. @Entity('users') maps the class to a table. @Column, @PrimaryGeneratedColumn, and relation decorators define the schema.

typescript
// users/user.entity.ts @Entity('users') export class User { @PrimaryGeneratedColumn() id: number; @Column({ length: 100 }) name: string; @Column({ unique: true }) email: string; @Column({ select: false }) // excluded from every SELECT unless explicitly requested password: string; @Column({ default: 'user' }) role: string; @OneToMany(() => Post, post => post.user) posts: Post[]; @CreateDateColumn() createdAt: Date; @UpdateDateColumn() updatedAt: Date; }

The select: false on password comes up in interviews. The column exists in the database, but TypeORM drops it from query results unless you call .addSelect('user.password') explicitly in a query builder.

Repository pattern

This is the standard way to talk to the database in NestJS. Register the entity in the feature module, then inject the typed repository into the service.

typescript
// users/users.module.ts @Module({ imports: [TypeOrmModule.forFeature([User])], providers: [UsersService], controllers: [UsersController], }) export class UsersModule {} // users/users.service.ts @Injectable() export class UsersService { constructor( @InjectRepository(User) private readonly usersRepo: Repository<User>, ) {} findAll(): Promise<User[]> { return this.usersRepo.find(); } findOne(id: number): Promise<User | null> { return this.usersRepo.findOneBy({ id }); } async create(dto: CreateUserDto): Promise<User> { const user = this.usersRepo.create(dto); // instantiates, does NOT save return this.usersRepo.save(user); // INSERT or UPDATE } async remove(id: number): Promise<void> { await this.usersRepo.delete(id); } }

create() just builds the entity object from the DTO. Nothing hits the database until save() is called. That distinction trips people up.

Relations and query builder

For simple relation loading, pass relations to find():

typescript
const users = await this.usersRepo.find({ relations: { posts: true }, });

When you need filtering, ordering, or joins, use createQueryBuilder:

typescript
const activeUsers = await this.usersRepo .createQueryBuilder('user') .leftJoinAndSelect('user.posts', 'post') .where('user.isActive = :active', { active: true }) .orderBy('user.createdAt', 'DESC') .take(10) .getMany();

The query builder solves the N+1 problem that appears when loading relations inside a loop. One query, one round trip.

Transactions

For operations that must succeed or fail together, inject DataSource and wrap the logic in dataSource.transaction():

typescript
@Injectable() export class OrdersService { constructor(private readonly dataSource: DataSource) {} async createOrder(dto: CreateOrderDto) { return this.dataSource.transaction(async manager => { const order = manager.create(Order, dto); await manager.save(order); await manager.decrement( Product, { id: dto.productId }, 'stock', dto.quantity, ); return order; }); } }

The manager inside the callback is an EntityManager scoped to the transaction. If anything throws, the whole transaction rolls back automatically.

Common mistakes

1. synchronize: true in production. TypeORM will alter or drop columns to match the entity definitions. One deployment can silently remove a column with real user data.

typescript
// ❌ never in production config synchronize: true, // ✅ use migrations in production synchronize: process.env.NODE_ENV !== 'production',

2. Missing TypeOrmModule.forFeature() in the feature module.

typescript
// ❌ UsersService will throw "No metadata found for User" @Module({ providers: [UsersService], }) export class UsersModule {} // ✅ register before injecting @Module({ imports: [TypeOrmModule.forFeature([User])], providers: [UsersService], }) export class UsersModule {}

3. Expecting create() to persist data. Repository.create() only instantiates the class. You still need save() to write to the database.

4. Accessing relations without loading them. TypeORM 0.3+ removed implicit lazy loading. Accessing user.posts without { relations: { posts: true } } or a query builder returns an empty array or undefined, not a database error.

5. Using a plain repository inside a transaction. The injected Repository<Entity> is not bound to the transaction context. Always use the manager argument passed to the dataSource.transaction() callback.

Real-world usage

  • Auth services: Repository<User>, query by email, retrieve password with .addSelect()
  • Paginated feeds: query builder with .skip() and .take()
  • Soft deletes: @DeleteDateColumn() decorator plus find({ withDeleted: false })
  • Multi-tenant apps: TypeOrmModule.forRootAsync() with a factory that reads tenant config at request time
  • Multiple databases: two forRoot() calls with different name values, then @InjectRepository(Entity, 'connectionName')

Follow-up questions

Q: What is the difference between save() and insert() in TypeORM?


A: save() checks whether the entity already has a primary key. If it does, it runs UPDATE. If not, INSERT. insert() always runs a raw INSERT and skips lifecycle hooks and cascade logic.

Q: Why use DataSource for transactions instead of the repository?


A: The repository does not know about the transaction context. The EntityManager from dataSource.transaction() shares one database connection for the whole callback, so all operations run inside the same transaction.

Q: What exactly does autoLoadEntities: true do?


A: It tells TypeORM to register any entity passed to TypeOrmModule.forFeature() anywhere in the application. Without it, you would list every entity manually in forRoot({ entities: [...] }).

Q: How do you avoid N+1 queries when loading relations?


A: Use createQueryBuilder with leftJoinAndSelect instead of loading relations in a loop. For bounded, small result sets, find({ relations: { posts: true } }) is also fine.

Q: Can you run multiple database connections in one NestJS app?


A: Yes. Call TypeOrmModule.forRoot() multiple times, each with a unique name option. Then pass that name as the second argument to @InjectRepository(Entity, 'secondaryDb').

Examples

Basic CRUD service with duplicate check

typescript
// users/users.service.ts @Injectable() export class UsersService { constructor( @InjectRepository(User) private readonly usersRepo: Repository<User>, ) {} async findByEmail(email: string): Promise<User | null> { return this.usersRepo.findOneBy({ email }); } async createUser(dto: CreateUserDto): Promise<User> { const existing = await this.findByEmail(dto.email); if (existing) throw new ConflictException('Email already taken'); const user = this.usersRepo.create(dto); return this.usersRepo.save(user); } }

findByEmail uses the short findOneBy form. The duplicate check runs before saving, so the service returns a clear 409 instead of a raw database constraint error.

Paginated list with total count

typescript
// articles/articles.service.ts async findPaginated(page: number, limit: number) { const [items, total] = await this.articlesRepo .createQueryBuilder('article') .leftJoinAndSelect('article.author', 'author') .where('article.published = :published', { published: true }) .orderBy('article.createdAt', 'DESC') .skip((page - 1) * limit) .take(limit) .getManyAndCount(); return { items, total, page, totalPages: Math.ceil(total / limit) }; }

getManyAndCount() returns a tuple: the result array and the total row count without pagination applied. One database round trip covers both.

Order placement with a transaction

typescript
// orders/orders.service.ts async placeOrder(userId: number, dto: CreateOrderDto): Promise<Order> { return this.dataSource.transaction(async manager => { const product = await manager.findOneByOrFail(Product, { id: dto.productId }); if (product.stock < dto.quantity) { throw new BadRequestException('Not enough stock'); } const order = manager.create(Order, { userId, ...dto }); await manager.save(order); await manager.decrement(Product, { id: dto.productId }, 'stock', dto.quantity); return order; }); }

findOneByOrFail throws EntityNotFoundError when the product does not exist. NestJS maps that to a 500 by default. Add a global exception filter or a try/catch to return a 404 instead.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?