Данные — больше половины проблем сервиса, и NestJS их не решает сам: ORM подключают отдельно. Стандарт Node-биндинга — TypeORM: он интегрируется с DI NestJS, репозитории внедряются как провайдеры, а сущности описываются декораторами — в одном стиле с остальным кодом.

Подключение: DataSource и модули

TypeOrmModule.forRoot поднимает соединение (DataSource) на старте, forFeature регистрирует репозитории сущностей в модуле.

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'postgres',
      url: process.env.DATABASE_URL,
      entities: [Product],
      migrationsRun: true,
    }),
  ],
})
export class AppModule {}

Конфигурацию обычно собирают через forRootAsync + ConfigService, чтобы строка подключения шла из настроек, а не из кода.

Сущности

Сущность — класс с @Entity; колонки — декораторами.

import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';

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

  @Column()
  name: string;

  @Column('int')
  price: number;
}

Репозитории

Репозиторий сущности внедряется через @InjectRepository; через него идут запросы.

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';

@Injectable()
export class ProductRepository {
  constructor(
    @InjectRepository(Product) private readonly repo: Repository<Product>,
  ) {}

  findById(id: number): Promise<Product | null> {
    return this.repo.findOne({ where: { id } });
  }

  save(product: Product): Promise<Product> {
    return this.repo.save(product);
  }
}

Чтобы @InjectRepository(Product) работал, модуль импортирует TypeOrmModule.forFeature([Product]). Свой класс-обёртка поверх Repository (как здесь) держит доступ к данным за чётким интерфейсом — это репозиторий слоя UCP, а не размазанные по коду вызовы ORM.

Транзакции

Граница транзакции в UCP — это граница сценария: один Handler = одна транзакция. Удобный способ — DataSource.transaction: коллбэк выполняется в транзакции, коммитится при успехе, откатывается при исключении.

import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';

@Injectable()
export class CreateProductHandler {
  constructor(private readonly dataSource: DataSource) {}

  async handle(command: CreateProductCommand): Promise<Product> {
    return this.dataSource.transaction(async (manager) => {
      const product = manager.create(Product, { name: command.name, price: command.price });
      return manager.save(product);
    });
  }
}

Решение «зафиксировать или откатить» принимает Handler, владеющий сценарием, а не репозиторий на каждый вызов save. Это та же дисциплина, что @Transactional на уровне сервиса в Spring-биндинге.

Миграции

Схему версионирует TypeORM-миграциями: они хранятся в репозитории, прогоняются на старте (migrationsRun: true) или командой CLI, и могут генерироваться по изменениям сущностей (автогенерацию всегда вычитывают руками). Правило то же, что в любом биндинге: схема едет миграциями, а не правится на проде вручную, и каждая миграция обратима. synchronize: true — только для локальных экспериментов, никогда на проде: он молча меняет схему и теряет данные.

Где это в UCP

Доступ к данным — в репозитории, репозиторий внедряется как провайдер, транзакция принадлежит Handler-у — слои те же, что в любом биндинге UCP, только на TypeORM. Чистая граница «репозиторий прячет ORM, транзакция на сценарий» — то, что делает данные сервиса предсказуемыми и тестируемыми, а значит, посильными одному продукт-инженеру.