Skip to content

บทที่ 4 — Database (TypeORM + Prisma)

← บทที่ 3 | สารบัญ | บทที่ 5: Authentication + JWT

⚠️ บทนี้ทดสอบกับ TypeORM 0.3.x / Prisma 6.x ณ มิ.ย. 2026 — Prisma 6 เป็น major version ปัจจุบัน เริ่มใช้ "rust-free query engine" (query engine ที่ไม่พึ่ง Rust binary แยกต่างหากอีกต่อไป) เป็น default มากขึ้น และ generator command บางตัวเปลี่ยนชื่อจาก v5 ถ้าโปรเจกต์เก่ายังอยู่ v5 ให้ตรวจ Prisma upgrade guide ก่อน upgrade

ส่วน TypeORM 0.3.x ตอนนี้อยู่ในโหมด maintenance (ออก release ช้า) — ฝั่ง Drizzle ORM กำลังมาแรงในสาย TS-native + performance ถ้าเริ่มโปรเจกต์ใหม่อยากได้ speed สูงสุดลองพิจารณาดู

NestJS ไม่มี ORM (Object-Relational Mapping — ตัวแปลงระหว่าง object ในโค้ดกับตารางในฐานข้อมูล ให้เราเขียนโค้ดแทนการเขียน SQL เอง) ในตัว — รู้แค่วิธี "ใส่ ORM ของ ecosystem (ระบบนิเวศ/กลุ่ม library + เครื่องมือรอบ ๆ) Node เข้าใน DI (Dependency Injection — กลไกฉีด dependency เข้า class ของ Nest, ทบทวนบทที่ 1)" บทนี้สอนสองตัวที่นิยมที่สุด: TypeORM และ Prisma พร้อมเปรียบเทียบ


1. เลือก ORM ตัวไหน — Decision Matrix

💡 มือใหม่ข้ามตารางนี้ได้ในรอบแรก — ถ้ายังไม่เคยเห็นโค้ด TypeORM/Prisma จริงมาก่อน แนะนำข้ามไปลองโค้ดใน "2. TypeORM — Setup" ก่อน แล้วค่อยย้อนกลับมาอ่านตารางเปรียบเทียบนี้ทีหลัง จะเข้าใจง่ายกว่าเห็นตัวอย่างจริงแล้ว

แง่มุมTypeORMPrismaDrizzleMikroORM
Schema definitionTS class + decoratorDSL (schema.prisma)TS codeTS class + decorator
MigrationCLI สร้างจาก entity diffCLI สร้างจาก schema diffdrizzle-kitCLI
Type safetyกลางสูงมาก (generate client)สูง (TS native)สูง
Active Record + Repositoryทั้งสองRepository onlyQuery builderIdentity Map (Unit of Work)
Performanceกลางกลาง-สูงสูงสุดกลาง
สำหรับ relations ซับซ้อนดีดีต้องเขียน join เองดีมาก (Unit of Work)
Communityใหญ่ที่สุดใหญ่ โตเร็วกำลังโตกลาง

ศัพท์ในตาราง:

  • DX (Developer Experience) = ประสบการณ์ของนักพัฒนา — เครื่องมือใช้ง่าย/error อ่านรู้เรื่อง/autocomplete ดีแค่ไหน
  • DSL (Domain-Specific Language) = ภาษาเฉพาะงาน — Prisma มี schema.prisma เป็นไฟล์ของตัวเอง (ไม่ใช่ TS)
  • Active Record = pattern ที่ entity object มี method .save()/.delete() ในตัว — เรียกจาก instance ได้เลย
  • Repository = pattern ที่แยก "ตัวจัดการ DB" (repository) ออกจาก entity — เรียก repo.save(entity) แทน
  • Identity Map = ORM จำว่าโหลด entity ตัวไหนมาแล้ว → ไม่ดึงซ้ำ
  • Unit of Work = รูปแบบที่ ORM จดทุกการเปลี่ยนแปลงไว้ก่อน แล้วค่อยบันทึกลง DB ทีเดียวตอน flush/commit (แทนที่จะยิงทันทีทุกบรรทัด)

คำแนะนำ:

  • โปรเจกต์ใหม่ + ทีมไม่มาก → Prisma (DX ดีสุด, error message ชัด)
  • ของเดิมเป็น TypeORM แล้ว → อยู่กับ TypeORM
  • ต้องการ performance สูงสุด + เขียน SQL ใกล้ ๆ → Drizzle
  • ใช้ Unit of Work / มาจากสาย JPA/Hibernate (JPA = Java Persistence API, Hibernate = ORM ยอดนิยมฝั่ง Java) → MikroORM

บทนี้จะลงลึกที่ TypeORM และ Prisma เพราะเป็นสองตัวที่เจอบ่อยที่สุด


2. TypeORM — Setup

bash
npm i @nestjs/typeorm typeorm pg  # postgres driver

Connection ใน root

typescript
// app.module.ts
import { TypeOrmModule } from "@nestjs/typeorm";

@Module({
  imports: [
    TypeOrmModule.forRootAsync({
      useFactory: (cfg: ConfigService) => ({
        type: "postgres",
        host: cfg.get("DB_HOST"),
        port: cfg.get<number>("DB_PORT"),
        username: cfg.get("DB_USER"),
        password: cfg.get("DB_PASS"),
        database: cfg.get("DB_NAME"),
        autoLoadEntities: true,
        // synchronize: true → DROP/CREATE schema ตาม entity ใน dev เท่านั้น
        synchronize: false,
        logging: ["error", "warn", "schema"],
      }),
      inject: [ConfigService],
    }),
    UsersModule,
  ],
})
export class AppModule {}

⚠️ Pitfall ที่ทำให้ data หาย: เปิด synchronize: true ใน production — TypeORM จะ DROP column ที่ entity ไม่มี เปิดเฉพาะตอนทดลอง dev เท่านั้น ทุกอย่างที่จริงจังต้องใช้ migration

Entity

typescript
// users/entities/user.entity.ts
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";

@Entity("users") // ชื่อ table
export class User {
  @PrimaryGeneratedColumn("uuid")
  id: string;

  @Column({ length: 100 })
  name: string;

  @Column({ unique: true })
  email: string;

