บทเรียน 27 · Day 6 · Architecture & Database

Project Layout และ GORM

~18 นาที · ต้องมี: บทเรียน 13 (struct tag), บทเรียน 12 (errors.Is), บทเรียน 25 (ctx)

จนถึงตอนนี้โค้ดของคุณอยู่ในไฟล์เดียว รันแล้วจบ บทนี้คือการก้าวไปสู่โปรเจกต์จริง — หลายไฟล์ หลาย package มี database จริง
ผมทดสอบ GORM ทั้งบทกับ PostgreSQL 17 ตัวจริงใน Docker ทุกผลลัพธ์ในหน้านี้คือของที่รันออกมาจริง ไม่ได้คัดจากเอกสาร

1 · internal/ — โฟลเดอร์ที่ compiler ปกป้องให้

myapp/
├── internal/
│   └── user/
│
└── cmd/
    └── api/

internal/ ไม่ใช่แค่ธรรมเนียมการตั้งชื่อ — Go บังคับใช้จริงที่ระดับ compiler

✅ ภายใน module เดียวกัน

// libapp/public/public.go
import "libapp/internal/secret"

// build ผ่าน

❌ จาก module อื่น

// other/main.go
import "libapp/internal/secret"

// use of internal package
// libapp/internal/secret
// not allowed
💡 ทำไมเรื่องนี้สำคัญกว่าที่คิด

ใน บทเรียน 13 คุณเห็นแล้วว่าตัวพิมพ์ใหญ่/เล็กควบคุมการเข้าถึงระดับตัวแปร
internal/ คือกลไกเดียวกันแต่ระดับทั้ง package — ให้ export ชื่อเป็นตัวใหญ่ได้อิสระข้างใน โดยที่โลกภายนอกยังแตะไม่ได้
ผลในทางปฏิบัติ: ของที่อยู่ใน internal/ คุณรีแฟกเตอร์ได้ตลอดเวลาโดยไม่ทำใครพัง — ซึ่งเป็นอิสรภาพที่มีค่ามากในโปรเจกต์ที่อยู่ยาว

🧰 เทียบให้เห็นภาพ — ครัวหลังร้าน กับ หน้าร้าน

ร้านอาหารร้านหนึ่งมีสองโซน · หน้าร้าน ที่ลูกค้าเดินเข้าไปนั่งได้ กับ ครัวหลังร้าน ที่ติดป้ายว่า “เฉพาะพนักงาน”
ในครัว พนักงานจะย้ายชั้นวางของ สลับที่หม้อ เปลี่ยนเตาใหม่ยังไงก็ได้ ลูกค้าไม่เดือดร้อนเลย เพราะลูกค้าไม่เคยเดินเข้ามาหยิบของในครัวอยู่แล้ว

internal/ คือครัวหลังร้านของโปรเจกต์ · ของที่อยู่นอก internal/ คือหน้าร้านที่คนอื่นเดินเข้ามาได้ — แต่หยิบได้เฉพาะของที่วางโชว์ไว้บนชั้น คือชื่อที่ขึ้นต้นด้วยตัวใหญ่เท่านั้น ชื่อตัวเล็กยังเป็นของหลังเคาน์เตอร์อยู่ดี

ที่ไม่เหมือนกัน: ป้าย “เฉพาะพนักงาน” ในร้านจริงเป็นแค่ป้าย คนดื้อ ๆ ก็เดินเข้าไปได้ แต่ internal/ ไม่ใช่ป้ายเตือน — compiler บล็อกให้จริง โปรเจกต์อื่นเขียน import เข้ามาเมื่อไหร่ build ไม่ผ่านตั้งแต่แรก ไม่มีทางเลี่ยง

🗣 ศัพท์ที่เพิ่งเรียนไป
internal package
package ที่อยู่ใต้โฟลเดอร์ internal/ · เวลาคุยกับ senior ให้พูดว่า “ตัวนี้เป็น internal package อยู่ module อื่น import ไม่ได้” จะสื่อกว่าพูดว่า “มันเป็นโฟลเดอร์ส่วนตัว”
public API (ของโปรเจกต์)
ชุดของที่เราตั้งใจให้คนอื่นเรียกใช้ · ของที่อยู่นอก internal/ และตั้งชื่อขึ้นต้นด้วยตัวใหญ่ ถือเป็น public API — ครบสองเงื่อนไขถึงจะนับ (ย้ายไฟล์ออกจาก internal/ เฉย ๆ ชื่อตัวเล็กก็ยังปิดอยู่เหมือนเดิม) · ประโยคที่เอาไปถามได้จริง: “อันนี้ควรอยู่ internal หรือควรเป็น public API ของ module”
breaking change
การแก้ที่ทำให้โค้ดของคนที่เรียกเราอยู่พัง เช่น เปลี่ยนชื่อฟังก์ชัน · ของที่อยู่ใน internal/ แก้ยังไงก็ไม่นับเป็น breaking change เพราะไม่มีคนนอกเรียกได้ตั้งแต่แรก
refactor
รื้อโครงโค้ดข้างในใหม่ โดยที่พฤติกรรมที่คนใช้เห็นยังเหมือนเดิม · ถาม AI หรือ senior ได้ว่า “ย้ายของพวกนี้เข้า internal ก่อนดีไหม จะได้ refactor ได้โดยไม่ทำใครพัง”

