Back to blog

Deep Dive: Database & ORM với TypeScript

typescriptdatabaseprismadrizzlepostgresql
Deep Dive: Database & ORM với TypeScript

Bạn đã biết cách xây dựng API trong TypeScript. Bây giờ đến tầng mà mọi thứ đều phụ thuộc vào: database. Chọn sai abstraction, bỏ sót index, hoặc kích hoạt N+1 query có thể âm thầm phá hủy hiệu suất và độ tin cậy của ứng dụng.

Bài viết này là hướng dẫn thực tiễn cho toàn bộ database stack trong TypeScript — từ lựa chọn ORM qua thiết kế schema, migrations, relations, transactions và tối ưu hóa query.

Điều kiện tiên quyết: Deep Dive: Node.js API Development Thời gian cam kết: 2–3 giờ

Bạn Sẽ Học Được Gì

✅ Hiểu bức tranh ORM: Prisma vs Drizzle vs raw SQL
✅ Thiết kế schema với Prisma và Drizzle với type safety đầy đủ
✅ Chạy migrations an toàn trong development và production
✅ Query relation mà không có vấn đề N+1
✅ Viết query phức tạp với raw SQL type-safe
✅ Sử dụng transaction để đảm bảo toàn vẹn dữ liệu
✅ Tối ưu hóa query với index và phân tích query
✅ Cấu hình connection pooling cho production


Bức Tranh Database TypeScript

PrismaDrizzleKyselyRaw SQL
Type Safety✅ Tự động sinh✅ Suy luận từ schema✅ Cấp query⚠️ Thủ công
Bundle SizeLớn (query engine)NhỏNhỏTối thiểu
MigrationsPrisma MigrateDrizzle KitThủ công/bên thứ 3Thủ công
Raw SQL$queryRawtemplate sqlNativeNative
Edge/Serverless⚠️ Cần adapter✅ Native✅✅
Độ khó họcThấpTrung bìnhTrung bìnhCao

Khi nào chọn gì:

  • Prisma: Hầu hết các team, ứng dụng quan hệ, cần tốc độ phát triển
  • Drizzle: Serverless/edge, muốn tính minh bạch SQL, lo ngại bundle size
  • Raw SQL / Kysely: Query báo cáo phức tạp, cần kiểm soát cấp DBA

Bài viết này đề cập Prisma và Drizzle — hai ORM được áp dụng rộng rãi nhất trong hệ sinh thái TypeScript — với các pattern raw SQL xuyên suốt.


Phần 1: Prisma

Cài Đặt và Setup

npm install prisma @prisma/client
npx prisma init  # Tạo prisma/schema.prisma và .env

Cấu hình database URL trong .env:

DATABASE_URL="postgresql://user:password@localhost:5432/myapp?schema=public"

Định Nghĩa Schema

Prisma schema là nguồn sự thật của bạn. Các type được sinh tự động từ nó.

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}
 
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}
 
model User {
  id        String   @id @default(cuid())
  email     String   @unique
  name      String?
  role      Role     @default(USER)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
 
  posts    Post[]
  profile  Profile?
  comments Comment[]
 
  @@index([email])
}
 
model Profile {
  id     String  @id @default(cuid())
  bio    String?
  userId String  @unique
  user   User    @relation(fields: [userId], references: [id], onDelete: Cascade)
}
 
model Post {
  id          String    @id @default(cuid())
  title       String
  content     String
  published   Boolean   @default(false)
  publishedAt DateTime?
  authorId    String
  author      User      @relation(fields: [authorId], references: [id])
  tags        Tag[]
  comments    Comment[]
  createdAt   DateTime  @default(now())
  updatedAt   DateTime  @updatedAt
 
  @@index([authorId])
  @@index([published, publishedAt(sort: Desc)])
}
 
model Comment {
  id        String   @id @default(cuid())
  content   String
  authorId  String
  postId    String
  author    User     @relation(fields: [authorId], references: [id])
  post      Post     @relation(fields: [postId], references: [id], onDelete: Cascade)
  createdAt DateTime @default(now())
 
  @@index([postId])
}
 