  @Column({ select: false }) // ไม่ select กลับ default (กัน leak password)
  password: string;

  @CreateDateColumn() // Nest/TypeORM ใส่วันเวลาให้อัตโนมัติตอน insert แถวใหม่ ไม่ต้องเขียนเอง
  createdAt: Date;

  @UpdateDateColumn() // อัปเดตวันเวลาให้อัตโนมัติทุกครั้งที่แถวนี้ถูกแก้ไข
  updatedAt: Date;
}

Module + Repository

typescript
// users/users.module.ts
@Module({
  imports: [TypeOrmModule.forFeature([User])], // register entity ให้ scope module นี้
  providers: [UsersService],
  controllers: [UsersController],
  exports: [UsersService],
})
export class UsersModule {}

// users/users.service.ts
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { hash } from "bcrypt"; // npm i bcrypt @types/bcrypt — async API ที่ไม่บล็อก event loop

@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User) private readonly repo: Repository<User>,
  ) {}

  findAll() { return this.repo.find(); }
  findOne(id: string) { return this.repo.findOneBy({ id }); }
  async create(dto: CreateUserDto) {
    // ตัวอย่างนี้ใช้ bcrypt เพื่อความเรียบง่าย — production แนะนำ argon2 (ดูบท 5: Authentication)
    // (import { hash } from "bcrypt" อยู่บนสุดของไฟล์แล้ว ตามที่เห็นด้านบน)
    // ⚠️ ลำดับ key สำคัญ: ต้องวาง `password: await hash(...)` ไว้ "หลัง" ...dto เสมอ
    //    เพื่อให้ค่า hash แล้วทับ password ดิบจาก dto — ถ้าสลับลำดับ หรือ dto มี field อื่นชื่อชนกันทีหลัง จะรั่วค่าดิบแบบเงียบ ๆ
    const u = this.repo.create({ ...dto, password: await hash(dto.password, 12) });
    return this.repo.save(u);
  }
  update(id: string, dto: UpdateUserDto) {
    // หมายเหตุ: repo.update() ข้าม optimistic-lock check ของ @VersionColumn แบบเงียบ ๆ (ดู §4.2)
    return this.repo.update(id, dto);
  }
  remove(id: string) { return this.repo.delete(id); }
}

Relations

typescript
@Entity("posts")
export class Post {
  @PrimaryGeneratedColumn("uuid") id: string;
  @Column() title: string;

  @ManyToOne(() => User, (u) => u.posts, { onDelete: "CASCADE" })
  @JoinColumn({ name: "author_id" })
  author: User;
}

@Entity("users")
export class User {
  // ...
  @OneToMany(() => Post, (p) => p.author)
  posts: Post[];
}

Query relation:

typescript
// ระบุชัดเจนว่าจะโหลด posts มาด้วย (เทียบกับ Prisma คือ `include: { posts: true }`)
this.repo.find({ relations: { posts: true } });
// หรือเขียนแบบ array string ก็ได้
this.repo.find({ relations: ["posts"] });
// หรือ QueryBuilder ที่ flexible กว่า
this.repo.createQueryBuilder("u")
  .leftJoinAndSelect("u.posts", "p")
  .where("u.id = :id", { id })
  .getOne();

⚠️ Pitfall (จุดพลาดที่เจอบ่อย): eager: true (โหลดข้อมูลที่เชื่อมโยงมาด้วยทุกครั้งอัตโนมัติ) ใน relation = N+1 ทันที (N+1 = ดึงรายการ N ตัวแล้ววนยิง query ย่อยอีก N ครั้ง รวม 1+N query — หนัก DB โดยไม่จำเป็น, §6 อธิบายเต็ม) ทุก find จะ join (เชื่อมตาราง) ตลอด → ปิด eager ไว้แล้วระบุ relations: ['posts'] (หรือ { posts: true }) ตรงจุดที่ใช้จริงเท่านั้น — Prisma ก็เป็นแบบเดียวกัน ต้องใส่ include: { posts: true } ชัด ๆ ที่ query

⚠️ ระวัง cascade: true บน relation ด้วย — ทำให้ repo.save(user) ลบ/บันทึก posts ทับโดยอัตโนมัติ; ผสมกับ onDelete: 'CASCADE' จะลบ posts ลูกตามไปเมื่อ user ถูกลบ ตั้งใจเมื่อต้องการเท่านั้น

Migrations

💡 TypeORM CLI 0.3.x ต้อง register ts-node ก่อน (เช่น "typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js" ใน package.json) ไม่งั้น CLI โหลด .ts ของ data-source / entity ไม่ได้

bash
# สร้าง migration จาก diff (ของ entity vs DB จริง)
npm run typeorm migration:generate -- ./src/migrations/CreateUsers -d ./data-source.ts

# run
npm run typeorm migration:run -- -d ./data-source.ts

# revert
npm run typeorm migration:revert -- -d ./data-source.ts

data-source.ts:

typescript
import { DataSource } from "typeorm";
import { User } from "./src/users/entities/user.entity";
import { Post } from "./src/posts/entities/post.entity";

export default new DataSource({
  type: "postgres",
  url: process.env.DATABASE_URL,
  // 👍 แนะนำ: import class ตรง ๆ (type-safe + boot เร็วกว่า glob)
  entities: [User, Post],
  migrations: ["src/migrations/*.ts", "dist/migrations/*.js"],
});

⚠️ Pitfall ที่กัด junior บ่อย: ถ้าจะใช้ glob แทน — ต้องครอบทั้ง .ts (dev) และ .js (หลัง build → dist/) เช่น entities: ['src/**/*.entity.ts', 'dist/**/*.entity.js']; ใส่แต่ .ts อย่างเดียว → dev ทำงาน แต่ production (รัน node dist/main.js) หา entity ไม่เจอ → TypeORM throw error ดังกล่องด้านล่าง

🇹🇭 No metadata for "User" was found = "หา metadata ของ entity User ไม่เจอ" — TypeORM ไม่ได้โหลด class User เข้า DataSource (ไม่อยู่ใน entities, path glob ไม่ match, หรือ build แล้วลืม .js); ทางแก้ที่ปลอดภัยที่สุดคือ import class ตรง ๆ อย่างในตัวอย่างข้างบน — TS compiler จะฟ้องตั้งแต่ build ถ้าลืม

