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.go | struct ที่ตรงกับตารางใน database | — |
dto.go | struct สำหรับ 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 ชิ้น คอลัมน์คือ field | users |
| Primary Key | คอลัมน์ที่ระบุตัวตนแถวได้ไม่ซ้ำ | id |
| Foreign Key | คอลัมน์ที่ชี้ไปหา PK ของอีกตาราง | orders.user_id |
| SQL | ภาษาที่ใช้สั่ง database | SELECT ... 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}]
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 จริง ทำทีละข้อ อย่าข้าม
- สร้างโครง:
cmd/api/main.go·internal/user/{model,repository,service,dto}.gogo mod init myappแล้วรันgo build ./...ให้ผ่านก่อนเขียนอะไร - เปิด Postgres ด้วย
compose.ymlข้างบน แล้วต่อด้วย GORM ในinternal/platform/database/postgres.go(โค้ดเต็มอยู่ในหัวข้อ 6.5)
รู้ว่าสำเร็จเมื่อConnect()คืนerrเป็นnil model.go:User{ID, Name, Email, PasswordHash}พร้อมgormtag
เรียกdb.AutoMigrate(&User{})ในmain.goหลังต่อ database สำเร็จ
รู้ว่าสำเร็จเมื่อเปิด TablePlus แล้วเห็นตารางusersโผล่มาdto.go:CreateUserRequestและUserResponse—UserResponseต้องไม่มีPasswordHashrepository.go:Create·FindByID·FindAll— ทุกตัวรับctx context.Contextเป็น parameter ตัวแรก แล้วใช้db.WithContext(ctx)(หน้าตาโค้ดอยู่ท้ายหัวข้อ 7)service.go:Registerที่ตรวจว่าnameไม่ว่าง และemailมี@ก่อนเรียก repository- ทดลอง 1: เรียก
FindByID(ctx, 9999)—errors.Is(err, gorm.ErrRecordNotFound)เป็น true ไหม?
แล้วลองFindAllบนตารางว่าง — ได้ error หรือ slice ว่าง? - ทดลอง 2: สร้าง user ที่ email ซ้ำ →
error หน้าตาเป็นยังไง?
แล้วห่อด้วย
AppErrorจากบทเรียน 12 ให้ผู้ใช้อ่านรู้เรื่อง - ทดลอง 3: เขียน
Transferที่ใช้db.Transactionโอนเงินระหว่างสองแถว แล้วจงใจreturn errors.New(...)กลางทาง — ยอดกลับมาเท่าเดิมไหม? - ทดลอง 4: เพิ่ม
DeletedAt gorm.DeletedAtแล้วลบ user คนหนึ่ง จากนั้นสร้างคนใหม่ด้วย email เดิม → เกิดอะไรขึ้น? ทำไม? - ท้าทาย — เชื่อมกับ 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 ต่อกันเป็นสามช่วง:
Generics (19–20) — เขียนครั้งเดียวใช้ได้หลาย type
Concurrency (21–26) — สร้าง · สื่อสาร · เลือก · ป้องกัน · ยกเลิก · จำกัด
โปรเจกต์จริง (27) — จัดโครง · ต่อ database
ตอนนี้คุณมีครบทุกชิ้นที่ต้องใช้สร้าง REST API แล้ว —
เหลือแค่ชั้น HTTP ซึ่งเป็นเนื้อหาสัปดาห์ที่ 3
💬 ภารกิจนี้ใหญ่ — ติดตรงไหนถามได้ทุกข้อ
โดยเฉพาะข้อ 5 (WithContext) และท้าทายข้อสุดท้าย
ถ้าอยากได้บทเรียนเรื่อง httptest หรือ
การเขียนเทสให้ repository ที่ต่อ database จริง (ยังไม่มีในสไลด์) — บอกได้
อ้างอิง
- Organizing a Go module — ทางการ · อธิบาย
cmd/และinternal/ - Go 1.4 Release Notes — Internal packages — ที่มาของกฎ
internal/ - GORM Guides · Error Handling · Soft Delete · Transactions
- pkg.go.dev — database/sql — ทางเลือกที่ไม่ใช้ ORM
- Docker Hub — postgres — ตัวเลือก environment ทั้งหมดของ image