← назад к разделу

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 или TypeORM synchronize на 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 того же класса, что и в проде.

Что почитать дальше