Back to blog

TypeScript: Tích Hợp Database với Express.js và Prisma

typescriptexpressprismapostgresqlbackend
TypeScript: Tích Hợp Database với Express.js và Prisma

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.

Thời gian: 2-3 giờ
Điều kiện: TypeScript: Xây Dựng REST API với Express.js

Những Gì Bạn Sẽ Học

✅ 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:

FeatureTại Sao Quan Trọng
Auto-generated typesQuery trả về typed objects — không cần tự define types
Schema-first designDefine database trong schema.prisma, generate mọi thứ từ đó
Migration systemSQL migrations generate từ schema diffs, version-controlled
IntelliSense everywhereAutocomplete đầy đủ cho queries, filters, includes — trong VS Code
TypeScript-nativeBuild 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:

npm install @prisma/client
npm install -D prisma

Khởi tạo Prisma:

npx prisma init

Lệnh này tạo:

  • prisma/schema.prisma — database schema
  • .env — với DATABASE_URL placeholder

Update .env với PostgreSQL connection string:

# .env
DATABASE_URL="postgresql://postgres:password@localhost:5432/taskdb?schema=public"

Chưa có PostgreSQL? Chạy bằng Docker:

docker run --name taskdb -e POSTGRES_PASSWORD=password -e POSTGRES_DB=taskdb -p 5432:5432 -d postgres:16

Cấu trúc project sau khi update:

src/
├── index.ts
├── app.ts
├── config/
│   └── env.ts
├── routes/
│   ├── index.ts
│   └── task.routes.ts
├── controllers/
│   └── task.controller.ts
├── services/
│   └── task.service.ts
├── repositories/
│   └── task.repository.ts      # ← Chỗ duy nhất thay đổi
├── middleware/
│   ├── error-handler.ts
│   ├── validate.ts
│   ├── request-id.ts
│   └── not-found.ts
├── schemas/
│   └── task.schema.ts
├── types/
│   └── index.ts
├── utils/
│   └── errors.ts
└── lib/
    └── prisma.ts                # ← Mới: Prisma client singleton
prisma/
├── schema.prisma                # ← Mới: Database schema
├── migrations/                  # ← Mới: Migration files
└── seed.ts                      # ← Mới: Seed script

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.prisma
generator 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.

Indexes

@@index([status])
@@index([priority])
@@index([createdAt])

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:

  1. Tạo prisma/migrations/20260310_init/migration.sql với SQL
  2. Apply migration vào database
  3. Regenerate Prisma Client với updated types

Xem SQL được generate:

-- CreateEnum
CREATE TYPE "TaskStatus" AS ENUM ('TODO', 'IN_PROGRESS', 'DONE');
CREATE TYPE "TaskPriority" AS ENUM ('LOW', 'MEDIUM', 'HIGH');
 
-- CreateTable
CREATE 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")
);
 
-- CreateTable
CREATE 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")
);
 
-- CreateIndex
CREATE 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.

// src/lib/prisma.ts
import { PrismaClient } from "@prisma/client";
 
const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};
 
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 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.ts
const 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;
  }
}

Sau (Prisma)

// src/repositories/task.repository.ts
import { prisma } from "@/lib/prisma.js";
import type { Task, Prisma } from "@prisma/client";
import type { CreateTaskInput, UpdateTaskInput, ListTasksQuery } from "@/schemas/task.schema.js";
 
export class TaskRepository {
  async findMany(query: ListTasksQuery): Promise<{ tasks: Task[]; total: number }> {
    const where: Prisma.TaskWhereInput = {};
 
    if (query.status) {
      where.status = query.status;
    }
    if (query.priority) {
      where.priority = query.priority;
    }
 
    const [tasks, total] = await Promise.all([
      prisma.task.findMany({
        where,
        orderBy: { [query.sortBy]: query.order },
        skip: (query.page - 1) * query.limit,
        take: query.limit,
        include: { tags: true },
      }),
      prisma.task.count({ where }),
    ]);
 
    return { tasks, total };
  }
 
