Làm chủ Drizzle ORM trong TypeScript: Schema Database Type-Safe, Migrations, và Relational Queries với PostgreSQL

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Bối cảnh & Tại sao chọn Drizzle ORM?

Nếu bạn đã từng làm việc với TypeScript backend, chắc hẳn bạn đã gặp phải vấn đề quen thuộc: tầng database và các TypeScript type của bạn tồn tại trong hai thế giới riêng biệt. Viết interface, viết Prisma model, viết Zod schema — và mỗi khi database thay đổi, bạn phải lùng sục cập nhật ở ba chỗ khác nhau. Nói “mệt mỏi” vẫn còn nhẹ.

Drizzle ORM giải quyết hoàn toàn vấn đề này. Không có ngôn ngữ schema riêng biệt, không có runtime nặng nề — schema của bạn chính là TypeScript của bạn. Định nghĩa một table và bạn nhận được các type miễn phí. Không cần bước sinh code, không có sự không khớp giữa những gì DB biết và những gì TypeScript biết.

Tôi đã chạy cái này trên production cho một dự án SaaS xử lý khoảng 150k query mỗi ngày. Sáu tháng sau: không có bất ngờ về type lúc runtime, output migration có thể dự đoán được, và các query cảm giác như đang viết TypeScript thay vì các chuỗi SQL bọc trong template literal.

Với các team cần có tầm nhìn rõ ràng về SQL mà không muốn từ bỏ type safety, Drizzle chiếm một vị trí mà không có công cụ nào khác lấp đầy được. Chuyển xuống raw SQL bất cứ khi nào bạn cần — ORM sẽ không cản bạn. Sự linh hoạt đó thực sự hiếm có.

Đây là những điểm nổi bật:

  • Schema-first với TypeScript — các định nghĩa table của bạn chỉ là các TypeScript object thông thường
  • drizzle-kit — công cụ CLI sinh và chạy SQL migration từ schema diff của bạn
  • Relational query API — khai báo relation một lần, sau đó query dữ liệu lồng nhau mà không cần viết JOIN thủ công
  • Runtime gọn nhẹ — không có phép thuật ẩn, không có query ngầm, hiệu năng có thể dự đoán được

Cài đặt

Bắt đầu với một dự án TypeScript mới, hoặc tích hợp vào dự án hiện có. Bạn cần Node.js 18+ và một instance PostgreSQL đang chạy.

Cài đặt dependencies

npm install drizzle-orm pg
npm install -D drizzle-kit @types/pg tsx typescript
  • drizzle-orm — thư viện chính
  • pg — driver PostgreSQL (Drizzle hỗ trợ nhiều driver)
  • drizzle-kit — CLI quản lý migration
  • tsx — cho phép chạy trực tiếp các file TypeScript trong quá trình phát triển

Khởi tạo TypeScript

npx tsc --init

Đảm bảo tsconfig.json của bạn có ít nhất các cài đặt sau:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "CommonJS",
    "strict": true,
    "outDir": "./dist"
  }
}

Cấu hình

Bước 1 — Định nghĩa kết nối database

Tạo src/db/index.ts để chứa kết nối và export instance db:

import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import * as schema from './schema';

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

export const db = drizzle(pool, { schema });

Lưu connection string trong file .env:

DATABASE_URL=postgresql://user:password@localhost:5432/mydb

Bước 2 — Viết schema

Mở (hoặc tạo) src/db/schema.ts. Các định nghĩa table là TypeScript thuần túy — không có decorator, không có cú pháp đặc biệt:

import { pgTable, serial, text, varchar, integer, timestamp } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';

export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  name: varchar('name', { length: 100 }).notNull(),
  email: text('email').notNull().unique(),
  createdAt: timestamp('created_at').defaultNow(),
});

export const posts = pgTable('posts', {
  id: serial('id').primaryKey(),
  title: varchar('title', { length: 255 }).notNull(),
  body: text('body').notNull(),
  authorId: integer('author_id').notNull().references(() => users.id),
  createdAt: timestamp('created_at').defaultNow(),
});

Tiếp theo là khai báo relation. Khai báo chúng để Drizzle biết cách join các table mà không cần bạn chỉ định lại mỗi lần:

export const usersRelations = relations(users, ({ many }) => ({
  posts: many(posts),
}));

export const postsRelations = relations(posts, ({ one }) => ({
  author: one(users, {
    fields: [posts.authorId],
    references: [users.id],
  }),
}));

Không có chuỗi ma thuật, không cần annotation type thủ công. TypeScript tự suy ra toàn bộ hình dạng dữ liệu chỉ từ các định nghĩa cột.

Bước 3 — Cấu hình drizzle-kit

Thêm file drizzle.config.ts ở thư mục gốc của dự án:

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;

Thêm các script vào package.json:

{
  "scripts": {
    "db:generate": "drizzle-kit generate",
    "db:migrate": "drizzle-kit migrate",
    "db:studio": "drizzle-kit studio",
    "db:push": "drizzle-kit push"
  }
}

Bước 4 — Sinh và chạy migration

Khi schema đã sẵn sàng, hãy sinh SQL migration:

npm run db:generate

Drizzle so sánh schema của bạn với trạng thái đã biết trước đó và ghi một file SQL vào thư mục ./drizzle/. Hãy mở ra xem. Bạn sẽ thấy SQL rõ ràng, dễ đọc — không có abstraction nào che giấu những gì thực sự đang xảy ra.

Sau đó áp dụng:

npm run db:migrate

Trong quá trình phát triển, db:push bỏ qua bước tạo file migration và áp dụng thay đổi trực tiếp lên database. Hữu ích khi bạn cần lặp nhanh mà chưa cần lưu lịch sử migration.