2 · จัดตามโดเมน ไม่ใช่ตามชนิดไฟล์

❌ จัดตามชนิด (แบบ MVC)

internal/
├── handlers/
│   ├── user.go
│   ├── order.go
│   └── product.go
├── services/
│   ├── user.go
│   ├── order.go
│   └── product.go
└── repositories/
    ├── user.go
    ├── order.go
    └── product.go

แก้ฟีเจอร์เดียวต้องเปิด 3 โฟลเดอร์ · ไฟล์ชื่อ user.go มี 3 ที่

✅ จัดตามโดเมน (แบบ Go)

internal/
├── user/
│   ├── handler.go
│   ├── service.go
│   ├── repository.go
│   ├── model.go
│   └── dto.go
├── order/
│   └── ...
└── product/
    └── ...

ทุกอย่างของ user อยู่ที่เดียว · ลบฟีเจอร์ = ลบทั้งโฟลเดอร์

ไฟล์หน้าที่รู้จักอะไรบ้าง
handler.goรับ HTTP · แปลง JSON · คืน status codeรู้จัก service · ไม่รู้จัก database
service.goกฎทางธุรกิจ · ตรวจความถูกต้องรู้จัก repository · ไม่รู้จัก HTTP
repository.goคุยกับ database อย่างเดียวรู้จัก SQL · ไม่รู้จักกฎธุรกิจ
model.gostruct ที่ตรงกับตารางใน database—
dto.gostruct สำหรับ request/response— (มี json tag)
   HTTP Request
        │
        ▼
   ┌──────────┐   ctx     ┌──────────┐   ctx    ┌──────────────┐   ctx   ┌────┐
   │ handler  │─────────► │ service  │────────► │ repository   │───────► │ DB │
   └──────────┘           └──────────┘          └──────────────┘         └────┘
    JSON ↔ struct        กฎธุรกิจ                SQL/GORM

   ลูกศรชี้ทางเดียว — repository ห้ามเรียก service กลับขึ้นไป
   ctx เดินทางไปด้วยทุกชั้น (บทเรียน 25) → ยกเลิกได้ทั้งเส้น
🍜 เทียบให้เห็นภาพ — สั่งอาหารในร้าน

ลองนึกถึงตอนสั่งก๋วยเตี๋ยว งานถูกส่งต่อกันเป็นทอด ๆ:
· พนักงานหน้าร้าน ฟังลูกค้าพูด แล้วจดเป็นใบออเดอร์ส่งเข้าครัว = handler.go (แปลง JSON ที่รับมา ให้กลายเป็นของที่โค้ดข้างในเข้าใจ)
· เชฟ อ่านใบออเดอร์ ตัดสินใจตามสูตร ถ้าลูกค้าสั่งไม่พิเศษเกินไปก็ทำให้ = service.go (กฎทางธุรกิจ)
· เด็กในครัว เดินไปหยิบเส้นกับหมูจากตู้เย็นตามที่เชฟสั่ง = repository.go · ตู้เย็น = database

จุดสำคัญคือต่างคนต่างไม่ต้องรู้เรื่องของกัน — เชฟไม่ต้องรู้ว่าลูกค้าจ่ายบัตรหรือเงินสด · คนเดินหยิบของไม่ต้องรู้สูตรก๋วยเตี๋ยว

ที่ไม่เหมือนกัน: ในร้านจริงเชฟจะเดินไปเปิดตู้เย็นเองก็ได้ ไม่ผิดอะไร แต่ในโค้ดเราจงใจห้าม ถ้า service ยิง SQL เองได้ วันที่อยากเปลี่ยนวิธีเก็บข้อมูล คุณต้องไล่แก้กฎธุรกิจทุกจุดที่มี SQL ปนอยู่ และเทสก็ต้องมี database จริงเสมอ

🗣 ศัพท์ที่เพิ่งเรียนไป
layer / layered architecture
การซอยโค้ดเป็น “ชั้น” ที่ส่งงานต่อกันทางเดียว · พูดว่า “โปรเจกต์นี้แบ่งเป็น handler / service / repository” แล้ว dev คนอื่นจะเห็นภาพโครงทั้งโปรเจกต์ทันที
business logic
“กฎของงาน” เช่น อีเมลต้องมี @ · ยอดเงินห้ามติดลบ · เป็นคำที่ใช้กันบ่อยมาก ถามได้ว่า “business logic ตัวนี้ควรอยู่ service ใช่ไหม”
repository
ชั้นที่คุยกับ database อย่างเดียว · เวลาถาม AI ให้พูดว่า “เขียน repository ของ Go ที่ใช้ GORM ให้หน่อย” จะได้โค้ดที่เข้ากับโครงนี้เลย ดีกว่าพูดว่า “เขียนโค้ดต่อ database”
separation of concerns
หลักการ “ใครมีหน้าที่อะไร ทำแค่นั้น” · เป็นเหตุผลเบื้องหลังตารางข้างบนทั้งตาราง · ได้ยินบ่อยตอน code review ว่า “ตรงนี้มัน mix concern กันอยู่” แปลว่าเอาสองหน้าที่มาปนกันในไฟล์เดียว
dependency direction
“ใครรู้จักใคร” · กฎคือลูกศรชี้ลงทางเดียวเสมอ · ถ้า repository เรียก service กลับขึ้นไป เรียกว่า circular dependency ซึ่ง Go จะ compile ไม่ผ่านให้เลยถ้าเป็นคนละ package · แต่ในโครงของบทนี้ทั้งสามไฟล์อยู่ package user เดียวกัน compiler จึงไม่ฟ้องให้ — กฎลูกศรทางเดียวเป็นวินัยที่ต้องคุมกันเองตอน review