Transactions

typescript
import { DataSource } from "typeorm";
import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common";

@Injectable()
export class TransferService {
  constructor(private readonly ds: DataSource) {}

  async transfer(fromId: string, toId: string, amount: number) {
    // ⚠️ ใช้ SERIALIZABLE + pessimistic_write lock — กัน race condition (สถานการณ์ที่ 2 transaction
    //    แย่งกันแก้แถวเดียวกันพร้อมกัน → ผลลัพธ์เพี้ยน) ใน financial transaction
    // 📖 รายละเอียดของ SERIALIZABLE / pessimistic_write อยู่ใน §4 "Transaction Isolation + Concurrency Control"
    //    มือใหม่ข้ามรายละเอียดตรงนี้ไปก่อนได้ — ตอนนี้แค่จำว่า "ล็อกเพื่อกัน 2 คนโอนพร้อมกัน" ก็พอ แล้วค่อยกลับมาอ่าน §4 ทีหลัง
    return this.ds.transaction("SERIALIZABLE", async (mgr) => {
      const from = await mgr.findOne(Account, {
        where: { id: fromId },
        lock: { mode: "pessimistic_write" }, // SELECT ... FOR UPDATE
      });
      const to = await mgr.findOne(Account, {
        where: { id: toId },
        lock: { mode: "pessimistic_write" },
      });
      if (!from || !to) throw new NotFoundException();
      if (from.balance < amount) throw new BadRequestException("insufficient");
      from.balance -= amount;
      to.balance += amount;
      await mgr.save([from, to]);
    });
  }
}

DataSource.transaction รัน callback ใน transaction — error = rollback อัตโนมัติ

⚠️ ตัวอย่างนี้แสดงเวอร์ชัน "ถูกต้องสำหรับ production" ถ้าตัดทั้ง "SERIALIZABLE" และ lock: pessimistic_write ออก โค้ดจะยังคอมไพล์และรันได้ปกติ — แต่ผลลัพธ์จะผิดในงานจริง

เหตุผล: ถ้า 2 คำสั่ง transfer วิ่งพร้อมกันบนบัญชีเดียวกัน ทั้งคู่จะอ่านยอดเดิมพร้อมกัน (เพราะ Read Committed คือ isolation level default ของ Postgres) แล้วต่างคนต่างเขียนทับ ผลคือเกิด lost update (การแก้ไขของอีกฝ่ายหายไปเงียบ ๆ) → ยอดเงินอาจติดลบได้โดยไม่มี error เตือน ดูหัวข้อ Transaction Isolation + Concurrency Control ด้านล่างสำหรับเหตุผลเต็มและทางเลือก optimistic locking

💡 หมายเหตุ trade-off: ใส่ทั้ง SERIALIZABLE + pessimistic_write คือ "belt-and-suspenders" (กันสองชั้น) — เลือกอย่างใดอย่างหนึ่งก็พอ:

  • SERIALIZABLE อย่างเดียว + retry on serialization failure (เหมาะถ้า conflict น้อย)
  • READ COMMITTED + pessimistic_write lock (เหมาะถ้า hot row contention สูง, ไม่อยาก retry) ใส่ทั้งคู่เพิ่ม overhead และโอกาส deadlock — บทนี้ใส่ทั้งคู่เพื่อสอน 2 concept พร้อมกัน

3. Prisma — Setup

bash
npm i -D prisma             # CLI (dev)
npm i @prisma/client        # client runtime
npx prisma init             # สร้าง prisma/schema.prisma + .env (Prisma 6.x)

Schema

