Przejdź do głównej zawartości

Najlepsze praktyki ORM

Dobre praktyki ORM dla agentów AI sprowadzają się do trzech ruchów: przypięcia wersji Prisma 7 lub TypeORM 1.x w kontekście agenta, żeby przestał pisać usunięte API, promptowania o kształty zapytań i niezmienniki zamiast o kod oraz testów integracyjnych na prawdziwym Postgresie jako dowodu. Asercja liczby zapytań łapie regresje N+1, a test współbieżności łapie sprzedaż ponad stan.

Ta receptura jest dla programistów, którzy oddają warstwę ORM serwisu w TypeScripcie Claude Code, Codeksowi lub Cursorowi. Schemat od agenta wygląda dobrze, testy na mockach przechodzą. Potem produkcja pokazuje N+1 na liście zamówień, dwóch klientów kupuje ostatnią sztukę towaru, a fragment z prisma.$use wywraca się przy starcie, bo Prisma 7 już tego nie ma.

  • Blok przypięcia wersji do AGENTS.md lub CLAUDE.md, który powstrzymuje agenta przed generowaniem kodu Prisma 6 lub TypeORM 0.3
  • Prompt na wielodostępny schemat Prisma 7 oraz konfigurację i klienta Prisma 7, które agenci wciąż mylą
  • Test liczby zapytań, który zamienia regresję N+1 w czerwony build w CI
  • Przepływ składania zamówienia, który nie sprzeda więcej, niż jest na stanie, i nigdy nie trzyma blokady wiersza w trakcie wywołania płatności
  • Zestaw testów Testcontainers, który agent uruchamia i naprawia w pętli, oraz tabelę bramek zatwierdzających zmianę

Agenci uczyli się na latach kodu Prisma 4–6 i TypeORM 0.2–0.3. Bez przypiętej wersji odtwarzają go z pełnym przekonaniem. Wpisz wersje i zakazane API do pliku kontekstu projektu, który czyta twoje narzędzie (AGENTS.md dla Codeksa i Cursora, CLAUDE.md dla Claude Code albo oba), żeby każda sesja od nich startowała:

## Database layer
- Prisma 7.10 (`prisma-client` generator, output in src/generated/prisma) with @prisma/adapter-pg. TypeORM 1.1 in services/billing.
- The datasource URL lives in prisma.config.ts, not schema.prisma. PrismaClient always gets an `adapter`.
- Never use: prisma.$use, prisma.$metrics, `url =` in the datasource block, @EntityRepository,
getCustomRepository, getManager(), new Connection(). Use $extends, OpenTelemetry, prisma.config.ts,
Repository.extend(), new DataSource().
- Money: Decimal @db.Decimal(12, 2) plus a currency column. Never @db.Money or Float.
- Every data-access change ships with an integration test on Postgres (Testcontainers), not a mock.

Usunięte API z tabeli sprawdzono w opublikowanych definicjach typów przypiętych wersji:

Agent piszeStanPisz zamiast tego
url = env("DATABASE_URL") w bloku datasourcePrzeniesione do prisma.config.ts w Prisma 7.0.0datasource: { url: env('DATABASE_URL') } w prisma.config.ts
new PrismaClient() bez opcjiPrisma 7 wymaga adaptera sterownika albo accelerateUrlnew PrismaClient({ adapter })
middleware prisma.$use(...)Nie ma go w kliencie @prisma/client 7.10.0rozszerzenie query przez $extends
prisma.$metricsFunkcja preview metryk usunięta w Prisma 7.0.0śledzenie OpenTelemetry
@EntityRepository, getCustomRepository, AbstractRepositorytypeorm 1.1.1 ich nie eksportujedataSource.getRepository(User).extend({...})
getManager(), new Connection(...)typeorm 1.1.1 ich nie eksportujedataSource.manager, new DataSource(...)

Zaprojektuj schemat Prisma 7, którego agent nie zrozumie opacznie

