Przejdź do głównej zawartości

Najlepsze praktyki ORM

Twoje zapytania Prisma przechodzą lokalnie każdy test, a potem produkcja pada: pula połączeń wyczerpuje się pod obciążeniem, N+1 na liście zamówień przekracza limit czasu, a „bezpieczna” migracja blokuje gorącą tabelę na 40 sekund. ORM miał ukryć przed tobą bazę danych — zamiast tego ukrył jej pułapki. Asystenci AI świetnie radzą sobie z pisaniem schematu i zapytania na szczęśliwej ścieżce, a równie dobrze z pewnością siebie podsuwają ci wycofane API. Ten przepis pokazuje, jak prowadzić Cursor, Claude Code i Codex w stronę kodu ORM, który przetrwa produkcję, i jak wyłapać wzorce, które robią źle.

  • Sposób pracy nad generowaniem bezpiecznego typowo, wielodostępnego schematu Prisma z usuwaniem miękkim i właściwymi indeksami
  • Powtarzalny prompt do znajdowania i naprawiania zapytań N+1, zanim trafią na produkcję
  • Transakcję SERIALIZABLE składania zamówienia z blokowaniem wierszy i kompensacją płatności, która faktycznie się kompiluje na Prisma 7 / TypeORM 1.0
  • Testy integracyjne z Testcontainers działające na prawdziwym Postgresie, a nie na mocku
  • Wycofane API (@EntityRepository, prisma.$use, $metrics), które AI wciąż sugeruje — i ich aktualne zamienniki
  • Kiedy serwer MCP dla Postgresa bije na głowę wklejanie wyniku \d+ do czatu

Projektowanie schematu to obszar, w którym AI błyszczy: rozumuje o relacjach, normalizacji i indeksach znacznie szybciej, niż jesteś w stanie je naszkicować. Twoim zadaniem jest go ograniczać — określić model wielodostępności, strategię usuwania miękkiego i kształty zapytań, które naprawdę uruchamiasz, tak aby indeksy odzwierciedlały rzeczywistość, a nie zgadywanie.

Skrócona wersja tego, jak wygląda dobra odpowiedź — zwróć uwagę na @db.Decimal, indeks usuwania miękkiego oraz indeks złożony uporządkowany tak, by pasował do zapytania:

model Product {
id String @id @default(cuid())
name String
slug String
category String
basePrice Decimal @db.Decimal(12, 2)
currency String @default("USD") @db.Char(3)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
organizationId String
organization Organization @relation(fields: [organizationId], references: [id])
variants ProductVariant[]
@@unique([organizationId, slug])
@@index([organizationId, category, deletedAt])
@@map("products")
}

To samo zadanie wygląda inaczej w każdym narzędziu — Cursor edytuje plik schematu w miejscu, Claude Code prowadzi pętlę migracji bezgłowo, a Codex uruchamia ją w odizolowanym worktree:

Otwórz schema.prisma, zaznacz model, który rozwijasz, i użyj trybu agenta z powyższym promptem. Cursor edytuje w miejscu i pokazuje diff dla każdego modelu — przejrzyj indeksy, zanim zaakceptujesz. Dodaj rozszerzenie Prisma do VS Code, aby Cursor widział błędy walidacji w kodzie; jeśli wygeneruje kolumnę @db.Money lub nieprawidłowe pole datasource, podkreślenie pojawi się natychmiast i możesz poprosić „fix the schema-validation errors shown in the Problems panel”.

TypeORM: pomiń usunięty wzorzec niestandardowych repozytoriów

Dział zatytułowany „TypeORM: pomiń usunięty wzorzec niestandardowych repozytoriów”

Jeśli twój stos to TypeORM, najczęstszym błędem AI jest wzorzec @EntityRepository(User) class UserRepository extends Repository<User>. Został usunięty w TypeORM 1.0 (razem z AbstractRepository i getCustomRepository). Kod, który go używa, nie skompiluje się. Aktualnym idiomem jest Repository.extend():

import { dataSource } from './data-source';
import { User } from './entities/User';
// TypeORM 1.0: extend a repository instead of subclassing Repository<User>
export const UserRepository = dataSource.getRepository(User).extend({
async findActiveByEmail(email: string) {
return this.createQueryBuilder('user')
.leftJoinAndSelect('user.organization', 'org')
.where('user.email = :email', { email })
.andWhere('user.deletedAt IS NULL')
.andWhere('org.deletedAt IS NULL')
.getOne();
},
});

N+1 to sztandarowa porażka ORM-ów: pętla, która wygląda niewinnie, wykonuje jedno zapytanie na każdy wiersz. AI dobrze ją wykrywa, gdy pokażesz mu pętlę i wygenerowany SQL razem — i dobrze przepisuje ją na pojedyncze zapytanie z eager loadingiem.

