jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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.

12 MIN READ Updated JUL 11, 2026

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:

  1. generator mới là prisma-clientbắt buộc khai báo output;
  2. connection URL cho CLI/migration nằm trong prisma.config.ts;
  3. 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 pg0 — 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;
  • Serializable và bounded retry P2034;
  • 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:

  1. expand: thêm display_name nullable, code ghi cả hai;
  2. backfill theo batch có checkpoint và rate limit;
  3. code đọc field mới, quan sát null/error;
  4. thêm NOT NULL/index bằng kỹ thuật tránh lock dài phù hợp;
  5. 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ặpHTTP/domain gợi ý
P2002unique constraintconflict có field/code ổn định
P2003foreign keyreferenced resource/relationship invalid
P2025record required không tồn tạinot found hoặc concurrent change
P2034write conflict/deadlockretry 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 modeNguyên nhânPhòng vệ
client tạo theo requestnhiều pool, hết connectionmột client/process
type-safe nhưng N+1query đặt trong loopprojection/batch/DataLoader + query count test
long interactive transactionnetwork/CPU trong callbacktransaction ngắn, outbox
migration và code không tương thíchdeploy không theo expand-contractcompatibility window
retry vô hạncontention hoặc outage kéo dàiretry budget + jitter + metric
raw SQL injectionghép input/identifiertagged template, allowlist, TypedSQL
serverless connection stormmỗi instance có poolcap 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.

  1. Client được generate bằng prisma-client vào src/generated/prisma; không còn prisma-client-js.
  2. CLI dùng prisma.config.ts; runtime dùng PrismaPg với pool/timeouts rõ ràng.
  3. Migration có check constraint số dư, amount và hai account khác nhau.
  4. 100 transfer đồng thời không tạo số dư âm; tổng tiền không đổi.
  5. replay cùng idempotency key trả cùng transfer; key trùng payload khác bị từ chối.
  6. test ép P2034 chứng minh bounded retry; metric retry tăng đúng.
  7. outbox row commit atomic với transfer; worker publish hai lần không tạo side effect trùng.
  8. query feed đi hết dữ liệu có timestamp trùng mà không thiếu/trùng.
  9. test đo query count bắt được phiên bản cố ý tạo N+1.
  10. shutdown test không còn connection/open handle.

Checklist production

  • Generator prisma-clientoutput; import từ generated path.
  • prisma.config.ts giữ 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

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.