model Tag {
  id    String @id @default(cuid())
  name  String @unique
  posts Post[]
}
 
enum Role {
  USER
  ADMIN
  MODERATOR
}

Chạy Migration

# Development: tạo và áp dụng migration
npx prisma migrate dev --name add-user-role
 
# Production: áp dụng các migration hiện có (không bao giờ sinh migration mới)
npx prisma migrate deploy
 
# Kiểm tra trạng thái DB hiện tại (hữu ích khi schema bị lệch)
npx prisma db pull
 
# Push thay đổi schema mà không có lịch sử migration (chỉ prototyping)
npx prisma db push

Cấu trúc file migration:

prisma/
  migrations/
    20240120_init/
      migration.sql
    20240125_add-user-role/
      migration.sql
  schema.prisma

Quy tắc production: Luôn chạy prisma migrate deploy (không phải dev) trong CI/CD pipeline. migrate dev chỉ dành cho development.

Thiết Lập Client

Tạo singleton để tránh làm cạn kiệt connection pool:

// lib/prisma.ts
import { PrismaClient } from '@prisma/client';
 
const globalForPrisma = global as unknown as { prisma: PrismaClient };
 
export const prisma =
  globalForPrisma.prisma ||
  new PrismaClient({
    log: process.env.NODE_ENV === 'development'
      ? ['query', 'error', 'warn']
      : ['error'],
  });
 
if (process.env.NODE_ENV !== 'production') {
  globalForPrisma.prisma = prisma;
}

Tại sao cần singleton? Trong development với hot reloading (Next.js, ts-node-dev), nếu không có pattern này bạn sẽ tạo PrismaClient mới mỗi lần hot reload và nhanh chóng làm cạn kiệt connection pool của database.

Các Thao Tác CRUD

import { prisma } from './lib/prisma';
 
// CREATE
const user = await prisma.user.create({
  data: {
    email: '[email protected]',
    name: 'Alice',
    profile: {
      create: { bio: 'Kỹ sư phần mềm' }, // Tạo lồng nhau
    },
  },
  include: { profile: true }, // Bao gồm relation trong response
});
// user được type đầy đủ: User & { profile: Profile | null }
 
// READ — bản ghi đơn
const found = await prisma.user.findUnique({
  where: { email: '[email protected]' },
  select: {
    id: true,
    email: true,
    name: true,
    // profile: false — KHÔNG được chọn (loại trừ trường nhạy cảm)
  },
});
 
// READ — nhiều bản ghi với lọc
const publishedPosts = await prisma.post.findMany({
  where: {
    published: true,
    author: {
      role: 'USER',           // Lọc theo relation lồng nhau
    },
    publishedAt: {
      gte: new Date('2024-01-01'), // Khoảng ngày
    },
  },
  orderBy: { publishedAt: 'desc' },
  take: 10,   // LIMIT
  skip: 0,    // OFFSET (cho phân trang)
  include: {
    author: { select: { id: true, name: true } },
    tags: true,
    _count: { select: { comments: true } }, // Đếm mà không tải tất cả comment
  },
});
 
// UPDATE
const updated = await prisma.user.update({
  where: { id: user.id },
  data: { name: 'Alice Smith' },
});
 
// UPSERT — tạo hoặc cập nhật
const upserted = await prisma.user.upsert({
  where: { email: '[email protected]' },
  update: { name: 'Alice Updated' },
  create: { email: '[email protected]', name: 'Alice New' },
});
 
// DELETE
await prisma.user.delete({ where: { id: user.id } });
 
// Pattern soft delete (nếu bạn thêm deletedAt vào schema)
await prisma.user.update({
  where: { id: user.id },
  data: { deletedAt: new Date() },
});

Vấn Đề N+1 và Cách Prisma Giải Quyết

Vấn đề N+1 là một trong những kẻ giết hiệu suất phổ biến nhất trong ứng dụng web. Nó xảy ra khi bạn tải danh sách bản ghi và sau đó query relation của mỗi bản ghi riêng biệt.

