NestJS та TypeORM у 2026 році: міграції, зв'язки та питання на співбесідах

NestJS у поєднанні з TypeORM залишається одним із найбільш зрілих стеків для Node.js-бекенду. Стаття розглядає міграції, зв'язки між сутностями, транзакції та типові питання технічних співбесід.

NestJS та TypeORM у 2026 році: міграції, зв'язки та питання на співбесідах

NestJS та TypeORM у 2026 році утворюють один із найбільш зрілих тандемів у світі бекенд-розробки на Node.js. NestJS вирізняється модульною архітектурою та вбудованою системою ін'єкції залежностей, тоді як TypeORM надає інструменти для типобезпечних операцій з базою даних. Водночас у щоденній проєктній практиці — особливо у сфері міграцій, складних зв'язків та взаємодії обох технологій — виникають питання, які регулярно з'являються і на технічних співбесідах.

Ця стаття розглядає ключові концепції, пов'язані з міграціями TypeORM у проєктах NestJS, описує різні типи зв'язків на основі конкретних прикладів коду та завершується набором практичних питань для співбесід, що поглиблюють розуміння теми.

TypeORM DataSource API

TypeORM кардинально переробив своє API конфігурації. Проєкти, які досі використовують ormconfig.json або createConnection, мають перейти на DataSource API. Усі приклади в цій статті використовують актуальну версію API.

Налаштування TypeORM у NestJS

Першим кроком є створення автономної конфігурації DataSource. Цей файл використовується CLI TypeORM для роботи з міграціями. Розділення конфігурації дозволяє CLI працювати незалежно від контексту NestJS.

src/config/typeorm.config.tstypescript
import { DataSource } from 'typeorm';
import { config } from 'dotenv';

config(); // Load .env variables

export default new DataSource({
  type: 'postgres',
  host: process.env.DB_HOST,
  port: parseInt(process.env.DB_PORT || '5432'),
  username: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  entities: ['src/**/*.entity.ts'],
  migrations: ['src/migrations/*.ts'],
  synchronize: false,
});

Властивість synchronize: false заслуговує на особливу увагу. У режимі розробки може виникнути спокуса встановити synchronize: true, щоб TypeORM автоматично оновлював схему бази даних. Однак у продакшн-середовищах це призводить до втрати даних та непередбачуваної поведінки. Натомість використовуються міграції.

У AppModule TypeORM інтегрується через асинхронну конфігурацію, де ConfigService надає змінні оточення:

src/app.module.tstypescript
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    TypeOrmModule.forRootAsync({
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        type: 'postgres',
        host: config.get('DB_HOST'),
        port: config.getOrThrow<number>('DB_PORT'),
        username: config.get('DB_USER'),
        password: config.get('DB_PASSWORD'),
        database: config.get('DB_NAME'),
        autoLoadEntities: true, // Auto-register entities from feature modules
        synchronize: false,     // Always false in production
      }),
    }),
  ],
})
export class AppModule {}

Різниця між config.get() і config.getOrThrow() тонка, але суттєва: getOrThrow() кидає виняток, якщо змінна відсутня. Для критичних конфігураційних значень, таких як порт бази даних, це гарантує миттєве виявлення помилок конфігурації замість тихого використання значень за замовчуванням.

Міграції: створення, виконання та відкат

Міграції становлять основу будь-якої професійної стратегії управління базою даних. Вони документують зміни схеми у версіонованому та відтворюваному вигляді. Наступні npm-скрипти спрощують роботу з CLI TypeORM:

package.json (scripts section)json
{
  "migration:generate": "typeorm-ts-node-commonjs migration:generate src/migrations/$npm_config_name -d src/config/typeorm.config.ts",
  "migration:run": "typeorm-ts-node-commonjs migration:run -d src/config/typeorm.config.ts",
  "migration:revert": "typeorm-ts-node-commonjs migration:revert -d src/config/typeorm.config.ts"
}

Практичний робочий процес виглядає так: спочатку модифікуються сутності, потім npm run migration:generate --name=AddUserTable автоматично генерує файл міграції, що відображає різницю між поточною схемою та визначеннями сутностей. Команда npm run migration:run застосовує міграцію, а npm run migration:revert скасовує останню застосовану міграцію.

Поширеною помилкою у проєктах є ручне написання міграцій, хоча migration:generate виконує порівняння автоматично. Ручні міграції мають сенс лише тоді, коли необхідна трансформація даних — наприклад, при перейменуванні стовпця з одночасною міграцією даних.

Перевірка згенерованих міграцій

Перейменування стовпця генерує операцію DROP + ADD замість ALTER RENAME. Це знищує наявні дані. SQL-інструкції в методі up() завжди слід перевіряти і за потреби коригувати вручну.

Моделювання сутностей та зв'язків

TypeORM використовує декоратори для відображення структур бази даних безпосередньо у класах TypeScript. Три найважливіші типи зв'язків — це OneToMany/ManyToOne, ManyToMany та OneToOne.

OneToMany та ManyToOne

Класичний зв'язок між користувачами та замовленнями демонструє взаємодію @OneToMany і @ManyToOne:

src/entities/user.entity.tstypescript
import { Entity, PrimaryGeneratedColumn, Column, OneToMany, CreateDateColumn } from 'typeorm';
import { Order } from './order.entity';

@Entity('users')
export class User {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ unique: true })
  email: string;

  @Column()
  name: string;

  @OneToMany(() => Order, (order) => order.user)
  orders: Order[];  // No DB column created here; relation lives on the Order side

  @CreateDateColumn()
  createdAt: Date;
}
src/entities/order.entity.tstypescript
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { User } from './user.entity';

@Entity('orders')
export class Order {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column('decimal', { precision: 10, scale: 2 })
  total: number;

  @Column({ default: 'pending' })
  status: string;

  @ManyToOne(() => User, (user) => user.orders, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'user_id' })  // Explicit FK column name
  user: User;
}

Ключовий момент: сторона @OneToMany не створює жодного стовпця у базі даних. Зовнішній ключ завжди знаходиться на стороні @ManyToOne. Декоратор @JoinColumn явно визначає назву стовпця зовнішнього ключа, що підвищує читабельність бази даних та запобігає конфліктам імен. Опція onDelete: 'CASCADE' забезпечує автоматичне видалення замовлень при видаленні пов'язаного користувача.

ManyToMany

Для продуктів і категорій підходить зв'язок ManyToMany. TypeORM автоматично створює проміжну таблицю:

src/entities/product.entity.tstypescript
import { Entity, PrimaryGeneratedColumn, Column, ManyToMany, JoinTable } from 'typeorm';
import { Category } from './category.entity';

@Entity('products')
export class Product {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column()
  name: string;

  @Column('decimal', { precision: 10, scale: 2 })
  price: number;

  @ManyToMany(() => Category, (category) => category.products)
  @JoinTable({ name: 'product_categories' })  // Owns the junction table
  categories: Category[];
}

Декоратор @JoinTable позначає сторону, що володіє зв'язком. Лише одна сторона може мати цей декоратор. Якщо проміжна таблиця має містити додаткові стовпці (наприклад, порядок сортування), замість цього потрібно створити окрему сутність із двома зв'язками @ManyToOne.

Готовий до співбесід з Node.js / NestJS?

Практикуйся з нашими інтерактивними симуляторами, flashcards та технічними тестами.

Патерн Repository та функціональні модулі

NestJS заохочує модуляризацію через функціональні модулі. Кожен модуль реєструє свої сутності за допомогою TypeOrmModule.forFeature() і отримує доступ до відповідних репозиторіїв:

src/orders/orders.module.tstypescript
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Order } from '../entities/order.entity';
import { OrdersService } from './orders.service';
import { OrdersController } from './orders.controller';

@Module({
  imports: [TypeOrmModule.forFeature([Order])],
  providers: [OrdersService],
  controllers: [OrdersController],
})
export class OrdersModule {}

У сервісі репозиторій ін'єктується за допомогою @InjectRepository. Для простих запитів достатньо Repository API, тоді як QueryBuilder застосовується у складніших сценаріях:

src/orders/orders.service.tstypescript
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Order } from '../entities/order.entity';

@Injectable()
export class OrdersService {
  constructor(
    @InjectRepository(Order)
    private readonly orderRepo: Repository<Order>,
  ) {}

  async findByUser(userId: string): Promise<Order[]> {
    return this.orderRepo.find({
      where: { user: { id: userId } },
      relations: ['user'],          // Eager-load the user relation
      order: { createdAt: 'DESC' }, // Most recent first
    });
  }

  async findWithFilters(status: string, minTotal: number): Promise<Order[]> {
    // QueryBuilder for complex queries
    return this.orderRepo
      .createQueryBuilder('order')
      .leftJoinAndSelect('order.user', 'user')
      .where('order.status = :status', { status })
      .andWhere('order.total >= :minTotal', { minTotal })
      .orderBy('order.total', 'DESC')
      .getMany();
  }
}

Метод findByUser демонструє, як фільтрувати зв'язки за допомогою вкладених умов Where. Масив relations керує завантаженням пов'язаних сутностей. Без цього оголошення TypeORM поверне лише замовлення без даних користувача.

QueryBuilder у findWithFilters надає більше контролю: leftJoinAndSelect завантажує зв'язок і робить його доступним у результатах, а параметризовані запити надійно запобігають атакам SQL injection.

Правильне використання транзакцій

Коли кілька операцій з базою даних мають виконуватися атомарно, транзакції є незамінними. TypeORM пропонує для цього метод DataSource.transaction():

src/orders/orders.service.ts (additional method)typescript
async createOrderWithItems(
  userId: string,
  items: { productId: string; quantity: number }[],
  dataSource: DataSource,
): Promise<Order> {
  return dataSource.transaction(async (manager) => {
    // All operations use the transactional manager
    const order = manager.create(Order, {
      user: { id: userId },
      total: 0,
      status: 'pending',
    });
    const savedOrder = await manager.save(order);

    let total = 0;
    for (const item of items) {
      const product = await manager.findOneByOrFail(Product, { id: item.productId });
      total += product.price * item.quantity;

      await manager.save(OrderItem, {
        order: savedOrder,
        product,
        quantity: item.quantity,
        unitPrice: product.price,
      });
    }

    savedOrder.total = total;
    return manager.save(savedOrder);
  });
}