  async findById(id: string): Promise<Task | null> {
    return prisma.task.findUnique({
      where: { id },
      include: { tags: true },
    });
  }
 
  async create(data: CreateTaskInput): Promise<Task> {
    return prisma.task.create({
      data: {
        title: data.title,
        description: data.description,
        status: data.status ?? "TODO",
        priority: data.priority ?? "MEDIUM",
        dueDate: data.dueDate,
        tags: data.tags
          ? {
              connectOrCreate: data.tags.map((tag) => ({
                where: { name: tag },
                create: { name: tag },
              })),
            }
          : undefined,
      },
      include: { tags: true },
    });
  }
 
  async update(id: string, data: UpdateTaskInput): Promise<Task | null> {
    try {
      return await prisma.task.update({
        where: { id },
        data: {
          ...data,
          dueDate: data.dueDate,
          tags: data.tags
            ? {
                set: [],
                connectOrCreate: data.tags.map((tag) => ({
                  where: { name: tag },
                  create: { name: tag },
                })),
              }
            : undefined,
        },
        include: { tags: true },
      });
    } catch (error) {
      if (
        error instanceof Error &&
        "code" in error &&
        (error as { code: string }).code === "P2025"
      ) {
        return null;
      }
      throw error;
    }
  }
 
  async delete(id: string): Promise<boolean> {
    try {
      await prisma.task.delete({ where: { id } });
      return true;
    } catch (error) {
      if (
        error instanceof Error &&
        "code" in error &&
        (error as { code: string }).code === "P2025"
      ) {
        return false;
      }
      throw error;
    }
  }
 
  async existsByTitle(title: string, excludeId?: string): Promise<boolean> {
    const count = await prisma.task.count({
      where: {
        title,
        ...(excludeId ? { id: { not: excludeId } } : {}),
      },
    });
    return count > 0;
  }
}

Gì Đổi, Gì Không

LayerĐổi?Lý Do
Repository✅ CóSwap Map operations thành Prisma queries
Service❌ KhôngVẫn gọi cùng repository methods
Controller❌ KhôngVẫn gọi cùng service methods
Routes❌ KhôngCùng endpoints, cùng middleware
Schemas❌ KhôngZod vẫn validate incoming requests
Error handling❌ KhôngCùng custom errors, cùng middleware

Đây chính xác là lý do chúng ta build layered architecture. Database chỉ là implementation detail nằm sau repository interface.

Các Prisma Patterns Quan Trọng

Parallel queries với Promise.all:

const [tasks, total] = await Promise.all([
  prisma.task.findMany({ where, orderBy, skip, take }),
  prisma.task.count({ where }),
]);

Hai queries chạy song song thay vì tuần tự. Cùng kết quả, nhanh gấp đôi.

connectOrCreate cho tags:

tags: {
  connectOrCreate: data.tags.map((tag) => ({
    where: { name: tag },
    create: { name: tag },
  })),
}

Nếu tag tồn tại, link nó. Nếu chưa, tạo rồi link. Một operation xử lý cả hai trường hợp.

Error code P2025:

if ((error as { code: string }).code === "P2025") {
  return null; // Record not found
}

Prisma throw P2025 khi update hay delete nhắm vào record không tồn tại. Chúng ta catch nó và return null để service layer xử lý bằng domain errors.


7. Update Zod Schemas

Zod schemas cần update nhẹ để match Prisma enums và thêm tag support:

// src/schemas/task.schema.ts
import { z } from "zod";
 
// Match Prisma enum values (uppercase)
export const taskStatusEnum = z.enum(["TODO", "IN_PROGRESS", "DONE"]);
export const taskPriorityEnum = z.enum(["LOW", "MEDIUM", "HIGH"]);
 
export const createTaskSchema = z.object({
  body: z.object({
    title: z.string().min(1).max(255),
    description: z.string().optional(),
    status: taskStatusEnum.optional(),
    priority: taskPriorityEnum.optional(),
    dueDate: z.coerce.date().optional(),
    tags: z.array(z.string().min(1).max(50)).optional(),
  }),
});
 