prisma
// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        String   @id @default(uuid())
  name      String
  email     String   @unique
  password  String
  posts     Post[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Post {
  id       String @id @default(uuid())
  title    String
  body     String
  authorId String
  author   User   @relation(fields: [authorId], references: [id], onDelete: Cascade)
}

Generate + Migrate

bash
npx prisma migrate dev --name init  # สร้าง migration + apply + regen client
npx prisma generate                  # regen หลังแก้ schema

Module + Service

Prisma ไม่มี official @nestjs/prisma — มี 2 ทางเลือก:

  1. nestjs-prisma (community package) — pre-built module, ไม่ต้องเขียน wrapper เอง เหมาะกับโปรเจกต์ที่อยาก get-started เร็ว
  2. เขียน wrapper เอง (วิธีที่บทนี้สอน) — control เต็ม + ลด dependency บุคคลที่สาม + เข้าใจ lifecycle ของ Prisma client ดีกว่า

บทนี้เลือกวิธีที่ 2 เพื่อให้เห็นกลไก connect/disconnect และ DI registration ชัดเจน:

typescript
// prisma/prisma.service.ts
import { Injectable, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
import { PrismaClient } from "@prisma/client";

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
  async onModuleInit() { await this.$connect(); }
  async onModuleDestroy() { await this.$disconnect(); }
}

// prisma/prisma.module.ts
@Global()
@Module({
  providers: [PrismaService],
  exports: [PrismaService],
})
export class PrismaModule {}

ใช้งาน

typescript
import { Injectable } from "@nestjs/common";
import { hashSync } from "bcrypt";

@Injectable()
export class UsersService {
  constructor(private readonly prisma: PrismaService) {}

  findAll() {
    return this.prisma.user.findMany({
      orderBy: { createdAt: "desc" },
    });
  }

  findOne(id: string) {
    return this.prisma.user.findUnique({
      where: { id },
      include: { posts: true },
    });
  }

  create(dto: CreateUserDto) {
    // ต้อง import sync version: `import { hashSync } from "bcrypt";`
    // ⚠️ hashSync บล็อก event loop ทั้งช่วงที่ hash ทำงาน (cost factor 12 ใช้เวลาพอควร)
    //    ตัวอย่างนี้ใช้ sync เพื่อให้สั้น — production ควรใช้ async `await hash(...)` แบบตัวอย่าง TypeORM ด้านบน กับ argon2 ดูบทที่ 5
    return this.prisma.user.create({
      data: { ...dto, password: hashSync(dto.password, 12) },
    });
  }

  update(id: string, dto: UpdateUserDto) {
    return this.prisma.user.update({ where: { id }, data: dto });
  }

  remove(id: string) {
    return this.prisma.user.delete({ where: { id } });
  }
}

Type safety สุดยอด — autocomplete ทุก field, ทุก relation Prisma รู้หมด

Transactions ใน Prisma

Interactive transaction (เหมือน TypeORM):

typescript
import { Prisma } from "@prisma/client"; // namespace `Prisma` มี enum / type ที่ generate มา

await this.prisma.$transaction(
  async (tx) => {
    await tx.account.update({ where: { id: fromId }, data: { balance: { decrement: amount } } });
    await tx.account.update({ where: { id: toId }, data: { balance: { increment: amount } } });
  },
  {
    // กัน lost update — ดูหัวข้อ "Transaction Isolation + Concurrency Control"
    isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
    timeout: 5000, // ยกเลิกถ้า lock ค้างเกิน 5 วินาที
  },
);

🚀 โซนขั้นสูง — ข้ามได้ (สำหรับมือใหม่)

⚠️ ถ้าใช้ PgBouncer ใน transaction pooling mode ดู §5.1 ก่อน สรุปเป็นข้อ ๆ:

  1. Prisma interactive transaction (callback $transaction) ต้องพึ่ง "session-level state" — พูดง่าย ๆ คือค่าที่ผูกติดกับการเชื่อมต่อฐานข้อมูลครั้งเดียวตลอดทั้งช่วง ไม่ใช่แค่ query เดียว ๆ
  2. pooler บางโหมด "ไม่รองรับ" การผูกค่าแบบนี้ → connection พังกลางคันได้
  3. ถ้าใช้ PgBouncer transaction mode (ปล่อย connection คืน pool ทุกครั้งหลัง commit) ต้องเพิ่ม ?pgbouncer=true ต่อท้าย DATABASE_URL และอ่านข้อจำกัดของ Prisma ให้ครบก่อนใช้จริง

🇹🇭 PgBouncer = connection pooler ภายนอกที่นั่งกลางระหว่าง app กับ Postgres ช่วยรีไซเคิล connection ให้ app เปิดได้เยอะกว่า max_connections ของ DB · Prisma Accelerate = pooler+cache ของ Prisma เอง เหมาะกับ serverless แทนการตั้ง PgBouncer ของตัวเอง

💡 Prisma ไม่มี explicit pessimistic lock — ต้องใช้ $queryRaw กับ SELECT ... FOR UPDATE ตรง ๆ หรือพึ่ง Serializable isolation + retry on conflict (P2034) ดูตัวอย่างใน section ถัดไป

Batch transaction (atomic, แต่ไม่มี logic ระหว่างกัน):

typescript
// ⚠️ ตัวอย่างนี้ใช้ prisma.log เพื่อสื่อไอเดีย "หลาย operation ใน transaction เดียว" เท่านั้น
//    ถ้าจะรันจริงต้องเพิ่ม model Log ใน schema.prisma ก่อน (ในบทนี้มีแค่ User กับ Post)
await this.prisma.$transaction([
  this.prisma.user.create({ data: dto }),
  this.prisma.post.create({ data: { title: "welcome", body: "...", authorId: dto.id } }),
]);

Soft Delete + Middleware

Prisma มี $extends API:

typescript
const prisma = new PrismaClient().$extends({
  query: {
    user: {
      async findMany({ args, query }) {
        args.where = { ...args.where, deletedAt: null };
        return query(args);
      },
    },
  },
});

⚠️ Caveat: $extends query hook ไม่ทำงานใน $transaction callback โดย default (Prisma รัน raw client ภายใน) — soft delete อาจถูก bypass ในระหว่าง transaction → ต้องตรวจ where เองหรือใช้ DB trigger เป็นชั้นป้องกันสุดท้าย

Raw query

typescript
// ✅ ปลอดภัย — Prisma แปลง ${email} เป็น parameter placeholder ($1) อัตโนมัติ
this.prisma.$queryRaw<User[]>`SELECT * FROM users WHERE email = ${email}`;
// execute (UPDATE/DELETE)
this.prisma.$executeRaw`UPDATE users SET active = false WHERE id = ${id}`;

⚠️ อันตราย — SQL injection: ถ้าใช้ $queryRawUnsafe / $executeRawUnsafe หรือ query() ของ TypeORM แล้วต่อ string ตรง ๆ → injection เต็ม ๆ

typescript
// ❌ ห้ามทำ
this.prisma.$queryRawUnsafe(`SELECT * FROM users WHERE email = '${email}'`);
dataSource.query(`SELECT * FROM users WHERE id = ${id}`); // TypeORM ก็เช่นกัน

// ✅ ถูก — TypeORM ส่ง parameter array
dataSource.query("SELECT * FROM users WHERE id = $1", [id]);
// หรือ QueryBuilder ที่ binding param
repo.createQueryBuilder("u").where("u.id = :id").setParameters({ id }).getOne();

4. Transaction Isolation + Concurrency Control

🚀 โซนขั้นสูง — ข้ามได้

ส่วนนี้เกี่ยวกับ DB internal ขั้นสูง (isolation level, lost update, phantom read, deadlock) — มือใหม่ที่เพิ่งเริ่ม CRUD ไม่จำเป็นต้องเข้าใจหมดในรอบแรก เข้าใจแค่ "ใช้ prisma.$transaction(async ...) แล้ว rollback อัตโนมัติ" ก็พอ ค่อยกลับมาอ่านตอนทำงาน financial / ระบบนับสต็อก

🇹🇭 ศัพท์ที่จะเจอ:

  • Isolation level = ระดับความเข้มงวดของ transaction ในการ "ไม่ให้คนอื่นเห็นการเปลี่ยนแปลงที่ยังไม่ commit"
  • Read Committed = อ่านได้เฉพาะข้อมูลที่ commit แล้ว (default Postgres) — เร็วแต่ไม่กัน lost update
  • Repeatable Read = ภายใน transaction เดียว อ่านซ้ำเห็นเหมือนเดิม
  • Serializable = เข้มสุด เสมือนรันทีละ transaction ไม่พร้อมกัน — ถ้าชนกัน DB จะ abort แล้วให้ retry
  • Lost update = เคสที่ 2 transaction อ่านยอดเดิม แล้วต่างคนต่างเขียนทับ → การแก้ของคนแรกหายไป
  • Phantom read = อ่านชุดผลลัพธ์ครั้งที่ 2 ในทรานแซกชันเดียวเจอแถวใหม่โผล่มา

ปัญหาที่ default isolation แก้ไม่ได้ — ตัวอย่าง transfer ใน §2 ถ้าใช้ default Read Committed:

text
เวลา    Tx A (โอน 100)              Tx B (โอน 80)
----    ------------------          ------------------
T1      SELECT balance FROM acc     (เห็น 100)
T2                                  SELECT balance FROM acc (เห็น 100)
T3      UPDATE balance = 0          (100 - 100)
T4                                  UPDATE balance = 20     (100 - 80)
        COMMIT                      COMMIT
ผล: ยอดเหลือ 20 แทนที่ควรเหลือ -80 (รวมโอน 180 จาก 100) → lost update

ทางแก้มี 2 แนว:

4.1 Pessimistic Lock (แนะนำสำหรับ hot row เช่นบัญชีเงิน)

ใช้ SELECT ... FOR UPDATE — ตัวที่ล็อกก่อน ตัวที่มาทีหลังต้องรอ → serialize เอง

TypeORM:

typescript
const from = await mgr.findOne(Account, {
  where: { id: fromId },
  lock: { mode: "pessimistic_write" },
});

Prisma (raw):

typescript
const [from] = await tx.$queryRaw<Account[]>`
  SELECT * FROM accounts WHERE id = ${fromId} FOR UPDATE
`;

✅ เข้าใจง่าย ผลคาดเดาได้ · ❌ deadlock ถ้าล็อกหลายแถวสลับลำดับ → ล็อกแถวเรียงตาม id เสมอ

4.2 Optimistic Concurrency (เหมาะกับงานที่ conflict น้อย)

ใส่ version column — update เช็คว่า version ยังเดิม → ถ้าใครชน รีทรายงานเอง

typescript
import { Entity, PrimaryColumn, Column, VersionColumn } from "typeorm";
import { OptimisticLockVersionMismatchError } from "typeorm";

@Entity()
export class Account {
  @PrimaryColumn() id: string;
  @Column() balance: number;
  @VersionColumn() version: number; // TypeORM auto-increment ตอน save
}

// ใช้ผ่าน save() ของ entity ที่ load มาก่อน เท่านั้น
async function transfer(id: string, delta: number) {
  const repo = ds.getRepository(Account);
  const acc = await repo.findOneByOrFail({ id }); // โหลด → version ติดมาด้วย
  acc.balance += delta;
  try {
    await repo.save(acc); // TypeORM ใส่ WHERE version = ? ให้อัตโนมัติ
  } catch (e) {
    if (e instanceof OptimisticLockVersionMismatchError) {
      // ใครก็แก้แทรกไป → reload + retry หรือคืน 409 Conflict
    }
    throw e;
  }
}

⚠️ ข้อจำกัดสำคัญของ @VersionColumn:

  • ทำงานเฉพาะกับ repository.save(entity) ที่ entity load มาแล้ว เท่านั้น
  • repository.update(id, dto), QueryBuilder.update(), bulk update — bypass version check เงียบ ๆ ไม่ throw error
  • ตัวอย่างใน §2 ที่ใช้ this.repo.update(id, dto) จะไม่มี optimistic lock แม้จะใส่ @VersionColumn ไว้
  • ต้อง wrap save() ใน try/catch จับ OptimisticLockVersionMismatchError เสมอ
  • ห้ามผสม save() ที่มี version กับ update() partial บน entity เดียวกัน เพราะ update() จะไม่ bump version → save ครั้งหน้าเห็น version ตรง แต่ข้อมูลเปลี่ยนแล้ว
    • ตัวอย่างให้เห็นภาพ: entity เริ่มที่ version = 1 → มีคนเรียก repo.update(id, { balance: 500 }) (ข้อมูลเปลี่ยนจริง แต่ version ยังเป็น 1 เพราะ update() ไม่ bump ให้) → คนถัดไป save() entity ที่ตัวเองโหลดมาตอน version ยังเป็น 1 → ระบบเห็นว่า version ตรง (ยัง 1) เลย "ยอมให้ save" ทั้งที่ balance จริงถูกคนแรกเปลี่ยนไปแล้ว → ทับข้อมูลกันแบบไม่มี error เตือน

หรือใน Prisma + isolation Serializable — DB จะ throw P2034 (serialization failure) → catch + retry:

typescript
async function withRetry<T>(fn: () => Promise<T>, max = 3): Promise<T> {
  for (let i = 0; i < max; i++) {
    try { return await fn(); }
    catch (e: any) {
      if (e.code === "P2034" && i < max - 1) {
        // ⚠️ exponential backoff + jitter — กัน "thundering herd" ที่ทุก client retry พร้อมกันแล้วชนซ้ำ
        const base = 50 * 2 ** i;                     // 50ms, 100ms, 200ms, ...
        const jitter = Math.random() * base;          // สุ่มเพิ่ม 0–base ms
        await new Promise((r) => setTimeout(r, base + jitter));
        continue; // serialization conflict → retry
      }
      throw e;
    }
  }
  throw new Error("unreachable");
}

4.3 Isolation Level — สรุปสั้น

LevelPostgres default?กัน lost update?กัน phantom read?overhead
Read Committedต่ำ
Repeatable Read✅ (Postgres)บางส่วนกลาง
Serializableสูง (serialization failure ต้อง retry)

แนวทางเลือกแบบสรุป:

  • งาน financial / inventory ที่ห้ามผิด → Serializable + retry หรือ Read Committed + pessimistic lock
  • งานทั่วไป (CRUD ไม่มี contention) → Read Committed พอ
  • รายงาน analytic ที่ต้องการ snapshot consistent → Repeatable Read

💡 เทียบกับ MySQL: default ของ MySQL InnoDB คือ Repeatable Read (ไม่ใช่ Read Committed เหมือน Postgres) — และมี gap lock เพิ่มเติม pitfall ไม่เหมือนกัน ตรวจ DB ที่ใช้ก่อนเลือก isolation


5. Connection Pooling — สำคัญในทุก ORM

Database มี connection limit (Postgres default = 100) — ถ้า app spawn connection ไม่จำกัด → DB ตาย

TypeORM:

typescript
TypeOrmModule.forRoot({
  // ...
  extra: { max: 20 }, // ส่งต่อ pg pool option
})

Prisma:

bash
# ตั้งใน DATABASE_URL
DATABASE_URL="postgresql://user:pass@localhost/db?connection_limit=20"

Pool sizing — เริ่มจากค่ากลาง แล้ว tune จากของจริง:

จุดเริ่มที่แนะนำ: ~10 connection ต่อ app instance สำหรับ I/O-bound API ทั่วไป แล้ว monitor + ปรับ — เป็น mental anchor ไม่ใช่กฎตายตัว

🚀 โซนขั้นสูง — ข้ามได้: ย่อหน้าถัดไปพูดถึง container CPU limit, num_physical_cpus, สูตร Prisma — รายละเอียดสำหรับ dev ที่ tune production บน Kubernetes/Docker; มือใหม่จำแค่ "เริ่มที่ ~10 connection ต่อ instance แล้ว monitor" ก็พอ ข้ามช่วงนี้ไปได้เลย

💡 Prisma default ใช้สูตร num_physical_cpus * 2 + 1 (Prisma docs) ซึ่งบน container ที่ CPU limit เพี้ยน หรือ workload เป็น I/O bound แท้ ๆ ก็อาจไม่เหมาะ → ตัวเลข 10 ด้านบนเป็นจุดเริ่มแบบ I/O-wait mental model แล้วค่อยขยับจาก metric จริง (ไม่ใช่ตัวเลข official)

⚠️ ตัวเลขในย่อหน้านี้เป็นค่ากลางที่ใช้ได้ในกรณีทั่วไป (rule of thumb ของผู้เขียน) ไม่ใช่ benchmark ที่มี source — ใช้เป็นจุดเริ่มเท่านั้น

🇹🇭 num_physical_cpus = จำนวน CPU core จริงของเครื่อง (ไม่นับ hyperthread) · container CPU limit = ค่าจำกัด CPU ที่ตั้งใน Docker/K8s ซึ่งมักไม่เท่ากับ core ของ host → สูตรอัตโนมัติเลยเพี้ยน

sql
-- ดูว่าใช้ไปกี่ตัว + มี idle in transaction (ค้างนาน) ไหม
SELECT state, count(*)
FROM pg_stat_activity
WHERE datname = 'mydb'
GROUP BY state;
  • ถ้า idle เยอะ → pool ใหญ่เกิน → ลด
  • ถ้าเห็น wait_event = ClientRead แล้ว latency พุ่ง + app เห็น "pool exhausted" → ใหญ่ไม่พอ → เพิ่ม แต่อย่าลืมว่ารวมทุก replica ห้ามเกิน max_connections
  • ถ้า idle in transaction เยอะ → app มี transaction leak ไม่ใช่ pool ปัญหา

🇹🇭 wait_event = ClientRead = backend ของ Postgres กำลังรอ "อ่านคำสั่งจาก client" — ถ้าตัวเลขนี้สูงพร้อมกับ "pool exhausted" แปลว่า connection ถูกจองค้างไว้ทั้งที่ไม่มีคำสั่งใหม่ → ขยาย pool หรือไปแก้ leak · max_connections = ค่ากำหนดสูงสุดของ Postgres ทั้งคลัสเตอร์ (default 100)

💡 สูตรเก่า (CPU cores * 2) + spindle_count มาจาก PostgreSQL wiki ยุค HDD (spindle = ก้านอ่านดิสก์ในฮาร์ดดิสก์แบบจานหมุน สมัยนั้นดิสก์อ่านพร้อมกันได้จำกัดตามจำนวนหัวอ่าน ยิ่งมี spindle เยอะยิ่งรองรับ concurrent I/O ได้มาก) — SSD/NVMe ไม่มี spindle แบบนี้แล้ว, container ที่ CPU limit ไม่เท่า host cores ก็ทำสูตรเพี้ยน → ใช้แค่เป็น mental anchor ไม่ใช่กฎ

🚀 ถ้าคุณ deploy บน serverless (ข้ามได้ถ้ารันบน VM/container ปกติ)

⚠️ Pitfall: Prisma + serverless (Vercel / AWS Lambda) → ทุก cold start (= function ตื่นจากศูนย์เมื่อนานๆ ถูกเรียก) สร้าง connection ใหม่ → DB เต็มเร็ว

  • ทางเลือก 1: ใช้ Prisma Accelerate (managed pooler + edge cache ของ Prisma เอง) — ตั้งค่าใน DATABASE_URL ที่ Prisma ออกให้ ง่ายสุดสำหรับ serverless
  • ทางเลือก 2: ตั้ง PgBouncer ใน transaction mode + ใส่ ?pgbouncer=true ในของ DATABASE_URL
  • ทางเลือก 3: ใช้ direct connection แต่จำกัด connection_limit=1-3 ต่อ instance + พึ่ง Postgres max_connections สูง (ไม่แนะนำเมื่อ scale)

5.1 statement_timeout — กัน query ค้างฆ่า DB

ตั้ง timeout ระดับ session ทุกครั้งที่ต่อ DB — query ที่ค้างเกินจะถูก kill อัตโนมัติ ไม่กิน connection ตลอดกาล:

TypeORM:

typescript
TypeOrmModule.forRoot({
  // ...
  extra: {
    max: 10,
    statement_timeout: 5000,           // 5s ต่อ statement
    idle_in_transaction_session_timeout: 10000, // 10s ใน idle tx
  },
})

Prisma: ตั้งใน DB-level (Postgres):

sql
ALTER ROLE app_user SET statement_timeout = '5s';

5.2 Read Replica Routing (write/read split)

อ่านมากกว่าเขียน 10:1 ขึ้นไป → split read query ไปอีก instance:

TypeORM:

typescript
TypeOrmModule.forRoot({
  type: "postgres",
  // 📌 หมายเหตุคำศัพท์: TypeORM 0.3.x ยังใช้ key `master`/`slaves` ใน config (เป็น public API)
  //    แม้คำเหล่านี้จะ deprecated ในภาษาสากลแล้ว (industry นิยม primary/replica) — รอ TypeORM rename ในรุ่นถัดไป
  replication: {
    master: { host: "primary.db", username: "...", password: "...", database: "..." },
    slaves: [
      { host: "replica1.db", username: "...", password: "...", database: "..." },
      { host: "replica2.db", username: "...", password: "...", database: "..." },
    ],
  },
})
// findOne/find → slave อัตโนมัติ; save/update → master
// บังคับ master: queryRunner.connect().then(qr => qr.query(..., [], qr.master))

Prisma: ใช้ extension @prisma/extension-read-replicas:

typescript
import { PrismaClient } from "@prisma/client";
import { readReplicas } from "@prisma/extension-read-replicas";

const prisma = new PrismaClient().$extends(
  readReplicas({ url: process.env.DATABASE_REPLICA_URL! }),
);

⚠️ Replication lag (ความหน่วงของการ replicate) — replica ตามหลัง master อยู่ราว 10–200 ms เพราะการคัดลอกข้อมูลข้ามเครื่องไม่ใช่ทันที → ถ้า app เขียนลง master เสร็จแล้วอ่านจาก replica ทันที อาจ "ไม่เห็น" data ที่เพิ่งบันทึก (write-then-read race)

  • ทางแก้: บังคับ query นั้น ๆ ให้ใช้ master/primary
  • หรือใช้ Postgres synchronous_commit = remote_apply (รอจน replica apply เสร็จก่อนยืนยัน commit — ช้าลงแต่ consistent)

6. N+1 Query Problem

โค้ดที่ดูปกติ:

typescript
const users = await prisma.user.findMany();
for (const u of users) {
  const posts = await prisma.post.findMany({ where: { authorId: u.id } });
  console.log(u.name, posts.length);
}
// → 1 + N queries (1 user query + N post queries)

ทางแก้ — eager load:

typescript
const users = await prisma.user.findMany({ include: { posts: true } });
// → 2 queries (Prisma optimize ให้)

TypeORM:

typescript
this.userRepo.find({ relations: { posts: true } });

หรือใช้ DataLoader สำหรับ GraphQL (บทที่ 7)


7. Indexing + Query Performance

ออกแบบ index จาก query pattern จริง:

prisma
model User {
  id        String   @id
  email     String   @unique
  status    String
  createdAt DateTime @default(now())

  @@index([status, createdAt(sort: Desc)]) // composite index — sort: Desc เสถียรตั้งแต่ Prisma 5 แล้ว ไม่ต้องเปิด previewFeatures
  @@index([email]) // ซ้ำกับ @unique แต่ระบุ explicit ก็ได้
}

TypeORM:

typescript
import { Entity, Column, Index } from "typeorm";

@Entity()
@Index(["status", "createdAt"])
export class User {
  @Index({ unique: true })
  @Column()
  email: string;
}

ดู query plan:

sql
EXPLAIN ANALYZE SELECT * FROM users WHERE status = 'active' ORDER BY created_at DESC LIMIT 20;

💡 ดูบท Database ของหนังสือนี้ (database/04) สำหรับ indexing เชิงลึก


8. Repository Pattern (เลือกใช้)

Service ที่ inject PrismaService/Repository<User> ตรง ๆ มี coupling สูง — บางคนแยก:

typescript
// users/users.repository.ts
export interface UsersRepository {
  findById(id: string): Promise<User | null>;
  create(data: CreateUserDto): Promise<User>;
}

export const USERS_REPO = Symbol("USERS_REPO");

@Injectable()
export class PrismaUsersRepository implements UsersRepository {
  constructor(private readonly prisma: PrismaService) {}
  findById(id: string) { return this.prisma.user.findUnique({ where: { id } }); }
  create(data: CreateUserDto) { return this.prisma.user.create({ data }); }
}

// module
{ provide: USERS_REPO, useClass: PrismaUsersRepository }

// service
constructor(@Inject(USERS_REPO) private repo: UsersRepository) {}

ประโยชน์: เปลี่ยน ORM ได้ไม่กระทบ service / mock ง่ายในเทส ข้อเสีย: ขั้นตอนเพิ่ม

ใช้เมื่อไหร่: ทีมใหญ่ที่อยากแยก domain logic (ตรรกะธุรกิจหลัก) ออกจาก persistence (ส่วนเก็บข้อมูลลง DB) ชัดเจน — แนวคิดสาย DDD (Domain-Driven Design = วิธีออกแบบที่ยึดเรื่องราวธุรกิจจริงเป็นศูนย์กลาง) ข้ามได้เมื่อไหร่: โปรเจกต์เล็ก, prototype (ตัวต้นแบบที่จะเขียนทิ้ง), CRUD ตรง ๆ ไม่มี logic ซับซ้อน


9. Migration Workflow ใน Production

Prisma:

bash
# dev: สร้าง migration จาก schema diff
npx prisma migrate dev --name add_user_status

# CI/production: apply migration ที่ commit แล้ว ไม่ generate ใหม่
npx prisma migrate deploy

TypeORM:

bash
# dev
npm run typeorm migration:generate -- ./src/migrations/AddUserStatus

# production
npm run typeorm migration:run

⚠️ ห้าม run migration ใน startup hook ของ Nest โดยตรง — ทำให้ rolling deploy เสี่ยง (pod เก่ายังรันโค้ดเดิม, pod ใหม่ migrate schema → โค้ดเก่าใช้ schema ใหม่ไม่ได้) แยก migration job ออกเป็นขั้นตอนก่อน rollout

💡 Expand-contract pattern (สำหรับ schema change ที่ต้อง backward-compatible ระหว่าง rolling deploy):

  1. Expand — เพิ่ม column ใหม่/ตารางใหม่ ที่ code เดิมยัง ignore ได้ (เช่น nullable)
  2. Migrate code — deploy code ใหม่ที่เขียน/อ่านทั้ง column เก่าและใหม่
  3. Contract — เมื่อ code เก่าถูกลบหมดแล้ว ค่อยลบ column เก่าใน migration ถัดไป ลำดับนี้ทำให้ทั้ง pod เก่าและใหม่ทำงานบน schema เดียวกันได้ตลอด rollout

10. Multi-tenancy ใน ORM

3 strategies:

A) Schema per tenant — PG schema แยก:

typescript
// ⚠️ ตัวอย่างต่อไปนี้ทำผิด 2 จุด — อย่า copy ตรง ๆ:
// 1. `tenant_schema` ใน scope จริงต้องมาจาก request context (เช่น req.tenant)
// 2. `$queryRaw` template tag จะ parameterize ${...} เป็น $1 — แต่ SET search_path
//    ไม่รับ parameter (identifier ไม่ใช่ value) → คำสั่งจะพังหรือถูก quote ผิด
// ทางที่ถูก: validate ชื่อ schema เองให้เป็น identifier ที่ปลอดภัย แล้วใช้ $executeRawUnsafe
const tenantSchema = req.tenant; // ต้อง validate ก่อนว่าเป็น [a-z_][a-z0-9_]+
if (!/^[a-z_][a-z0-9_]*$/.test(tenantSchema)) throw new BadRequestException("invalid tenant");
await prisma.$executeRawUnsafe(`SET search_path TO "${tenantSchema}"`);

B) Row per tenant — column tenantId:

prisma
model Post {
  // ...
  tenantId String
  @@index([tenantId])
}

ใช้ Prisma extension แทรก where: { tenantId } อัตโนมัติ

C) Database per tenant — แยก DB ทั้งหมด (heavyweight, ใช้กับ enterprise)

