Node.js Production Engineering 12 — Prisma 7 trong Production
Prisma ORM 7 từ cấu hình ESM, generated client và driver adapter tới migration an toàn, query có kiểm soát, transaction giữ invariant, pooling, observability và test production.
Prisma giúp một truy vấn sai tên field thất bại ở compile time. Nó không thể tự biết “số dư không được âm”, một email gửi sau COMMIT có cần atomic với write hay không, hoặc query relation vừa tạo ra bao nhiêu round-trip.
Đó là ranh giới quan trọng của ORM: type-safe query builder không phải consistency model. Bài này cấu hình đúng Prisma ORM 7 và giữ nguyên các invariant PostgreSQL ở phần trước thay vì giấu chúng sau API đẹp.
Kết thúc bài, bạn có thể:
- khởi tạo Prisma 7 đúng với ESM,
prisma.config.ts, generator mới và driver adapter; - thiết kế migration expand-contract, kể cả constraint chưa biểu diễn được trong Prisma Schema;
- kiểm soát projection, relation loading, N+1 và keyset pagination;
- cài transfer serializable có idempotency, database constraint và retry
P2034; - dùng raw SQL an toàn khi ORM không diễn đạt đủ;
- sizing pool, shutdown, quan sát query và test concurrency.
Baseline: Prisma ORM 7, PostgreSQL 18, Node.js 24 LTS. Prisma 6 dùng cấu hình khác; không trộn snippet giữa hai major version.
Prisma 7 thay đổi mental model kết nối
Đường đi của Prisma 7:
schema.prisma ── generate ──▶ generated TypeScript client
│
└── migrate ──────────▶ PostgreSQL schema
application ── PrismaClient ── driver adapter ── pg pool ── PostgreSQL
Ba thay đổi dễ làm tutorial cũ không chạy:
- generator mới là
prisma-clientvà bắt buộc khai báooutput; - connection URL cho CLI/migration nằm trong
prisma.config.ts; - runtime client cần driver adapter, ví dụ
@prisma/adapter-pg.
Prisma 7 dùng ESM. Bật rõ trong package.json:
{
"type": "module"
}
Cài package
npm install @prisma/client @prisma/adapter-pg pg dotenv
npm install --save-dev prisma @types/pg
npx prisma init --datasource-provider postgresql
Prisma 7 không tự động nạp environment variable theo cách các tutorial cũ giả định. Import dotenv/config cho CLI local; production nên inject biến môi trường từ platform/secret manager.
Cấu hình đúng Prisma 7
prisma.config.ts
CLI dùng direct connection cho migration và introspection. Nếu runtime đi qua PgBouncer, tách hai URL:
import 'dotenv/config';
import { defineConfig, env } from 'prisma/config';
export default defineConfig({
schema: 'prisma/schema.prisma',
migrations: {
path: 'prisma/migrations',
seed: 'tsx prisma/seed.ts',
},
datasource: {
url: env('DIRECT_DATABASE_URL'),
},
});
# Runtime URL: có thể là endpoint của pooler.
DATABASE_URL=postgresql://app:secret@pooler:6432/app
# CLI URL: direct database endpoint cho migrate/introspection.
DIRECT_DATABASE_URL=postgresql://migrator:secret@postgres:5432/app
Không đưa prisma:// hoặc prisma+postgres:// vào PrismaPg; adapter này cần PostgreSQL connection string trực tiếp. Prisma Accelerate có cách khởi tạo riêng.
prisma/schema.prisma
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
}
model User {
id BigInt @id @default(autoincrement())
email String @unique @db.Citext
posts Post[]
accounts Account[]
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
@@map("users")
}
model Post {
id BigInt @id @default(autoincrement())
authorId BigInt @map("author_id")
author User @relation(fields: [authorId], references: [id], onDelete: Restrict)
title String
body String
status PostStatus @default(DRAFT)
meta Json @default("{}")
publishedAt DateTime? @map("published_at") @db.Timestamptz(6)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
@@index([authorId, publishedAt(sort: Desc), id(sort: Desc)], map: "idx_posts_author_feed")
@@map("posts")
}
model Account {
id BigInt @id @default(autoincrement())
ownerId BigInt @map("owner_id")
owner User @relation(fields: [ownerId], references: [id])
balanceCents BigInt @default(0) @map("balance_cents")
sent Transfer[] @relation("transfer_from")
received Transfer[] @relation("transfer_to")
@@map("accounts")
}
model Transfer {
id String @id @db.Uuid
idempotencyKey String @unique @map("idempotency_key")
fromAccountId BigInt @map("from_account_id")
toAccountId BigInt @map("to_account_id")
fromAccount Account @relation("transfer_from", fields: [fromAccountId], references: [id])
toAccount Account @relation("transfer_to", fields: [toAccountId], references: [id])
amountCents BigInt @map("amount_cents")
status TransferStatus @default(PENDING)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
@@map("transfers")
}
model OutboxEvent {
id String @id @db.Uuid
aggregateType String @map("aggregate_type")
aggregateId String @map("aggregate_id")
eventType String @map("event_type")
payload Json
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
publishedAt DateTime? @map("published_at") @db.Timestamptz(6)
@@index([publishedAt, createdAt])
@@map("outbox_events")
}
enum PostStatus {
DRAFT
PUBLISHED
ARCHIVED
}
enum TransferStatus {
PENDING
COMPLETED
}
Prisma Schema không biểu diễn mọi PostgreSQL constraint. Tạo migration rồi chỉnh SQL trước khi apply:
npx prisma migrate dev --create-only --name init_money_invariants
-- Thêm vào migration.sql đã sinh.
CREATE EXTENSION IF NOT EXISTS citext;
ALTER TABLE accounts
ADD CONSTRAINT accounts_balance_non_negative
CHECK (balance_cents >= 0);
ALTER TABLE transfers
ADD CONSTRAINT transfers_amount_positive CHECK (amount_cents > 0),
ADD CONSTRAINT transfers_accounts_differ CHECK (from_account_id <> to_account_id);
CREATE INDEX idx_posts_meta_gin
ON posts USING gin (meta jsonb_path_ops);
Database constraint mới là nguồn thực thi invariant cuối cùng; generated type chỉ bảo vệ shape của lời gọi từ TypeScript.
Khởi tạo client và pool có giới hạn
// src/db/client.ts
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../generated/prisma/client.js';
const connectionString = process.env.DATABASE_URL;
if (!connectionString) throw new Error('DATABASE_URL is required');
const adapter = new PrismaPg({
connectionString,
max: 10,
connectionTimeoutMillis: 5_000,
idleTimeoutMillis: 30_000,
application_name: 'content-api',
});
export const prisma = new PrismaClient({
adapter,
log: [
{ emit: 'event', level: 'error' },
{ emit: 'event', level: 'warn' },
],
});
Tạo một client/pool cho mỗi process lâu sống, không tạo theo request. Prisma 7 lấy pool semantics từ driver adapter; các query parameter kiểu connection_limit của Prisma 6 không còn là cách cấu hình chính.
Ngân sách connection vẫn là:
replica tối đa × adapter.max + worker/migration/admin headroom
≤ database connection budget
connectionTimeoutMillis mặc định của pg là 0 — chờ không giới hạn. Production nên có timeout và metric cho pool wait thay vì để request treo đến deadline bên ngoài.
Query có type không đồng nghĩa query rẻ
Projection làm giảm dữ liệu truyền và thu hẹp type trả về:
const user = await prisma.user.findUnique({
where: { email: 'ann@example.com' },
select: {
id: true,
email: true,
posts: {
where: { status: 'PUBLISHED' },
orderBy: [{ publishedAt: 'desc' }, { id: 'desc' }],
take: 5,
select: { id: true, title: true, publishedAt: true },
},
},
});
Không khẳng định include luôn là một query hay luôn là hai query. Relation loading phụ thuộc connector, option và phiên bản. Bật query tracing trong môi trường kiểm thử, đo số round-trip và xem plan của SQL thật.
Failure mode N+1 vẫn xuất hiện khi gọi query trong loop:
// Không làm thế này trên một collection lớn.
for (const post of posts) {
await prisma.user.findUnique({ where: { id: post.authorId } });
}
Giải pháp có thể là nested select, query batch in, DataLoader theo request hoặc TypedSQL. Chọn dựa trên cardinality và plan, không dựa trên tên pattern.
Keyset pagination với composite cursor
Order gồm hai field thì predicate cursor cũng phải gồm hai field:
interface PostCursor {
publishedAt: Date;
id: bigint;
}
async function listPublished(authorId: bigint, cursor?: PostCursor) {
return prisma.post.findMany({
where: {
authorId,
status: 'PUBLISHED',
...(cursor
? {
OR: [
{ publishedAt: { lt: cursor.publishedAt } },
{ publishedAt: cursor.publishedAt, id: { lt: cursor.id } },
],
}
: {}),
},
orderBy: [{ publishedAt: 'desc' }, { id: 'desc' }],
take: 21,
select: { id: true, title: true, publishedAt: true },
});
}
Lấy pageSize + 1 để biết còn trang sau, trả tối đa pageSize, encode { version, publishedAt, id } thành cursor opaque. Không đi qua JSON trực tiếp với bigint; serialize id thành string.
Transaction giữ invariant, không chỉ “all or nothing”
Ba API có vai trò khác nhau:
- nested write cho dữ liệu phụ thuộc trong một aggregate;
$transaction([])cho chuỗi operation độc lập không có branch ở giữa;- interactive transaction cho read-check-write, luôn giữ ngắn.
Transfer dưới đây dùng:
- unique idempotency key;
- row lock theo thứ tự xác định;
- conditional debit + database check constraint;
Serializablevà bounded retryP2034;- outbox row trong cùng transaction.
import { randomUUID } from 'node:crypto';
import { setTimeout as sleep } from 'node:timers/promises';
import { Prisma } from '../generated/prisma/client.js';
import { prisma } from './client.js';
interface TransferCommand {
id: string;
idempotencyKey: string;
fromAccountId: bigint;
toAccountId: bigint;
amountCents: bigint;
}
function isPrismaCode(error: unknown, code: string): boolean {
return (
error instanceof Prisma.PrismaClientKnownRequestError && error.code === code
);
}
async function executeTransfer(cmd: TransferCommand) {
if (cmd.amountCents <= 0n || cmd.fromAccountId === cmd.toAccountId) {
throw new Error('invalid transfer');
}
for (let attempt = 1; ; attempt++) {
try {
return await prisma.$transaction(
async (tx) => {
await tx.transfer.create({
data: {
id: cmd.id,
idempotencyKey: cmd.idempotencyKey,
fromAccountId: cmd.fromAccountId,
toAccountId: cmd.toAccountId,
amountCents: cmd.amountCents,
},
});
const ids = [cmd.fromAccountId, cmd.toAccountId].sort((a, b) =>
a < b ? -1 : a > b ? 1 : 0
);
await tx.$queryRaw`
SELECT id
FROM accounts
WHERE id IN (${Prisma.join(ids)})
ORDER BY id
FOR UPDATE
`;
const accounts = await tx.account.findMany({
where: { id: { in: ids } },
select: { id: true },
});
if (accounts.length !== 2) throw new Error('account not found');
const debit = await tx.account.updateMany({
where: {
id: cmd.fromAccountId,
balanceCents: { gte: cmd.amountCents },
},
data: { balanceCents: { decrement: cmd.amountCents } },
});
if (debit.count !== 1) throw new Error('insufficient funds');
await tx.account.update({
where: { id: cmd.toAccountId },
data: { balanceCents: { increment: cmd.amountCents } },
});
const transfer = await tx.transfer.update({
where: { id: cmd.id },
data: { status: 'COMPLETED' },
});
await tx.outboxEvent.create({
data: {
id: randomUUID(),
aggregateType: 'transfer',
aggregateId: transfer.id,
eventType: 'transfer.completed',
payload: {
transferId: transfer.id,
amountCents: transfer.amountCents.toString(),
},
},
});
return { transfer, replayed: false };
},
{
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
maxWait: 3_000,
timeout: 5_000,
}
);
} catch (error) {
if (isPrismaCode(error, 'P2034') && attempt < 4) {
await sleep(25 * 2 ** (attempt - 1) + Math.random() * 50);
continue;
}
if (isPrismaCode(error, 'P2002')) {
const existing = await prisma.transfer.findUnique({
where: { idempotencyKey: cmd.idempotencyKey },
});
if (
existing &&
existing.fromAccountId === cmd.fromAccountId &&
existing.toAccountId === cmd.toAccountId &&
existing.amountCents === cmd.amountCents &&
existing.status === 'COMPLETED'
) {
return { transfer: existing, replayed: true };
}
throw new Error('idempotency conflict');
}
throw error;
}
}
}
Không gọi payment gateway, email hoặc broker trong interactive transaction. Network call kéo dài lock và không rollback cùng database. Outbox worker đọc outbox_events, publish idempotently rồi đánh dấu published_at.
Raw SQL: escape hatch có kỷ luật
Tagged template parameterize value:
const rows = await prisma.$queryRaw<Array<{ id: bigint; title: string }>>`
SELECT id, title
FROM posts
WHERE author_id = ${authorId}
AND meta @> ${JSON.stringify({ featured: true })}::jsonb
`;
Không ghép user input vào $queryRawUnsafe. Identifier động như table/column không thể được parameterize theo cách value được parameterize; map từ allowlist cố định hoặc dùng TypedSQL đã review.
TypedSQL phù hợp cho query phức tạp cần type generated từ file SQL. Nó không loại bỏ nhu cầu xem plan, constraint và migration ownership.
Migration production: expand, migrate, contract
npx prisma migrate dev --name add_optional_slug # chỉ development
npx prisma migrate deploy # CI/release job
npx prisma migrate status
Quy trình đổi name thành display_name mà không downtime:
- expand: thêm
display_namenullable, code ghi cả hai; - backfill theo batch có checkpoint và rate limit;
- code đọc field mới, quan sát null/error;
- thêm
NOT NULL/index bằng kỹ thuật tránh lock dài phù hợp; - contract: ngừng ghi và sau đó xóa field cũ.
prisma db push chỉ hợp prototype/database disposable. Nó không thay migration history có review. Down migration không được mặc định là an toàn; data loss thường cần forward fix.
Error contract và observability
Map error Prisma sang error domain tại adapter boundary:
| Prisma code | Ý nghĩa thường gặp | HTTP/domain gợi ý |
|---|---|---|
P2002 | unique constraint | conflict có field/code ổn định |
P2003 | foreign key | referenced resource/relationship invalid |
P2025 | record required không tồn tại | not found hoặc concurrent change |
P2034 | write conflict/deadlock | retry nội bộ, sau budget trả unavailable/conflict |
Không trả raw Prisma message, SQL hay stack cho client.
Quan sát tối thiểu:
- query duration histogram theo operation/model, không label bằng raw SQL/user id;
- pool wait/acquire timeout;
- transaction duration, retry count và abort reason;
- slow query sample đã redact parameter;
- trace span nối HTTP → use case → Prisma → PostgreSQL;
- outbox lag và publish retry.
Query logging đầy đủ có thể chứa PII/secret và tạo volume lớn. Chỉ bật có sampling/redaction trong môi trường phù hợp.
Shutdown theo thứ tự: readiness off → HTTP drain → worker drain → await prisma.$disconnect().
Failure modes và trade-off
| Failure mode | Nguyên nhân | Phòng vệ |
|---|---|---|
| client tạo theo request | nhiều pool, hết connection | một client/process |
| type-safe nhưng N+1 | query đặt trong loop | projection/batch/DataLoader + query count test |
| long interactive transaction | network/CPU trong callback | transaction ngắn, outbox |
| migration và code không tương thích | deploy không theo expand-contract | compatibility window |
| retry vô hạn | contention hoặc outage kéo dài | retry budget + jitter + metric |
| raw SQL injection | ghép input/identifier | tagged template, allowlist, TypedSQL |
| serverless connection storm | mỗi instance có pool | cap concurrency, external pooler/managed option |
Prisma phù hợp khi team coi schema/migration/type generated là lợi thế. Với query-heavy analytics, PostgreSQL feature đặc thù hoặc SQL là ngôn ngữ chính của team, driver/query builder/TypedSQL có thể rõ ràng hơn. ORM là lựa chọn kiến trúc, không phải cấp độ trưởng thành.
Lab và acceptance criteria
Nâng payment service ở phần 11 sang Prisma 7.
- Client được generate bằng
prisma-clientvàosrc/generated/prisma; không cònprisma-client-js. - CLI dùng
prisma.config.ts; runtime dùngPrismaPgvới pool/timeouts rõ ràng. - Migration có check constraint số dư, amount và hai account khác nhau.
- 100 transfer đồng thời không tạo số dư âm; tổng tiền không đổi.
- replay cùng idempotency key trả cùng transfer; key trùng payload khác bị từ chối.
- test ép
P2034chứng minh bounded retry; metric retry tăng đúng. - outbox row commit atomic với transfer; worker publish hai lần không tạo side effect trùng.
- query feed đi hết dữ liệu có timestamp trùng mà không thiếu/trùng.
- test đo query count bắt được phiên bản cố ý tạo N+1.
- shutdown test không còn connection/open handle.
Checklist production
- Generator
prisma-clientcóoutput; import từ generated path. -
prisma.config.tsgiữ CLI URL; runtime client có đúng driver adapter. - Một PrismaClient/process; pool có budget và timeout.
- Constraint quan trọng tồn tại trong migration SQL.
- Transaction có isolation, timeout, idempotency và bounded retry.
- External side effect đi qua outbox hoặc cơ chế idempotent tương đương.
- Query relation được đo round-trip/plan, không giả định từ API shape.
- Raw SQL parameterized; identifier động đi qua allowlist.
- Migration dùng expand-contract và được test với dữ liệu thực tế hóa.
- Metric/trace không lộ SQL parameter hoặc tạo cardinality vô hạn.
Tài liệu chính thức
- Prisma ORM 7 — Upgrade guide
- Prisma 7 — Generating Prisma Client
- Prisma Config reference
- Prisma 7 — Database drivers
- Prisma — Connection pool
- Prisma — Transactions and P2034 retry
- Prisma — Raw queries
- Prisma — TypedSQL
Phần tiếp theo chuyển sang Redis. Câu hỏi không còn là “cache thế nào”, mà là mỗi primitive Redis cung cấp guarantee gì khi process crash, key hết hạn, replica failover hoặc consumer xử lý message hai lần.