export const updateTaskSchema = z.object({
  params: z.object({
    id: z.string().uuid(),
  }),
  body: z.object({
    title: z.string().min(1).max(255).optional(),
    description: z.string().optional(),
    status: taskStatusEnum.optional(),
    priority: taskPriorityEnum.optional(),
    dueDate: z.coerce.date().optional(),
    tags: z.array(z.string().min(1).max(50)).optional(),
  }),
});
 
export const getTaskSchema = z.object({
  params: z.object({
    id: z.string().uuid(),
  }),
});
 
export const listTasksSchema = z.object({
  query: z.object({
    page: z.coerce.number().int().positive().default(1),
    limit: z.coerce.number().int().positive().max(100).default(20),
    status: taskStatusEnum.optional(),
    priority: taskPriorityEnum.optional(),
    sortBy: z.enum(["createdAt", "title", "priority", "dueDate"]).default("createdAt"),
    order: z.enum(["asc", "desc"]).default("desc"),
  }),
});
 
export type CreateTaskInput = z.infer<typeof createTaskSchema>["body"];
export type UpdateTaskInput = z.infer<typeof updateTaskSchema>["body"];
export type ListTasksQuery = z.infer<typeof listTasksSchema>["query"];

Thay đổi chính so với version trước:

  • Enum values viết hoa (TODO thay vì todo) để match PostgreSQL enum conventions
  • Thêm dueDate field với z.coerce.date() — chấp nhận cả date strings và Date objects
  • Thêm tags array — strings map tới Tag model
  • Thêm dueDate vào danh sách sortable fields

8. Transactions

Một số operations cần atomic — hoặc thành công hết, hoặc fail hết. Prisma làm transactions rất dễ.

Ví Dụ: Bulk Task Assignment

Tưởng tượng cần assign nhiều tasks sang status "IN_PROGRESS" với tag mới:

// src/repositories/task.repository.ts (thêm method này)
async bulkUpdateStatus(
  ids: string[],
  status: string,
  tag?: string,
): Promise<number> {
  const result = await prisma.$transaction(async (tx) => {
    // Đầu tiên, verify tất cả tasks tồn tại
    const existingCount = await tx.task.count({
      where: { id: { in: ids } },
    });
 
    if (existingCount !== ids.length) {
      throw new Error(
        `Expected ${ids.length} tasks, found ${existingCount}`,
      );
    }
 
    // Update tất cả tasks
    const updated = await tx.task.updateMany({
      where: { id: { in: ids } },
      data: {
        status: status as "TODO" | "IN_PROGRESS" | "DONE",
        updatedAt: new Date(),
      },
    });
 
    // Thêm tag nếu có
    if (tag) {
      const tagRecord = await tx.tag.upsert({
        where: { name: tag },
        update: {},
        create: { name: tag },
      });
 
      for (const id of ids) {
        await tx.task.update({
          where: { id },
          data: {
            tags: { connect: { id: tagRecord.id } },
          },
        });
      }
    }
 
    return updated.count;
  });
 
  return result;
}

Cách $transaction hoạt động:

  1. Prisma mở database transaction
  2. Tất cả operations trong callback dùng tx client (không phải global prisma)
  3. Nếu bất kỳ operation nào throw, toàn bộ transaction rollback
  4. Nếu callback hoàn thành, mọi thứ commit

Khi nào dùng transactions:

  • Batch operations — update 50 tasks, all or nothing
  • Multi-table changes — tạo task VÀ thêm tags atomically
  • Business rules cần consistency — check + update phải atomic
  • Financial operations — trừ tài khoản này, cộng tài khoản kia

Khi nào không cần:

  • Single create, update, hay delete — đã atomic sẵn
  • 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:

# .env
DATABASE_URL="postgresql://user:pass@host:5432/db?schema=public&connection_limit=20&pool_timeout=10"
ParameterDefaultMô Tả
connection_limitnum_cpus * 2 + 1Max connections trong pool
pool_timeout10 (giây)Chờ bao lâu để có free connection