ทำไมต้องมี dto.go แยกจาก model.go

// model.go — หน้าตาในฐานข้อมูล
type User struct {
    ID           int64
    Name         string
    Email        string
    PasswordHash string     // ← ห้ามหลุดออกไปเด็ดขาด
}

// dto.go — หน้าตาที่ส่งออกไปข้างนอก
type UserResponse struct {
    ID    int64  `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"`
}

นี่คือคำตอบของปัญหาที่เจอใน บทเรียน 13 — แยก struct ปลอดภัยกว่าการพึ่ง json:"-" เพราะเพิ่ม field ใหม่เข้า model แล้วลืมใส่ tag = ข้อมูลหลุดทันที
ส่วน DTO ต้องตั้งใจเพิ่ม ถึงจะหลุดออกไปได้

🪪 เทียบให้เห็นภาพ — แฟ้มประวัติ กับ ป้ายชื่อห้อยคอ

ฝ่ายบุคคลของบริษัทมีแฟ้มประวัติพนักงานอยู่ในตู้ ข้างในมีทุกอย่าง — เงินเดือน เลขบัญชี ประวัติการลาป่วย
แต่สิ่งที่พนักงานห้อยคอเดินไปมาคือป้ายชื่อ ที่มีแค่ ชื่อ · แผนก · รูป

เวลามีคนนอกมาถามว่า “คนนี้ชื่ออะไร” เรายื่นป้ายชื่อให้ดู ไม่ได้ยกแฟ้มทั้งแฟ้มไปให้
model.go คือแฟ้มในตู้ · dto.go คือป้ายชื่อ

ที่ไม่เหมือนกัน: ป้ายชื่อทำครั้งเดียวแล้วห้อยยาว ๆ ได้ แต่ DTO ต้องเขียนโค้ดคัดค่าทีละ field จาก model ทุกครั้งที่จะตอบกลับ ตัวภาษา Go ไม่ได้คัดให้เอง ต้องเขียนเอง (หรือหา library / code generator มาช่วย) — นั่นคือราคาที่จ่ายเพื่อแลกกับความปลอดภัย และเป็นราคาที่คุ้ม เพราะการลืมคัดจะทำให้ข้อมูลหายไป ไม่ใช่หลุดออกไป

🗣 ศัพท์ที่เพิ่งเรียนไป
DTO (Data Transfer Object)
struct ที่มีไว้ “ขนข้อมูลข้ามเขตแดน” อย่างเดียว เช่น ส่งออกไปเป็น JSON · เวลาถามให้พูดว่า “ตัวนี้เป็น DTO หรือเป็น model” คำถามเดียวนี้บอกได้เลยว่า struct นั้นเอาไว้ทำอะไร
model / entity
struct ที่แทน 1 แถวในตาราง database · บาง repo เรียก entity บางที่เรียก model · หลายทีมใช้แทนกัน แต่บางทีม (สาย DDD / clean architecture) แยกสองคำนี้ชัดเจน — entity คือ object ของโดเมนที่ไม่รู้จัก database เลย ส่วน model คือ struct ที่ผูกกับตาราง · เข้าโปรเจกต์ใหม่ให้ถามก่อนว่าทีมนี้เรียกอะไร
expose
“เปิดออกไปให้ข้างนอกเห็น” · ประโยคที่ได้ยินตอน review บ่อยมากคือ “อันนี้ expose PasswordHash ออกไปนะ” = ฟิลด์ลับหลุดออก API แล้ว
mapping
การคัดค่าจาก struct หนึ่งไปอีก struct หนึ่ง · เอาไปถาม AI ได้ตรง ๆ ว่า “เขียนฟังก์ชัน mapping จาก User model ไป UserResponse DTO ให้หน่อย”

3 · โครงเต็มที่ใช้งานจริง

myapp/
├── cmd/
│   └── api/
│       └── main.go          ← จุดเริ่ม ประกอบทุกอย่างเข้าด้วยกัน
│
├── internal/
│   ├── user/
│   │   ├── handler.go
│   │   ├── service.go
│   │   ├── repository.go
│   │   ├── model.go
│   │   └── dto.go
│   │
│   ├── product/
│   │   └── ...
│   │
│   └── platform/            ← ของกลางที่ทุกโดเมนใช้
│       └── database/
│           └── postgres.go
│
├── go.mod
└── go.sum

cmd/ มีได้หลายตัว — cmd/api/ เป็น HTTP server · cmd/worker/ เป็นตัวประมวลผลเบื้องหลัง · cmd/migrate/ เป็นเครื่องมือ migrate
ทุกตัวใช้ internal/ ร่วมกัน แต่ main.go คนละไฟล์
ถ้ายังมีตัวเดียว ก็ยังควรใส่ใน cmd/api/ — เผื่อวันหน้าจะได้ไม่ต้องรื้อ

