Skip to main content

Як 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, а не репозиторій

Налаштування модуля

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, // підтягує всі сутності з forFeature() автоматично synchronize: process.env.NODE_ENV !== 'production', // ⚠️ тільки для розробки })

autoLoadEntities: true означає, що не потрібно перераховувати сутності в forRoot(). Кожен виклик TypeOrmModule.forFeature([User]) реєструє їх без змін у кореневому конфігу.

Визначення сутності

Сутності - це звичайні TypeScript-класи з декораторами TypeORM. @Entity('users') прив'язує клас до таблиці. @Column, @PrimaryGeneratedColumn і декоратори зв'язків описують схему.

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 }) // не потрапляє в 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-модулі, потім впроваджуєш типізований репозиторій у сервіс.

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); // створює інстанс, НЕ зберігає 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():

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

Коли потрібна фільтрація, сортування або JOIN-и, використовуй 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();

Query builder вирішує проблему N+1, яка виникає при завантаженні зв'язків у циклі. Один запит отримує все за один раунд.

Транзакції

Для операцій, які мають виконатись разом або не виконатись взагалі, впроваджуй DataSource і використовуй 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; }); } }

manager всередині колбеку - це EntityManager, прив'язаний до транзакції. Якщо будь-яка операція кидає помилку, вся транзакція відкочується автоматично.

Типові помилки

1. synchronize: true в продакшені. TypeORM змінюватиме або видалятиме стовпці, щоб привести їх у відповідність до сутностей. Один деплой може знищити стовпець з реальними даними.

typescript
// ❌ ніколи не роби так в продакшені synchronize: true, // ✅ використовуй міграції synchronize: process.env.NODE_ENV !== 'production',

2. Пропущений TypeOrmModule.forFeature() у feature-модулі.

typescript
// ❌ 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-сервіс з перевіркою дублікатів

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 використовує скорочену форму findOneBy. Перевірка дублікатів відбувається до збереження, тому сервіс повертає зрозумілий 409, а не сиру помилку constraint бази.

Пагінований список із загальним підрахунком

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() повертає кортеж: масив результатів і загальну кількість рядків без пагінації. Один запит до бази замість двох.

Створення замовлення з транзакцією

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 кидає EntityNotFoundError, якщо продукт не знайдено. За замовчуванням NestJS перетворить це на 500. Щоб повернути правильний 404, додай глобальний фільтр винятків або обгорни виклик у try/catch.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?