ORM Best Practices
PLORM best practices for AI agents come down to three moves: pin the Prisma 7 or TypeORM 1.x version in the agent’s context so it stops writing removed APIs, prompt for query shapes and invariants rather than code, and let integration tests on a real Postgres prove the result. A query-count assertion catches N+1 regressions; a concurrency test catches overselling.
This recipe is for developers who let Claude Code, Codex or Cursor write the ORM layer of a TypeScript service. The agent’s schema looks right and its tests pass against mocks. Then production shows an N+1 on the orders list, two customers buy the last unit of stock, and a snippet that uses prisma.$use fails at startup because Prisma 7 no longer has it.
What these ORM recipes give you
Section titled “What these ORM recipes give you”- A version-pin block for
AGENTS.mdorCLAUDE.mdthat stops the agent from generating Prisma 6 or TypeORM 0.3 code - A prompt for a multi-tenant Prisma 7 schema, plus the Prisma 7 config and client setup agents still get wrong
- A query-count test that turns an N+1 regression into a red CI run
- An order-placement flow that cannot oversell and never holds a row lock across a payment call
- A Testcontainers suite the agent can run and fix in a loop, and a table of the gates that sign the change off
Pin the ORM version in the agent’s context first
Section titled “Pin the ORM version in the agent’s context first”Agents were trained on years of Prisma 4–6 and TypeORM 0.2–0.3 code. Without a version pin they reproduce it confidently. Put the versions and the banned APIs in the project context file that your tool reads (AGENTS.md for Codex and Cursor, CLAUDE.md for Claude Code, or both), so every session starts from them:
## 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.The removed APIs below were checked against the published type definitions of the pinned versions:
| Agent writes | Status | Write instead |
|---|---|---|
url = env("DATABASE_URL") in the datasource block | Moved to prisma.config.ts in Prisma 7.0.0 | datasource: { url: env('DATABASE_URL') } in prisma.config.ts |
new PrismaClient() with no options | Prisma 7 requires a driver adapter or accelerateUrl | new PrismaClient({ adapter }) |
prisma.$use(...) middleware | Not in the @prisma/client 7.10.0 client | a query extension through $extends |
prisma.$metrics | Metrics preview removed in Prisma 7.0.0 | OpenTelemetry tracing |
@EntityRepository, getCustomRepository, AbstractRepository | Not exported by typeorm 1.1.1 | dataSource.getRepository(User).extend({...}) |
getManager(), new Connection(...) | Not exported by typeorm 1.1.1 | dataSource.manager, new DataSource(...) |
Design a Prisma 7 schema the agent cannot misread
Section titled “Design a Prisma 7 schema the agent cannot misread”Agents reason about relations and indexes quickly. Your job is to state the tenancy model, the soft-delete rule and the queries you actually run, so the indexes match real query shapes instead of guesses.
A good response looks like this excerpt. Note the provider-only datasource, the Decimal column and the composite index ordered to match the query:
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")}The connection string now lives in prisma.config.ts, and the client gets a driver adapter. Prisma 7 also stopped loading .env automatically, so load it yourself:
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.tsimport { 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 });The same schema change runs differently in each tool:
Run the edit-validate loop headlessly, with Bash limited to Prisma commands:
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 writes the migration SQL without applying it, so you read the lock implications before anything touches the database. It still connects to DATABASE_URL and creates a shadow database, so point it at a local or Testcontainers Postgres, never a shared one.
A schema change regenerates client types across the repository, so give Codex its own managed worktree:
codex --worktree -c default_permissions=":workspace" \ "Add an AuditLog model with organizationId and deletedAt, run npx prisma generate, \ and fix every call site that now fails npx tsc --noEmit."--worktree (Codex CLI 0.157.1) creates the worktree for you. Permission profiles are beta; the legacy equivalent is --sandbox workspace-write, and the two do not compose, so pick one.
Open prisma/schema.prisma and give Cursor’s agent the prompt above. Review the per-file diff, paying most attention to the @@index lines, before you accept it. With the Prisma extension installed, validation errors appear in the Problems panel, and “fix the schema errors in the Problems panel” is a complete follow-up prompt.
Use Repository.extend() on TypeORM 1.x
Section titled “Use Repository.extend() on TypeORM 1.x”On TypeORM the most common agent mistake is @EntityRepository(User) class UserRepository extends Repository<User>. TypeORM 1.1.1 does not export that decorator, so the code does not compile. The current idiom is 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(); },});Find and kill N+1 queries with a query-count test
Section titled “Find and kill N+1 queries with a query-count test”The N+1 is the ORM’s signature failure: an innocent loop issues one query per row. Agents fix it well when they see the loop and the SQL together. They also reintroduce it in a later refactor, so the fix is only half the job. The other half is a test that counts queries.
The fix is a nested include, which Prisma resolves without a query per user:
const users = await prisma.user.findMany({ where: { organizationId, deletedAt: null }, include: { orders: { where: { status: 'COMPLETED', deletedAt: null } } },});The test is what keeps it fixed. The count must not grow with the number of rows:
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);});When the SQL itself is slow rather than repeated, move to SQL optimization patterns, which reads the EXPLAIN plan.
Write an order flow that cannot oversell or double-charge
Section titled “Write an order flow that cannot oversell or double-charge”Order placement is where agent-written ORM code costs money. The agent’s default is one big transaction that locks stock, calls the payment provider and commits. That holds row locks for the length of an HTTP call, and in Prisma the interactive transaction times out after five seconds by default. The safer shape has three phases:
- Reserve in a short transaction: create a
PENDINGorder and decrement stock only where enough stock remains. - Charge outside any transaction, with the order id as the idempotency key.
- Confirm by marking the order
PAID, or release the reservation if the charge failed.
A conditional updateMany makes the stock check and the decrement one atomic statement. Under PostgreSQL’s default READ COMMITTED isolation, a concurrent update re-evaluates the WHERE clause against the committed row, so two buyers cannot both take the last unit:
// 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 keylet 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 PAIDawait prisma.order.updateMany({ where: { id: order.id, status: 'PENDING' }, data: { status: 'PAID', paymentId: payment.id },});If phase 3 fails, the customer has paid for a PENDING order. Do not refund blindly from a catch. Let a reconciliation job match PENDING orders older than a few minutes against the payment provider and finish or cancel them. On TypeORM the same reserve step uses dataSource.transaction('READ COMMITTED', ...) with .setLock('pessimistic_write') on the variants before the stock check.
Set the connection pool where the ORM actually reads it
Section titled “Set the connection pool where the ORM actually reads it”Agents often write a datasource block with connectionLimit or a pool = { ... } object. Neither field exists, and prisma validate rejects them. On Prisma 7 the pool belongs to the driver: @prisma/adapter-pg accepts a pg pool config (the max: 10 in src/db.ts above) or an existing pg.Pool. The ?connection_limit= URL parameter from Prisma 6 tutorials is a Prisma 6 idiom; configure the pg pool instead. TypeORM passes pool options through extra:
const dataSource = new DataSource({ type: 'postgres', url: process.env.DATABASE_URL, extra: { max: 10, idleTimeoutMillis: 30_000, statement_timeout: 60_000 },});Size the pool per process, then multiply by the number of processes. Ten connections in each of 50 serverless instances is 500 connections, which exceeds the default max_connections of 100 in PostgreSQL. On serverless, put a pooler such as PgBouncer in front and keep max small.
Prove it on real Postgres with Testcontainers
Section titled “Prove it on real Postgres with Testcontainers”Mocking the query builder tests the mock. @testcontainers/postgresql starts a disposable Postgres in Docker, so locks, constraints and migrations run for real. In version 12 the constructor requires an image, and .start() returns a StartedPostgreSqlContainer. Agents often omit the image.
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();});Each tool drives the red-green loop differently:
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 *)"Headless mode suits this loop: container startup is slow, and you do not need to watch each re-run.
codex --worktree -c default_permissions=":workspace" \ "Run npx vitest run tests/integration/order-service.test.ts and fix placeOrder until it passes. \ Do not edit the assertions."Testcontainers needs a reachable Docker daemon. If the sandbox blocks the Docker socket, run the suite in CI and paste the failure back instead of widening the sandbox.
Give Cursor’s agent the prompt above and let it run the suite in the integrated terminal. When the concurrency test fails, paste the failing output back and ask it to explain which interleaving produced it before it changes code.
Give the agent the live schema through a Postgres MCP server
Section titled “Give the agent the live schema through a Postgres MCP server”For schema and N+1 work the agent does better when it reads the real indexes, row estimates and EXPLAIN output instead of your model file. Postgres MCP Pro (PyPI postgres-mcp 0.3.0, from Crystal DBA; last release 2025-05-16, so pin it and watch the repo) provides that; run it against a development or staging database with a read-only role:
# Claude Code: project scope, written to .mcp.jsonclaude mcp add -s project postgres -e 'DATABASE_URI=${DEV_DATABASE_URL}' \ -- uvx postgres-mcp --access-mode=restricted
# Codex: user scope, written to ~/.codex/config.tomlcodex mcp add postgres --env DATABASE_URI="$DEV_DATABASE_URL" \ -- uvx postgres-mcp --access-mode=restrictedThe single quotes keep ${DEV_DATABASE_URL} literal in .mcp.json. Claude Code expands it from each developer’s environment, so no credential is committed. The Codex line uses double quotes because ~/.codex/config.toml is per-user and stores the value the shell expanded. Cursor takes the same command and arguments in its own MCP config. Database MCP servers has all three configs and the read-only role to create first.
How to verify agent-written ORM code without reading every line
Section titled “How to verify agent-written ORM code without reading every line”Read the migration SQL and the invariants. Let gates check the rest:
| Gate | What it proves | Runs in |
|---|---|---|
npx prisma validate and npx tsc --noEmit | The schema is valid Prisma 7 and every call site matches the generated types | Pre-commit hook and CI |
| A grep for the banned APIs in the pin block | No removed API slipped back in | CI |
| Query-count tests | N+1 regressions fail the build | CI, against Testcontainers |
| Concurrency and partial-failure tests | No oversell, no orphaned charge | CI, against Testcontainers |
npx prisma migrate status against the target database | Applied migrations match the repository; no hand-edited drift | Deploy job, before migrate deploy |
| A human read of the migration SQL | Locks, drops, and backfills are intended | PR review |
The developer who opens the PR owns the invariants in the tests. A reviewer signs off on the migration SQL and the test names, not on every line of the generated repository code. For the gate order that turns this into a pipeline, see testing in the agent lifecycle.
When agent-written ORM code breaks in production
Section titled “When agent-written ORM code breaks in production”- The generated code targets a dead major. Symptoms:
prisma.$use is not a function, aurlvalidation error in thedatasourceblock, or TypeORM imports that do not resolve. Recovery: add the pin block from the first section to the context file and paste the error back with “this is Prisma 7.10 / TypeORM 1.1”. Then add the banned-API grep to CI. - A release candidate arrived through
npx prisma. Symptoms: the CLI version and@prisma/clientdiffer, andgenerateoutput changes. Recovery: pin both packages to 7.10.0 inpackage.jsonand run the CLI fromnode_modules. - Pool exhaustion under load. Symptom: timeouts while acquiring a connection. Recovery: count
max× processes againstmax_connections, lowermax, and add PgBouncer for serverless. - Serialization retry storms. Symptom: bursts of error
40001turned into 500s. Recovery: drop toREAD COMMITTEDwith a conditional update or row lock, or add a bounded retry on40001only. - Orphaned charges. Symptom: a paid customer with a
PENDINGorder. Recovery: run the reconciliation job, and add the partial-failure test from the Testcontainers prompt so the path stays covered. - The N+1 returns after a refactor. Symptom: latency grows with page size. Recovery: the query-count test should already be red; if it is green, the fixture is too small to show growth, so seed more rows.
- Migration drift. Symptom:
migrate deployfails in CI after someone changed the database by hand. Recovery: fail the deploy onmigrate status, and never runprisma db pushagainst a shared database. Database migration patterns covers the zero-downtime rewrite.
Where to go next with ORM work
Section titled “Where to go next with ORM work”- Database recipes: the hub that picks the right recipe for the task in front of you
- Database migration patterns: expand and contract, lint gates, and rollback for the migrations this page generates
- SQL optimization patterns:
EXPLAINplans and index design when the query is slow rather than repeated - Database development with AI agents: the end-to-end workflow from spec to rollout
- Integration testing with AI: Testcontainers patterns beyond the database layer
Did this guide help you?
One tap, no sign-in.
What will you do with it?
How did it read?
What got in the way? Pick any that apply
Your role?
Thanks. I read every answer. Michał Jaskólski
Did this guide help?
One tap each. Close whenever you like.
no sign-in