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
| Prisma | Drizzle | Kysely | Raw SQL | |
|---|---|---|---|---|
| Type Safety | ✅ Tự động sinh | ✅ Suy luận từ schema | ✅ Cấp query | ⚠️ Thủ công |
| Bundle Size | Lớn (query engine) | Nhỏ | Nhỏ | Tối thiểu |
| Migrations | Prisma Migrate | Drizzle Kit | Thủ công/bên thứ 3 | Thủ công |
| Raw SQL | $queryRaw | template sql | Native | Native |
| Edge/Serverless | ⚠️ Cần adapter | ✅ Native | ✅ | ✅ |
| Độ khó học | Thấp | Trung bình | Trung bình | Cao |
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à .envCấ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 pushCấu trúc file migration:
prisma/
migrations/
20240120_init/
migration.sql
20240125_add-user-role/
migration.sql
schema.prismaQuy tắc production: Luôn chạy
prisma migrate deploy(không phảidev) trong CI/CD pipeline.migrate devchỉ 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 studioQuery 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 nhauAggregation 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 consoleVớ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 đề
WHERElọ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 BYcho 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 seedPhầ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.