Bước 5 — Viết query type-safe

Với schema đã được kết nối, mọi query đều mang đầy đủ type inference của TypeScript. Tạo file src/index.ts:

import 'dotenv/config';
import { db } from './db';
import { users, posts } from './db/schema';
import { eq } from 'drizzle-orm';

// Thêm một user mới
const [newUser] = await db.insert(users).values({
  name: 'Alice',
  email: '[email protected]',
}).returning();

console.log(newUser.id); // TypeScript biết đây là kiểu number

// Thêm một bài viết liên kết với user vừa tạo
await db.insert(posts).values({
  title: 'Bài viết đầu tiên của tôi',
  body: 'Xin chào từ Drizzle ORM',
  authorId: newUser.id,
});

// Query với relation (không cần JOIN thủ công)
const result = await db.query.users.findFirst({
  where: eq(users.id, newUser.id),
  with: {
    posts: true,
  },
});

console.log(result?.posts); // TypeScript tự suy ra toàn bộ kiểu dữ liệu

Chạy thử:

npx tsx src/index.ts

API with trong db.query.* sử dụng các relation bạn đã khai báo trước đó. Phía sau hậu trường, Drizzle sinh ra một JOIN hiệu quả — bạn nhận lại các object lồng nhau thay vì các hàng dữ liệu phẳng.

Bước 6 — Lọc và chọn các cột cụ thể

Lấy toàn bộ cột hiếm khi là điều bạn muốn. API select của Drizzle cũng làm cho partial select trở nên type-safe:

const titles = await db
  .select({ title: posts.title, author: users.name })
  .from(posts)
  .innerJoin(users, eq(posts.authorId, users.id))
  .where(eq(users.name, 'Alice'));

// titles được định kiểu là { title: string; author: string }[]

Không cần ép kiểu, không cần as any. Kiểu trả về tự động thu hẹp chính xác về những gì bạn đã chọn.

Kiểm tra & Giám sát

Drizzle Studio — trình khám phá DB trực quan

Drizzle đi kèm một giao diện web có thể khởi chạy ở local:

npm run db:studio

Mở https://local.drizzle.studio. Duyệt các hàng dữ liệu, kiểm tra table, chạy query thủ công — rất tiện trong quá trình phát triển để xác nhận migration đã được áp dụng đúng.

Kiểm tra lịch sử migration

Các migration đã áp dụng được theo dõi trong bảng __drizzle_migrations. Query bảng này để xem những gì đã được áp dụng:

psql $DATABASE_URL -c "SELECT * FROM __drizzle_migrations ORDER BY created_at DESC LIMIT 10;"

Mỗi file trong ./drizzle/ sẽ có một entry tương ứng sau khi chạy. Nếu không thấy trong bảng? Nghĩa là chưa được áp dụng.

Ghi log SQL query trong quá trình phát triển

Truyền logger vào drizzle() để xem SQL thực tế đang được thực thi:

export const db = drizzle(pool, {
  schema,
  logger: true, // in toàn bộ SQL ra stdout
});

Tắt tính năng này trên production. Đây là cách nhanh nhất để phát hiện sớm các vấn đề N+1 — nếu bạn thấy cùng một query lặp đi lặp lại trong một vòng lặp, bạn cần đưa nó ra ngoài hoặc xử lý theo lô.

Xử lý thay đổi schema an toàn

Cập nhật kiểu cột hoặc thêm constraint? Chạy lại db:generate. Drizzle so sánh với snapshot trước và ghi file migration tăng dần.

Hãy đọc SQL được sinh ra trước khi chạy db:migrate — đặc biệt khi xóa cột hoặc thay đổi kiểu dữ liệu có thể gây mất dữ liệu:

# Xem trước những gì đã thay đổi trước khi áp dụng
npm run db:generate
cat drizzle/0001_*.sql  # đọc SQL vừa được sinh ra
npm run db:migrate      # áp dụng sau khi đã xem xét

Với các lần triển khai production, hãy chạy migration trong pipeline CI/CD của bạn trước khi khởi động ứng dụng — không phải khi ứng dụng khởi động. Cách này giữ cho việc triển khai có tính nguyên tử và tránh race condition khi scale theo chiều ngang.

Script kiểm tra nhanh

Một health check đơn giản để xác nhận kết nối DB và schema đang đồng bộ:

// src/db/health.ts
import { db } from '.';
import { sql } from 'drizzle-orm';

export async function checkDb() {
  const result = await db.execute(sql`SELECT 1 AS ok`);
  console.log('Kết nối DB thành công:', result.rows[0]);
}

Chạy sau mỗi lần triển khai để xác nhận kết nối trước khi phục vụ traffic.

Các bước tiếp theo

Khi setup này đã chạy ổn định, các bước tiếp theo rõ ràng là thêm transaction (db.transaction()), tinh chỉnh kích thước connection pool phù hợp với tải thực tế của bạn, và khám phá prepared statement của Drizzle cho các query tần suất cao. Tài liệu chính thức đề cập đến tất cả những điều này một cách chi tiết.

Điều khiến tôi ấn tượng nhất sau khi trải qua setup này: khoảng cách giữa database và TypeScript thực sự thu hẹp lại đáng kể. Thay đổi một cột trong schema và TypeScript lập tức đánh dấu mọi query phụ thuộc vào nó — trước khi bạn chạm đến database. Chỉ riêng điều đó đã giúp phát hiện nhiều bug mà với cách tiếp cận truyền thống chỉ có thể lộ ra trên production.

Share: