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.
Co z tego wyniesiesz
Dział zatytułowany „Co z tego wyniesiesz”- 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ę
SERIALIZABLEskł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
Dział zatytułowany „Projektowanie schematu”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”.
Pozwól Claude Code przejąć pełną pętlę edycja–walidacja z poziomu terminala:
claude -p "Add a soft-deletable AuditLog model to schema.prisma, then run \'npx prisma validate' and 'npx prisma migrate dev --name add_audit_log --create-only'. \Show me the generated SQL before applying it." \ --allowedTools "Read,Edit,Bash"Ponieważ migracja jest --create-only, Claude Code zapisuje SQL, ale go nie stosuje — najpierw przeglądasz konsekwencje blokowania. To bezgłowy wzorzec, który wyłapuje DROP COLUMN udające zmianę przyrostową.
Zmiany w schemacie dotykają wygenerowanych typów klienta w całym repozytorium, więc uruchom Codex w worktree, aby main pozostał czysty:
git worktree add ../app-schema -b schema/audit-logcodex --ask-for-approval on-request \ "In this worktree, add an AuditLog model with org scoping and soft deletes, \ regenerate the Prisma client, and update any call sites that now fail type-check."Przejrzyj diff w worktree, a następnie scal. Powierzchnie IDE i Cloud w Codex prowadzą tę samą zmianę; worktree po prostu izoluje wygenerowane na nowo typy node_modules/.prisma.
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(); },});Eliminowanie zapytań N+1
Dział zatytułowany „Eliminowanie zapytań N+1”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 respectedconst users = await prisma.user.findMany({ where: { organizationId, deletedAt: null }, include: { orders: { where: { status: 'COMPLETED', deletedAt: null } }, },});Transakcja, która przetrwa awarię
Dział zatytułowany „Transakcja, która przetrwa awarię”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 compensationasync 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(); }}Pula połączeń: nie ufaj wygenerowanej konfiguracji
Dział zatytułowany „Pula połączeń: nie ufaj wygenerowanej konfiguracji”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 },});Testy integracyjne na prawdziwym Postgresie
Dział zatytułowany „Testy integracyjne na prawdziwym Postgresie”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.
Pozwól Claude Code uruchamiać zestaw i naprawiać błędy w pętli:
claude -p "Run 'npm test -- order-service.integration', and if the concurrency \assertion fails, find the missing lock in placeOrder and fix it. Re-run until green." \ --allowedTools "Read,Edit,Bash"Bezgłowa pętla sprawdza się tu idealnie: start Testcontainers jest powolny, a ty nie chcesz pilnować każdego kolejnego uruchomienia.
Uruchom zestaw w worktree, aby niestabilne sprzątanie kontenera nigdy nie zepsuło twojego głównego checkoutu. codex --ask-for-approval on-request "run the integration suite and fix the deadlock the logs show" — powierzchnia Cloud w Codex może uruchomić ten sam zestaw na PR poprzez integrację z GitHubem.
Monitorowanie wydajności z aktualnymi API
Dział zatytułowany „Monitorowanie wydajności z aktualnymi API”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 middlewareconst 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ą:
# 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=restrictedTen 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.
Kiedy to się psuje
Dział zatytułowany „Kiedy to się psuje”- Dryf migracji.
prisma migrate devdziała lokalnie, alemigrate deployzawodzi w CI, bo ktoś edytował bazę ręcznie. Uruchamiajprisma migrate statusw CI i wywal build przy dryfie; nigdy nie róbprisma db pushna 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 niskiconnection_limitna instancję. Domyślne 25 połączeń × 50 lambd od AI stopi Postgresa. - Burze błędów serializacji. Transakcja
SERIALIZABLEbez pętli ponawiania zamienia współbieżność w błędy 500 (kod40001). 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
includeznika 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.
Co dalej
Dział zatytułowany „Co dalej”- Wzorce migracji bazy danych — ewolucja schematu bez przestojów w głębi
- Wzorce optymalizacji SQL —
EXPLAIN ANALYZEi strategia indeksów poza ORM-em - Dostęp do bazy danych przez MCP — podłączenie Postgres MCP tylko do odczytu we wszystkich trzech narzędziach
- Testy integracyjne z AI — wzorce Testcontainers w całym stosie