Dział zatytułowany „Zaprojektuj schemat Prisma 7, którego agent nie zrozumie opacznie”

Agenci szybko rozumują o relacjach i indeksach. Twoja rola to podać model wielodostępności, regułę miękkiego usuwania i zapytania, które naprawdę wykonujesz, żeby indeksy pasowały do rzeczywistych kształtów zapytań, a nie do zgadywania.

Dobra odpowiedź wygląda jak ten fragment. Zwróć uwagę na blok datasource z samym dostawcą, kolumnę Decimal i indeks złożony ułożony pod zapytanie:

generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
}
model Product {
id String @id @default(cuid())
organizationId String
slug String
category String
basePrice Decimal @db.Decimal(12, 2)
currency String @default("USD") @db.Char(3)
deletedAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id])
variants ProductVariant[]
@@unique([organizationId, slug])
@@index([organizationId, category, deletedAt])
@@map("products")
}

Adres połączenia (connection string) jest teraz w prisma.config.ts, a klient dostaje adapter sterownika. Prisma 7 przestała też sama wczytywać .env, więc zrób to jawnie:

prisma.config.ts
import 'dotenv/config';
import { defineConfig, env } from 'prisma/config';
export default defineConfig({
schema: 'prisma/schema.prisma',
migrations: { path: 'prisma/migrations' },
datasource: { url: env('DATABASE_URL') },
});
// src/db.ts
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from './generated/prisma/client';
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL, max: 10 });
export const prisma = new PrismaClient({ adapter });

Ta sama zmiana schematu przebiega w każdym narzędziu inaczej:

Uruchom pętlę edycja–walidacja bez interfejsu, z Bashem ograniczonym do poleceń Prismy:

Okno terminala
claude -p "Add a soft-deletable AuditLog model to prisma/schema.prisma, run 'npx prisma validate', \
then 'npx prisma migrate dev --name add_audit_log --create-only'. Print the generated SQL and \
flag any DROP, ALTER TYPE or NOT NULL without a default." \
--allowedTools Read Edit "Bash(npx prisma *)"

--create-only zapisuje SQL migracji bez stosowania go, więc czytasz skutki blokad, zanim cokolwiek dotknie bazy. Polecenie i tak łączy się z DATABASE_URL i tworzy bazę cieniową (shadow database), więc kieruj je na lokalnego Postgresa albo kontener z Testcontainers, nigdy na bazę współdzieloną.

W TypeORM najczęstszy błąd agenta to @EntityRepository(User) class UserRepository extends Repository<User>. TypeORM 1.1.1 nie eksportuje tego dekoratora, więc kod się nie kompiluje. Aktualny idiom to Repository.extend():

export const UserRepository = dataSource.getRepository(User).extend({
findActiveByEmail(email: string) {
return this.createQueryBuilder('user')
.innerJoinAndSelect('user.organization', 'org')
.where('user.email = :email', { email })
.andWhere('user.deletedAt IS NULL AND org.deletedAt IS NULL')
.getOne();
},
});

N+1 to sztandarowa awaria ORM: niewinna pętla wysyła jedno zapytanie na każdy wiersz. Agenci dobrze to naprawiają, gdy widzą pętlę i SQL razem. Równie chętnie wprowadzają problem z powrotem przy późniejszym refaktorze, więc poprawka to tylko połowa pracy. Druga połowa to test, który liczy zapytania.

Poprawka to zagnieżdżony include, który Prisma rozwiązuje bez zapytania na każdego użytkownika:

const users = await prisma.user.findMany({
where: { organizationId, deletedAt: null },
include: { orders: { where: { status: 'COMPLETED', deletedAt: null } } },
});

To test pilnuje, żeby poprawka przetrwała. Liczba zapytań nie może rosnąć razem z liczbą wierszy:

const queries: string[] = [];
const prisma = new PrismaClient({ adapter, log: [{ emit: 'event', level: 'query' }] });
prisma.$on('query', (e) => queries.push(e.query));
it('loads users with orders in a constant number of queries', async () => {
await seedOrg('small', 3);
await seedOrg('large', 20);
queries.length = 0;
await listUsersWithOrders(prisma, 'small');
const small = queries.length;
queries.length = 0;
await listUsersWithOrders(prisma, 'large');
expect(queries.length).toBe(small);
});

Gdy sam SQL jest wolny, a nie powtarzany, przejdź do wzorców optymalizacji SQL, które czytają plan EXPLAIN.

Napisz przepływ zamówienia bez sprzedaży ponad stan i podwójnego obciążenia

Dział zatytułowany „Napisz przepływ zamówienia bez sprzedaży ponad stan i podwójnego obciążenia”

Składanie zamówień to miejsce, w którym kod ORM od agenta kosztuje pieniądze. Domyślnie agent pisze jedną dużą transakcję, która blokuje stany, woła operatora płatności i zatwierdza. Trzyma przez to blokady wierszy przez cały czas trwania wywołania HTTP, a w Prismie transakcja interaktywna domyślnie kończy się timeoutem po pięciu sekundach. Bezpieczniejszy kształt ma trzy fazy:

  1. Rezerwacja w krótkiej transakcji: utwórz zamówienie PENDING i zmniejsz stan tylko tam, gdzie wystarcza towaru.
  2. Obciążenie poza jakąkolwiek transakcją, z identyfikatorem zamówienia jako kluczem idempotencji.
  3. Potwierdzenie: oznacz zamówienie jako PAID albo zwolnij rezerwację, jeśli płatność się nie udała.

Warunkowy updateMany łączy sprawdzenie stanu i dekrementację w jedną atomową instrukcję. Przy domyślnej izolacji READ COMMITTED w PostgreSQL współbieżna aktualizacja ponownie ocenia klauzulę WHERE względem zatwierdzonego wiersza, więc dwóch kupujących nie weźmie ostatniej sztuki:

// Phase 1: reserve (Prisma 7)
const order = await prisma.$transaction(async (tx) => {
for (const item of items) {
const { count } = await tx.productVariant.updateMany({
where: { id: item.variantId, stockQuantity: { gte: item.quantity } },
data: { stockQuantity: { decrement: item.quantity } },
});
if (count === 0) throw new InsufficientStockError(item.variantId); // rolls back all items
}
const total = orderTotal(items); // sum of unitPrice * quantity, computed as Decimal
return tx.order.create({
data: {
organizationId,
userId,
status: 'PENDING',
total,
currency,
items: { create: items.map(({ variantId, quantity, unitPrice }) => ({ variantId, quantity, unitPrice })) },
},
});
});
// Phase 2: charge outside the transaction; the provider deduplicates on the key
let payment: { id: string };
try {
payment = await payments.charge({ amount: order.total, currency: order.currency, idempotencyKey: order.id });
} catch (err) {
await releaseReservation(order.id); // restores stock, marks the order CANCELLED
throw err;
}
// Phase 3: confirm; only a PENDING order can become PAID
await prisma.order.updateMany({
where: { id: order.id, status: 'PENDING' },
data: { status: 'PAID', paymentId: payment.id },
});

Jeśli faza 3 się nie powiedzie, klient zapłacił za zamówienie PENDING. Nie zwracaj pieniędzy na ślepo z bloku catch. Niech zadanie uzgadniające porównuje zamówienia PENDING starsze niż kilka minut z danymi operatora płatności i je domyka albo anuluje. W TypeORM ten sam krok rezerwacji używa dataSource.transaction('READ COMMITTED', ...) z .setLock('pessimistic_write') na wariantach przed sprawdzeniem stanu.

Ustaw pulę połączeń tam, gdzie ORM naprawdę ją czyta

Dział zatytułowany „Ustaw pulę połączeń tam, gdzie ORM naprawdę ją czyta”