เลือกตามสเกล: row = scale ดี, schema = ดี-กลาง, db = ดีสุดในแง่ isolation แต่จัดการยาก


11. Audit / Soft Delete / Timestamp

ระบบจริงมักไม่อยากลบข้อมูลทิ้งถาวร และอยากรู้ว่าแถวไหนสร้าง/แก้เมื่อไหร่ — soft delete (มาร์ค deletedAt แทนการลบจริง) และ timestamp อัตโนมัติ (createdAt/updatedAt) ตอบโจทย์นี้ ส่วนนี้แสดงวิธีทำทั้งใน Prisma และ TypeORM:

Soft delete (Prisma extension):

typescript
const prisma = new PrismaClient().$extends({
  query: {
    user: {
      // ✅ ใช้ client ที่ Prisma ส่งให้ (Prisma.getExtensionContext / arg ที่ destructure ได้)
      //    เลี่ยงเรียก `prisma.user.update` ตัวด้านนอก → จะ recursion ลงไปใน extension ซ้ำ
      async delete({ args, model, operation, query }) {
        // เรียก update บน model เดียวกัน โดยไม่กระโดดกลับเข้า extension wrapper
        // 📖 pattern นี้อิง Prisma Client Extensions "query" component (ดู https://www.prisma.io/docs/orm/prisma-client/client-extensions/query)
        //    ตรวจสอบ syntax ล่าสุดกับเอกสารทางการก่อนใช้จริง เพราะ API นี้ปรับรายละเอียดตามเวอร์ชัน
        return (this as any).user.update({
          where: args.where,
          data: { deletedAt: new Date() },
        });
      },
      async findMany({ args, query }) {
        args.where = { ...args.where, deletedAt: null };
        return query(args);
      },
    },
  },
});

