Як NestJS інтегрується з TypeORM?
Інтеграція NestJS + TypeORM - з'єднує шар бази даних з модульною системою NestJS через пакет @nestjs/typeorm, надаючи кожному feature-модулю власний типізований репозиторій.
Теорія
TL;DR
- Встановлюєш
@nestjs/typeorm typeorm pg, один раз налаштовуєшTypeOrmModule.forRoot() - Кожен feature-модуль реєструє свої сутності через
TypeOrmModule.forFeature([Entity]) - У сервіс впроваджуєш
Repository<Entity>через декоратор@InjectRepository() synchronize: trueавтоматично змінює схему БД - безпечно в розробці, небезпечно в продакшені- Для транзакцій впроваджуєш
DataSource, а не репозиторій
Налаштування модуля
// 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, // підтягує всі сутності з forFeature() автоматично
synchronize: process.env.NODE_ENV !== 'production', // ⚠️ тільки для розробки
})autoLoadEntities: true означає, що не потрібно перераховувати сутності в forRoot(). Кожен виклик TypeOrmModule.forFeature([User]) реєструє їх без змін у кореневому конфігу.
Визначення сутності
Сутності - це звичайні TypeScript-класи з декораторами TypeORM. @Entity('users') прив'язує клас до таблиці. @Column, @PrimaryGeneratedColumn і декоратори зв'язків описують схему.
// 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 }) // не потрапляє в SELECT без явного запиту
password: string;
@Column({ default: 'user' })
role: string;
@OneToMany(() => Post, post => post.user)
posts: Post[];
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}Параметр select: false на полі password - один з тих деталей, які регулярно питають на співбесідах. Стовпець є в базі, але TypeORM не включає його в результати, поки не викличеш .addSelect('user.password') явно в query builder.
Шаблон репозиторію (Repository pattern)
Стандартний спосіб роботи з БД у NestJS. Реєструєш сутність у feature-модулі, потім впроваджуєш типізований репозиторій у сервіс.
// 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); // створює інстанс, НЕ зберігає
return this.usersRepo.save(user); // INSERT або UPDATE
}
async remove(id: number): Promise<void> {
await this.usersRepo.delete(id);
}
}create() просто будує об'єкт класу з DTO. До бази нічого не йде, поки не викличеш save(). Це часта точка непорозуміння.
Зв'язки і query builder
Для простого завантаження зв'язків передай relations у find():
const users = await this.usersRepo.find({
relations: { posts: true },
});Коли потрібна фільтрація, сортування або JOIN-и, використовуй 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();Query builder вирішує проблему N+1, яка виникає при завантаженні зв'язків у циклі. Один запит отримує все за один раунд.
Транзакції
Для операцій, які мають виконатись разом або не виконатись взагалі, впроваджуй DataSource і використовуй 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;
});
}
}manager всередині колбеку - це EntityManager, прив'язаний до транзакції. Якщо будь-яка операція кидає помилку, вся транзакція відкочується автоматично.
Типові помилки
1. synchronize: true в продакшені.
TypeORM змінюватиме або видалятиме стовпці, щоб привести їх у відповідність до сутностей. Один деплой може знищити стовпець з реальними даними.
// ❌ ніколи не роби так в продакшені
synchronize: true,
// ✅ використовуй міграції
synchronize: process.env.NODE_ENV !== 'production',2. Пропущений TypeOrmModule.forFeature() у feature-модулі.
// ❌ UsersService отримає помилку "No metadata found for User"
@Module({
providers: [UsersService],
})
export class UsersModule {}
// ✅ реєструй перед впровадженням
@Module({
imports: [TypeOrmModule.forFeature([User])],
providers: [UsersService],
})
export class UsersModule {}3. Очікування, що create() збереже дані.
Repository.create() лише створює інстанс класу. Для запису в базу потрібен save().
4. Звернення до зв'язків без їх завантаження.
TypeORM 0.3+ прибрав неявне ліниве (lazy) завантаження. Якщо звернутись до user.posts без { relations: { posts: true } } або query builder, отримаєш порожній масив або undefined, а не помилку бази.
5. Транзакції через звичайний репозиторій.
Впроваджений Repository<Entity> не прив'язується до контексту транзакції. Завжди використовуй manager з колбеку dataSource.transaction().
Де зустрічається в продакшені
- Сервіси автентифікації:
Repository<User>, пошук за email, полеpasswordчерез.addSelect() - Пагіновані списки: query builder з
.skip()і.take() - М'яке видалення (soft delete):
@DeleteDateColumn()іfind({ withDeleted: false }) - Мультитенантні застосунки:
TypeOrmModule.forRootAsync()з фабрикою, яка читає конфіг тенанта - Кілька баз даних: два виклики
forRoot()з різнимиname, потім@InjectRepository(Entity, 'secondaryDb')
Питання від інтерв'юера
Q: Яка різниця між save() і insert() в TypeORM?
A: save() перевіряє, чи є в сутності первинний ключ. Є - запускає UPDATE, немає - INSERT. insert() завжди робить чистий INSERT і пропускає lifecycle hooks та каскадну логіку.
Q: Чому для транзакцій використовують DataSource, а не репозиторій?
A: Репозиторій не знає про контекст транзакції. EntityManager з dataSource.transaction() ділить одне з'єднання з базою для всього колбеку, тому всі операції потрапляють в одну транзакцію.
Q: Що робить autoLoadEntities: true?
A: Наказує TypeORM реєструвати будь-яку сутність, передану в TypeOrmModule.forFeature() в будь-якому модулі. Без цього кожну сутність треба вручну перераховувати в forRoot({ entities: [...] }).
Q: Як уникнути проблеми N+1 при роботі зі зв'язками?
A: Використовуй createQueryBuilder з leftJoinAndSelect замість завантаження зв'язків окремо. Для невеликих наборів даних з передбачуваною кількістю записів find({ relations: ... }) теж підходить.
Q: Чи можна підключити кілька баз даних в одному NestJS-застосунку?
A: Так. Викликай TypeOrmModule.forRoot() кілька разів з різними параметрами name. Потім передавай ім'я з'єднання другим аргументом в @InjectRepository(Entity, 'connectionName').
Приклади
Базовий CRUD-сервіс з перевіркою дублікатів
// 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 використовує скорочену форму findOneBy. Перевірка дублікатів відбувається до збереження, тому сервіс повертає зрозумілий 409, а не сиру помилку constraint бази.
Пагінований список із загальним підрахунком
// 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() повертає кортеж: масив результатів і загальну кількість рядків без пагінації. Один запит до бази замість двох.
Створення замовлення з транзакцією
// 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 кидає EntityNotFoundError, якщо продукт не знайдено. За замовчуванням NestJS перетворить це на 500. Щоб повернути правильний 404, додай глобальний фільтр винятків або обгорни виклик у try/catch.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.