Rozwiązaniem jest niemal zawsze zagnieżdżony include (Prisma) lub leftJoinAndSelect (TypeORM):

// Prisma: one query, orders eager-loaded, soft deletes respected
const users = await prisma.user.findMany({
where: { organizationId, deletedAt: null },
include: {
orders: { where: { status: 'COMPLETED', deletedAt: null } },
},
});

Składanie zamówienia to kanoniczny przepływ „wszystko musi się udać albo nic”: zablokuj inwentarz, utwórz zamówienie, zmniejsz stan magazynowy, obciąż kartę, a jeśli obciążenie się powiedzie, ale któryś z późniejszych kroków rzuci wyjątek — zwróć pieniądze. AI zwykle pisze szczęśliwą ścieżkę i catch, który połyka kompensację. Pułapka poniżej jest prawdziwa i była w pierwotnej wersji tego przepisu: kod czytał error.paymentId, ale rzucony Error nie ma takiej właściwości, więc zwrot nigdy się nie uruchamiał.

Rozwiązaniem jest śledzenie identyfikatora obciążonej płatności w lokalnej zmiennej wewnątrz try, tak aby catch mógł skompensować:

// TypeORM 1.0 — SERIALIZABLE transaction with pessimistic locking + payment compensation
async placeOrder(userId: string, items: OrderItemInput[]): Promise<Order> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction('SERIALIZABLE');
let chargedPaymentId: string | undefined;
try {
const variants = await queryRunner.manager
.createQueryBuilder(ProductVariant, 'v')
.setLock('pessimistic_write')
.whereInIds(items.map((i) => i.variantId))
.getMany();
for (const item of items) {
const variant = variants.find((v) => v.id === item.variantId);
if (!variant || variant.stockQuantity < item.quantity) {
throw new InsufficientInventoryError(`Insufficient stock for ${variant?.name}`);
}
}
const order = await queryRunner.manager.save(Order, {
userId,
status: OrderStatus.PENDING,
items: items.map((i) => ({
variantId: i.variantId,
quantity: i.quantity,
price: variants.find((v) => v.id === i.variantId)!.price,
})),
});
for (const item of items) {
await queryRunner.manager.decrement(
ProductVariant,
{ id: item.variantId },
'stockQuantity',
item.quantity,
);
}
const payment = await this.paymentService.charge({ orderId: order.id, amount: order.total, userId });
chargedPaymentId = payment.id; // track for compensation
order.status = OrderStatus.PAID;
order.paymentId = payment.id; // requires `paymentId String?` on the Order model
await queryRunner.manager.save(order);
await queryRunner.commitTransaction();
await this.emailService.sendOrderConfirmation(order); // side effects after commit
return order;
} catch (e) {
await queryRunner.rollbackTransaction();
if (chargedPaymentId) await this.paymentService.refund(chargedPaymentId);
throw e;
} finally {
await queryRunner.release();
}
}

Częstą halucynacją AI jest blok datasource w Prisma z connectionLimit i obiektem pool = { ... }. Te pola nie istnieją w języku schematu Prisma — schemat nie przechodzi walidacji. Rozmiar puli ustawia się przez łańcuch połączenia (?connection_limit=25&pool_timeout=30) na Prisma ≤6; na Prisma 7 tych parametrów URL już nie ma — rozmiar puli pochodzi z konfiguracji samego adaptera sterownika (np. max w adapterze pg). Ogranicz blok datasource wyłącznie do provider i url:

datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// DATABASE_URL="postgresql://user:pass@host:5432/db?connection_limit=25&pool_timeout=30"

TypeORM udostępnia ustawienia puli pod extra w DataSource, co jest faktycznie prawidłowe:

const dataSource = new DataSource({
type: 'postgres',
url: process.env.DATABASE_URL,
extra: { max: 25, min: 5, idleTimeoutMillis: 30000, statement_timeout: 60000 },
});

Mockowanie buildera zapytań testuje twój mock, a nie twój SQL. Testcontainers uruchamia jednorazowy Postgres, więc twoja logika transakcji, blokowania i migracji działa naprawdę. Aktualne @testcontainers/postgresql (12.x) wymaga argumentu obrazu, a .start() zwraca StartedPostgreSqlContainer — AI często pomija obraz i myli typ zmiennej.

import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
let container: StartedPostgreSqlContainer;
beforeAll(async () => {
container = await new PostgreSqlContainer('postgres:16')
.withDatabase('testdb')
.withUsername('test')
.withPassword('test')
.start();
process.env.DATABASE_URL = container.getConnectionUri();
await runMigrations(process.env.DATABASE_URL); // run real migrations, not synchronize
});
afterAll(async () => {
await container.stop();
});