4 · Database พื้นฐานที่ต้องรู้

คำคืออะไรตัวอย่าง
Tableตาราง — แถวคือข้อมูล 1 ชิ้น คอลัมน์คือ fieldusers
Primary Keyคอลัมน์ที่ระบุตัวตนแถวได้ไม่ซ้ำid
Foreign Keyคอลัมน์ที่ชี้ไปหา PK ของอีกตารางorders.user_id
SQLภาษาที่ใช้สั่ง databaseSELECT ... WHERE ...
Transactionกลุ่มคำสั่งที่ สำเร็จทั้งหมด หรือไม่สำเร็จเลยโอนเงิน: หักบัญชี A + เพิ่มบัญชี B

เปิด PostgreSQL ด้วย Docker — ไฟล์ compose.yml:

services:
  postgres:
    image: postgres:17
    container_name: devpool-postgres
    restart: always
    environment:
      POSTGRES_PASSWORD: Pe@devp00l
    ports:
      - "5432:5432"
    volumes:
      - ./pgdata:/var/lib/postgresql/data

volumes คือส่วนที่ทำให้ข้อมูลไม่หายเมื่อลบ container — ไม่ใส่บรรทัดนี้ ข้อมูลหายทุกครั้งที่ docker compose down
ดูข้อมูลด้วยเครื่องมือ GUI ได้ที่ TablePlus

5 · database/sql หรือ GORM

การคุยกับ database มีสองแนวทางให้เลือก
แนวแรกคือเขียน SQL เองทุกตัวอักษร แล้วไล่หยิบค่าที่ได้กลับมาใส่ตัวแปรทีละตัว
แนวที่สองคือใช้ ORM — โปรแกรมตัวกลางที่แปลง struct ในโค้ดเรา ให้กลายเป็น SQL ให้อัตโนมัติ แล้วแปลงผลลัพธ์กลับมาใส่ struct ให้ด้วย
GORM คือ ORM ที่คนใช้เยอะที่สุดในโลก Go

database/sql — มากับ Go

rows, err := db.Query(
    `SELECT id, name FROM orders
     WHERE user_id = ?`, userID,
)
if err != nil {
    return err
}
defer rows.Close()

for rows.Next() {
    var id int
    var name string
    rows.Scan(&id, &name)
}

ยาวกว่า แต่เห็นทุก SQL ที่ยิงจริง · ไม่มี dependency · คุมประสิทธิภาพได้เต็มที่

GORM — ORM ยอดนิยมของ Go

var orders []Order

db.Where(
    "user_id = ?", userID,
).Find(&orders)

สั้นกว่ามาก · แปลง struct ให้อัตโนมัติ · แต่มองไม่เห็นว่ายิง SQL อะไรออกไป

ชุมชน Go แบ่งเป็นสองฝ่ายจริง ๆ ในเรื่องนี้ — ต่างจาก Java/PHP ที่ ORM เป็นค่ามาตรฐาน
หลายทีมเลือก database/sql + sqlc หรือ sqlx เพราะอยากเห็น SQL ตรง ๆ
คอร์สสอน GORM ก็เรียน GORM ให้เป็น — แต่รู้ไว้ว่ามีอีกทางที่นิยมพอ ๆ กัน และต้องอ่าน SQL ออกอยู่ดี เพราะเวลา query ช้า คุณจะต้องไปดูว่า ORM แปลออกมาเป็นอะไร

6 · ติดตั้ง GORM

mkdir api && cd api
go mod init wongnok

go get -u gorm.io/gorm
go get -u gorm.io/driver/postgres

go mod tidy      ← อย่าลืมบรรทัดนี้

⚠️ กับดักเดิมจากบทเรียน 17 — หลัง go get ทั้งสองตัวจะขึ้นว่า // indirect ใน go.mod เพราะยังไม่มีโค้ดไหน import จริง
เขียนโค้ดแล้วสั่ง go mod tidy คำว่า indirect ถึงจะหายไป — ถ้าเจอ missing go.sum entry คำตอบคือ go mod tidy เกือบทุกครั้ง
เวอร์ชันที่ผมทดสอบ: gorm.io/gorm v1.31.2 · gorm.io/driver/postgres v1.6.2 · PostgreSQL 17

6.5 · ต่อ Postgres ด้วย GORM

ติดตั้งแล้วแต่ยังไม่ได้ต่ออะไรเลย — โค้ดที่ทำให้โปรแกรมคุยกับ database ได้จริงมีแค่นี้:

// internal/platform/database/postgres.go
package database

import (
    "gorm.io/driver/postgres"
    "gorm.io/gorm"
)

func Connect() (*gorm.DB, error) {
    // DSN = สายอักขระบอกว่าจะต่อไปที่ไหน ด้วยชื่อใคร รหัสอะไร
    dsn := "host=localhost user=postgres password=Pe@devp00l dbname=postgres port=5432 sslmode=disable TimeZone=Asia/Bangkok"
    return gorm.Open(postgres.Open(dsn), &gorm.Config{})
}