Serverless Environments

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 PgBouncer
DATABASE_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:

// src/index.ts
import { prisma } from "@/lib/prisma.js";
 
process.on("SIGINT", async () => {
  await prisma.$disconnect();
  process.exit(0);
});
 
process.on("SIGTERM", async () => {
  await prisma.$disconnect();
  process.exit(0);
});

10. Seeding

Seeds populate database với data ban đầu. Cần thiết cho:

  • Onboarding developer mới (clone, seed, bắt đầu code)
  • Reset development databases
  • Tạo test scenarios
// prisma/seed.ts
import { PrismaClient } from "@prisma/client";
 
const prisma = new PrismaClient();
 
async function main() {
  console.log("🌱 Seeding database...");
 
  // Xóa data cũ
  await prisma.task.deleteMany();
  await prisma.tag.deleteMany();
 
  // Tạo tags
  const tags = await Promise.all(
    ["backend", "frontend", "devops", "bug", "feature"].map((name) =>
      prisma.tag.create({ data: { name } }),
    ),
  );
 
  // Tạo tasks
  const tasks = [
    {
      title: "Set up CI/CD pipeline",
      description: "Configure GitHub Actions for automated testing and deployment",
      status: "TODO" as const,
      priority: "HIGH" as const,
      tags: { connect: [{ id: tags[2]!.id }, { id: tags[4]!.id }] },
    },
    {
      title: "Fix login redirect bug",
      description: "Users are redirected to 404 after successful login",
      status: "IN_PROGRESS" as const,
      priority: "HIGH" as const,
      tags: { connect: [{ id: tags[1]!.id }, { id: tags[3]!.id }] },
    },
    {
      title: "Add dark mode support",
      description: "Implement theme switching with system preference detection",
      status: "TODO" as const,
      priority: "MEDIUM" as const,
      tags: { connect: [{ id: tags[1]!.id }, { id: tags[4]!.id }] },
    },
    {
      title: "Optimize database queries",
      description: "Add indexes and review N+1 query patterns",
      status: "TODO" as const,
      priority: "MEDIUM" as const,
      tags: { connect: [{ id: tags[0]!.id }] },
    },
    {
      title: "Write API documentation",
      description: "Document all endpoints with request/response examples",
      status: "DONE" as const,
      priority: "LOW" as const,
      tags: { connect: [{ id: tags[0]!.id }] },
    },
  ];
 
  for (const task of tasks) {
    await prisma.task.create({ data: task });
  }
 
  console.log(`✅ Created ${tags.length} tags and ${tasks.length} tasks`);
}
 
main()
  .catch((e) => {
    console.error("❌ Seed failed:", e);
    process.exit(1);
  })
  .finally(async () => {
    await prisma.$disconnect();
  });

Thêm seed command vào package.json:

{
  "prisma": {
    "seed": "tsx prisma/seed.ts"
  }
}

Chạy:

npx prisma db seed

Mẹo: prisma migrate reset tự động chạy seed script sau khi reset. Tiện cho việc bắt đầu lại từ đầu trong development.


11. Testing Database Code

Testing database code cần database thật — mock Prisma thì mất hết ý nghĩa. Chúng ta sẽ dùng test database riêng với proper setup và teardown.

Cài Test Dependencies

npm install -D vitest

Cấu Hình Vitest

// vitest.config.ts
import { defineConfig } from "vitest/config";
import path from "path";
 
export default defineConfig({
  test: {
    globals: true,
    environment: "node",
    setupFiles: ["./src/__tests__/setup.ts"],
    include: ["src/**/*.test.ts"],
  },
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "./src"),
    },
  },
});

Test Setup

// src/__tests__/setup.ts
import { prisma } from "@/lib/prisma.js";
import { beforeEach, afterAll } from "vitest";
 
// Xóa database trước mỗi test
beforeEach(async () => {
  await prisma.task.deleteMany();
  await prisma.tag.deleteMany();
});
 
// Disconnect sau khi tất cả tests chạy xong
afterAll(async () => {
  await prisma.$disconnect();
});

Repository Tests