Każde narzędzie prowadzi tę pętlę inaczej:

Wygeneruj test w trybie agenta, a następnie uruchom go z wbudowanego terminala. Gdy asercja współbieżności zawiedzie, wklej z powrotem nieudane wyjście i poproś Cursor o dodanie logowania na poziomie wierszy — może iterować nad strategią blokowania, mając diff przed oczami.

Dwa kolejne usunięte API, które AI uwielbia sugerować:

  • Middleware prisma.$use(...) — przestarzałe w Prisma 4.16, usunięte w 6.14. Zamiennik: rozszerzenie klienta przez $extends.
  • prisma.$metrics.json() — funkcja podglądu metryk została usunięta w Prisma 7. Do obserwowalności produkcyjnej użyj zamiast tego śledzenia OpenTelemetry.

Aktualnym sposobem mierzenia czasu zapytań i oznaczania powolnych jest rozszerzenie klienta na poziomie zapytania:

// Prisma 7: client extension replaces the removed $use middleware
const prisma = new PrismaClient().$extends({
query: {
$allModels: {
async $allOperations({ model, operation, args, query }) {
const start = Date.now();
const result = await query(args);
const duration = Date.now() - start;
if (duration > 1000) {
logger.warn('Slow query', { model, operation, duration });
}
return result;
},
},
},
});

Pozwól bazie danych przyjrzeć się samej sobie: Postgres MCP

Dział zatytułowany „Pozwól bazie danych przyjrzeć się samej sobie: Postgres MCP”

Przy projektowaniu schematu i polowaniu na N+1 asystent działa znacznie lepiej, gdy widzi żywy schemat — rzeczywiste indeksy, liczbę wierszy i wynik EXPLAIN ANALYZE — zamiast nieaktualnej wklejki. Serwer MCP dla Postgresa daje mu dostęp do odczytu, by zrobić dokładnie to. Sięgnij po utrzymywany serwer, taki jak crystaldba/postgres-mcp (Postgres MCP Pro — strojenie indeksów, plany EXPLAIN, kontrole kondycji i konfigurowalny tryb dostępu), skierowany na twoją bazę deweloperską:

Okno terminala
# Claude Code — Postgres MCP Pro against a dev DB (restricted access mode)
# Flags (--transport, --env) must come before the server name; -- separates the command.
claude mcp add --transport stdio --env DATABASE_URI="postgresql://localhost:5432/dev" \
postgres -- uvx postgres-mcp --access-mode=restricted

Ten sam serwer konfiguruje się w Cursor i Codex przez ich ustawienia MCP — polecenie i argumenty są identyczne we wszystkich trzech narzędziach. Po jego podłączeniu „look at the actual indexes on orders and tell me which of my hot queries do a sequential scan” staje się prawdziwą odpowiedzią popartą przez EXPLAIN, a nie zgadywaniem.

  • Dryf migracji. prisma migrate dev działa lokalnie, ale migrate deploy zawodzi w CI, bo ktoś edytował bazę ręcznie. Uruchamiaj prisma migrate status w CI i wywal build przy dryfie; nigdy nie rób prisma db push na współdzielonym środowisku.
  • Wyczerpanie puli pod obciążeniem. Objawy: Timed out fetching a new connection from the connection pool. Na serverless każda instancja otwiera własną pulę — postaw przed nią PgBouncer (lub Prisma Accelerate) i ustaw niski connection_limit na instancję. Domyślne 25 połączeń × 50 lambd od AI stopi Postgresa.
  • Burze błędów serializacji. Transakcja SERIALIZABLE bez pętli ponawiania zamienia współbieżność w błędy 500 (kod 40001). Dodaj ograniczone ponawianie z jitterem, uruchamiane wyłącznie przy kodzie serializacji.
  • Zwrot, który nigdy się nie uruchamia. Jeśli twoja kompensacja czyta właściwość z rzuconego błędu zamiast ze śledzonej zmiennej lokalnej, obciążone, ale nieudane zamówienie nigdy nie zwraca pieniędzy. Testuj ścieżkę częściowej awarii jawnie — spraw, by płatność się powiodła, a kolejny zapis do bazy rzucił wyjątek.
  • Regresje N+1 wracają chyłkiem. Nowy include znika w refaktorze i pętla wraca. Asercja liczby zapytań w teście integracyjnym sprawia, że regresja wywala CI, a nie produkcję.
  • Wygenerowany kod celuje w martwe wydanie major. $use, $metrics, @EntityRepository, new Connection() — jeśli którekolwiek się pojawi, asystent użył starego cięcia treningowego. Podaj mu dokładne wersje (Prisma 7.8 / TypeORM 1.0), a fragment sam się poprawi.