Данные — больше половины проблем сервиса, и 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, транзакция на сценарий» — то, что делает данные сервиса предсказуемыми и тестируемыми, а значит, посильными одному продукт-инженеру.