Audit (บันทึกประวัติ insert/update/delete):

  • ใช้ trigger (โค้ดที่ DB เรียกเองอัตโนมัติเมื่อข้อมูลเปลี่ยน) ที่ DB เป็น authoritative source (แหล่งความจริงที่เชื่อถือได้ที่สุด) (ดีสุด)
  • หรือ Prisma $on("query") + outbox pattern (เขียน event ลงตาราง outbox พร้อมกับข้อมูลจริงในทรานแซกชันเดียว แล้วค่อยส่งทีหลัง — กัน event หายตอนระบบล่ม)

12. Testing — In-memory DB

สำหรับ unit test ของ service — mock Repository/PrismaService

สำหรับ integration test — ใช้ Testcontainers (docker postgres ตัวจริง):

bash
npm i -D @testcontainers/postgresql
typescript
import { execSync } from "node:child_process";
import { PostgreSqlContainer, StartedPostgreSqlContainer } from "@testcontainers/postgresql";

let container: StartedPostgreSqlContainer;
beforeAll(async () => {
  container = await new PostgreSqlContainer().start();
  process.env.DATABASE_URL = container.getConnectionUri();
  // run migration
  execSync("npx prisma migrate deploy");
});
afterAll(() => container.stop());

แม่นยำกว่า in-memory mock มาก เพราะ test ใช้ DB engine จริง