// ❌ VẤN ĐỀ N+1: 1 query cho posts + N query cho mỗi author
const posts = await prisma.post.findMany({ take: 20 });
 
for (const post of posts) {
  // Lệnh này thực hiện một DB query riêng cho MỖI bài đăng!
  const author = await prisma.user.findUnique({ where: { id: post.authorId } });
  console.log(`${post.title} bởi ${author?.name}`);
}
// Tổng cộng: 21 query cho 20 bài đăng
// ✅ ĐÃ GIẢI QUYẾT: include tải tất cả relation trong 1-2 query
const posts = await prisma.post.findMany({
  take: 20,
  include: {
    author: { select: { id: true, name: true, email: true } },
    tags: true,
    _count: { select: { comments: true } },
  },
});
// Prisma batch các relation query — thường tổng 2-3 query
 
// ✅ THAY THẾ: select cho bạn kiểm soát chính xác hơn
const posts2 = await prisma.post.findMany({
  take: 20,
  select: {
    id: true,
    title: true,
    publishedAt: true,
    author: { select: { name: true } },        // Chỉ tên
    _count: { select: { comments: true } },
    tags: { select: { name: true } },           // Chỉ tên tag
  },
});

Transaction

Dùng transaction khi nhiều thao tác phải thành công hoặc thất bại cùng nhau:

// Transaction tuần tự (tốt cho các thao tác phụ thuộc)
const result = await prisma.$transaction(async (tx) => {
  // Tất cả thao tác dùng `tx`, không phải `prisma`
  const sender = await tx.user.findUnique({
    where: { id: senderId },
    select: { balance: true },
  });
 
  if (!sender || sender.balance < amount) {
    throw new Error('Số dư không đủ'); // Tự động rollback
  }
 
  const updatedSender = await tx.user.update({
    where: { id: senderId },
    data: { balance: { decrement: amount } },
  });
 
  const updatedReceiver = await tx.user.update({
    where: { id: receiverId },
    data: { balance: { increment: amount } },
  });
 
  await tx.transaction.create({
    data: { senderId, receiverId, amount, status: 'COMPLETED' },
  });
 
  return { sender: updatedSender, receiver: updatedReceiver };
});
 
// Transaction batch (cho các thao tác độc lập, chạy song song)
const [users, posts] = await prisma.$transaction([
  prisma.user.findMany({ take: 5 }),
  prisma.post.findMany({ take: 5, where: { published: true } }),
]);

Raw SQL với Prisma

Khi abstraction của ORM không đủ:

// Query raw type-safe với tagged template literal
const users = await prisma.$queryRaw<{ id: string; email: string; postCount: bigint }[]>`
  SELECT
    u.id,
    u.email,
    COUNT(p.id) AS "postCount"
  FROM "User" u
  LEFT JOIN "Post" p ON p."authorId" = u.id
  WHERE u."createdAt" > ${new Date('2024-01-01')}
  GROUP BY u.id, u.email
  ORDER BY "postCount" DESC
  LIMIT ${10}
`;
 
// Lưu ý: Prisma trả về BigInt cho COUNT — chuyển đổi nếu cần
const formatted = users.map(u => ({
  ...u,
  postCount: Number(u.postCount),
}));
 
// Mutation raw (không có type inference — dùng cẩn thận)
await prisma.$executeRaw`
  UPDATE "Post"
  SET "publishedAt" = NOW()
  WHERE "published" = true AND "publishedAt" IS NULL
`;

Phần 2: Drizzle ORM

Drizzle theo cách tiếp cận khác: SQL-first, có nghĩa là định nghĩa TypeScript của bạn trông giống SQL và các type được suy luận từ định nghĩa schema — không cần file schema riêng, không có bước sinh code.

Cài Đặt và Setup

npm install drizzle-orm postgres
npm install -D drizzle-kit
// drizzle.config.ts
import type { Config } from 'drizzle-kit';
 