ทำไม user กับ dbname เป็น postgres ทั้งคู่ — เพราะ compose.yml ข้างบน ตั้งแค่ POSTGRES_PASSWORD ไม่ได้ตั้ง POSTGRES_USER / POSTGRES_DB image จึงใช้ค่าเริ่มต้นชื่อ postgres ให้ทั้งสองอย่าง
sslmode=disable เพราะต่อกับ Postgres ในเครื่องตัวเอง (ขึ้น production ค่อยเปลี่ยน)
รู้ว่าต่อติดแล้วเมื่อ Connect() คืน err เป็น nil

7 · GORM Model และ CRUD

type User struct {
    ID    uint   `gorm:"primaryKey"`
    Name  string
    Email string `gorm:"unique"`
}

struct tag อีกชุดหนึ่ง — คนละตัวกับ json: ที่เรียนในบทเรียน 13 แต่ทำงานแบบเดียวกัน: เป็น metadata ที่ library อ่านตอนรัน
struct หนึ่งตัวมีได้ทั้งสอง tag พร้อมกัน

ตอนนี้เรามีแค่ struct ในโค้ด — ตารางจริงในฐานข้อมูลยังไม่มี
AutoMigrate คือคำสั่งที่ให้ GORM อ่าน struct แล้วสร้าง/ปรับตารางให้ตรงกัน:

db.AutoMigrate(&User{})
// GORM จะ CREATE TABLE users ให้ถ้ายังไม่มี
// ถ้ามีแล้วแต่ struct เพิ่ม field ใหม่ มันจะ ADD COLUMN ให้
// แต่มันไม่เคยลบคอลัมน์ทิ้ง — ปลอดภัยไว้ก่อน

เรียกครั้งเดียวตอนโปรแกรมเริ่ม (ใน main.go หลังต่อ database สำเร็จ) พอสำหรับตอนเรียน
ระบบ production จริงใช้เครื่องมือ migration แยกแทน (migration = การเก็บทุกการเปลี่ยนโครงตารางเป็นไฟล์ทีละขั้น เรียงตามลำดับ จะได้ย้อนกลับได้ และคนอื่นในทีมรันตามได้ตรงกัน) ซึ่งเป็นเนื้อหา Day 7

user := User{Name: "Somchai", Email: "somchai@example.com"}
db.Create(&user)

fmt.Println(user.ID)

ยังไม่ได้ query อะไรกลับมา — user.ID เป็นอะไร?

ผมรันกับ Postgres จริง ได้:

Create: RowsAffected=1 Error=<nil>  → ID ที่ได้กลับมา = 1

GORM เขียนค่ากลับเข้า struct ของคุณ — เพราะคุณส่ง &user ไป มันจึงแก้ตัวจริงได้ (บทเรียน 07)
ถ้าส่ง user เฉย ๆ ไม่มีดาว → GORM แก้สำเนา → ID ของคุณยังเป็น 0

สังเกตอีกอย่าง: GORM ไม่คืน error เป็นค่าที่สอง
มันคืน *gorm.DB ที่มี .Error และ .RowsAffected อยู่ข้างใน — ต่างจากทุกอย่างที่คุณเรียนมา ตั้งแต่บทเรียน 05
แปลว่า ลืมเช็ค .Error ได้ง่ายมาก เพราะ compiler ไม่บังคับเหมือน multiple return value

// CREATE
db.Create(&user)

// READ
var user User
db.First(&user, 1)                                  // หา id = 1

var users []User
db.Find(&users)                                    // เอาทั้งหมด

var vipUsers []User
db.Where("name = ?", "Somchai").Find(&vipUsers)  // มีเงื่อนไข

// UPDATE
db.Model(&user).Update("Name", "Somsri")

// DELETE
db.Delete(&user, 1)

ชื่อตารางถูกเดาให้อัตโนมัติ — ผมตรวจแล้ว struct User → ตาราง users (ตัวเล็ก + เติม s) · OrderItem → order_items
ใช้เครื่องหมาย ? ใน Where เสมอ อย่าต่อสตริงเอง — นั่นคือช่องโหว่ SQL injection (คนร้ายพิมพ์ข้อความแปลก ๆ เข้ามาเป็นค่าค้นหา แล้วข้อความนั้นกลายเป็นคำสั่ง SQL ที่ database ทำตามจริง)

ผูก ctx เข้ากับ query — ทุกคำสั่งที่ยิง database ควรพก ctx ไปด้วย ตามที่เรียนใน บทเรียน 25:

func (r *Repository) FindByID(ctx context.Context, id uint) (*User, error) {
    var u User
    err := r.db.WithContext(ctx).First(&u, id).Error
    return &u, err
}

WithContext(ctx) ต้องมาก่อนคำสั่งจริง (First / Find / Create) เสมอ เพราะมันคืน *gorm.DB ตัวใหม่ที่ถือ ctx ไว้ แล้วค่อยเอาตัวนั้นไปยิง query
ผลคือถ้าผู้ใช้ปิดหน้าเว็บกลางคัน query ที่ยังค้างอยู่จะถูกยกเลิกไปด้วย ไม่ปล่อยให้ database ทำงานให้คนที่ไม่รออยู่แล้ว

8 · สี่กับดักของ GORM ที่ผมเจอตอนทดสอบ