Agenci często piszą blok datasource z connectionLimit albo obiektem pool = { ... }. Żadne z tych pól nie istnieje, a prisma validate je odrzuca. W Prisma 7 pula należy do sterownika: @prisma/adapter-pg przyjmuje konfigurację puli pg (to max: 10 w src/db.ts wyżej) albo gotowy pg.Pool. Parametr URL ?connection_limit= z tutoriali o Prisma 6 to idiom Prisma 6; zamiast niego skonfiguruj pulę pg. TypeORM przekazuje opcje puli przez extra:

const dataSource = new DataSource({
type: 'postgres',
url: process.env.DATABASE_URL,
extra: { max: 10, idleTimeoutMillis: 30_000, statement_timeout: 60_000 },
});

Rozmiar puli ustalasz na proces, potem mnożysz przez liczbę procesów. Dziesięć połączeń w każdej z 50 instancji serverless to 500 połączeń, czyli więcej niż domyślne max_connections równe 100 w PostgreSQL. W środowisku serverless postaw przed bazą pooler, np. PgBouncer, i trzymaj max nisko.

Udowodnij to na prawdziwym Postgresie z Testcontainers

Dział zatytułowany „Udowodnij to na prawdziwym Postgresie z Testcontainers”

Mockowanie query buildera testuje mocka. @testcontainers/postgresql uruchamia jednorazowego Postgresa w Dockerze, więc blokady, ograniczenia i migracje działają naprawdę. W wersji 12 konstruktor wymaga obrazu, a .start() zwraca StartedPostgreSqlContainer. Agenci często pomijają obraz.

import { execSync } from 'node:child_process';
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
let container: StartedPostgreSqlContainer;
beforeAll(async () => {
container = await new PostgreSqlContainer('postgres:17').start();
process.env.DATABASE_URL = container.getConnectionUri();
execSync('npx prisma migrate deploy', { env: process.env, stdio: 'inherit' });
}, 120_000);
afterAll(async () => {
await container.stop();
});

Każde narzędzie prowadzi pętlę czerwone–zielone po swojemu:

Okno terminala
claude -p "Run 'npx vitest run tests/integration/order-service.test.ts'. If the concurrency \
test fails, find the missing guard in placeOrder and fix the code, not the test. Re-run until green." \
--allowedTools Read Edit "Bash(npx vitest *)" "Bash(npx prisma *)"

Tryb bez interfejsu pasuje do tej pętli: kontener startuje wolno, a ty nie musisz pilnować każdego przebiegu.

Pokaż agentowi żywy schemat przez serwer MCP dla Postgresa

Dział zatytułowany „Pokaż agentowi żywy schemat przez serwer MCP dla Postgresa”

Przy pracy nad schematem i N+1 agent radzi sobie lepiej, gdy czyta prawdziwe indeksy, szacunki liczby wierszy i wynik EXPLAIN zamiast twojego pliku modelu. Daje mu to Postgres MCP Pro (PyPI postgres-mcp 0.3.0, od Crystal DBA; ostatnie wydanie 2025-05-16, więc przypnij wersję i obserwuj repozytorium); uruchamiaj go na bazie deweloperskiej lub stagingowej z rolą tylko do odczytu:

Okno terminala
# Claude Code: project scope, written to .mcp.json
claude mcp add -s project postgres -e 'DATABASE_URI=${DEV_DATABASE_URL}' \
-- uvx postgres-mcp --access-mode=restricted
# Codex: user scope, written to ~/.codex/config.toml
codex mcp add postgres --env DATABASE_URI="$DEV_DATABASE_URL" \
-- uvx postgres-mcp --access-mode=restricted