// src/__tests__/repositories/task.repository.test.ts
import { describe, it, expect } from "vitest";
import { TaskRepository } from "@/repositories/task.repository.js";
 
const repository = new TaskRepository();
 
describe("TaskRepository", () => {
  describe("create", () => {
    it("should create a task with default values", async () => {
      const task = await repository.create({
        title: "Test task",
      });
 
      expect(task.id).toBeDefined();
      expect(task.title).toBe("Test task");
      expect(task.status).toBe("TODO");
      expect(task.priority).toBe("MEDIUM");
      expect(task.createdAt).toBeInstanceOf(Date);
    });
 
    it("should create a task with tags", async () => {
      const task = await repository.create({
        title: "Tagged task",
        tags: ["backend", "feature"],
      });
 
      expect(task.tags).toHaveLength(2);
      expect(task.tags.map((t) => t.name)).toContain("backend");
      expect(task.tags.map((t) => t.name)).toContain("feature");
    });
 
    it("should reuse existing tags", async () => {
      await repository.create({
        title: "First task",
        tags: ["backend"],
      });
 
      const second = await repository.create({
        title: "Second task",
        tags: ["backend"],
      });
 
      // Cùng tag được reuse, không duplicate
      expect(second.tags).toHaveLength(1);
      expect(second.tags[0]!.name).toBe("backend");
    });
  });
 
  describe("findMany", () => {
    it("should filter by status", async () => {
      await repository.create({ title: "Todo task", status: "TODO" });
      await repository.create({ title: "Done task", status: "DONE" });
 
      const result = await repository.findMany({
        page: 1,
        limit: 20,
        status: "TODO",
        sortBy: "createdAt",
        order: "desc",
      });
 
      expect(result.tasks).toHaveLength(1);
      expect(result.tasks[0]!.title).toBe("Todo task");
      expect(result.total).toBe(1);
    });
 
    it("should paginate results", async () => {
      for (let i = 0; i < 5; i++) {
        await repository.create({ title: `Task ${i}` });
      }
 
      const page1 = await repository.findMany({
        page: 1,
        limit: 2,
        sortBy: "createdAt",
        order: "asc",
      });
 
      const page2 = await repository.findMany({
        page: 2,
        limit: 2,
        sortBy: "createdAt",
        order: "asc",
      });
 
      expect(page1.tasks).toHaveLength(2);
      expect(page2.tasks).toHaveLength(2);
      expect(page1.total).toBe(5);
      expect(page1.tasks[0]!.title).not.toBe(page2.tasks[0]!.title);
    });
  });
 
  describe("update", () => {
    it("should update task fields", async () => {
      const task = await repository.create({ title: "Original" });
      const updated = await repository.update(task.id, {
        title: "Updated",
        status: "DONE",
      });
 
      expect(updated!.title).toBe("Updated");
      expect(updated!.status).toBe("DONE");
      expect(updated!.updatedAt.getTime()).toBeGreaterThan(
        task.updatedAt.getTime(),
      );
    });
 
    it("should return null for non-existent task", async () => {
      const result = await repository.update(
        "00000000-0000-0000-0000-000000000000",
        { title: "Nope" },
      );
      expect(result).toBeNull();
    });
 
    it("should replace tags on update", async () => {
      const task = await repository.create({
        title: "Tagged",
        tags: ["backend", "bug"],
      });
 
      const updated = await repository.update(task.id, {
        tags: ["frontend", "feature"],
      });
 
      expect(updated!.tags).toHaveLength(2);
      expect(updated!.tags.map((t) => t.name)).toContain("frontend");
      expect(updated!.tags.map((t) => t.name)).not.toContain("backend");
    });
  });
 
  describe("delete", () => {
    it("should delete existing task", async () => {
      const task = await repository.create({ title: "To delete" });
      const result = await repository.delete(task.id);
 
      expect(result).toBe(true);
 
      const found = await repository.findById(task.id);
      expect(found).toBeNull();
    });
 
    it("should return false for non-existent task", async () => {
      const result = await repository.delete(
        "00000000-0000-0000-0000-000000000000",
      );
      expect(result).toBe(false);
    });
  });
 
  describe("existsByTitle", () => {
    it("should find existing title", async () => {
      await repository.create({ title: "Unique title" });
      const exists = await repository.existsByTitle("Unique title");
      expect(exists).toBe(true);
    });
 
    it("should exclude specific id", async () => {
      const task = await repository.create({ title: "My title" });
      const exists = await repository.existsByTitle("My title", task.id);
      expect(exists).toBe(false);
    });
  });
});