export default {
  schema: './src/db/schema.ts',
  out: './drizzle',
  dialect: 'postgresql',
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
} satisfies Config;

Định Nghĩa Schema

// src/db/schema.ts
import {
  pgTable, pgEnum, text, varchar, boolean, timestamp,
  uniqueIndex, index, primaryKey
} from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
 
export const roleEnum = pgEnum('role', ['USER', 'ADMIN', 'MODERATOR']);
 
export const users = pgTable('users', {
  id:        text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  email:     varchar('email', { length: 255 }).notNull().unique(),
  name:      varchar('name', { length: 255 }),
  role:      roleEnum('role').default('USER').notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
}, (table) => ({
  emailIdx: uniqueIndex('users_email_idx').on(table.email),
}));
 
export const posts = pgTable('posts', {
  id:          text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  title:       varchar('title', { length: 500 }).notNull(),
  content:     text('content').notNull(),
  published:   boolean('published').default(false).notNull(),
  publishedAt: timestamp('published_at'),
  authorId:    text('author_id').notNull().references(() => users.id),
  createdAt:   timestamp('created_at').defaultNow().notNull(),
  updatedAt:   timestamp('updated_at').defaultNow().notNull(),
}, (table) => ({
  authorIdx:    index('posts_author_idx').on(table.authorId),
  publishedIdx: index('posts_published_idx').on(table.published, table.publishedAt),
}));
 
export const comments = pgTable('comments', {
  id:        text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  content:   text('content').notNull(),
  authorId:  text('author_id').notNull().references(() => users.id),
  postId:    text('post_id').notNull().references(() => posts.id, { onDelete: 'cascade' }),
  createdAt: timestamp('created_at').defaultNow().notNull(),
}, (table) => ({
  postIdx: index('comments_post_idx').on(table.postId),
}));
 
// Định nghĩa relation cho join query
export const usersRelations = relations(users, ({ many, one }) => ({
  posts:    many(posts),
  comments: many(comments),
}));
 
export const postsRelations = relations(posts, ({ one, many }) => ({
  author:   one(users, { fields: [posts.authorId], references: [users.id] }),
  comments: many(comments),
}));
 
export const commentsRelations = relations(comments, ({ one }) => ({
  author: one(users, { fields: [comments.authorId], references: [users.id] }),
  post:   one(posts, { fields: [comments.postId], references: [posts.id] }),
}));
 
// Export type được suy luận từ schema
export type User    = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type Post    = typeof posts.$inferSelect;
export type NewPost = typeof posts.$inferInsert;

Thiết Lập Database Client

// src/db/index.ts
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
 
const connectionString = process.env.DATABASE_URL!;
 
// Để thực thi query (tắt prefetch cho serverless)
const client = postgres(connectionString, {
  max: process.env.NODE_ENV === 'production' ? 10 : 1,
});
 
export const db = drizzle(client, { schema });
export type Database = typeof db;

Migration với Drizzle Kit

# Sinh file migration từ thay đổi schema
npx drizzle-kit generate
 
# Áp dụng migration vào database
npx drizzle-kit migrate
 
# Xem schema trong Drizzle Studio (web UI)
npx drizzle-kit studio

Query với Drizzle

API của Drizzle ánh xạ gần với SQL, giúp dễ dàng suy luận về query nào đang được thực thi:

import { db } from './db';
import { users, posts, comments } from './db/schema';
import { eq, and, gte, desc, count, isNull, like, inArray } from 'drizzle-orm';
 
// INSERT
const [newUser] = await db
  .insert(users)
  .values({ email: '[email protected]', name: 'Alice' })
  .returning(); // Trả về bản ghi đã insert
// newUser được type là User
 
// SELECT với lọc
const publishedPosts = await db
  .select({
    id:          posts.id,
    title:       posts.title,
    publishedAt: posts.publishedAt,
    authorName:  users.name,
  })
  .from(posts)
  .innerJoin(users, eq(posts.authorId, users.id))
  .where(
    and(
      eq(posts.published, true),
      gte(posts.publishedAt, new Date('2024-01-01'))
    )
  )
  .orderBy(desc(posts.publishedAt))
  .limit(10);
 