Pojedyncze cudzysłowy zostawiają ${DEV_DATABASE_URL} dosłownie w .mcp.json. Claude Code rozwija tę zmienną ze środowiska każdego dewelopera, więc do repozytorium nie trafia żadne hasło. W linii Codeksa są podwójne cudzysłowy, bo ~/.codex/config.toml jest plikiem użytkownika i zapisuje wartość rozwiniętą przez powłokę. Cursor przyjmuje to samo polecenie i argumenty we własnej konfiguracji MCP. Serwery MCP dla baz danych zawierają wszystkie trzy konfiguracje i rolę tylko do odczytu, którą trzeba utworzyć najpierw.

Jak weryfikować kod ORM od agenta bez czytania każdej linii

Dział zatytułowany „Jak weryfikować kod ORM od agenta bez czytania każdej linii”

Czytaj SQL migracji i niezmienniki. Resztę niech sprawdzą bramki:

BramkaCo udowadniaGdzie działa
npx prisma validate i npx tsc --noEmitSchemat jest poprawny dla Prisma 7, a każde wywołanie zgadza się z wygenerowanymi typamiHook pre-commit i CI
Grep za zakazanymi API z bloku przypięciaŻadne usunięte API nie wróciłoCI
Testy liczby zapytańRegresje N+1 psują buildCI, na Testcontainers
Testy współbieżności i częściowej awariiBrak sprzedaży ponad stan i osieroconych płatnościCI, na Testcontainers
npx prisma migrate status na docelowej bazieZastosowane migracje zgadzają się z repozytorium; brak ręcznego dryfuJob wdrożenia, przed migrate deploy
Lektura SQL migracji przez człowiekaBlokady, usunięcia i backfille są zamierzoneReview PR

Programista, który otwiera PR, odpowiada za niezmienniki w testach. Recenzent zatwierdza SQL migracji i nazwy testów, a nie każdą linię wygenerowanego kodu repozytorium. Kolejność bramek, która składa to w pipeline, opisuje strona testowanie w cyklu życia z agentem.

  • Wygenerowany kod celuje w martwą wersję major. Objawy: prisma.$use is not a function, błąd walidacji url w bloku datasource albo importy TypeORM, które się nie rozwiązują. Naprawa: dodaj blok przypięcia z pierwszej sekcji do pliku kontekstu i wklej błąd z dopiskiem „this is Prisma 7.10 / TypeORM 1.1”. Potem dodaj do CI grep za zakazanymi API.
  • Przez npx prisma przyszła wersja kandydująca. Objawy: wersja CLI różni się od @prisma/client, a wynik generate się zmienia. Naprawa: przypnij oba pakiety do 7.10.0 w package.json i uruchamiaj CLI z node_modules.
  • Wyczerpanie puli pod obciążeniem. Objaw: timeouty przy pobieraniu połączenia. Naprawa: policz max × liczba procesów względem max_connections, zmniejsz max i dodaj PgBouncer w środowisku serverless.
  • Burze ponowień serializacji. Objaw: serie błędów 40001 zamienione w odpowiedzi 500. Naprawa: zejdź do READ COMMITTED z warunkową aktualizacją lub blokadą wiersza albo dodaj ograniczone ponowienia tylko dla 40001.
  • Osierocone płatności. Objaw: klient zapłacił, a zamówienie wisi jako PENDING. Naprawa: uruchom zadanie uzgadniające i dodaj test częściowej awarii z promptu Testcontainers, żeby ta ścieżka została pokryta.
  • N+1 wraca po refaktorze. Objaw: opóźnienie rośnie z rozmiarem strony. Naprawa: test liczby zapytań powinien już być czerwony; jeśli jest zielony, dane testowe są za małe, żeby pokazać wzrost, więc dodaj więcej wierszy.
  • Dryf migracji. Objaw: migrate deploy pada w CI, bo ktoś zmienił bazę ręcznie. Naprawa: niech wdrożenie zatrzymuje się, gdy migrate status wykaże rozbieżność, i nigdy nie uruchamiaj prisma db push na współdzielonej bazie. Przepisanie bez przestoju opisują wzorce migracji baz danych.