Skip to content

ORM Best Practices

Your Prisma queries pass every test locally, then production falls over: the connection pool exhausts under load, an N+1 in the orders list times out, and a “safe” migration locks a hot table for 40 seconds. The ORM was supposed to abstract the database away — instead it hid the footguns. AI assistants are excellent at writing the schema and the happy-path query, and equally good at confidently handing you a deprecated API. This recipe shows how to drive Cursor, Claude Code, and Codex toward ORM code that survives production, and how to catch the patterns they get wrong.

  • A workflow for generating a type-safe, multi-tenant Prisma schema with soft deletes and the right indexes
  • A repeatable prompt for finding and fixing N+1 queries before they hit production
  • A SERIALIZABLE order-placement transaction with row locking and payment compensation that actually compiles on Prisma 7 / TypeORM 1.0
  • Testcontainers integration tests that run against a real Postgres, not a mock
  • The deprecated APIs (@EntityRepository, prisma.$use, $metrics) AI still suggests — and the current replacements
  • Where a Postgres MCP server beats pasting \d+ output into chat

Schema design is where AI shines: it reasons about relationships, normalization, and indexes far faster than you can sketch them. Your job is to constrain it — specify the tenancy model, the soft-delete strategy, and the query shapes you actually run, so the indexes match reality instead of guesswork.

A trimmed version of what a good response looks like — note the @db.Decimal, the soft-delete index, and the composite index ordered to match the query:

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")
}

The same task differs per tool — Cursor edits the schema file in place, Claude Code drives the migration loop headlessly, Codex runs it in an isolated worktree:

Open schema.prisma, select the model you’re evolving, and use agent mode with the prompt above. Cursor edits in place and shows a diff per model — review the indexes before accepting. Add the Prisma VS Code extension so Cursor sees validation errors inline; if it emits a @db.Money column or an invalid datasource field, the squiggle appears immediately and you can prompt “fix the schema-validation errors shown in the Problems panel.”

TypeORM: skip the removed custom-repository pattern

Section titled “TypeORM: skip the removed custom-repository pattern”

If your stack is TypeORM, the single most common AI mistake is the @EntityRepository(User) class UserRepository extends Repository<User> pattern. It was removed in TypeORM 1.0 (along with AbstractRepository and getCustomRepository). Code using it will not compile. The current idiom is 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();
},
});

The N+1 is the ORM’s signature failure: a loop that looks innocent issues one query per row. AI is good at spotting it when you show it the loop and the generated SQL together — and good at rewriting it to a single eager-loaded query.

The fix is almost always a nested include (Prisma) or 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 } },
},
});

Order placement is the canonical “everything must succeed or nothing does” flow: lock inventory, create the order, decrement stock, charge the card, and if the charge succeeds but a later step throws, refund it. AI usually writes the happy path and a catch that swallows the compensation. The trap below is real and was in the original version of this recipe: it read error.paymentId, but a thrown Error has no such property, so the refund never fired.

The fix is to track the charged payment id in a local variable inside the try, so the catch can compensate:

// 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();
}
}

Connection pooling: don’t trust the generated config

Section titled “Connection pooling: don’t trust the generated config”

A frequent AI hallucination is a Prisma datasource block with connectionLimit and a pool = { ... } object. Those fields do not exist in the Prisma schema language — the schema fails validation. Pool size is set via the connection string (?connection_limit=25&pool_timeout=30) on Prisma ≤6; on Prisma 7 those URL params are gone — pool size comes from the driver adapter’s own config (e.g. the pg adapter’s max). Keep the datasource block to just provider and url:

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

TypeORM exposes pool settings under extra on the DataSource, which is genuinely valid:

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

Mocking the query builder tests your mock, not your SQL. Testcontainers spins up a throwaway Postgres so your transaction, locking, and migration logic run for real. The current @testcontainers/postgresql (12.x) requires an image argument and .start() returns a StartedPostgreSqlContainer — AI often omits the image and mistypes the variable.

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();
});

Each tool drives this loop differently:

Generate the test with agent mode, then run it from the integrated terminal. When a concurrency assertion fails, paste the failing output back and ask Cursor to add row-level logging — it can iterate on the locking strategy with the diff visible.

Two more removed APIs that AI loves to suggest:

  • prisma.$use(...) middleware — deprecated in Prisma 4.16, removed in 6.14. Replacement: a client extension via $extends.
  • prisma.$metrics.json() — the metrics preview feature was removed in Prisma 7. For production observability, use OpenTelemetry tracing instead.

The current way to time queries and flag slow ones is a query-level client extension:

// 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;
},
},
},
});

Let the database introspect itself: Postgres MCP

Section titled “Let the database introspect itself: Postgres MCP”

For schema design and N+1 hunting, the assistant works far better when it can see the live schema — actual indexes, row counts, and EXPLAIN ANALYZE output — instead of a stale paste. A Postgres MCP server gives it read access to do exactly that. Reach for a maintained server such as crystaldba/postgres-mcp (Postgres MCP Pro — index tuning, EXPLAIN plans, health checks, and a configurable access mode) pointed at your dev database:

Terminal window
# 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

The same server is configured in Cursor and Codex via their MCP settings — the command and args are identical across all three tools. With it connected, “look at the actual indexes on orders and tell me which of my hot queries do a sequential scan” becomes a real answer backed by EXPLAIN, not a guess.

  • Migration drift. prisma migrate dev works locally but migrate deploy fails in CI because someone edited the DB by hand. Run prisma migrate status in CI and fail the build on drift; never prisma db push to a shared environment.
  • Pool exhaustion under load. Symptoms: Timed out fetching a new connection from the connection pool. On serverless, every instance opens its own pool — put PgBouncer (or Prisma Accelerate) in front and set connection_limit low per instance. AI’s default of 25 connections × 50 lambdas will melt Postgres.
  • Serialization-failure storms. A SERIALIZABLE transaction without a retry loop turns concurrency into 500s (error 40001). Add bounded, jittered retries that only fire on the serialization code.
  • The refund that never fires. If your compensation reads a property off the thrown error instead of a tracked local variable, a charged-but-failed order never refunds. Test the partial-failure path explicitly — make the payment succeed and the next DB write throw.
  • N+1 regressions slip back in. A new include gets dropped in a refactor and the loop returns. Assert the query count in an integration test so the regression fails CI, not production.
  • Generated code targets a dead major. $use, $metrics, @EntityRepository, new Connection() — if any appear, the assistant used an old training cut. Tell it the exact versions (Prisma 7.8 / TypeORM 1.0) and the snippet self-corrects.