8.1 First ฟ้องว่าไม่เจอ แต่ Find ไม่ฟ้อง

var missing User
err := db.First(&missing, 9999).Error
// err = record not found · errors.Is(err, gorm.ErrRecordNotFound) = true

var none []User
res := db.Where("id = ?", 9999).Find(&none)
// res.Error = <nil>  ·  res.RowsAffected = 0   ← ไม่ error!

⚠️ นี่คือกับดักที่คนเจอบ่อยที่สุดใน GORM
First คาดหวังว่าต้องเจอ → ไม่เจอ = error
Find คาดหวัง "เอาเท่าที่มี" → ไม่มี = slice ว่าง ไม่ใช่ error
ถ้าเช็คแต่ .Error หลัง Find คุณจะไม่มีวันรู้ว่าไม่เจอ — ต้องเช็ค len(users) == 0 หรือ RowsAffected เอง
ใช้ errors.Is(err, gorm.ErrRecordNotFound) เสมอ ไม่ใช่ == (บทเรียน 12)

8.2 Update/Delete ที่ไม่มีเงื่อนไข ถูกบล็อก

db.Model(&User{}).Update("Balance", 0).Error
// WHERE conditions required
// errors.Is(err, gorm.ErrMissingWhereClause) = true

db.Delete(&User{}).Error
// WHERE conditions required

GORM กันไม่ให้คุณลบทั้งตารางโดยบังเอิญ — เป็นฟีเจอร์ ไม่ใช่บั๊ก · ถ้าตั้งใจจริงต้องใส่ .Where("1 = 1") ให้ชัดเจน

8.3 Transaction — rollback ทำงานจริง

err := db.Transaction(func(tx *gorm.DB) error {
    tx.Model(&Account{}).Where("owner = ?", "A").
        Update("balance", gorm.Expr("balance - ?", 50))   // ← ให้ database เป็นคนลบเอง

    return errors.New("ระบบพังกลางคัน")   // ← คืน error = rollback
})

gorm.Expr แปลว่า "อย่าเอาค่านี้ไปเป็นตัวเลข ให้ส่งเป็นสูตรลงไปให้ database คิดเอง" — SQL ที่ออกมาจึงเป็น SET balance = balance - 50
ถ้าเขียน Update("balance", balance-50) โดยเอาค่า balance ที่อ่านมาก่อนหน้ามาลบในโค้ด Go มันจะกลายเป็น SET balance = 50 ซึ่งทับค่าที่คนอื่นเพิ่งแก้ไปทั้งก้อน
เป็น data race เวอร์ชัน database แบบเดียวกับ counter++ ใน บทเรียน 24 เป๊ะ ๆ — อ่าน แล้วคิด แล้วเขียนกลับ โดยมีคนอื่นแทรกกลางทางได้

ผมทดสอบกับยอดเงิน A = 100:

Transaction คืน error → ระบบพังกลางคัน
ยอดของ A หลัง rollback = 100   ← ไม่ถูกหัก

เมื่อ callback คืน nil GORM จะ commit ให้ — ผมลองโอน 50 จาก A ไป B:

หลัง transaction สำเร็จ: [{ID:1 Owner:A Balance:50} {ID:2 Owner:B Balance:150}]
💡 กฎเดียวที่ต้องจำเรื่อง transaction ใน GORM

callback คืน error = rollback · คืน nil = commit
และต้องใช้ tx ข้างใน ไม่ใช่ db — เผลอใช้ db เมื่อไหร่ คำสั่งนั้นจะอยู่นอก transaction แล้ว rollback ไม่ถึง (บั๊กที่หายากมาก เพราะทดสอบตอน happy path จะดูปกติทุกอย่าง)

8.4 Soft delete — ลบแล้วยังอยู่

type Post struct {
    ID        uint `gorm:"primaryKey"`
    Title     string
    DeletedAt gorm.DeletedAt `gorm:"index"`   // ← field นี้เปลี่ยนทุกอย่าง
}

ผมสร้าง 1 แถว แล้วสั่ง Delete แล้วนับ:

หลัง Delete: Find เห็น 0 แถว
แต่ Unscoped เห็น 1 แถว   ← ข้อมูลยังอยู่ในตาราง!

⚠️ มี field ชื่อ DeletedAt แบบ gorm.DeletedAt เมื่อไหร่ Delete จะเปลี่ยนพฤติกรรมทันที — จาก DELETE FROM กลายเป็น UPDATE SET deleted_at = now()
ทุก query หลังจากนั้นจะเติม WHERE deleted_at IS NULL ให้เอง
ข้อดี: กู้ข้อมูลคืนได้ · เก็บประวัติได้
ข้อควรระวัง: คนที่ไม่รู้จะงงว่าทำไม UNIQUE(email) ยังชนอยู่ทั้งที่ "ลบไปแล้ว" — เพราะแถวนั้นยังอยู่จริง ๆ
อยากลบจริงต้องใช้ db.Unscoped().Delete(&post)

🗑 เทียบให้เห็นภาพ — ถังขยะในคอมพิวเตอร์

