TypeORM или Prisma: интеграция ORM
TypeORM и Prisma оба подключают ORM в Nest, но источник истины для типобезопасности разный: классы @Entity с декораторами, регистрируемые через TypeOrmModule, либо schema.prisma, генерирующая клиент, который ты оборачиваешь в PrismaService.
Новому сервису нужна база, и первый PR в репозитории решает за следующие два года. Один ревьюер хочет классы @Entity() — «модель и есть TypeScript, ей там и место». Другой хочет schema.prisma — «схема и есть источник истины, клиент генерируется, дрифта не будет». Оба варианта будут работать. Но это не спор о вкусах: два пути кладут источник истины для типобезопасности в разные места, отдают миграции разным инструментам и интегрируются с dependency injection Nest принципиально по-разному. Один даёт тебе DI-нативные repository; другой — один сгенерированный клиент, который ты должен обернуть сам. Выбирай по ограничению, а не по настроению.
TypeORM: истина — это entities, repository инжектятся
В пути TypeORM ты один раз настраиваешь DataSource в корне, затем регистрируешь entities по фиче-модулям. Подключение асинхронно, потому что его значения приходят из ConfigService, — поэтому берёшь TypeOrmModule.forRootAsync с useFactory, а не статический forRoot:
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { User } from './user.entity';
@Module({
imports: [
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
type: 'postgres',
host: config.get('DB_HOST'),
port: config.get<number>('DB_PORT'),
username: config.get('DB_USER'),
password: config.get('DB_PASS'),
database: config.get('DB_NAME'),
entities: [User],
synchronize: false, // НИКОГДА не true в production
}),
}),
],
})
export class AppModule {}Entity — это обычный класс, помеченный @Entity(), и его колонки — это схема:
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true })
email: string;
@Column({ default: true })
isActive: boolean;
}Метаданные этого декоратора и есть источник истины для типобезопасности — TypeScript-класс задаёт и рантайм-маппинг колонок, и генератор миграций. Чтобы использовать entity в фиче-модуле, ты регистрируешь его через forFeature, который провайдит для него repository в DI-контейнер, и инжектишь этот repository через @InjectRepository:
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './user.entity';
import { UsersService } from './users.service';
@Module({
imports: [TypeOrmModule.forFeature([User])], // провайдит Repository<User>
providers: [UsersService],
})
export class UsersModule {}import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './user.entity';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private readonly users: Repository<User>,
) {}
findActive(): Promise<User[]> {
return this.users.find({ where: { isActive: true } });
}
}Repository<User> — это граница персистентности: твой сервис говорит с ней, а не с сырым SQL или глобальным клиентом. Это DI-нативно — repository является обычным провайдером Nest, который мокается в тестах подменой провайдера getRepositoryToken(User).
Prisma: истина — это схема, клиент ты оборачиваешь сам
Prisma переворачивает источник истины. Один файл schema.prisma объявляет модели; prisma generate читает его и выдаёт полностью типизированный клиент. Ты не пишешь entity-классы — схема генерирует типы:
// schema.prisma
generator client {
provider = "prisma-client-js"
}
model User {
id Int @id @default(autoincrement())
email String @unique
isActive Boolean @default(true)
}У Nest нет первоклассного Prisma-провайдера для DI, поэтому ты предоставляешь его сам: injectable PrismaService, который наследует сгенерированный PrismaClient и открывает подключение в onModuleInit. Это та интеграционная обвязка, которую ты пишешь руками:
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
}Предоставь его один раз, затем инжекти единственный сервис — нет per-entity @InjectRepository, только типизированные аксессоры моделей на клиенте:
import { Injectable } from '@nestjs/common';
import { PrismaService } from './prisma.service';
import { User } from '@prisma/client';
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
findActive(): Promise<User[]> {
return this.prisma.user.findMany({ where: { isActive: true } });
}
}Обрати внимание, чего нет: ни forFeature, ни токена repository, ни декоратора на модели. Вся поверхность API — это this.prisma.<model>.<operation>, а тип User приходит из @prisma/client, регенерируясь каждый раз при изменении схемы.
Настоящее решение: где живёт истина
Когда оцениваешь эти варианты в реальном PR, аргументы о вкусе быстро перестают иметь значение — ограничения не перестают. Ни один не верен универсально — они различаются по трём осям, которые сеньор взвешивает против заявленного ограничения. Источник истины для типобезопасности — первая: entity-классы с декораторами TypeScript (TypeORM) против .prisma-схемы, которая генерирует клиент (Prisma). Миграции вытекают из этого: TypeORM генерирует и прогоняет файлы миграций, выведенные из твоих entities; Prisma Migrate сравнивает схему и выдаёт SQL-миграции. И эргономика DI расходится: TypeORM даёт per-entity repository как первоклассные провайдеры Nest; Prisma даёт один клиент, который ты оборачиваешь и инжектишь повсюду.
| Ось | TypeORM | Prisma |
|---|---|---|
| Источник истины | TS-классы с декоратором @Entity() | schema.prisma генерирует клиент |
| Миграции | Генерация + прогон файлов миграций из entities | Prisma Migrate сравнивает схему → SQL |
| Эргономика DI | Per-entity Repository<T>, инжектится | Один PrismaService, ты пишешь + инжектишь |
| Интеграция с Nest | Первоклассный модуль @nestjs/typeorm | Нет первоклассного DI — провайдишь сам |
▸Почему это работает
Почему synchronize: true — тот единственный переключатель, который нельзя трогать в production? Он велит TypeORM менять живую схему при каждом старте под твои entities — молча, без файла миграции и без ревью. Переименуй свойство — и старая колонка дропается вместе с её данными; изменение уезжает в момент рестарта процесса, без записи о том, что произошло. Это удобная игрушка для одноразового локального наброска и инцидент с потерей данных, ждущий в staging или prod. Дисциплинированный путь — synchronize: false плюс сгенерированные файлы миграций, которые ты ревьюишь и прогоняешь осознанно.
Твоя команда хочет, чтобы сама схема базы была единственным источником истины, с полностью сгенерированными TypeScript-типами, которые не могут разойтись со схемой, и готова написать одну обёртку-сервис. Какой слой персистентности подходит под это ограничение?
В пути TypeORM как сервис фиче-модуля получает Repository<User>, чтобы делать запросы?
Почему PrismaService должен реализовывать OnModuleInit и вызывать await this.$connect()?
- 01Пройди всю обвязку TypeORM от подключения до запроса: что настраивает DataSource, что регистрирует entity на модуль и как сервис получает свой repository?
- 02Пройди всю обвязку Prisma: откуда берутся типы, почему ты должен написать PrismaService и как сервис делает запрос?
И TypeORM, и Prisma интегрируют ORM в Nest, но кладут источник истины для типобезопасности в разные дома, что каскадом влияет на миграции и DI. В пути TypeORM истина — классы @Entity() с декораторами: ты настраиваешь DataSource один раз через TypeOrmModule.forRootAsync с useFactory, питаемой ConfigService (с synchronize: false), регистрируешь каждую entity на фиче-модуль через TypeOrmModule.forFeature([User]), чтобы провайдить её repository, и инжектишь этот repository через @InjectRepository(User) repo: Repository<User> — repository является DI-нативной границей персистентности, а файлы миграций генерируются из entities. В пути Prisma истина — schema.prisma: prisma generate выдаёт полностью типизированный клиент, и поскольку у Nest нет первоклассного Prisma DI, ты сам оборачиваешь его в PrismaService, который extends PrismaClient implements OnModuleInit и вызывает await this.$connect() в onModuleInit; ты инжектишь этот один сервис и вызываешь prisma.user.findMany(), а изменения схемы держит Prisma Migrate — нигде нет @InjectRepository. Решение driven ограничением: схема-как-источник-истины со сгенерированными, не дрейфующими типами указывает на Prisma; DI-нативные per-entity repository указывают на TypeORM; сырой SQL меняет и то, и другое на контроль. И в обоих случаях synchronize: true — production-антипаттерн: он молча переписывает живую схему и дропает колонки, — поэтому ты всегда используешь отревьюенные миграции вместо него. Теперь, когда увидишь в PR synchronize: true «только для staging», знай ровно, на что возражать и почему.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.