// UPDATE
await db
  .update(users)
  .set({ name: 'Alice Smith', updatedAt: new Date() })
  .where(eq(users.id, userId));
 
// DELETE
await db.delete(comments).where(eq(comments.postId, postId));
 
// Query với relation (dùng relational query API)
const userWithPosts = await db.query.users.findFirst({
  where: eq(users.email, '[email protected]'),
  with: {
    posts: {
      where: eq(posts.published, true),
      orderBy: desc(posts.createdAt),
      limit: 5,
      with: {
        comments: { limit: 3 },
      },
    },
  },
});
// Kết quả được type đầy đủ bao gồm các relation lồng nhau

Aggregation và Query Phức Tạp

// Đếm với GROUP BY
const postCounts = await db
  .select({
    authorId:  posts.authorId,
    postCount: count(posts.id),
  })
  .from(posts)
  .where(eq(posts.published, true))
  .groupBy(posts.authorId)
  .orderBy(desc(count(posts.id)));
 
// Subquery
import { sql } from 'drizzle-orm';
 
const activeAuthors = await db
  .select({
    id:    users.id,
    email: users.email,
    name:  users.name,
    publishedPostCount: sql<number>`
      (SELECT COUNT(*) FROM ${posts}
       WHERE ${posts.authorId} = ${users.id}
       AND ${posts.published} = true)
    `.as('published_post_count'),
  })
  .from(users)
  .having(sql`published_post_count > 0`);

Transaction với Drizzle

const result = await db.transaction(async (tx) => {
  const [sender] = await tx
    .select({ balance: users.balance })
    .from(users)
    .where(eq(users.id, senderId))
    .for('update'); // SELECT FOR UPDATE — khóa cấp hàng
 
  if (!sender || sender.balance < amount) {
    tx.rollback(); // Rollback tường minh
  }
 
  await tx
    .update(users)
    .set({ balance: sql`balance - ${amount}` })
    .where(eq(users.id, senderId));
 
  await tx
    .update(users)
    .set({ balance: sql`balance + ${amount}` })
    .where(eq(users.id, receiverId));
 
  return { success: true };
});

Phần 3: Tối Ưu Hóa Query

Thiết kế schema tốt và sử dụng ORM đúng cách đã đưa bạn đi xa. Nhưng các ứng dụng production cần tối ưu hóa có chủ đích.

Hiểu Query Plan

Luôn kiểm tra xem ORM của bạn sinh ra query gì. Trong PostgreSQL:

-- Chạy EXPLAIN ANALYZE để xem query plan
EXPLAIN ANALYZE
SELECT u.email, COUNT(p.id) as post_count
FROM users u
LEFT JOIN posts p ON p.author_id = u.id
GROUP BY u.id;
 
-- Tìm kiếm:
-- Seq Scan (xấu trên bảng lớn) → cần index
-- Index Scan (tốt)
-- Hash Join / Nested Loop (hiểu chi phí)

Với Prisma, bật query logging trong development:

new PrismaClient({ log: ['query'] })
// Mỗi SQL query được in ra console

Với Drizzle, nó minh bạch — bạn viết code giống SQL, bạn biết cái gì chạy.

Chiến Lược Index

Index là tối ưu hóa có tác động nhất bạn có thể thực hiện. Khi nào thêm chúng:

// Prisma: thêm index vào schema
model Post {
  // ...
  @@index([authorId])                           // Foreign key (luôn index!)
  @@index([published, publishedAt(sort: Desc)]) // Composite: lọc + sắp xếp
  @@index([title])                              // Nếu bạn tìm kiếm theo title
}
// Drizzle: định nghĩa index trong định nghĩa bảng
export const posts = pgTable('posts', {
  // định nghĩa cột...
}, (table) => ({
  authorIdx:    index('posts_author_idx').on(table.authorId),
  publishedIdx: index('posts_pub_idx').on(table.published, table.publishedAt),
  // Cho full-text search:
  titleSearchIdx: index('posts_title_search_idx')
    .on(table.title)
    .using('gin', sql`to_tsvector('english', ${table.title})`),
}));