ลบไฟล์บนเครื่องแล้วมันไม่ได้หายไปทันที มันไปนอนอยู่ในถังขยะ
มองจากโฟลเดอร์เดิม ไฟล์หายไปแล้วจริง ๆ แต่พื้นที่ในดิสก์ยังถูกใช้อยู่ ต้องกด “ล้างถังขยะ” อีกทีถึงจะหายจริง

Delete ของ struct ที่มีฟิลด์ DeletedAt = โยนลงถังขยะ · Unscoped().Delete(&post) = Shift+Delete — ลบแถวที่ระบุทิ้งถาวรโดยไม่ผ่านถังขยะ ไม่ใช่ “ล้างถังขยะทั้งถัง” (ใช้กับแถวที่ยังไม่เคยถูกลบก็ได้ ลบทิ้งถาวรได้ตรง ๆ เลย)

ส่วน Unscoped().Find() = ปิดตัวกรอง — เห็นทั้งของในโฟลเดอร์และของในถังขยะพร้อมกัน ไม่ใช่เปิดดูเฉพาะของในถัง
เพราะ Unscoped() แปลว่า “ถอด WHERE deleted_at IS NULL ออก” เท่านั้น ผลคือได้ทุกแถวในตาราง · ถ้าอยากได้เฉพาะแถวที่ลบไปแล้วจริง ๆ ต้องต่อเงื่อนไข deleted_at IS NOT NULL เอง

ที่ไม่เหมือนกัน: ถังขยะย้ายไฟล์ออกจากโฟลเดอร์เดิมจริง ๆ ชื่อไฟล์เดิมจึงว่างให้ใช้ซ้ำได้ทันที
แต่ soft delete ไม่ย้ายแถวไปไหนเลย แถวยังนั่งอยู่ที่เดิมในตาราง แค่ถูกเติมวันที่ลบลงไปในคอลัมน์ deleted_at
เพราะแบบนี้ UNIQUE(email) ถึงยังชนอยู่ — database ไม่รู้จักคำว่า “ลบแบบนุ่มนวล” มันเห็นแค่ว่าแถวนั้นยังอยู่

🗣 ศัพท์ที่เพิ่งเรียนไป
soft delete / hard delete
soft delete = ทำเครื่องหมายว่าลบแล้ว แต่ข้อมูลยังอยู่ · hard delete = ลบแถวออกจริง · คำถามที่ควรถามตั้งแต่ออกแบบตารางคือ “ตารางนี้จะใช้ soft delete หรือ hard delete” เพราะเลือกแล้วเปลี่ยนทีหลังเจ็บตัว
constraint
กฎที่ database บังคับให้ข้อมูลทุกแถวต้องทำตาม เช่น UNIQUE (ห้ามซ้ำ) หรือ NOT NULL (ห้ามว่าง) · เวลาเจอ error ให้พูดว่า “ติด unique constraint ตอน insert” จะได้คำตอบตรงกว่าพูดว่า “insert ไม่ได้”
partial index (unique index แบบมีเงื่อนไข)
index ที่บังคับกฎเฉพาะบางแถว เช่น “ห้าม email ซ้ำเฉพาะแถวที่ยังไม่ถูกลบ” · นี่คือทางแก้มาตรฐานของปัญหาข้างบน · ถาม AI ได้ว่า “เขียน partial unique index บน email where deleted_at is null ใน Postgres ยังไง”
ORM
ตัวกลางที่แปลง struct ในโค้ดเป็น SQL ให้อัตโนมัติ · GORM คือ ORM ตัวหนึ่ง · ประโยคที่ใช้ได้จริงเวลา query ช้า: “ขอดู SQL ที่ ORM แปลออกมาหน่อย”

ทดสอบความเข้าใจ

1. internal/ ต่างจากโฟลเดอร์ธรรมดายังไง?

ผมทดสอบด้วย 2 module: ภายใน module เดียวกัน import ได้ปกติ · จาก module อื่นได้ use of internal package ... not allowed
ชื่อตัวใหญ่ข้างในยังเป็น exported อยู่ (ตัวเลือกที่ 2 ผิด) — แค่ขอบเขตการมองเห็นถูกจำกัดที่ระดับ module
ผลจริง: ของใน internal/ รีแฟกเตอร์ได้โดยไม่ทำใครพัง

2. ทำไมต้องมี dto.go แยกจาก model.go?

ต่อจากบทเรียน 13: พึ่ง json:"-" แปลว่า "ปลอดภัยตราบใดที่ไม่มีใครลืม"
แยก struct แปลว่า "ต้องตั้งใจเพิ่มถึงจะหลุด" — เพิ่ม PasswordHash เข้า model วันไหน DTO ก็ไม่กระทบ
ปลอดภัยโดยค่าเริ่มต้น ดีกว่าปลอดภัยเพราะจำได้

3. db.Where("id = ?", 9999).Find(&users) เมื่อไม่มีข้อมูลตรงเลย?

ผมรันจริงกับ Postgres: Find(id=9999) Error = <nil> · RowsAffected = 0
Find ไม่ถือว่า "ไม่เจอ" เป็นความผิดพลาด — ต่างจาก First ที่คืน ErrRecordNotFound
ถ้าเช็คแต่ .Error คุณจะเข้าใจผิดว่าสำเร็จ — ต้องดู len() หรือ RowsAffected เอง