🛠️ Checkpoint 4 — ลงมือทำ

ใช้ Prisma + Postgres:

  1. docker run -d --name pg -e POSTGRES_PASSWORD=pass -p 5432:5432 postgres:16
  2. ติดตั้ง Prisma + setup schema.prisma ที่มี User กับ Post (1-N)
  3. npx prisma migrate dev --name init
  4. สร้าง UsersModule + PostsModule ที่ inject PrismaService
  5. เพิ่ม endpoint GET /users/:id ที่ include posts
  6. ทำ transaction: POST /users/:id/posts/bulk รับ array posts → create ทั้งหมดใน 1 transaction (rollback ถ้าตัวใดผิด)
  7. เปิด prisma.$on("query") ใน dev → log SQL ที่ Prisma generate → ดูว่า include 3 posts ใช้ queries กี่ครั้ง

สรุปบทที่ 4

  • ORM เลือกตามทีมและสไตล์ — Prisma DX ดีสุดสำหรับโปรเจกต์ใหม่
  • ห้าม synchronize: true ใน production — ใช้ migration
  • Connection pool ต้องตั้งให้พอดี (โดยเฉพาะ serverless ที่ต้องมี pooler)
  • N+1 = ระวังทุก ORM, ใช้ include/relations
  • Index ตาม query pattern + ดู EXPLAIN ANALYZE
  • Migration ใน production = job แยก ไม่ใช่ startup hook
  • Test ด้วย Testcontainers แม่นกว่า mock

บทต่อไปจะใส่ auth ที่ปลอดภัยจริง

→ บทที่ 5: Authentication + JWT


Glossary: ../glossary.md · Style guide: ../CONTRIBUTING.md last_verified: 2026-06-03 · review report: ../REVIEW-2026-06-03.md