Quy tắc ngón tay cái cho index:

  • Luôn index foreign key (Prisma và Drizzle KHÔNG tự động làm điều này)
  • Index cột dùng trong mệnh đề WHERE lọc bảng lớn
  • Dùng composite index khi bạn lọc bởi nhiều cột cùng nhau
  • Index cột dùng trong ORDER BY cho result set lớn
  • Đừng over-index — mỗi index làm chậm writes

Phân Trang: Offset vs Cursor

// ❌ Phân trang OFFSET chậm dần trên dataset lớn
// Database phải scan và bỏ qua `skip` hàng mỗi lần
const page3 = await prisma.post.findMany({
  skip: 200,   // trang 3 của 100 mỗi trang — scan 200 hàng trước
  take: 100,
  orderBy: { createdAt: 'desc' },
});
 
// ✅ Phân trang CURSOR là O(1) — luôn nhanh
// "Cho tôi các item sau cursor này"
const afterCursor = await prisma.post.findMany({
  take: 100,
  cursor: { id: lastSeenId },    // Bắt đầu từ nơi chúng ta dừng lại
  skip: 1,                        // Bỏ qua chính cursor
  orderBy: { createdAt: 'desc' },
});
 
// Phân trang cursor với Drizzle
const { lastId } = req.query;
 
const results = await db
  .select()
  .from(posts)
  .where(lastId ? sql`${posts.id} < ${lastId}` : undefined)
  .orderBy(desc(posts.createdAt))
  .limit(100);
 
const nextCursor = results.length === 100 ? results[99].id : null;

Chỉ Select Những Gì Bạn Cần

// ❌ Tải TẤT CẢ cột bao gồm trường content lớn
const posts = await prisma.post.findMany({ take: 20 });
 
// ✅ Chỉ chọn những gì trang listing cần
const posts = await prisma.post.findMany({
  take: 20,
  select: {
    id: true,
    title: true,
    publishedAt: true,
    author: { select: { name: true } },
    _count: { select: { comments: true } },
    // content: KHÔNG bao gồm — tiết kiệm băng thông trên trường text lớn
  },
});

Connection Pooling

Mỗi Node.js process duy trì một pool kết nối database. Quá ít → request xếp hàng. Quá nhiều → database bị quá tải.

// Prisma: cấu hình pool size
new PrismaClient({
  datasources: {
    db: {
      url: `${process.env.DATABASE_URL}?connection_limit=10&pool_timeout=20`,
    },
  },
});
 
// Drizzle (postgres.js): cấu hình pool
const client = postgres(process.env.DATABASE_URL!, {
  max:          10,   // Số kết nối tối đa
  idle_timeout: 20,   // Đóng kết nối nhàn rỗi sau 20 giây
  connect_timeout: 10,
});

Cho serverless/edge (Vercel, Cloudflare Workers):

Dùng connection pooler — Neon built-in pooling, PgBouncer hoặc pgBouncer của Supabase:

// Driver serverless Neon — không cần kết nối persistent
import { neon } from '@neondatabase/serverless';
 
const sql = neon(process.env.DATABASE_URL!);
const result = await sql`SELECT * FROM users WHERE id = ${userId}`;
 
// Hoặc với Drizzle:
import { drizzle } from 'drizzle-orm/neon-http';
const db = drizzle(sql, { schema });

Phần 4: Các Pattern Thực Tiễn

Repository Pattern

Tập trung hóa truy cập database giúp testing dễ hơn và giữ business logic tách khỏi query:

// src/repositories/post.repository.ts
import { prisma } from '../lib/prisma';
import type { Prisma } from '@prisma/client';
 