4. struct มี DeletedAt gorm.DeletedAt แล้วสั่ง db.Delete(&post)?

ผมทดสอบ: หลัง Delete → Find เห็น 0 แถว แต่ Unscoped().Find เห็น 1 แถว
GORM เปลี่ยนเป็น UPDATE SET deleted_at = now() และเติม WHERE deleted_at IS NULL ให้ทุก query
ระวัง unique constraint — แถวที่ "ลบแล้ว" ยังชนอยู่
ลบจริงต้อง db.Unscoped().Delete(...)

🛠️ ภารกิจ — โปรเจกต์จริงตัวแรกของคุณ

ภารกิจนี้ยาวที่สุดในคอร์ส เพราะเป็นครั้งแรกที่มีหลายไฟล์ + database จริง ทำทีละข้อ อย่าข้าม

  1. สร้างโครง: cmd/api/main.go · internal/user/{model,repository,service,dto}.go
    go mod init myapp แล้วรัน go build ./... ให้ผ่านก่อนเขียนอะไร
  2. เปิด Postgres ด้วย compose.yml ข้างบน แล้วต่อด้วย GORM ใน internal/platform/database/postgres.go (โค้ดเต็มอยู่ในหัวข้อ 6.5)
    รู้ว่าสำเร็จเมื่อ Connect() คืน err เป็น nil
  3. model.go: User{ID, Name, Email, PasswordHash} พร้อม gorm tag
    เรียก db.AutoMigrate(&User{}) ใน main.go หลังต่อ database สำเร็จ
    รู้ว่าสำเร็จเมื่อเปิด TablePlus แล้วเห็นตาราง users โผล่มา
  4. dto.go: CreateUserRequest และ UserResponse — UserResponse ต้องไม่มี PasswordHash
  5. repository.go: Create · FindByID · FindAll — ทุกตัวรับ ctx context.Context เป็น parameter ตัวแรก แล้วใช้ db.WithContext(ctx) (หน้าตาโค้ดอยู่ท้ายหัวข้อ 7)
  6. service.go: Register ที่ตรวจว่า name ไม่ว่าง และ email มี @ ก่อนเรียก repository
  7. ทดลอง 1: เรียก FindByID(ctx, 9999) — errors.Is(err, gorm.ErrRecordNotFound) เป็น true ไหม?
    แล้วลอง FindAll บนตารางว่าง — ได้ error หรือ slice ว่าง?
  8. ทดลอง 2: สร้าง user ที่ email ซ้ำ → error หน้าตาเป็นยังไง? แล้วห่อด้วย AppError จากบทเรียน 12 ให้ผู้ใช้อ่านรู้เรื่อง
  9. ทดลอง 3: เขียน Transfer ที่ใช้ db.Transaction โอนเงินระหว่างสองแถว แล้วจงใจ return errors.New(...) กลางทาง — ยอดกลับมาเท่าเดิมไหม?
  10. ทดลอง 4: เพิ่ม DeletedAt gorm.DeletedAt แล้วลบ user คนหนึ่ง จากนั้นสร้างคนใหม่ด้วย email เดิม → เกิดอะไรขึ้น? ทำไม?
  11. ท้าทาย — เชื่อมกับ Day 6 ทั้งวัน: เขียน ImportUsers(ctx, []CreateUserRequest) ที่ใช้ worker pool (บทเรียน 26) สร้าง user พร้อมกัน
    ตั้งจำนวน worker เท่าไหร่? (ใบ้: หัวข้อ 5 ของบทเรียน 26 แถวสุดท้าย)
    รัน go test -race ./... ด้วย

สร้างไม่ได้ — ชน unique constraint
เพราะ soft delete ไม่ได้ลบแถวจริง มันแค่ตั้ง deleted_at แถวเดิมที่มี email นั้นยังอยู่ในตาราง และ constraint ระดับ database ไม่รู้จักแนวคิด "ลบแบบนุ่มนวล" ของ GORM เลย
นี่คือเหตุผลที่ระบบจริงมักทำ unique index แบบมีเงื่อนไข (WHERE deleted_at IS NULL) หรือเปลี่ยนไปใช้ hard delete สำหรับ field ที่ต้อง unique

🎉 จบ Day 6 แล้ว — และเป็นวันที่หนักที่สุด

เก้าบทของ Day 6 ต่อกันเป็นสามช่วง:
Generics (19–20) — เขียนครั้งเดียวใช้ได้หลาย type
Concurrency (21–26) — สร้าง · สื่อสาร · เลือก · ป้องกัน · ยกเลิก · จำกัด
โปรเจกต์จริง (27) — จัดโครง · ต่อ database

ตอนนี้คุณมีครบทุกชิ้นที่ต้องใช้สร้าง REST API แล้ว — เหลือแค่ชั้น HTTP ซึ่งเป็นเนื้อหาสัปดาห์ที่ 3

💬 ภารกิจนี้ใหญ่ — ติดตรงไหนถามได้ทุกข้อ โดยเฉพาะข้อ 5 (WithContext) และท้าทายข้อสุดท้าย
ถ้าอยากได้บทเรียนเรื่อง httptest หรือ การเขียนเทสให้ repository ที่ต่อ database จริง (ยังไม่มีในสไลด์) — บอกได้

อ้างอิง