Усередині транзакції для всіх операцій використовується виключно manager. Якби натомість було використано звичайний репозиторій, ці операції виконувалися б поза транзакцією, і помилка на наступному кроці могла б призвести до неузгоджених даних. У разі помилки всередині колбеку вся транзакція автоматично відкочується.

QueryRunner vs transaction()

DataSource.transaction() підходить для простих багатокрокових операцій. QueryRunner надає ручне керування точками commit/rollback та savepoint'ами — корисно при часткових відкатах у пакетній обробці.

Найпоширеніші питання на співбесідах щодо NestJS та TypeORM

Технічні співбесіди з NestJS та TypeORM зазвичай охоплюють три сфери: розуміння архітектури, практичне розв'язання проблем та уникнення типових помилок.

Чому synchronize має бути вимкнено у продакшні?

Опція synchronize: true автоматично синхронізує схему бази даних із визначеннями сутностей при кожному запуску застосунку. Це може видалити стовпці, змінити типи даних або знищити таблиці без жодної документації цих змін. У продакшн-середовищах це призводить до втрати даних. Міграції натомість забезпечують версіоновані, відтворювані та оборотні зміни схеми.

Яка різниця між migration:generate та migration:create?

Команда migration:generate порівнює поточні визначення сутностей зі схемою бази даних і автоматично генерує необхідні SQL-інструкції. migration:create створює порожній файл міграції, в якому SQL-команди пишуться вручну. Останній варіант корисний для міграцій даних або операцій заповнення (seeding).

На якій стороні знаходиться зовнішній ключ у зв'язку OneToMany/ManyToOne?

Зовнішній ключ завжди знаходиться на стороні @ManyToOne. Сторона @OneToMany не створює жодного стовпця у базі даних — вона слугує лише для відображення зворотного зв'язку в TypeScript.

Коли використовувати QueryBuilder замість Repository API?

Repository API (find, findOne, save) підходить для простих CRUD-операцій і запитів із прямими умовами фільтрації. QueryBuilder застосовується, коли потрібні складні join'и, підзапити, агрегатні функції або динамічно побудовані запити.

Як вирішувати циклічні залежності між модулями?

NestJS надає forwardRef() для обробки циклічних залежностей між модулями. Проте їх наявність зазвичай свідчить про архітектурну проблему. Кращим рішенням є винесення спільної логіки в окремий модуль.

Що відбувається, якщо всередині транзакції використовується звичайний репозиторій замість manager?

Операції через звичайний репозиторій виконуються поза транзакцією. Це означає, що зміни негайно фіксуються (commit) і не можуть бути відкочені у разі помилки. Це призводить до неузгодженості даних.

Яка різниця між @JoinColumn та @JoinTable?

@JoinColumn використовується у зв'язках OneToOne та ManyToOne для позначення сторони, що володіє зовнішнім ключем. @JoinTable застосовується виключно у зв'язках ManyToMany і визначає проміжну таблицю (junction table), що з'єднує обидві сторони зв'язку.

Які стратегії завантаження зв'язків існують у TypeORM?

TypeORM пропонує дві основні стратегії: eager loading (зв'язок автоматично завантажується при кожному запиті) та lazy loading (зв'язок завантажується лише при першому зверненні, потребує використання Promise). На практиці найчастіше застосовується явне зазначення зв'язків для завантаження через масив relations або QueryBuilder.

Яка різниця між forRoot та forFeature у TypeOrmModule?

forRoot() налаштовує з'єднання з базою даних і викликається один раз у кореневому модулі. forFeature() реєструє сутності в контексті конкретного функціонального модуля, надаючи доступ до відповідних репозиторіїв для ін'єкції.

Чим відрізняється save від insert у TypeORM?

save перевіряє, чи існує сутність (за первинним ключем) — якщо так, виконує оновлення; якщо ні, створює новий запис. insert завжди створює новий запис і працює швидше, оскільки не потребує попереднього запиту. Проте insert не запускає підписки сутностей та каскадні збереження.

Готовий до співбесід з Node.js / NestJS?

Практикуйся з нашими інтерактивними симуляторами, flashcards та технічними тестами.

Висновок

Поєднання NestJS та TypeORM створює зрілу екосистему для типобезпечної бекенд-розробки на Node.js. Ключові висновки можна підсумувати наступним чином:

  • Міграції замінюють synchronize у будь-якому середовищі за межами локальної розробки
  • Зв'язки вимагають чіткого розуміння того, яка сторона володіє зовнішнім ключем
  • Транзакції мають послідовно виконуватися через manager, а не через звичайні репозиторії
  • QueryBuilder доповнює Repository API там, де прості методи досягають своїх обмежень
  • На технічних співбесідах оцінюється не лише знання синтаксису, а насамперед здатність обґрунтувати архітектурні рішення

Починай практикувати!

Перевір свої знання з нашими симуляторами співбесід та технічними тестами.

Поділитися

Пов'язані статті