export class PostRepository {
  async findPublished(options: {
    cursor?: string;
    limit?: number;
    authorId?: string;
  }) {
    const { cursor, limit = 20, authorId } = options;
 
    return prisma.post.findMany({
      take: limit,
      ...(cursor && { cursor: { id: cursor }, skip: 1 }),
      where: {
        published: true,
        ...(authorId && { authorId }),
      },
      orderBy: { publishedAt: 'desc' },
      select: {
        id:          true,
        title:       true,
        publishedAt: true,
        author:      { select: { id: true, name: true } },
        _count:      { select: { comments: true } },
      },
    });
  }
 
  async findById(id: string) {
    return prisma.post.findUnique({
      where: { id },
      include: {
        author:   { select: { id: true, name: true } },
        tags:     true,
        comments: {
          orderBy: { createdAt: 'desc' },
          take: 50,
          include: { author: { select: { id: true, name: true } } },
        },
      },
    });
  }
 
  async create(data: Prisma.PostCreateInput) {
    return prisma.post.create({ data });
  }
 
  async publish(id: string) {
    return prisma.post.update({
      where: { id },
      data: { published: true, publishedAt: new Date() },
    });
  }
}
 
export const postRepository = new PostRepository();

Soft Delete

Thay vì xóa bản ghi vĩnh viễn (phá vỡ foreign key, mất audit trail):

// Thêm vào bất kỳ model nào cần soft delete
model Post {
  // ...
  deletedAt DateTime?
 
  @@index([deletedAt]) // Để lọc các bản ghi đã xóa hiệu quả
}
// Luôn lọc ra các bản ghi đã xóa
const activePosts = await prisma.post.findMany({
  where: {
    published: true,
    deletedAt: null, // ← Loại trừ soft-deleted
  },
});
 
// Soft delete
async function softDelete(id: string) {
  await prisma.post.update({
    where: { id },
    data: { deletedAt: new Date() },
  });
}
 
// Hard delete (chỉ admin, với xác nhận)
async function hardDelete(id: string) {
  await prisma.post.delete({ where: { id } });
}

Seed Database

// prisma/seed.ts
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcrypt';
 
const prisma = new PrismaClient();
 
async function main() {
  // Dùng upsert để seed là idempotent (an toàn khi chạy nhiều lần)
  const admin = await prisma.user.upsert({
    where: { email: '[email protected]' },
    update: {},
    create: {
      email:        '[email protected]',
      name:         'Admin User',
      role:         'ADMIN',
      passwordHash: await bcrypt.hash('dev-password', 10),
    },
  });
 
  await prisma.post.createMany({
    data: [
      { title: 'Bài Đầu Tiên', content: '...', published: true,  authorId: admin.id },
      { title: 'Bản Nháp',     content: '...', published: false, authorId: admin.id },
    ],
    skipDuplicates: true,
  });
 
  console.log('Seed hoàn tất');
}
 
main()
  .catch(console.error)
  .finally(() => prisma.$disconnect());
// package.json — đăng ký seed script
{
  "prisma": {
    "seed": "ts-node prisma/seed.ts"
  }
}
npx prisma db seed

Phần 5: Testing Code Database

Code database nổi tiếng là khó test. Đây là các pattern hoạt động hiệu quả.

Dùng Test Database

// jest.config.ts hoặc vitest.config.ts
export default {
  globalSetup:    './test/setup.ts',
  globalTeardown: './test/teardown.ts',
};
 
// test/setup.ts
import { execSync } from 'child_process';
 
export async function setup() {
  // Trỏ đến test database
  process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
 
  // Chạy migration trên test DB
  execSync('npx prisma migrate deploy', { stdio: 'inherit' });
}
 
// test/teardown.ts
export async function teardown() {
  // Dọn dẹp — tùy chọn nếu bạn dùng DB mới cho mỗi lần test
}

Reset Giữa Các Test

// test/helpers/db.ts
import { prisma } from '../../lib/prisma';
 
export async function cleanDatabase() {
  // Xóa theo thứ tự ngược dependency (tôn trọng foreign key)
  await prisma.$transaction([
    prisma.comment.deleteMany(),
    prisma.post.deleteMany(),
    prisma.profile.deleteMany(),
    prisma.user.deleteMany(),
  ]);
}
 
