TypeScript: Tích Hợp Database với Express.js và Prisma
typescriptexpressprismapostgresqlbackend
Trong bài trước, chúng ta đã build xong task management API với Express.js — Zod validation, layered architecture, error handling, đủ cả. Nhưng có một vấn đề: toàn bộ data nằm trong Map, restart server là mất sạch.
Giờ fix thôi. Chúng ta sẽ thay in-memory store bằng PostgreSQL qua Prisma ORM, và bạn sẽ thấy layered architecture phát huy tác dụng — chỉ cần thay repository layer. Mọi thứ còn lại giữ nguyên.
✅ Prisma ORM setup — schema, client generation, và VS Code integration
✅ Schema design — models, relations, enums, và field constraints
✅ Migrations — tạo, apply, và quản lý database changes an toàn
✅ Type-safe CRUD — thay in-memory store bằng database queries thực sự
✅ Transactions — atomic operations: hoặc thành công hết, hoặc fail hết
✅ Connection pooling — xử lý production traffic mà không cạn connections
✅ Seeding — populate database với initial hoặc test data
✅ Testing — unit và integration tests cho database layer
1. Tại Sao Prisma?
Trong bài ORM guide, chúng ta đã so sánh chi tiết Prisma, TypeORM, và Drizzle. Đây là phiên bản ngắn gọn vì sao Prisma phù hợp cho series này:
Feature
Tại Sao Quan Trọng
Auto-generated types
Query trả về typed objects — không cần tự define types
Schema-first design
Define database trong schema.prisma, generate mọi thứ từ đó
Migration system
SQL migrations generate từ schema diffs, version-controlled
IntelliSense everywhere
Autocomplete đầy đủ cho queries, filters, includes — trong VS Code
TypeScript-native
Build cho TypeScript từ đầu, không phải gắn thêm sau
Điểm mạnh thực sự: types mà Prisma generate chạy xuyên suốt codebase. Khi bạn đổi một field trong schema, TypeScript báo lỗi ở mọi chỗ cần update.
2. Project Setup
Bắt đầu từ nơi dừng lại ở Bài 3. Cài Prisma và PostgreSQL driver:
Thấy không? Layered architecture tỏa sáng — chúng ta chỉ đụng vào task.repository.ts và thêm Prisma client. Controllers, services, middleware, routes — tất cả giữ nguyên.
3. Schema Design
Thay thế file prisma/schema.prisma được generate với task management schema:
// prisma/schema.prismagenerator client { provider = "prisma-client-js"}datasource db { provider = "postgresql" url = env("DATABASE_URL")}enum TaskStatus { TODO IN_PROGRESS DONE}enum TaskPriority { LOW MEDIUM HIGH}model Task { id String @id @default(uuid()) title String @db.VarChar(255) description String? @db.Text status TaskStatus @default(TODO) priority TaskPriority @default(MEDIUM) dueDate DateTime? @map("due_date") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") tags Tag[] @@index([status]) @@index([priority]) @@index([createdAt]) @@map("tasks")}model Tag { id String @id @default(uuid()) name String @unique @db.VarChar(50) tasks Task[] @@map("tags")}
Hãy phân tích các design decisions:
Enums vs Strings
enum TaskStatus { TODO IN_PROGRESS DONE}
PostgreSQL native enums được enforce ở database level. Không có giá trị invalid nào có thể lọt vào, kể cả từ raw SQL hay application khác cùng truy cập database. Prisma generate TypeScript enums tương ứng, nên bạn có compile-time safety luôn.
Field Mapping
dueDate DateTime? @map("due_date")
@map cho phép dùng camelCase trong TypeScript nhưng giữ snake_case trong database — convention mà mỗi ecosystem mong đợi. @@map("tasks") trên model làm tương tự cho table names.
Chúng ta index các fields hay filter và sort. Không có index, mỗi query scan toàn bộ table. Với 100 rows thì chẳng sao. Với 100,000 rows thì khác hoàn toàn.
Relations
Model Tag tạo many-to-many relationship. Prisma tự xử lý join table — không cần tạo thủ công.
4. Migrations
Generate migration đầu tiên:
npx prisma migrate dev --name init
Lệnh này làm ba việc:
Tạo prisma/migrations/20260310_init/migration.sql với SQL
Apply migration vào database
Regenerate Prisma Client với updated types
Xem SQL được generate:
-- CreateEnumCREATE TYPE "TaskStatus" AS ENUM ('TODO', 'IN_PROGRESS', 'DONE');CREATE TYPE "TaskPriority" AS ENUM ('LOW', 'MEDIUM', 'HIGH');-- CreateTableCREATE TABLE "tasks" ( "id" TEXT NOT NULL, "title" VARCHAR(255) NOT NULL, "description" TEXT, "status" "TaskStatus" NOT NULL DEFAULT 'TODO', "priority" "TaskPriority" NOT NULL DEFAULT 'MEDIUM', "due_date" TIMESTAMP(3), "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP(3) NOT NULL, CONSTRAINT "tasks_pkey" PRIMARY KEY ("id"));-- CreateTableCREATE TABLE "tags" ( "id" TEXT NOT NULL, "name" VARCHAR(50) NOT NULL, CONSTRAINT "tags_pkey" PRIMARY KEY ("id"));-- CreateTable (join table)CREATE TABLE "_TagToTask" ( "A" TEXT NOT NULL, "B" TEXT NOT NULL, CONSTRAINT "_TagToTask_AB_pkey" PRIMARY KEY ("A","B"));-- CreateIndexCREATE INDEX "tasks_status_idx" ON "tasks"("status");CREATE INDEX "tasks_priority_idx" ON "tasks"("priority");CREATE INDEX "tasks_created_at_idx" ON "tasks"("created_at");CREATE UNIQUE INDEX "tags_name_key" ON "tags"("name");
Migration Workflow
Workflow cho development và production khác nhau:
Development (prisma migrate dev): Generate migration SQL, apply, regenerate client. Interactive — có thể reset database nếu cần.
Production (prisma migrate deploy): Chỉ apply pending migrations. Non-interactive, an toàn cho CI/CD. Không bao giờ generate migrations mới.
Thêm Field Mới
Khi cần thêm field sau này:
model Task { // ... existing fields completedAt DateTime? @map("completed_at")}
npx prisma migrate dev --name add-completed-at
Prisma diff schema với database và chỉ generate ALTER statement cần thiết:
ALTER TABLE "tasks" ADD COLUMN "completed_at" TIMESTAMP(3);
5. Prisma Client Singleton
Đừng bao giờ tạo nhiều Prisma Client instances. Trong development với hot reload, mỗi lần file thay đổi tạo một connection pool mới. Cạn connections nhanh lắm.
Tại sao globalThis? Trong development, module-level variables bị recreate khi hot reload. globalThis persist qua các lần reload, nên chúng ta reuse cùng Prisma instance.
Logging levels:
Development: Log queries (để debug), errors, và warnings
Production: Chỉ log errors (performance)
6. Update Repository Layer
Đây là lúc thu hoạch. Thay in-memory Map bằng Prisma queries. Interface giữ nguyên — service layer không biết và cũng chẳng cần biết là chúng ta đã đổi sang database thật.
Trước (In-Memory)
// Cũ: src/repositories/task.repository.tsconst tasks = new Map<string, Task>();export class TaskRepository { async findMany(query: ListTasksQuery) { let results = Array.from(tasks.values()); // Filter, sort, paginate thủ công... return { tasks: results, total }; } async create(data: CreateTaskInput) { const task = { id: uuidv4(), ...data, createdAt: new Date(), updatedAt: new Date() }; tasks.set(task.id, task); return task; }}
Read-only queries — không có data changes để rollback
Independent operations — các tasks không phụ thuộc nhau
9. Connection Pooling
Mỗi Prisma Client duy trì một connection pool. Điều này critical cho production — mở database connection mới tốn 20-50ms. Pooling tái sử dụng connections.
Default Behavior
Prisma dùng pool size mặc định theo công thức:
pool_size = num_physical_cpus * 2 + 1
Trên máy 4-core, đó là 9 connections. Với hầu hết applications, thế là đủ.
Cấu Hình Pool
Cho high-traffic applications, cấu hình qua connection URL:
Nếu deploy lên serverless (Vercel, AWS Lambda), mỗi function instance tạo pool riêng. Với hàng trăm functions chạy đồng thời, database connections sẽ cạn.
Dùng Prisma Accelerate hoặc external pooler như PgBouncer:
# Với PgBouncerDATABASE_URL="postgresql://user:pass@pgbouncer-host:6432/db?pgbouncer=true"
Flag pgbouncer=true báo Prisma dùng compatible query modes.
Graceful Shutdown
Luôn disconnect khi shutdown để giải phóng connections:
# Tạo test databasecreatedb taskdb_test# Set test DATABASE_URLexport DATABASE_URL="postgresql://postgres:password@localhost:5432/taskdb_test?schema=public"# Apply migrations vào test databasenpx prisma migrate deploy# Chạy testsnpm test
Best practice: Dùng database riêng cho tests. Đừng bao giờ chạy tests trên development database — deleteMany() trong setup sẽ xóa sạch data.
12. Error Handling cho Database Operations
Prisma throw các error codes cụ thể map tới meaningful HTTP responses. Thêm Prisma error handler vào middleware:
// src/middleware/error-handler.ts (updated)import type { Request, Response, NextFunction } from "express";import { Prisma } from "@prisma/client";import { AppError } from "@/utils/errors.js";export function errorHandler( err: Error, _req: Request, res: Response, _next: NextFunction,) { // Xử lý Prisma errors if (err instanceof Prisma.PrismaClientKnownRequestError) { switch (err.code) { case "P2002": { const target = (err.meta?.target as string[])?.join(", "); res.status(409).json({ error: { code: "CONFLICT", message: `Unique constraint violation on: ${target}`, }, }); return; } case "P2025": res.status(404).json({ error: { code: "NOT_FOUND", message: "Record not found", }, }); return; case "P2003": res.status(400).json({ error: { code: "FOREIGN_KEY_VIOLATION", message: "Referenced record does not exist", }, }); return; } } // Xử lý custom errors if (err instanceof AppError) { res.status(err.statusCode).json({ error: { code: err.code ?? "APP_ERROR", message: err.message, }, }); return; } // Unknown errors console.error("Unhandled error:", err); res.status(500).json({ error: { code: "INTERNAL_ERROR", message: process.env.NODE_ENV === "production" ? "An unexpected error occurred" : err.message, }, });}
Các Prisma error codes phổ biến:
Code
Ý Nghĩa
HTTP Status
P2002
Unique constraint violation
409 Conflict
P2025
Record not found
404 Not Found
P2003
Foreign key constraint failure
400 Bad Request
P2014
Required relation violation
400 Bad Request
13. Test Toàn Bộ API
Khởi động server và test bằng curl:
npm run dev
Tạo Task với Tags
curl -s -X POST http://localhost:3000/api/v1/tasks \ -H "Content-Type: application/json" \ -d '{ "title": "Integrate Prisma ORM", "description": "Replace in-memory store with PostgreSQL", "priority": "HIGH", "tags": ["backend", "database"] }' | jq