TypeORM покрывает часть Node.js-проектов, но на рынке часто встречаются Prisma и Drizzle. Все три библиотеки работают с реляционной базой и типизированным TypeScript, но предлагают разный способ описывать данные и запросы.
Разберём одну небольшую модель и одинаковые операции. Этого хватит, чтобы прочитать чужой проект и сознательно выбрать инструмент.
Примеры Prisma ниже относятся к Prisma 6. В Prisma 7 адрес подключения из schema.prisma перенесён в prisma.config.ts, а в Prisma ORM 8 вместо schema.prisma и PrismaClient появились contract.prisma и db. Для нового проекта проверяйте мажорную версию и её руководство по миграции, а не переносите примеры без изменений.
Три подхода
TypeORM строится вокруг сущностей и репозиториев. Класс описывает таблицу, декораторы задают колонки и связи, DataSource и EntityManager управляют сохранением и транзакциями.
Prisma переносит основную часть модели в schema.prisma. По ней генерируется отдельный типобезопасный клиент: поля модели и аргументы запросов проверяются компилятором. В коде приложения работают с PrismaClient.
Drizzle описывает таблицы обычным TypeScript. Запросы выглядят близко к SQL: select, where, join, orderBy и limit остаются узнаваемыми. Генерация кода не обязательна.
Выбор не сводится к скорости. Важнее, насколько естественно команда пишет запросы и насколько много готовой инфраструктуры ожидает от ORM.
Модель данных в Prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Seller {
id Int @id @default(autoincrement())
email String @unique
products Product[]
}
model Product {
id Int @id @default(autoincrement())
name String
price Decimal @db.Decimal(12, 2)
sellerId Int
seller Seller @relation(fields: [sellerId], references: [id])
}
После prisma generate появляется клиент с типами Seller, Product и специализированными аргументами запросов. Миграция создаёт таблицы через prisma migrate dev, а миграции для прода применяются через prisma migrate deploy.
Запросы в Prisma
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
export async function productsOfSeller(sellerId: number) {
return prisma.product.findMany({
where: { sellerId },
select: { id: true, name: true, price: true },
orderBy: { id: "asc" },
take: 50,
});
}
findMany принимает декларативный фильтр. В select перечисляют только нужные поля, чтобы не тащить всю сущность и связи. Для выборки с продавцом используют include, а не отдельный запрос в цикле:
const products = await prisma.product.findMany({
where: { sellerId },
include: { seller: { select: { email: true } } },
});
Транзакция выглядит так:
await prisma.$transaction(async (tx) => {
const order = await tx.order.create({
data: { customerId, status: "NEW" },
});
await tx.product.update({
where: { id: productId },
data: { stock: { decrement: 1 } },
});
return order;
});
Если callback выбросит ошибку, операции откатятся. Для денежных операций дополнительно проверяют затронутое количество строк и правила изоляции, а не полагаются только на сам факт транзакции.
Таблица в Drizzle
import {
integer,
numeric,
pgTable,
varchar,
} from "drizzle-orm/pg-core";
export const sellers = pgTable("sellers", {
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
email: varchar("email", { length: 320 }).notNull().unique(),
});
export const products = pgTable("products", {
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
name: varchar("name", { length: 300 }).notNull(),
price: numeric("price", { precision: 12, scale: 2 }).notNull(),
sellerId: integer("seller_id")
.notNull()
.references(() => sellers.id),
});
Схема остаётся TypeScript-кодом. Её можно разложить по модулям, переиспользовать типы и читать как обычный исходник. Миграции обычно генерируют через drizzle-kit generate, а применяют через drizzle-kit migrate или собственный миграционный раннер.
Запросы в Drizzle
import { drizzle } from "drizzle-orm/node-postgres";
import { asc, eq, sql } from "drizzle-orm";
import { Pool } from "pg";
import { orders, products } from "./schema";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const db = drizzle(pool);
export async function productsOfSeller(sellerId: number) {
return db
.select({
id: products.id,
name: products.name,
price: products.price,
})
.from(products)
.where(eq(products.sellerId, sellerId))
.orderBy(asc(products.id))
.limit(50);
}
Здесь почти виден итоговый SQL. Это удобно, когда команда уже хорошо знает PostgreSQL и хочет типобезопасность без дополнительного слоя абстракции.
Связь можно получить через join:
const rows = await db
.select({
id: products.id,
name: products.name,
sellerEmail: sellers.email,
})
.from(products)
.innerJoin(sellers, eq(products.sellerId, sellers.id))
.where(eq(products.sellerId, sellerId));
Транзакция тоже остаётся похожей на работу с SQL:
await db.transaction(async (tx) => {
const [order] = await tx
.insert(orders)
.values({ customerId, status: "NEW" })
.returning({ id: orders.id });
const updated = await tx
.update(products)
.set({ stock: sql`${products.stock} - 1` })
.where(eq(products.id, productId))
.returning({ id: products.id });
if (updated.length !== 1) {
throw new Error("Product was not updated");
}
return order;
});
returning позволяет проверить фактически изменённую строку. Как и в Prisma, callback определяет границу транзакции.
Что выбрать
Prisma подходит, если команда хочет декларативную схему, готовый клиент и быстрый старт без ручного написания большинства запросов. Это удобно для CRUD, типовых связей и стандартных миграций.
Drizzle подходит, если запросы важно читать почти как SQL, а сложные выборки не должны исчезать за абстракцией. Хороший сценарий: PostgreSQL используется не только как хранилище сущностей, но и как полноценный движок отчётов.
TypeORM подходит, если проект уже построен на сущностях, репозиториях и декораторах. Особенно когда команда использует NestJS и существующие интеграции с DataSource.
Менять работающую библиотеку только ради моды не нужно. Для нового модуля можно выбрать отдельный инструмент, но границу между двумя подходами лучше сделать явной: отдельный persistence-адаптер и понятный владелец транзакций.
Миграции и прод
- Не запускайте
prisma migrate dev,drizzle-kit pushили TypeORMsynchronizeна production. - Миграцию ревьюят как код: блокировки, время выполнения, объём перезаписи и путь отката.
- Сначала расширяют схему, затем выкатывают код, который умеет работать и со старой, и с новой структурой.
- Запуск миграций должен быть одноразовой управляемой операцией, а не гонкой всех реплик приложения.
Тестирование
Тесты на SQLite не доказывают совместимость с PostgreSQL: типы Decimal, numeric, блокировки, RETURNING, изоляция и план запроса отличаются.
Для интеграционного теста поднимают PostgreSQL через Testcontainers, применяют миграции и выполняют сценарий через тот же адаптер, который использует приложение:
it("декрементирует остаток в одной транзакции", async () => {
await expect(purchase(productId, customerId)).resolves.toMatchObject({
status: "NEW",
});
await expect(stockOf(productId)).resolves.toBe(99);
});
Проверяют не только успех, но и откат: вторая операция должна упасть, после чего заказ и изменение остатка исчезают.
Коротко
- Prisma генерирует типобезопасный клиент по
schema.prismaи хорошо подходит для декларативного CRUD. - Drizzle описывает таблицы и запросы на TypeScript рядом с SQL и оставляет больше контроля над PostgreSQL.
- TypeORM остаётся естественным выбором для проектов на сущностях и репозиториях.
- Миграции выполняют управляемо, а интеграционные тесты запускают на PostgreSQL того же класса, что и в проде.
Что почитать дальше
- TypeORM и Data Mapper — где заканчивается ORM и начинается доменный репозиторий.
- Миграции TypeORM — безопасный путь изменения схемы.
- Интеграционные тесты TypeORM — проверка на настоящем PostgreSQL.