// Trong file test của bạn:
import { cleanDatabase } from '../helpers/db';
 
beforeEach(async () => {
  await cleanDatabase();
});
 
afterAll(async () => {
  await prisma.$disconnect();
});

Ví Dụ Integration Test

// test/post.repository.test.ts
import { postRepository } from '../../src/repositories/post.repository';
import { prisma } from '../../src/lib/prisma';
import { cleanDatabase } from '../helpers/db';
 
describe('PostRepository', () => {
  beforeEach(cleanDatabase);
  afterAll(() => prisma.$disconnect());
 
  describe('findPublished', () => {
    it('chỉ trả về các bài đăng đã publish', async () => {
      const user = await prisma.user.create({
        data: { email: '[email protected]', name: 'Test User' },
      });
 
      await prisma.post.createMany({
        data: [
          { title: 'Đã Publish', content: '...', published: true,  authorId: user.id, publishedAt: new Date() },
          { title: 'Bản Nháp',   content: '...', published: false, authorId: user.id },
        ],
      });
 
      const posts = await postRepository.findPublished({});
 
      expect(posts).toHaveLength(1);
      expect(posts[0].title).toBe('Đã Publish');
    });
 
    it('hỗ trợ phân trang dựa trên cursor', async () => {
      const user = await prisma.user.create({
        data: { email: '[email protected]', name: 'Test User' },
      });
 
      const created = await prisma.post.createManyAndReturn({
        data: Array.from({ length: 5 }, (_, i) => ({
          title:       `Bài ${i}`,
          content:     '...',
          published:   true,
          publishedAt: new Date(Date.now() - i * 1000),
          authorId:    user.id,
        })),
      });
 
      const firstPage = await postRepository.findPublished({ limit: 2 });
      expect(firstPage).toHaveLength(2);
 
      const secondPage = await postRepository.findPublished({
        limit:  2,
        cursor: firstPage[1].id,
      });
      expect(secondPage).toHaveLength(2);
      expect(secondPage[0].id).not.toBe(firstPage[1].id);
    });
  });
});

Tóm Tắt và Những Điểm Quan Trọng

Kết Quả Học Tập:
✅ Prisma là lựa chọn phù hợp cho hầu hết các dự án TypeScript — DX tuyệt vời, type tự động sinh, hệ sinh thái mạnh
✅ Drizzle tỏa sáng trong môi trường serverless/edge và khi bạn muốn tính minh bạch SQL không có overhead
✅ N+1 query luôn được giải quyết theo cùng cách: dùng include/join để tải relation hàng loạt, không phải từng hàng
✅ Index mọi foreign key — ORM không tự động làm điều này và FK không có index gây full table scan
✅ Cursor pagination luôn nhanh hơn offset pagination cho dataset lớn
✅ Cấu hình connection pooling quan trọng cho production — khớp pool size với max connection của DB
✅ Integration test với database test thực sự là test có giá trị nhất cho code database
✅ Repository pattern giữ query logic tập trung và business logic sạch sẽ

Tầng database là nền tảng của tính đúng đắn và hiệu suất ứng dụng của bạn. Thiết kế schema đúng, index có chủ đích, và để ORM xử lý các phần lặp đi lặp lại trong khi bạn dùng raw SQL khi cần độ chính xác.

Bước Tiếp Theo

  • TS-10: Deep Dive: Testing & DevOps — bài cuối cùng trong series TypeScript, đề cập Vitest, Playwright E2E, CI/CD với GitHub Actions và Docker deployment
  • Series DB: SQL & NoSQL roadmap sắp tới sẽ đi sâu hơn về PostgreSQL internals, MongoDB, Redis và các pattern database scaling

Đây là bài #9 trong series TypeScript Full-Stack Roadmap.

📬 Đăng ký nhận bản tin

Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.

Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.

💬 Comments

Đăng nhập để bình luận

Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.