Chạy Tests

Thêm test scripts vào package.json:

{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest"
  }
}

Setup test database:

# Tạo test database
createdb taskdb_test
 
# Set test DATABASE_URL
export DATABASE_URL="postgresql://postgres:password@localhost:5432/taskdb_test?schema=public"
 
# Apply migrations vào test database
npx prisma migrate deploy
 
# Chạy tests
npm 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ĩaHTTP Status
P2002Unique constraint violation409 Conflict
P2025Record not found404 Not Found
P2003Foreign key constraint failure400 Bad Request
P2014Required relation violation400 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
{
  "data": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "title": "Integrate Prisma ORM",
    "description": "Replace in-memory store with PostgreSQL",
    "status": "TODO",
    "priority": "HIGH",
    "dueDate": null,
    "createdAt": "2026-03-10T10:00:00.000Z",
    "updatedAt": "2026-03-10T10:00:00.000Z",
    "tags": [
      { "id": "...", "name": "backend" },
      { "id": "...", "name": "database" }
    ]
  }
}

List Tasks với Filters

curl -s "http://localhost:3000/api/v1/tasks?status=TODO&priority=HIGH&sortBy=createdAt&order=desc" | jq

Update Task

curl -s -X PATCH http://localhost:3000/api/v1/tasks/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
  -H "Content-Type: application/json" \
  -d '{
    "status": "IN_PROGRESS",
    "tags": ["backend", "database", "prisma"]
  }' | jq

Unique Constraint Error

curl -s -X POST http://localhost:3000/api/v1/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Integrate Prisma ORM"}' | jq
{
  "error": {
    "code": "CONFLICT",
    "message": "Task with title 'Integrate Prisma ORM' already exists"
  }
}

14. Tổng Quan Architecture

Đây là cách tất cả layers phối hợp sau khi tích hợp database:

LayerTrách NhiệmThay Đổi Gì
MiddlewareParse body, validate, log, handle errorsThêm Prisma error handling
ControllerHTTP ↔ service translationKhông
ServiceBusiness rules, orchestrationKhông
RepositoryData access abstractionMap → Prisma queries
Prisma ClientQuery building, connection poolingLayer mới
PostgreSQLData storage, constraints, indexesMới

Tổng Kết

Chủ ĐềNhững Gì Đã Làm
Prisma SetupSchema-first design với enums, relations, indexes
MigrationsGenerate, apply, và quản lý database changes với version control
Repository SwapThay in-memory Map bằng Prisma queries — zero changes cho service/controller
TransactionsAtomic operations với $transaction cho bulk updates
Connection PoolingSingleton pattern, pool configuration, serverless considerations
SeedingReproducible development data với prisma db seed
TestingIntegration tests với real database, proper setup/teardown
Error HandlingPrisma error codes map tới HTTP responses

Key takeaways:

  • Layered architecture đáng đồng tiền bát gạo — swap data layer chỉ cần đổi đúng một file
  • Prisma types chạy xuyên codebase — đổi schema là TypeScript báo lỗi khắp nơi
  • Migrations được version-controlled — database schema tiến hóa cùng code
  • Test với real database — mock ORM calls che giấu bugs thật
  • Connection pooling là critical — đặc biệt trong serverless environments

Đây là bài cuối trong series TypeScript Full-Stack Development. Giờ bạn đã có production-grade TypeScript backend với:

Series: TypeScript Full-Stack Development
Bài trước: TypeScript: Xây Dựng REST API với Express.js

📬 Đă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.