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 withTypeOrmModule.forRoot() - Each feature module registers its entities via
TypeOrmModule.forFeature([Entity]) - Inject
Repository<Entity>into services using the@InjectRepository()decorator synchronize: truerewrites database schema automatically - fine in development, destructive in production- For transactions, inject
DataSourcedirectly, not the repository
Module setup
// 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.
// 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.
// 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():
const users = await this.usersRepo.find({
relations: { posts: true },
});When you need filtering, ordering, or joins, use createQueryBuilder:
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():
@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.
// ❌ never in production config
synchronize: true,
// ✅ use migrations in production
synchronize: process.env.NODE_ENV !== 'production',2. Missing TypeOrmModule.forFeature() in the feature module.
// ❌ 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 plusfind({ withDeleted: false }) - Multi-tenant apps:
TypeOrmModule.forRootAsync()with a factory that reads tenant config at request time - Multiple databases: two
forRoot()calls with differentnamevalues, 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
// 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
// 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
// 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 readyA concise answer to help you respond confidently on this topic during an interview.