บทเรียน 48 · Day 9 · Config

internal/config — ประกอบทุกอย่างเข้าด้วยกัน

~25 นาที · ต้องมี: บทเรียน 45 (caarlos0/env) · บทเรียน 46 (.env) · บทเรียน 47 (validator/v10)

สามบทที่แล้วได้เครื่องมือมาสามตัว: อ่านไฟล์ .env · แปลง env เป็น struct · ตรวจว่าค่าใช้ได้จริง
แต่ละตัวเราลองแยกชิ้น บทนี้คือบทที่เอามาต่อกันเป็นของจริง — โฟลเดอร์ internal/config/ ที่เป็นที่เดียวในโปรเจกต์ที่รู้ว่าแอปต้องการ env อะไรบ้าง
สิ่งที่ผมจะพิสูจน์ให้ดูด้วยผลรันจริง: Go บังคับเรื่อง internal/ ให้เองยังไง · ทำไม Load() ถึง wrap error ของ ParseAs แต่ห้าม wrap error ของ Validate · และ config ที่ผิดสามที่พร้อมกันทำให้แอปตายตั้งแต่ยังไม่ทันเปิดพอร์ต
ระหว่างทางมี สามจุดที่สไลด์เล่าไม่ครบ ซึ่งผมรันแล้วเจอ — จะบอกให้ครบทุกจุด

🧰 ของที่ใช้ในบทนี้ — ทุก output ในหน้านี้มาจากการรันจริงบน

go version go1.26.5 darwin/arm64

github.com/caarlos0/env/v11        v11.4.1
github.com/joho/godotenv           v1.5.1
github.com/go-playground/validator/v10  v10.30.3
gorm.io/gorm                       v1.31.2
gorm.io/driver/postgres            v1.6.2
postgres:16-alpine                 # ผ่าน colima (Docker 29.6.2)

โปรเจกต์ที่ผมรันชื่อ module wongnok/api · go build ./... และ go vet ./... ผ่านทั้งก้อน
เกร็ดที่ต้องรู้ก่อนอ่านต่อ: ผมรัน Postgres ที่พอร์ต 55432 ไม่ใช่ 5432 เพราะพอร์ตนั้นบนเครื่องผมมีของอื่นจองอยู่ — ถ้าคุณเห็นเลขพอร์ตแปลก ๆ ใน DSN ตัวอย่าง นั่นคือสาเหตุ ไม่ใช่กฎอะไร

1 · โครงโปรเจกต์ — โฟลเดอร์ใหม่ที่จะเพิ่ม

สไลด์วางโครงไว้แบบนี้ ส่วนที่เป็นของใหม่ในบทนี้คือ internal/config/ ทั้งโฟลเดอร์:

api/ ├── cmd/ │ └── api/ │ └── main.go ← จุดเริ่มโปรแกรม เรียก config.Load() ที่นี่ที่เดียว ├── internal/ │ ├── config/ ← โฟลเดอร์ใหม่ของบทนี้ │ │ ├── config.go ← Load() · Validate() ที่รวมทุก section │ │ ├── app.go ← APP_NAME · APP_ENV · APP_PORT · APP_SHUTDOWN_TIMEOUT │ │ ├── logging.go ← LOG_LEVEL · LOG_FORMAT │ │ ├── database.go ← POSTGRES_DSN · DB_* │ │ └── config_test.go ← เทสต์ของ config เอง │ ├── httputil/ ← ของเดิม │ └── user/ ← ของเดิม ├── docs/ ← Swagger ที่ swag สร้าง (บทเรียน 41) ├── .env ← ค่าจริงของเครื่องคุณ — ห้าม commit ├── .env.example ← โครงเปล่า ๆ ไว้ให้เพื่อนก๊อป — commit ได้ └── Makefile

หลักการเดียวของโฟลเดอร์นี้: 1 ไฟล์ = 1 เรื่อง
ไม่ได้แบ่งตาม "ไฟล์ยาวเกินไปแล้ว" แต่แบ่งตามกลุ่มของค่าที่เปลี่ยนพร้อมกัน — ค่าที่เกี่ยวกับ log อยู่ใน logging.go ค่าที่เกี่ยวกับฐานข้อมูลอยู่ใน database.go
ผลที่ได้ตอนโปรเจกต์โต: เพิ่ม config กลุ่มใหม่ = เพิ่มไฟล์ใหม่ ไม่ต้องไปแทรกกลางไฟล์เดิมที่คนอื่นกำลังแก้อยู่
วันหนึ่งต้องใช้ Redis ก็สร้าง redis.go แล้วเติมบรรทัดเดียวใน Validate() — จบ

2 · ทำไมต้องเป็นโฟลเดอร์แยก — เหตุผล 4 ข้อ

คำถามที่ควรถามก่อนคือ "ทำไมไม่ประกาศ struct Config ไว้ใน main.go เลย มันก็ที่เดียวเหมือนกัน"
สไลด์ให้เหตุผลไว้สี่ข้อ และมีข้อหนึ่งที่ไม่ใช่เรื่องของวินัย แต่เป็นเรื่องที่ Go บังคับให้เอง:

เหตุผลแปลว่าอะไรในทางปฏิบัติ
จุดเดียวที่รู้ว่าแอปต้องการ env อะไรบ้าง คนใหม่เข้าทีมถามว่า "ต้องตั้งอะไรบ้างถึงจะรันได้" → เปิดโฟลเดอร์เดียว อ่าน struct tag ครบ ไม่ต้อง grep os.Getenv ทั้งโปรเจกต์
internal/ = คนนอก import ไม่ได้ ไม่ใช่ข้อตกลงกันเอง — Go บังคับตอน build ให้เลย ดูผลรันข้างล่าง
ส่งเป็น argument ได้ NewHandler(svc, cfg.Auth) แทนที่จะ os.Getenv ข้างใน handler — handler รับค่าที่ตรวจแล้วมา ไม่ต้องไปหาเอง
test ง่าย config.Database{}.Validate() เรียกได้ตรง ๆ ในเทสต์ — ต้องได้ error เพราะ struct เปล่ายังขาด POSTGRES_DSN

ข้อ 2 — internal/ ไม่ใช่คำแนะนำ มันเป็นกฎของ compiler

คำว่า module ในที่นี้คือหน่วยที่มีไฟล์ go.mod หนึ่งไฟล์คุมอยู่ — โปรเจกต์ wongnok/api ของเราคือหนึ่ง module
กฎของ Go คือ: package ที่อยู่ใต้โฟลเดอร์ชื่อ internal จะ import ได้เฉพาะโค้ดที่อยู่ใต้โฟลเดอร์แม่ของ internal นั้น
ผมทดสอบสองทาง — จาก ใน module เดียวกัน กับจาก module อื่น ที่ replace ชี้มาที่โฟลเดอร์เดียวกันเป๊ะ:

===== A. import จากใน module เดียวกัน (cmd/api → internal/config) =====
inside module, import ok: wongnok
exit=0

===== B. import จาก module อื่น =====
package outsider
	main.go:6:2: use of internal package wongnok/api/internal/config not allowed
exit=1

อ่านเคส B ให้ดี — มันไม่ใช่คำเตือน ไม่ใช่ go vet ที่บ่นแล้วปล่อยผ่าน แต่คือ go build ตกด้วย exit code 1 โค้ดนั้น compile ไม่ออกเลย
แปลว่าถ้าวันหนึ่งมีคนเอา repo คุณไปเป็น library แล้วอยากแอบใช้ config ของคุณ — เขาทำไม่ได้ตั้งแต่แรก คุณจึงแก้ struct Config ได้อย่างอิสระ โดยไม่ต้องกลัวว่าจะไปพังของใคร
เทียบกับการวางไว้ที่ pkg/config/ ซึ่งใครก็ import ได้: พอมีคนใช้แล้ว การเปลี่ยนชื่อ field ก็กลายเป็น breaking change ทันที

🚪 เทียบให้เห็นภาพ — ประตูหลังร้านที่ล็อกจริง ไม่ใช่แค่ป้ายแปะ

ร้านกาแฟร้านหนึ่งมีสองโซน: หน้าร้านที่ลูกค้าเดินเข้าได้ กับหลังร้านที่เป็นห้องเก็บของกับครัว
โฟลเดอร์ internal/ คือหลังร้าน — และมันไม่ใช่ป้ายกระดาษเขียนว่า "เฉพาะพนักงาน" ที่ใครอยากเดินผ่านก็ผ่านได้ แต่เป็นประตูที่ล็อกอยู่จริง
"พนักงาน" ในที่นี้คือโค้ดที่อยู่ใต้โฟลเดอร์แม่ของ internal — cmd/api/main.go เดินเข้าได้สบาย เพราะอยู่ในร้านเดียวกัน (module เดียวกัน)
ส่วนคนจากร้านอื่น (module อื่น) ดันประตูยังไงก็ไม่เปิด — ผลรันเคส B ข้างบนคือเสียงประตูที่ไม่ยอมเปิดนั่นเอง

ที่ไม่เหมือนกัน: สองข้อ และทั้งสองข้อคือจุดที่คนเข้าใจผิดบ่อย —
(1) ประตูล็อกของจริงกันไม่ให้คนเห็นของข้างใน แต่ internal/ ไม่ได้กันเรื่องนั้นเลย ใครเปิด repo ก็อ่านโค้ดได้ครบทุกบรรทัด · มันกันแค่การเขียน import ข้าม module เท่านั้น
(2) มันไม่ใช่เครื่องมือด้านความปลอดภัย — ย้าย .env ไปวางใต้ internal/ ก็ไม่ได้ทำให้ secret ปลอดภัยขึ้นแม้แต่นิดเดียว เรื่องนั้นเป็นหน้าที่ของ .gitignore คนละชั้นกัน

🗣 ศัพท์ที่เพิ่งเรียนไป
module
"ร้านทั้งร้าน" = 1 โปรเจกต์ในสายตา Go · หน่วยที่มีไฟล์ go.mod คุมอยู่หนึ่งไฟล์ · ประโยคที่เอาไปพูดได้จริง: "cmd กับ internal อยู่ใน module เดียวกันไหมครับ"
module path
ชื่อเต็มของ module ที่เขียนไว้บรรทัดแรกของ go.mod — ของเราคือ wongnok/api · พิมพ์ถาม AI ได้เลย: "module path ผมคือ wongnok/api ทำไม import wongnok/api/internal/config จากอีก module ไม่ได้"
internal package
package ที่อยู่ใต้โฟลเดอร์ชื่อ internal เป๊ะ ๆ (สะกดอื่นไม่นับ) · พูดว่า "ผมย้าย config ไปเป็น internal package แล้ว ข้างนอก import ไม่ได้" จะสื่อกว่าพูดว่า "ผมย้ายไปโฟลเดอร์ชื่อ internal"
breaking change
การแก้ที่ทำให้โค้ดของคนที่ใช้ของเราอยู่พัง เช่นเปลี่ยนชื่อ field · ประโยคที่จะได้ยินจาก senior: "อยู่ใต้ internal ก็เปลี่ยนชื่อ field ได้เลย ไม่นับเป็น breaking change"
compile error (build ตก)
พังตั้งแต่ตอนแปลโค้ด ยังไม่ทันได้รันสักบรรทัด — ต่างจาก "พังตอน runtime" ที่โปรแกรมเริ่มทำงานไปแล้ว · เคส B ข้างบนคือ compile error และนี่คือเหตุผลที่มันน่าเชื่อถือกว่าข้อตกลงในทีม

3 · anti-pattern vs pattern — env ต้องอ่านที่ไหน

นี่คือหัวใจของทั้งบท และเป็นจุดที่คนพลาดกันมากที่สุดตอนเริ่มใช้ config struct: มี struct แล้ว แต่ยังแอบเรียก os.Getenv ลึก ๆ ข้างในอยู่ดี

❌ ห้าม — อ่าน env ลึกใน handler

func (h *Handler) Login(c *gin.Context) {
    secret := os.Getenv("JWT_SECRET")
    // ...
}

ลืมตั้ง env → secret เป็น "" · ไม่มีอะไรเตือน handler ยังตอบ 200 ตามปกติ

✅ ทำแบบนี้ — รับ config ทาง constructor

func NewHandler(svc Service, cfg config.Auth) *Handler {
    return &Handler{svc: svc, cfg: cfg}
}

ค่าที่ handler ได้ ผ่าน validate มาแล้ว ตั้งแต่ตอน start · ขาดค่า = แอปไม่ขึ้นตั้งแต่แรก

ฟังดูเป็นเรื่องความสวยงามของโค้ด แต่มันไม่ใช่ ผมเขียนสองแบบให้ยิงจริงด้วย httptest โดยไม่ตั้ง JWT_SECRET เลย (env -u JWT_SECRET) แล้วดูว่าแต่ละแบบตอบอะไร:

JWT_SECRET ตอนนี้ = ""

=== แบบห้าม (os.Getenv ใน handler) ===
status : 200
body   : signed with secret="" (len=0)

=== แบบทำ (validate ตอน start ก่อนถึง handler) ===
แอปตายตั้งแต่ตอน start:
Key: 'Auth.JWTSecret' Error:Field validation for 'JWTSecret' failed on the 'required' tag
⚠️ บรรทัด status : 200 คือบรรทัดที่น่ากลัวที่สุดในบทนี้

handler แบบซ้าย ทำงานสำเร็จ — มัน "เซ็น" token ด้วยกุญแจที่มีความยาว 0 แล้วตอบกลับสวยงาม ไม่มี error ไม่มี log ไม่มีอะไรผิดปกติให้เห็นเลย
token ที่เซ็นด้วยกุญแจว่างคือ token ที่ใครก็ปลอมได้ — และคุณจะรู้ตัวก็ตอนที่มีคนปลอมแล้ว
ส่วนแบบขวา แอปไม่ยอมเริ่มทำงานตั้งแต่แรก ซึ่งน่ารำคาญกว่ามากตอนพัฒนา แต่เป็นความน่ารำคาญที่คุณเจอตอนบ่ายวันอังคาร ไม่ใช่ตอนตีสามวันเสาร์

📌 หลักการที่จำไปใช้ได้ตลอด: อ่าน env ที่ "ขอบ" ของแอปครั้งเดียว แล้วส่งค่าต่อลงไปเป็น argument
ขอบของแอปคือ main() — จุดเดียวที่รู้จักโลกภายนอก
ทุกอย่างที่ลึกกว่านั้น (handler · service · repository) ไม่ควรรู้ด้วยซ้ำว่ามีสิ่งที่เรียกว่า environment variable อยู่ในโลก มันควรรู้แค่ว่า "ฉันได้ cfg.Auth มา และค่าในนั้นใช้ได้"

🗣 ศัพท์ที่เพิ่งเรียนไป
fail fast
"ถ้าจะพัง พังตั้งแต่ต้นเลย" — ของไม่พร้อมก็อย่าเพิ่งเปิดร้าน · ประโยคที่เอาไปพูดได้จริง: "ผมอยากให้ config fail fast ตอน start ไม่ใช่ไปพังตอน request แรก"
dependency injection (DI)
"ยื่นของที่ต้องใช้ให้ถึงมือ" แทนที่จะปล่อยให้มันเดินไปหาเอง · NewHandler(svc, cfg.Auth) คือ DI แบบง่ายที่สุด ไม่ต้องมี framework อะไรเลย · พูดว่า "handler ตัวนี้รับ config ทาง constructor แบบ DI"
constructor
ฟังก์ชัน NewXxx() ที่ประกอบของให้พร้อมใช้ก่อนส่งคืน · Go ไม่มี constructor ในตัวภาษาแบบ Java/C# เลยใช้ธรรมเนียมตั้งชื่อขึ้นต้นด้วย New แทน
silent failure
พังแบบไม่มีใครรู้ ไม่มี error ไม่มี log · บรรทัด status : 200 ที่เซ็น token ด้วยกุญแจความยาว 0 ข้างบน คือตัวอย่างที่ชัดที่สุดในบทนี้ · พิมพ์ถาม AI ได้: "โค้ดนี้มีจุดไหนเป็น silent failure บ้าง"
boundary / edge ของแอป
"ขอบ" ที่แอปติดกับโลกภายนอก — env · ไฟล์ · network · ในบทนี้คือ main() · หลักที่ senior พูดกันบ่อย: "อ่านของจากข้างนอกที่ boundary ครั้งเดียว แล้วส่งค่าต่อเข้าไปข้างใน"

4 · config.go — ตัวประกอบ

ไฟล์นี้ไม่ประกาศ env สักตัว หน้าที่มันมีสองอย่างเท่านั้น: รวม section เข้าด้วยกัน กับ เป็นประตูเดียวที่คนนอกเรียกเข้ามา

// internal/config/config.go
package config

import (
	"errors"
	"fmt"

	"github.com/caarlos0/env/v11"
	"github.com/go-playground/validator/v10"
)

// ใช้ตัวเดียวทั้ง package — validator แคชข้อมูล struct ไว้ข้างใน
var validate = validator.New(validator.WithRequiredStructEnabled())

type Config struct {
	App      App
	Logging  Logging
	Database Database
}

func Load() (Config, error) {
	cfg, err := env.ParseAs[Config]()
	if err != nil {
		return Config{}, fmt.Errorf("parse environment: %w", err)
	}

	// ไม่ wrap ด้วย fmt.Errorf — errors.Join จัดบรรทัดไว้แล้ว
	if err := cfg.Validate(); err != nil {
		return Config{}, err
	}

	return cfg, nil
}

// รวม error ของ "ทุก section" มาแสดงทีเดียว
// เพิ่ม Redis? สร้าง redis.go + เติม 1 บรรทัดตรงนี้
func (cfg Config) Validate() error {
	return errors.Join(
		cfg.App.Validate(),
		cfg.Logging.Validate(),
		cfg.Database.Validate(),
	)
}

อ่านลำดับใน Load() ให้ชัด — มันคือสามบรรทัดที่สรุปทั้งสามบทที่แล้ว: ParseAs → Validate → คืนค่า
และมีรายละเอียดสองอย่างที่ดูเหมือนเรื่องสไตล์ แต่จริง ๆ มีเหตุผลที่วัดได้

รายละเอียดที่ 1 — ทำไม wrap อันบน แต่ไม่ wrap อันล่าง

fmt.Errorf("...: %w", err) คือการ ห่อ error เดิมไว้ข้างใน แล้วเติมข้อความนำหน้า เพื่อบอกว่ามันเกิดที่ไหน (%w คือช่องที่เก็บ error ตัวเดิมไว้ให้ errors.Is/errors.As ขุดกลับมาได้)
ส่วน errors.Join(a, b, c) คือการ มัด error หลายตัวเป็นก้อนเดียว โดยที่ตัวไหนเป็น nil มันข้ามให้ และถ้าเป็น nil หมดก็คืน nil
คำถามคือทำไมอันหนึ่ง wrap อีกอันไม่ wrap ทั้งที่มันดูเป็น error เหมือนกัน

ทายก่อนรัน — errors.Join เอา error สองตัวมามัดกัน แล้วผมพิมพ์ออกมาสองแบบ

joined := errors.Join(errA, errB)

fmt.Println(joined)                                    // A. คืนตรง ๆ
fmt.Println(fmt.Errorf("validate config: %w", joined))  // B. wrap ก่อน

errors.Join คั่น error สองตัวด้วยอะไร? และพอเอาไป wrap ด้วย fmt.Errorf แล้ว คำว่า validate config: จะไปโผล่ตรงไหน — ทุกบรรทัด หรือบรรทัดเดียว?

===== A. คืน err ตรง ๆ (ไม่ wrap) =====
Key: 'App.Port' Error:Field validation for 'Port' failed on the 'lte' tag
Key: 'Logging.Level' Error:Field validation for 'Level' failed on the 'oneofci' tag

===== B. wrap ด้วย fmt.Errorf("validate config: %w") =====
validate config: Key: 'App.Port' Error:Field validation for 'Port' failed on the 'lte' tag
Key: 'Logging.Level' Error:Field validation for 'Level' failed on the 'oneofci' tag

errors.Join คั่นด้วย \n (ขึ้นบรรทัดใหม่) ไม่มีช่องว่าง ไม่มีตัวคั่นอื่น และไม่มี \n ปิดท้าย — ผมยืนยันด้วยการพิมพ์แบบ %q: errors.Join(a,b,c) ของสตริง alpha/beta/gamma ได้ "alpha\nbeta\ngamma" ยาว 16 ตัวอักษรพอดี (5+1+4+1+5)

พอ wrap แล้ว prefix ไปแปะแค่บรรทัดแรกบรรทัดเดียว — บรรทัดที่สองกลายเป็นบรรทัดลอย ๆ ที่อ่านแล้วไม่รู้ว่าเป็นพวกกับใคร
ถ้ามี error ห้า section ก็จะได้ prefix หนึ่งครั้งกับบรรทัดกำพร้าอีกสี่บรรทัด

นี่คือคำตอบทั้งหมด: ParseAs คืน error ก้อนเดียว การ wrap จึงช่วยบอกที่มาได้จริง · แต่ Validate คืน error ที่ จัดบรรทัดของตัวเองมาเรียบร้อยแล้ว การ wrap มีแต่จะทำให้รูปแบบเสีย
กฎที่ใช้ได้ทั่วไป: wrap error ที่เป็นก้อนเดียว · อย่า wrap error ที่เป็นหลายบรรทัด

📦 เทียบให้เห็นภาพ — สติกเกอร์ที่แปะติดได้แค่ชิ้นบนสุด

ที่ศูนย์กระจายสินค้า ของถูกตีกลับมาสองแบบ
· fmt.Errorf("...: %w", err) = เอาของใส่กล่องอีกใบ แล้วเขียนหน้ากล่องว่ามาจากไหน — "ตีกลับจากคลัง A" · แกะกล่องออกมา ของเดิมยังอยู่ครบ ไม่ได้หายไปไหน
· errors.Join(a, b, c) = เอาของสามชิ้นมามัดรวมด้วยเชือกเส้นเดียว ไม่มีกล่องครอบ วางเรียงกันสามชิ้นอย่างนั้น
ทีนี้ถ้าเอาสติกเกอร์ "ตีกลับจากคลัง A" ไปแปะบนมัดสามชิ้นนั้น มันแปะติดได้แค่ชิ้นบนสุด — อีกสองชิ้นไม่มีป้าย ใครมาหยิบไปก็ไม่รู้ว่าเป็นพวกกับใคร
นี่คือเหตุผลทั้งหมดที่ Load() wrap อันบน แต่ไม่ wrap อันล่าง

ที่ไม่เหมือนกัน: ที่ศูนย์จริงเราเดินไปแปะสติกเกอร์เพิ่มทีละชิ้นได้ แต่ error ใน Go เวลาพิมพ์ออกมาเป็นข้อความก้อนเดียวที่มี \n คั่นอยู่ข้างใน — fmt.Errorf ทำได้อย่างเดียวคือต่อข้อความไว้ข้างหน้าสุด ไม่มีวิธีสั่งให้มันไปแปะทุกบรรทัด

🗣 ศัพท์ที่เพิ่งเรียนไป
wrap error
"ใส่กล่องแล้วเขียนหน้ากล่อง" — เติมข้อความบอกที่มา โดยที่ error เดิมยังอยู่ข้างในครบ · ประโยคที่เอาไปพูดได้จริง: "ตรงนี้ผม wrap error ด้วย %w เพื่อให้รู้ว่าพังตอนอ่าน env"
%w เทียบกับ %v
ข้อความที่พิมพ์ออกมาเหมือนกันทุกตัวอักษร แต่ %w เก็บ error ตัวเดิมไว้ให้ขุดกลับได้ ส่วน %v เก็บแค่ตัวหนังสือ · ผมรันเทียบมาแล้ว: ทั้งคู่พิมพ์ออกมาว่า layer: base failure เหมือนกัน แต่ errors.Is ตอบ true กับตัวที่ใช้ %w และตอบ false กับตัวที่ใช้ %v
unwrap / error chain
"โซ่ของ error ที่ห่อกันเป็นชั้น ๆ" · การแกะกล่องออกทีละชั้นเรียกว่า unwrap · พิมพ์ถาม AI ได้: "ช่วยไล่ error chain ของ error ตัวนี้ให้หน่อย"
errors.Is / errors.As
สองวิธีขุดหาของในโซ่ — Is ถามว่า "ในโซ่มี error ตัวนี้ อยู่ไหม" · As ถามว่า "ในโซ่มี error ชนิดนี้ ไหม ถ้ามีขอตัวมันมาใช้ด้วย" · Humanize() ในหัวข้อ 8 ใช้ As
multi-error
error หลายตัวที่ถูกมัดเป็นก้อนเดียว — ในไลบรารีมาตรฐานคือ errors.Join · พูดว่า "Validate() มันคืน multi-error นะ อย่าไป wrap ทับ"

รายละเอียดที่ 2 — Load() คืน Config ไม่ใช่ *Config

สังเกตว่าตอน error โค้ดเขียน return Config{}, err ไม่ใช่ return nil, err — เพราะมันคืนค่า (value) ไม่ใช่ตัวชี้ (pointer)
ผมรันเทียบสองเวอร์ชันให้ดูว่ามันต่างกันตรงไหนจริง ๆ:

sizeof(config.Config)  = 120 bytes
sizeof(*config.Config) = 8 bytes

[value ] err != nil? true | cfgVal.App.Port = 0 | cfgVal.App.Addr() = ":0"
[ptr   ] err != nil? true | cfgPtr == nil? true
[ptr   ] panic ตอนอ่าน cfgPtr.App.Port: runtime error: invalid memory address or nil pointer dereference

เวอร์ชัน value ตอน error คืน Config{} ที่ยังเรียกเมธอดต่อได้ — Addr() ตอบ ":0" ซึ่งอาจไม่มีประโยชน์ แต่มันไม่พัง
เวอร์ชัน pointer คืน nil ถ้าคนเรียกเผลอลืมเช็ค err ก็ panic ทันที
ราคาที่จ่ายคือ copy 120 byte แทน 8 byte ซึ่งเล็กมาก และ Load() ถูกเรียกครั้งเดียวตอน start

แต่เหตุผลที่หนักแน่นกว่านั้นคือข้อสอง — ใครแก้ config หลัง validate ผ่านไปแล้วได้บ้าง ผมเขียน handler สองตัว ตัวหนึ่งรับ value ตัวหนึ่งรับ pointer แล้วให้ทั้งคู่สั่ง cfg.App.Port = 1 เหมือนกัน:

ก่อนเรียก              : Port=8080 Addr=":8080"
หลัง handlerA(value)   : Port=8080 Addr=":8080"
หลัง handlerB(pointer) : Port=1 Addr=":1"  <-- validate ผ่านไปแล้ว แต่ค่าเปลี่ยนได้

ส่ง value = แต่ละคนได้สำเนาของตัวเอง แก้แล้วไม่กระทบตัวจริง → ค่าที่ผ่าน validate แล้ว ยัง valid ตลอดอายุโปรแกรม
ส่ง pointer = ใครก็ตามที่ถือตัวชี้อยู่ แก้ค่าที่ผ่านการตรวจแล้วได้ทุกเมื่อ แล้วการ validate ตอน start ก็หมดความหมายไปเลย

📄 เทียบให้เห็นภาพ — ถ่ายสำเนาแจก กับ ส่งตัวจริงเวียนไปทุกแผนก

เอกสารใบหนึ่งที่ผ่านการตรวจและประทับตราเรียบร้อยแล้ว ต้องส่งให้อีกสามแผนกใช้งาน ทำได้สองวิธี
· ถ่ายสำเนาแจกคนละใบ (คืน Config เป็น value) — ใครจะขีดจะเขียนบนสำเนาของตัวเองก็เรื่องของเขา ตัวจริงไม่เปลี่ยน
· ส่งตัวจริงเวียนไปทีละแผนก (คืน *Config) — แผนกที่สองขีดฆ่าตัวเลขทิ้ง แผนกที่สามก็เห็นรอยขีดนั้นด้วย ทั้งที่ตราประทับยังอยู่ · นี่คือบรรทัด Port=1 Addr=":1" ในผลรันข้างบน

แล้วตอนผิดพลาด สองแบบนี้ยื่นของกลับมาไม่เหมือนกันด้วย
· value คืน Config{} = ยื่นแบบฟอร์มเปล่าที่ยังไม่ได้กรอก — เปิดอ่านได้ทุกช่อง แค่ว่างเปล่า (Addr() จึงตอบ ":0" ออกมาได้โดยไม่พัง)
· pointer คืน nil = ยื่นซองเปล่ามาให้ — พอเปิดซองหาเอกสารแล้วไม่มีอะไรอยู่ข้างใน โปรแกรมก็ล้มทันที

ที่ไม่เหมือนกัน: สองข้อ —
(1) ถ่ายสำเนาในชีวิตจริงเสียทั้งเวลาและกระดาษ แต่ในโค้ดคือคัดลอก 120 byte ซึ่งเร็วจนวัดแทบไม่ออก และ Load() ถูกเรียกครั้งเดียวตอน start เท่านั้น
(2) สำเนาที่ Go ทำให้เป็นสำเนาชั้นเดียว — Config ของเรามีแต่ string · int · time.Duration จึงแยกขาดจากกันได้จริง แต่ถ้าวันหนึ่งเติม field ที่เป็น slice หรือ map เข้าไปเมื่อไหร่ ของข้างในนั้นยังชี้ไปที่เดียวกัน สำเนาจะไม่ได้แยกขาดอีกต่อไป

🗣 ศัพท์ที่เพิ่งเรียนไป
value / pass by value
"ส่งสำเนา" · Go ส่ง argument แบบคัดลอกเสมอ ไม่มีข้อยกเว้น — ที่ pointer แก้ของต้นทางได้ ก็เพราะสิ่งที่ถูกคัดลอกไปคือที่อยู่ ไม่ใช่ตัวของ
pointer
"ที่อยู่ของของ" ไม่ใช่ตัวของ · เขียนว่า *Config · ขนาดของมันคงที่ 8 byte บนเครื่อง 64-bit ไม่ว่า struct ปลายทางจะใหญ่แค่ไหน
zero value
ค่าเริ่มต้นที่ Go แจกให้เองเมื่อเราไม่ได้กำหนด — 0 · "" · nil · Config{} คือ zero value ของทั้ง struct · จำคำนี้ไว้ให้ดี เดี๋ยวหัวข้อ 5 จะกลับมาใช้ ตอนอธิบายว่า required เช็คอะไรกันแน่
nil pointer dereference
"เปิดซองเปล่าแล้วหาของข้างใน" — สาเหตุ panic อันดับหนึ่งของมือใหม่ · เห็นข้อความ invalid memory address or nil pointer dereference เมื่อไหร่ ให้ไปหาว่าตัวแปรตัวไหนเป็น nil ตอนถูกเรียกใช้
panic
โปรแกรมล้มกลางทางแบบไม่ได้วางแผนไว้ ต่างจากการคืน error ที่เราตั้งใจให้เกิด · ประโยคที่เอาไปพูดได้จริง: "ตรงนี้ผมอยากให้คืน error ไม่อยากให้ panic"
shallow copy
"สำเนาชั้นเดียว" — คัดลอกแค่ชั้นบนสุด ของที่ชี้ต่อลงไป (slice · map · pointer) ยังใช้ร่วมกันอยู่ · พิมพ์ถาม AI ได้: "struct ตัวนี้ copy แล้วเป็น shallow copy ตรงไหนบ้าง"

5 · app.go — section แรก

ไฟล์แต่ละ section หน้าตาเหมือนกันหมดสามส่วน: struct + helper + Validate() ของตัวเอง

// internal/config/app.go
package config

import (
	"fmt"
	"time"
)

type App struct {
	Name            string        `env:"APP_NAME" envDefault:"wongnok" validate:"required"`
	Env             string        `env:"APP_ENV" envDefault:"development" validate:"required,oneof=development staging production"`
	Port            int           `env:"APP_PORT" envDefault:"8080" validate:"gte=1,lte=65535"`
	ShutdownTimeout time.Duration `env:"APP_SHUTDOWN_TIMEOUT" envDefault:"10s" validate:"required"`
}

func (app App) Addr() string       { return fmt.Sprintf(":%d", app.Port) }
func (app App) IsProduction() bool { return app.Env == "production" }

func (app App) Validate() error { return validate.Struct(app) }
envdefaultกฎที่บังคับตกแล้วเห็นอะไร (ผลรันจริง)
APP_NAME wongnok required —
APP_ENV development oneof=development staging production APP_ENV=prodction (พิมพ์ตก i) → Key: 'App.Env' … failed on the 'oneof' tag
APP_PORT 8080 gte=1,lte=65535 APP_PORT=99999 → Key: 'App.Port' … failed on the 'lte' tag
APP_SHUTDOWN_TIMEOUT 10s required — ห้ามเป็น 0s APP_SHUTDOWN_TIMEOUT=0s → Key: 'App.ShutdownTimeout' … failed on the 'required' tag

ทุกเคสในคอลัมน์ขวาผมรันจริง และทุกเคส env.ParseAs ผ่านหมด — "99999" เป็น int ได้ · "prodction" เป็น string ได้ · "0s" เป็น time.Duration ได้
นี่คือการแบ่งงานที่ชัดเจนระหว่างสองไลบรารี: env ตอบว่า "แปลงเป็นชนิดนั้นได้ไหม" · validator ตอบว่า "ค่านั้นใช้ได้จริงไหม"

ทายก่อนรัน — ShutdownTimeout ติด validate:"required" อยู่ และเราเพิ่งเห็นว่ามันจับ 0s ได้
ทีนี้มีคนพิมพ์ผิดเป็นค่าติดลบ:

$ env APP_SHUTDOWN_TIMEOUT=-5s go run ./cmd/probe

ผลจะเป็นอย่างไร? ParseAs พังไหม · validator จับได้ไหม
(คำใบ้: ลองนิยามคำว่า required ให้แม่นก่อน — มันเช็คอะไรกันแน่?)

$ env POSTGRES_DSN="postgres://u:p@localhost:5432/db" APP_SHUTDOWN_TIMEOUT=-5s go run ./cmd/probe
ParseAs  : OK -> App.Port=8080 App.Env="development" Log.Level="info" Shutdown=-5s DSN="postgres://u:p@localhost:5432/db"
Validate : OK

ผ่านฉลุยทั้งสองด่าน — ค่าติดลบหลุดเข้าไปในแอปเรียบร้อย
เหตุผล: required ของ validator แปลว่า "ห้ามเป็น zero value" เท่านั้น ไม่ได้แปลว่า "ต้องมากกว่าศูนย์"
zero value ของ time.Duration คือ 0s — ส่วน -5s ไม่ใช่ zero มันจึงผ่านกฎ อย่างถูกต้องตามนิยาม

ผมลองเติม gt=0 เข้าไปแล้วรันซ้ำทั้งสี่ค่า:

// validate:"required,gt=0"
-5s    -> Key: 'A.T' Error:Field validation for 'T' failed on the 'gt' tag
0s     -> Key: 'A.T' Error:Field validation for 'T' failed on the 'required' tag
500ms  -> <nil>
10s    -> <nil>

คราวนี้ครบ — required รับหน้าที่กัน 0s ส่วน gt=0 กันค่าติดลบ
⚠️ ข้อนี้ต่างจากสไลด์: สไลด์โชว์เคส 0s ว่าโดนจับ ซึ่งถูก แต่ทำให้เข้าใจไปว่า required คุมค่าเวลาได้ครบ — มันคุมได้ครึ่งเดียว
ของจริงที่ควรเขียนคือ validate:"required,gt=0"

⚠️ กับดักพี่น้องกันอีกข้อ: gte=1 เปล่า ๆ บน time.Duration

สไลด์เขียนบรรทัดเดียวรวบว่า "gte=1,lte=65535 — ช่วงตัวเลข ใช้กับ time.Duration ได้ เช่น gte=1s" ซึ่งปนกันสองเรื่อง ผมทดสอบแยกให้ดู:

// tag: gte=1  (ไม่ใส่หน่วย)
gte=1 with 1ns  -> <nil>
gte=1 with 0    -> Key: 'A.T' Error:Field validation for 'T' failed on the 'gte' tag
gte=1 with 500ms -> <nil>

// tag: gte=1s,lte=30s  (ใส่หน่วย)
0s     -> Key: 'B.T' Error:Field validation for 'T' failed on the 'gte' tag
1s     -> <nil>
15s    -> <nil>
31s    -> Key: 'B.T' Error:Field validation for 'T' failed on the 'lte' tag

gte=1 บน Duration หมายถึง 1 นาโนวินาที ไม่ใช่ 1 วินาที เพราะข้างในมันเป็น int64 ที่นับเป็นนาโน — 500ms จึงผ่านสบาย ๆ
ถ้าจะคุมเป็นหน่วยเวลา ต้องเขียนหน่วยเสมอ: gte=1s
ข่าวดี: gte/lte เป็นแบบ inclusive ทั้งคู่ — 1s พอดีผ่าน · 31s ตก

6 · logging.go — section ที่สั้นที่สุด

// internal/config/logging.go
package config

// ไฟล์นี้ไม่ต้อง import อะไรเลย — validate มาจาก config.go ที่อยู่ package เดียวกัน

type Logging struct {
	Level  string `env:"LOG_LEVEL" envDefault:"info" validate:"required,oneofci=debug info warn error"`
	Format string `env:"LOG_FORMAT" envDefault:"json" validate:"required,oneofci=json text"`
}

func (logging Logging) Validate() error { return validate.Struct(logging) }

ตัวใหม่ในนี้คือ oneofci — เหมือน oneof ทุกอย่าง ยกเว้นว่าไม่สนตัวพิมพ์ใหญ่เล็ก (ci = case-insensitive) ผมรันยืนยัน:

---- tag: oneofci=Dev Staging PROD ----
oneofci "dev"    -> <nil>
oneofci "DEV"    -> <nil>
oneofci "prod"   -> <nil>
oneofci "PrOd"   -> <nil>
oneofci "qa"     -> Key: 'C.Env' Error:Field validation for 'Env' failed on the 'oneofci' tag

เขียนรายการใน tag เป็นตัวพิมพ์อะไรก็ได้ ผู้ใช้จะพิมพ์มาแบบไหนก็ได้ ตราบใดที่ตัวสะกดตรง
เหมาะกับ log level มาก เพราะคนตั้งกันทั้ง debug, DEBUG, Debug ซึ่งทั้งสามอันหมายถึงสิ่งเดียวกันจริง ๆ

🧭 แล้วทำไม APP_ENV ถึงใช้ oneof ธรรมดา ไม่ใช้ oneofci?
สองบรรทัดนี้อยู่คนละไฟล์แต่ควรเข้าใจคู่กัน — มันไม่ใช่ความไม่สม่ำเสมอ แต่เป็นการเลือกที่มีเหตุผล
· LOG_LEVEL เป็นค่าที่คนพิมพ์ให้มนุษย์อ่าน ผิดพิมพ์ใหญ่เล็กไม่ได้แปลว่าเจตนาเปลี่ยน → ยอมรับหมด
· APP_ENV เป็นค่าที่โค้ดเอาไปเทียบตรง ๆ — ดู IsProduction() ที่เขียน app.Env == "production" ไว้
ถ้ายอมรับ PRODUCTION เข้ามาได้ IsProduction() จะตอบ false ทั้งที่กำลังรันอยู่บน production จริง — เข้มไว้ตรงนี้ปลอดภัยกว่า

⚠️ oneof ห้ามคั่นด้วย comma — และมันไม่พังตรงที่คุณคิด

รายการใน oneof คั่นด้วย ช่องว่าง เท่านั้น ถ้าเผลอเขียน oneof=dev,staging,prod ตามความเคยชิน ผลคือ:

before Struct()
panic: Undefined validation function 'staging' on field 'Env'

goroutine 1 [running]:
github.com/go-playground/validator/v10.(*Validate).parseFieldTagsRecursive(...)
	.../cache.go:311 +0x9e4
github.com/go-playground/validator/v10.(*Validate).extractStructCache(...)
github.com/go-playground/validator/v10.(*Validate).Struct(...)
main.main()
exit status 2

สังเกตบรรทัดแรกให้ดี: before Struct() ถูกพิมพ์ออกมาแล้ว
แปลว่า validator.New() ผ่านฉลุย — โปรแกรมเริ่มทำงานได้ปกติ แล้วไป panic ตอนเรียก validate.Struct() ครั้งแรก (จังหวะที่มันสร้าง cache ของ struct นั้น)
และข้อความ error ไม่มีคำว่า oneof อยู่เลยสักตัว — มันบอกว่า "ไม่รู้จัก validator ชื่อ staging" เพราะ comma ถูกอ่านเป็นตัวคั่นระหว่าง tag staging กับ prod จึงกลายเป็นชื่อกฎที่ไม่มีอยู่จริง
⚠️ จุดนี้สไลด์บอกแค่ว่า "จะ panic" ซึ่งทำให้เข้าใจว่าพังตั้งแต่ตอน init — ของจริงคือ go build ผ่าน · แอป start ผ่าน · แล้วไปตายตอน request แรก

7 · database.go — ที่ที่ struct tag ไปไม่ถึง

ถ้าอ่านสามไฟล์ที่แล้วแล้วสรุปว่า "ก็แค่แปะ tag ให้ครบ ก็ไม่ต้องเขียน if อีกเลย" — ไฟล์นี้คือไฟล์ที่มาบอกว่าไม่จริง

ก่อน — เขียน if เองทุกกฎ

// internal/config/database.go — เวอร์ชันก่อน
package config

import (
	"errors"
	"time"
)

type Database struct {
	PostgresDSN     string        `env:"POSTGRES_DSN"`
	MaxOpenConns    int           `env:"DB_MAX_OPEN_CONNS" envDefault:"25"`
	MaxIdleConns    int           `env:"DB_MAX_IDLE_CONNS" envDefault:"25"`
	ConnMaxLifetime time.Duration `env:"DB_CONN_MAX_LIFETIME" envDefault:"5m"`
}

func (database Database) Validate() error {
	if database.PostgresDSN == "" {
		return errors.New("POSTGRES_DSN is required")
	}
	if database.MaxOpenConns < 1 || database.MaxOpenConns > 500 {
		return errors.New("DB_MAX_OPEN_CONNS must be between 1 and 500")
	}
	if database.ConnMaxLifetime <= 0 {
		return errors.New("DB_CONN_MAX_LIFETIME must be greater than 0")
	}
	return nil
}

ยาว · เห็นทีละ error (return ทันทีที่เจอตัวแรก) · ข้อความเป็นภาษาที่เราเขียนเอง

หลัง — ย้ายกฎขึ้นไปอยู่บน tag

// internal/config/database.go — เวอร์ชันหลัง
package config

import "time"

type Database struct {
	PostgresDSN     string        `env:"POSTGRES_DSN" validate:"required"`
	MaxOpenConns    int           `env:"DB_MAX_OPEN_CONNS" envDefault:"25" validate:"gte=1,lte=500"`
	MaxIdleConns    int           `env:"DB_MAX_IDLE_CONNS" envDefault:"25" validate:"gte=1,lte=500"`
	ConnMaxLifetime time.Duration `env:"DB_CONN_MAX_LIFETIME" envDefault:"5m" validate:"required"`
}

func (database Database) Validate() error {
	return validate.Struct(database)
}

สั้น · เห็น error ครบทุก field พร้อมกัน · กฎอยู่ติดกับ field ที่มันคุม

⚠️ จุดที่สไลด์ข้าม — การ refactor นี้ทำ behaviour หายไปหนึ่งข้อ

ผมสลับไฟล์สองเวอร์ชันแล้วรันด้วย env ตัวเดียวกันเป๊ะ:

$ env DB_CONN_MAX_LIFETIME=-5s go run ./cmd/probe

########## [ก่อน refactor]
ParseAs  : OK
Validate : FAILED ->
DB_CONN_MAX_LIFETIME must be greater than 0

########## [หลัง refactor]
ParseAs  : OK
Validate : OK          <-- ค่าติดลบหลุดไปแล้ว

if เดิมเขียนว่า ConnMaxLifetime <= 0 ซึ่งกันติดลบได้ แต่ validate:"required" กันได้แค่ 0s — เหตุผลเดียวกับหัวข้อ 5
ถ้าจะให้เท่าเดิมจริง ๆ ต้องเขียน validate:"required,gt=0"
บทเรียนที่ใหญ่กว่าเรื่องนี้: เวลา refactor validation อย่าเทียบว่า "โค้ดสั้นลงไหม" ให้เทียบว่า ค่าชุดเดิมยังให้ผลเดิมไหม — วิธีที่ถูกคือเขียนเทสต์ก่อน refactor ไม่ใช่หลัง

แล้วความต่างอีกข้อที่ควรรู้: เวอร์ชัน if คืน error ทีละตัว (เจอ POSTGRES_DSN ว่างก็ return เลย ไม่ดูข้ออื่นต่อ) · ส่วน validate.Struct คืนครบทุก field พร้อมกัน ผมยืนยันด้วย struct ที่ผิด 5 field รวดเดียว:

=== err.Error() ===
Key: 'App.Name' Error:Field validation for 'Name' failed on the 'required' tag
Key: 'App.Port' Error:Field validation for 'Port' failed on the 'lte' tag
Key: 'App.Env' Error:Field validation for 'Env' failed on the 'oneof' tag
Key: 'App.Host' Error:Field validation for 'Host' failed on the 'hostname|ip' tag
Key: 'App.Timeout' Error:Field validation for 'Timeout' failed on the 'gte' tag
=== count ===
len: 5

ครบทั้งห้าจริง ๆ — และสังเกตว่าแต่ละบรรทัดถูกคั่นด้วย \n มาให้แล้ว ตั้งแต่ก่อนถึงมือ errors.Join (validator มี ValidationErrors.Error() ที่ทำเอง)

สิ่งที่ struct tag เขียนไม่ได้ — และเหตุผลที่ Validate() ต้องเป็น method

สไลด์หน้า database.go ยกตัวอย่างกฎที่ไม่มี tag ไหนเขียนได้: DSN สองอันนี้ต่างกันแค่ท้ายสุด แต่ผลลัพธ์คนละเรื่อง

DSNเกิดอะไรขึ้น
postgres://u:p@db:5432/wongnok ok — มีชื่อ database (wongnok) ต่อท้าย
postgres://u:p@db:5432 ต่อติด แต่ไปโผล่ที่ database ผิดตัว — ไม่มี error ให้เห็น

tag url ที่ validator มีให้ ตรวจแค่ว่า "รูปร่างเป็น URL ไหม" — ทั้งสองบรรทัดผ่านหมด
กฎ "ต้องมีชื่อ database ต่อท้าย host" จึงต้องเขียนเอง และนี่คือคำตอบว่า ทำไม Validate() ถึงต้องเป็น method ของ struct ไม่ใช่ปล่อยให้ validator ทำคนเดียว:

// เมธอดนี้เอาไปแทน Validate() เดิมในไฟล์ internal/config/database.go
// แทนแล้วต้องเติม import ให้ครบเป็น: "errors" · "fmt" · "net/url" · "strings" · "time"
func (database Database) Validate() error {
	// ชั้นที่ 1 — กฎมาตรฐาน ให้ validator ทำ
	if err := validate.Struct(database); err != nil {
		return err
	}

	// ชั้นที่ 2 — กฎเฉพาะทางที่ tag เขียนไม่ได้ เขียนเองตรงนี้
	u, err := url.Parse(database.PostgresDSN)
	if err != nil {
		return fmt.Errorf("POSTGRES_DSN: %w", err)
	}
	if strings.Trim(u.Path, "/") == "" {
		return errors.New("POSTGRES_DSN: ไม่มีชื่อ database ต่อท้าย host")
	}

	return nil
}

โครงสองชั้นนี้คือรูปแบบที่ควรจำ — struct tag ครอบคลุมกฎมาตรฐาน (required · ช่วงตัวเลข · ค่าที่รู้จัก) ให้สั้นและอ่านง่าย
ส่วนกฎที่รู้เรื่องเฉพาะของโดเมนเรา (DSN ต้องมีชื่อ db · ต้องมี MaxIdle ≤ MaxOpen · ฯลฯ) เขียนต่อท้ายในเมธอดเดียวกัน
ที่มันนั่งอยู่ใน Validate() ของ section นั้นได้พอดี เพราะ errors.Join ใน config.go ไม่สนใจว่าข้างในทำอะไร ขอแค่คืน error หรือ nil

📌 เกร็ดที่จะทำให้คุณเสียเวลาน้อยลงหนึ่งชั่วโมง — ถ้าคิดจะใช้ tag hostname|ip ตรวจ DB_HOST (เครื่องหมาย | คือ "หรือ") ผมรันมาแล้ว 9 ค่า ได้ผลครบทุกค่าแบบนี้:
ผ่าน 6 — localhost · api.example.com · 127.0.0.1 · ::1 · 10.0.0.1 · my-db.internal
ตก 3 และสองตัวแรกคือที่คนไม่คาดคิด: db_server (มี underscore) · example.com:5432 (มี port ติดมา) · "" (ค่าว่าง — กับดักตอนลืมตั้ง DB_HOST)
ถ้า docker-compose.yml ของคุณตั้งชื่อ service ที่มี underscore หรือ .env เก็บ host พร้อมพอร์ตไว้ในตัวเดียว คุณจะ validate ไม่ผ่านทั้งที่ค่าถูกต้องทุกอย่าง
อีกอย่าง: ชื่อ tag ในข้อความ error พิมพ์ออกมาเป็นก้อนเดียว 'hostname|ip' ไม่ได้แยกว่าตกข้อไหน

8 · Error หน้าตาเป็นยังไง

ทีนี้มาดูของจริง — ผมตั้ง env ให้ผิด สามที่พร้อมกัน คนละ section แล้วรัน ./cmd/api:

ทายก่อนรัน — สั่งแบบนี้ (สังเกตว่า POSTGRES_DSN= คือตั้งเป็นค่าว่าง)

$ env APP_PORT=99999 LOG_LEVEL=BANANA POSTGRES_DSN= go run ./cmd/api

แอปจะบ่นกี่ข้อ? ข้อเดียวแล้วตาย (เจออันแรกก็พอ) หรือครบสามข้อ (บ่นทีเดียวให้หมด)
และเรียงลำดับยังไง — ตามลำดับที่ผมพิมพ์ใน command line หรือตามลำดับที่ประกาศไว้ในโค้ด?

(1) ของแถมฟรี — errors.Join จัดบรรทัดให้แล้ว

=== raw error ===
Key: 'App.Port' Error:Field validation for 'Port' failed on the 'lte' tag
Key: 'Logging.Level' Error:Field validation for 'Level' failed on the 'oneofci' tag
Key: 'Database.PostgresDSN' Error:Field validation for 'PostgresDSN' failed on the 'required' tag

ครบสามข้อในรอบเดียว — ไม่ต้องแก้ทีละข้อแล้วรันใหม่สามรอบ
ลำดับไม่ได้ตามที่พิมพ์ใน command line แต่ตามที่ประกาศไว้ในโค้ด: App → Logging → Database (ตามลำดับใน errors.Join) และในแต่ละ section เรียงตามลำดับ field ใน struct
แปลว่าลำดับนี้คงที่ทุกครั้ง — เอาไปเขียนเทสต์เทียบตรง ๆ ได้

อ่านออกอยู่ แต่มี noise เยอะ (Error:Field validation for ซ้ำทุกบรรทัด) พอใช้ได้สำหรับทีมเล็ก แต่ถ้าอยากให้สวยกว่านี้ก็มีทางเลือก:

(2) หลังใส่ Humanize()

time=... level=ERROR msg="load configuration"
invalid configuration:
  - App.Port: ผิดกฎ "lte"
  - Logging.Level: ผิดกฎ "oneofci"
  - Database.PostgresDSN: ผิดกฎ "required"
time=... level=ERROR msg=fatal error="load configuration: Key: 'App.Port' ..."
exit status 1

สั้นลงเยอะ และบอกครบว่า field ไหน · ผิดกฎอะไร
บรรทัด time=... level=ERROR สองบรรทัดมาจาก slog TextHandler ใน main.go (หัวข้อ 9) — บรรทัดบนคือ bootLogger.Error("load configuration") บรรทัดล่างคือ slog.Error("fatal", "error", err) ที่พิมพ์ error ดิบไว้ให้ระบบเก็บ log
ส่วนสี่บรรทัดตรงกลางคือของที่ Humanize() พิมพ์ลง os.Stderr ให้ คน อ่าน
หัวใจของมันคือสอง method ของ FieldError: fe.Namespace() ให้ "App.Port" · fe.Tag() ให้ "lte"

ตัว Humanize() ที่ผมใช้เขียนสั้น ๆ แบบนี้ (อยู่ในไฟล์ humanize.go) — มันคือการแกะกล่องสองชั้น: ชั้นนอกคือก้อนที่ errors.Join มัดไว้ ชั้นในคือ ValidationErrors ของแต่ละ section

// internal/config/humanize.go
package config

import (
	"errors"
	"fmt"
	"strings"

	"github.com/go-playground/validator/v10"
)

func Humanize(err error) string {
	if err == nil {
		return ""
	}
	var lines []string

	// ชั้นนอก: errors.Join คืน error ที่มีเมธอด Unwrap() []error
	var joined interface{ Unwrap() []error }
	errs := []error{err}
	if errors.As(err, &joined) {
		errs = joined.Unwrap()
	}

	for _, e := range errs {
		// ชั้นใน: แต่ละ section คืน validator.ValidationErrors
		var ve validator.ValidationErrors
		if errors.As(e, &ve) {
			for _, fe := range ve {
				lines = append(lines, fmt.Sprintf("  - %s: ผิดกฎ %q", fe.Namespace(), fe.Tag()))
			}
			continue
		}
		lines = append(lines, "  - "+e.Error())
	}

	return "invalid configuration:\n" + strings.Join(lines, "\n")
}

errors.As(err, &ve) คือ "ขุดลงไปในกล่อง error ดูว่ามีตัวที่เป็นชนิดนี้ไหม ถ้ามีเอามาใส่ ve" — ผมยืนยันว่ามันขุดทะลุ errors.Join ลงไปเจอ ValidationErrors ครบทุกตัวจริง:

errors.As -> true len: 4
errors.As through Join -> true 4

และแต่ละ FieldError ที่ได้มามีข้อมูลมากกว่าที่เราใช้ (ก้อนนี้ผมพิมพ์ช่อง Value ด้วย %#v — จำไว้ก่อน เดี๋ยวมันจะสำคัญ):

Namespace="Cfg.Name"      Field="Name"    Tag="required" Param=""         Kind=string Type=string        Value=""
Namespace="Cfg.Env"       Field="Env"     Tag="oneof"    Param="dev prod" Kind=string Type=string        Value="qa"
Namespace="Cfg.Timeout"   Field="Timeout" Tag="gte"      Param="1s"       Kind=int64  Type=time.Duration Value=100000000
Namespace="Cfg.DB.Port"   Field="Port"    Tag="lte"      Param="65535"    Kind=int    Type=int           Value=99999

สองอย่างที่น่าสังเกต:
· Namespace ของ field ที่ซ้อนอยู่ใน struct ลูกเป็น "Cfg.DB.Port" — validator ไล่ลงไปใน nested struct ให้เอง โดยไม่ต้องแปะ tag อะไรที่ field DB เลย
· Param() คืนพารามิเตอร์ของกฎเป็นสตริง เช่น "dev prod" หรือ "65535" — เอาไปเขียนข้อความว่า "ต้องเป็นหนึ่งใน dev prod" ได้เลย

⚠️ สามข้อที่ต้องระวังกับกล่อง output หน้านี้

1. Value=100000000 บรรทัดที่สาม เป็นผลของ %#v ไม่ใช่นิสัยของ time.Duration — Value() มี static type เป็น interface{} ก็จริง แต่ค่าข้างในยังเป็น time.Duration เต็มตัว ซึ่งมี String() ติดมาด้วย ดังนั้นวิธีพิมพ์เปลี่ยนคำตอบ — ผมรัน fe.Value() ตัวเดียวกันสี่แบบ:
%T → time.Duration · %v → 100ms · %s → 100ms · %d และ %#v → 100000000
แปลว่าถ้าเอาไปโชว์ผู้ใช้ด้วย %v ปกติ ได้ 100ms อ่านออกสบาย — จะได้ตัวเลขนาโนวินาทีก็ต่อเมื่อเจาะจงใช้ %d หรือ %#v อย่างในกล่องข้างบนเท่านั้น
ที่ต้องระวังจริง ๆ คือตอนเอาไปคำนวณต่อ ไม่ใช่ตอนพิมพ์: fe.Value() เป็น interface{} จึงต้อง type-assert (fe.Value().(time.Duration)) ก่อน ถึงจะบวกลบกับ duration อื่นได้

2. บรรทัด exit status 1 ไม่ใช่ output ของโปรแกรม — เป็นข้อความที่ go run พิมพ์เองเมื่อโปรแกรมลูกจบด้วย exit code ที่ไม่ใช่ 0 ผม build เป็น binary แล้วรันตรง ๆ เทียบ: ไม่มีบรรทัดนี้เลย
ถ้าคุณเห็นบรรทัดนี้ในกล่องตัวอย่างที่ไหน ให้รู้ว่าคนเขียนใช้ go run ไม่ใช่ว่า os.Exit(1) พิมพ์อะไรออกมา

3. บล็อกสามบรรทัดข้างบนใช้ได้เฉพาะ database.go เวอร์ชันหลัง refactor — ผมรันด้วยเวอร์ชันก่อน refactor ด้วย env ชุดเดียวกัน บรรทัดที่สามกลายเป็น:
POSTGRES_DSN is required (ข้อความที่เราเขียนเองใน if) แทนที่จะเป็น Key: 'Database.PostgresDSN' … 'required' tag
ซึ่งสมเหตุสมผล แต่ถ้าเอาผลของเวอร์ชันหนึ่งไปวางคู่กับโค้ดอีกเวอร์ชันเมื่อไหร่ คนอ่านจะงงทันที

🔍 รอยรั่วของ Humanize() ตัวนี้ที่ผมเจอตอนรัน — และวิธีคิดที่ได้จากมัน
ผมลองให้ ParseAs พังจริง ๆ (ไม่ใช่ validation) ด้วย APP_PORT=abc:

time=... level=ERROR msg="load configuration"
invalid configuration:
  - parse error on field "Port" of type "int": strconv.ParseInt: parsing "abc": invalid syntax

ปัญหาสองข้อ:
(1) พาดหัวยังขึ้นว่า invalid configuration: ทั้งที่นี่เป็นparse error ไม่ใช่ค่าที่ผิดกฎ — คนอ่านจะไปนั่งหาว่ากฎไหนผิด
(2) ข้อความที่ Load() ตั้งใจใส่ไว้ (parse environment: env:) หายไป
สาเหตุ: errors.As(err, &joined) ที่มองหา interface{ Unwrap() []error } มันวิ่งทะลุ fmt.Errorf ลงไปเจอ error รวมของ env/v11 ซึ่งบังเอิญมีเมธอด Unwrap() []error เหมือนกันเป๊ะ
บทเรียนที่เอาไปใช้ได้กว้างกว่าเรื่องนี้: การจับ error ด้วย รูปร่างของ interface แบบลอย ๆ มันจับ "ทุกอย่างที่หน้าตาเหมือนกัน" ไม่ใช่ "สิ่งที่เราหมายถึง"
ทางแก้: แยกเคส parse ออกมาก่อนตั้งแต่ใน Load() (เช่นทำ error type ของตัวเอง) แล้วค่อยให้ Humanize ตัดสินใจจากตรงนั้น

9 · main.go — เอาไปใช้จริง

main() ทำอย่างเดียว: เรียก run() แล้วแปลง error เป็น exit code
ส่วนงานจริงทั้งหมดอยู่ใน run() ซึ่งเป็นลำดับ 5 ขั้นที่ห้ามสลับ:

// cmd/api/main.go
package main

import (
	"fmt"
	"log/slog"
	"net/http"
	"os"
	"strings"

	"github.com/joho/godotenv"
	"gorm.io/driver/postgres"
	"gorm.io/gorm"

	"wongnok/api/internal/config"
)

func main() {
	if err := run(); err != nil {
		slog.Error("fatal", "error", err)
		os.Exit(1)
	}
}

func run() error {
	// 1) logger ชั่วคราว — ยังไม่รู้ level/format เพราะยังไม่ได้อ่าน config
	bootLogger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
		Level: slog.LevelInfo,
	}))
	slog.SetDefault(bootLogger)

	_ = godotenv.Load() // 2) .env → environment (ไม่มีไฟล์ก็ไม่เป็นไร)

	// 3) โหลด config — พังตรงนี้ = ตายตรงนี้ ไม่ต้องรอ request แรก
	cfg, err := config.Load()
	if err != nil {
		bootLogger.Error("load configuration")
		fmt.Fprintf(os.Stderr, "%s\n", config.Humanize(err))
		return fmt.Errorf("load configuration: %w", err)
	}

	// 4) logger ตัวจริง — สร้างจาก config ที่ผ่าน validate แล้ว
	logger := newLogger(cfg.Logging)
	slog.SetDefault(logger)
	logger.Info("configuration loaded",
		"app", cfg.App.Name, "env", cfg.App.Env, "addr", cfg.App.Addr())

	// 5) จากตรงนี้ไป มั่นใจได้ว่า cfg ทุกค่าใช้งานได้จริง
	db, err := gorm.Open(postgres.Open(cfg.Database.PostgresDSN), &gorm.Config{})
	if err != nil {
		return fmt.Errorf("open database: %w", err)
	}
	_ = db // ... SetMaxOpenConns / SetMaxIdleConns / SetConnMaxLifetime / Ping ...

	mux := http.NewServeMux()
	// ... ผูก handler เข้า mux ตามบทที่ผ่านมา ...
	return http.ListenAndServe(cfg.App.Addr(), mux)
}

// ขั้นที่ 4 ของ run() อยู่ในฟังก์ชันนี้ — หัวข้อถัดไปจะยกมาอธิบายทีละบรรทัด
func newLogger(cfg config.Logging) *slog.Logger {
	var level slog.Level
	if err := level.UnmarshalText([]byte(cfg.Level)); err != nil {
		level = slog.LevelInfo // validate ผ่านมาแล้ว ปกติไม่มีทางเข้าบรรทัดนี้
	}
	opts := &slog.HandlerOptions{Level: level}

	if strings.EqualFold(cfg.Format, "text") {
		return slog.New(slog.NewTextHandler(os.Stderr, opts))
	}
	return slog.New(slog.NewJSONHandler(os.Stderr, opts))
}

โค้ดก้อนนี้ก๊อปไปวางเป็นไฟล์แล้ว go build ผ่านจริง — บรรทัด _ = db มีไว้เพราะ Go ไม่ยอมให้ประกาศตัวแปรแล้วไม่ใช้ (ตัวแปรที่ประกาศแล้วทิ้งไว้เฉย ๆ คือ compile error ไม่ใช่แค่คำเตือน)
พอคุณเขียนบรรทัด SetMaxOpenConns / Ping ที่ตามมาจริง ๆ db ก็ถูกใช้งานแล้ว ให้ลบ _ = db ทิ้งไปได้เลย

ทำไมต้องมี logger สองตัว — ปัญหาไก่กับไข่

ขั้นที่ 1 ดูเหมือนเปลืองโค้ดโดยใช่เหตุ ทำไมไม่สร้าง logger ตัวเดียวให้จบ?
เพราะ logger ตัวจริงต้องรู้ LOG_LEVEL กับ LOG_FORMAT ซึ่งอยู่ใน config
แต่ขั้นตอนโหลด config เองก็ต้องการที่ log ตอนมันพัง
ไก่กับไข่พอดี — และทางออกคือยอมมี logger ธรรมดา ๆ ตัวหนึ่งไว้ก่อน (TextHandler · level Info · ไม่ต้องอ่าน config อะไรเลย) แล้วค่อยเปลี่ยนเป็นตัวจริงหลังโหลดสำเร็จ

🧭 รูปแบบนี้เจอได้ทั่วไปเวลาระบบต้อง bootstrap ตัวเอง — "ของชั่วคราวที่ไม่ต้องพึ่งใคร ใช้ไปก่อน แล้วเปลี่ยนเป็นของจริงเมื่อพร้อม"
ผลพลอยได้ที่มองไม่เห็นตอนอ่านโค้ด: log สองบรรทัดแรกของแอปจะเป็น รูปแบบข้อความธรรมดา ส่วนบรรทัดหลังจากนั้นเป็น JSON ตามที่ config สั่ง — ไม่ใช่ bug แต่เป็นผลของลำดับที่ตั้งใจไว้

newLogger() — จุดที่ LOG_LEVEL กับ LOG_FORMAT ได้ใช้งานจริง

หัวข้อ 6 เราตั้งกฎให้ Logging ไว้แล้ว แต่ยังไม่เคยเห็นว่ามันถูกใช้ตรงไหน — คำตอบอยู่ที่ฟังก์ชันสั้น ๆ ที่ต่อท้ายไฟล์ main.go เมื่อกี้ ขอยกมาดูใกล้ ๆ อีกที เพราะมันคือขั้นที่ 4 ของ run() ทั้งหมด:

func newLogger(cfg config.Logging) *slog.Logger {
	var level slog.Level
	if err := level.UnmarshalText([]byte(cfg.Level)); err != nil {
		level = slog.LevelInfo // validate ผ่านมาแล้ว ปกติไม่มีทางเข้าบรรทัดนี้
	}
	opts := &slog.HandlerOptions{Level: level}

	if strings.EqualFold(cfg.Format, "text") {
		return slog.New(slog.NewTextHandler(os.Stderr, opts))
	}
	return slog.New(slog.NewJSONHandler(os.Stderr, opts))
}

ผมรันด้วย Logging สามชุดเทียบกัน (แต่ละชุดสั่ง Debug · Info · Warn อย่างละบรรทัด):

--- level=info format=json ---
{"time":"...","level":"INFO","msg":"configuration loaded","app":"wongnok","addr":":8080"}
{"time":"...","level":"WARN","msg":"warn line"}

--- level=DEBUG format=text ---
time=... level=DEBUG msg="debug line"
time=... level=INFO msg="configuration loaded" app=wongnok addr=:8080
time=... level=WARN msg="warn line"

--- level=warn format=JSON ---
{"time":"...","level":"WARN","msg":"warn line"}

สามอย่างที่กล่องนี้ยืนยัน:
· ชุดแรก ไม่มีบรรทัด DEBUG — LOG_LEVEL=info กรองทิ้งจริง
· ชุดที่สาม LOG_LEVEL=warn เหลือบรรทัดเดียว
· "DEBUG" กับ "JSON" ตัวใหญ่ก็ใช้ได้ เพราะ UnmarshalText กับ strings.EqualFold ไม่สนตัวพิมพ์ — เข้าคู่กับ tag oneofci ที่เราตั้งไว้ในหัวข้อ 6 พอดี
(สาม package ที่ฟังก์ชันนี้ใช้ — log/slog · os · strings — อยู่ใน import ของ main.go ข้างบนครบแล้ว ไม่ต้องเติมอะไรอีก)

พิสูจน์ว่า "ตายตั้งแต่ start" จริงไหม

ประโยคว่า "config ผิดแล้วแอปตายตั้งแต่ start" ฟังบ่อยจนกลายเป็นคำขวัญ ผมเลยรันแล้ววัดสามชั้นให้เห็นกับตา — สั่ง config ผิดสามที่เหมือนเดิม แล้วเช็คว่า process ยังอยู่ไหม · มีใคร bind พอร์ต 8080 ไหม · curl ต่อติดไหม:

--- ps เช็คว่า process ยังอยู่ไหม ---
process ตายไปแล้ว

--- lsof :8080 ---
(ไม่มี process ไหน listen :8080)

--- curl /healthz ---
http_code=000
curl exit=7

EXIT_CODE=1

curl exit=7 คือ Failed to connect — ไม่ใช่ 404 ไม่ใช่ 500 แต่คือไม่มีอะไรอยู่ปลายสายให้ต่อเลย
นี่คือสิ่งที่เราต้องการจริง ๆ: ไม่มีหน้าต่างเวลาแม้แต่วินาทีเดียวที่แอปเปิดรับ request ทั้งที่ config ยังผิดอยู่

ทีนี้ตั้ง config ให้ถูกแล้วรันใหม่ (มี Postgres รออยู่จริง) — ได้ลำดับตามที่ออกแบบไว้เป๊ะ:

--- curl /healthz ---
ok
http_code=200

--- stderr ของ server ---
{"time":"2026-08-22T10:13:43.048067+07:00","level":"INFO","msg":"configuration loaded","app":"wongnok","env":"development","addr":":8080"}
{"time":"2026-08-22T10:13:43.084594+07:00","level":"INFO","msg":"database connected"}
{"time":"2026-08-22T10:13:43.085393+07:00","level":"INFO","msg":"server starting","addr":":8080"}

สังเกตว่า log ออกมาเป็น JSON แล้ว — เพราะบรรทัดพวกนี้พิมพ์หลังขั้นที่ 4 logger ตัวจริงจึงถูกสร้างจาก cfg.Logging ที่มี LOG_FORMAT=json เป็น default

10 · config_test.go — เทสต์ config ที่ไม่ต้องมีไฟล์ .env

t.Setenv(key, value) คือของที่ทำให้เทสต์ config ง่ายขึ้นมาก — มันตั้ง env ให้เฉพาะระหว่างเทสต์นั้น แล้วคืนค่าเดิมให้อัตโนมัติเมื่อเทสต์จบ ไม่ต้องเขียน cleanup เอง และไม่ต้องมีไฟล์ .env ในเครื่อง CI

// internal/config/config_test.go
package config_test

import (
	"strings"
	"testing"

	"wongnok/api/internal/config"
)

func TestLoad_ThreeErrorsAtOnce(t *testing.T) {
	t.Setenv("APP_PORT", "99999")
	t.Setenv("LOG_LEVEL", "BANANA")
	t.Setenv("POSTGRES_DSN", "")

	_, err := config.Load()
	if err == nil {
		t.Fatal("Load() error = nil, want error")
	}

	got := config.Humanize(err)
	for _, want := range []string{
		`App.Port: ผิดกฎ "lte"`,
		`Logging.Level: ผิดกฎ "oneofci"`,
		`Database.PostgresDSN: ผิดกฎ "required"`,
	} {
		if !strings.Contains(got, want) {
			t.Errorf("Humanize() ไม่มี %q\nได้:\n%s", want, got)
		}
	}
}

func TestLoad_ReturnsZeroValueOnError(t *testing.T) {
	t.Setenv("POSTGRES_DSN", "")

	cfg, err := config.Load()
	if err == nil {
		t.Fatal("want error")
	}
	// value ไม่ใช่ pointer -> เรียกเมธอดต่อได้เลย ไม่ panic
	if cfg.App.Addr() != ":0" {
		t.Errorf("Addr() = %q, want %q", cfg.App.Addr(), ":0")
	}
}

ในไฟล์จริงผมมีอีกตัวหนึ่งชื่อ TestLoad_Defaults (เช็คว่า envDefault ทำงานจริง) ซึ่งไม่ได้ยกมาโชว์ตรงนี้ — ตอนรันจึงเห็นสามตัว:

$ go test ./internal/config/ -v
=== RUN   TestLoad_Defaults
--- PASS: TestLoad_Defaults (0.00s)
=== RUN   TestLoad_ThreeErrorsAtOnce
--- PASS: TestLoad_ThreeErrorsAtOnce (0.00s)
=== RUN   TestLoad_ReturnsZeroValueOnError
--- PASS: TestLoad_ReturnsZeroValueOnError (0.00s)
PASS
ok  	wongnok/api/internal/config	0.572s

เทสต์ตัวที่สามคือตัวที่พิสูจน์เหตุผลข้อ "คืน value ไม่ใช่ pointer" จากหัวข้อ 4 — เรียก cfg.App.Addr() ได้ทั้งที่ Load() เพิ่งคืน error มา ถ้าเปลี่ยนเป็น *Config เมื่อไหร่ บรรทัดนี้จะ panic ทันที

📌 เกร็ดเรื่องประสิทธิภาพที่เกี่ยวกับบรรทัด var validate = validator.New(...)
เอกสารของ validator บอกว่ามันออกแบบมาให้ใช้เป็น singleton (สร้างครั้งเดียวใช้ทั้งโปรแกรม) เพราะมันแคชข้อมูล struct ไว้ข้างใน — ผมวัดจริงด้วย benchmark ที่เรียก validate.Struct(cfg.App) (section เดียว 4 field) รอบละครั้ง ต่างกันแค่ตัว validator มาจากไหน:

cpu: Apple M4
BenchmarkSingleton-10      	 7712509	     145.9 ns/op	     48 B/op	  1 allocs/op
BenchmarkNewEachTime-10    	  124420	    9609 ns/op	  16617 B/op	232 allocs/op

เรียก New() ใหม่ทุกครั้ง ช้ากว่าประมาณ 66 เท่า และจองหน่วยความจำ 232 ครั้งต่อรอบ เทียบกับ 1 ครั้ง
ตัวเลข "กี่เท่า" ผูกกับ struct ที่วัด — ผมลองวัดทั้ง Config (สาม section รวดในรอบเดียว) ได้ 434.0 ns/op เทียบ 13341 ns/op คือต่างกัน 31 เท่า เพราะฝั่ง singleton มีงานให้ทำมากขึ้น ส่วนฝั่ง New() ต้นทุนคงที่ก้อนใหญ่เท่าเดิม
สิ่งที่ไม่เปลี่ยนไม่ว่าจะวัด struct ไหน คือช้ากว่าหลายสิบเท่า และจองหน่วยความจำหลักร้อยครั้งต่อรอบ เทียบกับหลักหน่วย
เหตุผลไม่ใช่แค่ "ทิ้งแคช struct" อย่างที่มักได้ยิน — New() ต้องคัดลอกตารางกฎ built-in ทั้งชุดใหม่ทุกครั้งด้วย
สำหรับ config ที่โหลดครั้งเดียวตอน start ตัวเลขนี้ไม่มีผลอะไรเลย แต่ถ้าคุณเอา validator ตัวเดียวกันนี้ไปใช้ตรวจ request body ทุก request (ซึ่ง Gin ทำอยู่) ความต่างนี้จะเห็นทันที

🔎 อีกเรื่องเกี่ยวกับ WithRequiredStructEnabled() ที่เอกสารเขียนไว้สั้นมาก
ผมรันเทียบ validator.New() เปล่า ๆ กับ New(WithRequiredStructEnabled()) แล้วพบว่ามันต่างกันจุดเดียวเป๊ะ: tag required ที่แปะบน struct ลูกที่ไม่ใช่ pointer

plain  / zero nested struct  -> <nil>              // tag ถูกเมิน
opt    / zero nested struct  -> Key: 'Cfg.DB' Error:Field validation for 'DB' failed on the 'required' tag

--- pointer nested (*DB), nil ---
plain -> Key: 'Cfg2.DB' Error:... failed on the 'required' tag
opt   -> Key: 'Cfg2.DB' Error:... failed on the 'required' tag   // เท่ากันทั้งคู่

ถ้าโค้ดของคุณใช้ *DB (pointer) ตลอด option นี้จะไม่เปลี่ยนอะไรเลย — ซึ่งเป็นเหตุผลที่หลายคนใส่แล้วลองแล้วไม่เห็นความต่าง แล้วสรุปว่ามันไม่มีผล
ใน Config ของเราที่ประกาศ App App · Logging Logging · Database Database เป็น non-pointer ทั้งหมด — option นี้จึงมีผลจริง ถ้าวันหนึ่งเราแปะ validate:"required" บน field พวกนั้น

11 · Mental Model — ท่อลำเลียงค่า

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

.env │ godotenv ▼ Environment Variables │ caarlos0/env ▼ Config Struct │ validator ▼ Valid Config │ ▼ Application

ถ้ายังนึกภาพไม่ออก สไลด์เทียบกับงานเอกสารที่เคาน์เตอร์ ซึ่งเข้าใจง่ายกว่ามาก:

ตัวจริงในโค้ดเทียบกับคนที่เคาน์เตอร์ทำอะไร
.env โพย จดค่าที่ต้องใช้ไว้ก่อน ยังไม่ได้ยื่นให้ใคร
godotenv คนเอาโพยไปกรอกเข้าระบบ อ่านจากกระดาษ ใส่เข้า environment ให้โปรแกรมเห็น
caarlos0/env คนจัดข้อมูลลงช่องในฟอร์ม เอาค่าที่เป็น string ยัดลงช่องที่ถูกชนิด — ช่องไหนว่าง เติม default ให้
validator ฝ่ายตรวจเอกสาร ตรวจว่าครบและถูกกฎไหม ไม่ครบตีกลับ — และตีกลับพร้อมกันทุกข้อที่ผิด
internal/config เคาน์เตอร์รับเรื่อง ที่เดียวที่รู้ทุกอย่าง — ใครอยากได้ค่าต้องผ่านตรงนี้ ห้ามเดินอ้อมไปหลังร้าน
Application คนที่ได้ใช้ข้อมูลที่ผ่านการตรวจแล้ว ไม่ต้องเช็คซ้ำ ไม่ต้องรู้ว่าค่ามาจากไหน — ของที่ถึงมือแล้วคือของที่ใช้ได้

ประโยคเดียวที่สรุปทั้งบท: ค่าที่ยังไม่ผ่านการตรวจ กับค่าที่ผ่านแล้ว เป็นของคนละชนิดกัน และ internal/config คือเส้นแบ่งระหว่างสองโลกนั้น
ก่อนเส้นนี้: ทุกอย่างเป็น string ที่ไม่มีใครรับรอง · หลังเส้นนี้: ทุกค่าถูกชนิด ถูกกฎ และใช้ได้เลย
ทุกครั้งที่คุณเผลอเขียน os.Getenv ลึก ๆ ในโค้ด คุณกำลังเจาะรูให้ค่าที่ไม่ผ่านการตรวจข้ามเส้นมา

12 · แบบฝึกหัด — จับคู่

ทั้งหกข้อคือหกอย่างที่ผ่านมาในสี่บทนี้ ถ้าตอบได้ครบแปลว่าคุณเห็นภาพแล้วว่าใครทำอะไร

ความต้องการตัวเลือก
1. อ่านไฟล์ .env แล้วยัดค่าเข้า environmentA. validate:"oneof=..."
2. แปลง "8080" เป็น int ใส่ลง structB. .gitignore
3. ตรวจว่า APP_ENV เป็นค่าที่รู้จักC. godotenv/autoload
4. กำหนดค่า default ถ้าไม่ได้ตั้ง envD. env.ParseAs[Config]()
5. กันไม่ให้ secret หลุดขึ้น GitE. errors.Join
6. รวม error ของทุก section มาแสดงที่เดียวF. envDefault

จับคู่ให้ครบทั้งหกข้อก่อนกดดูเฉลย — ตอบในใจหรือจดลงกระดาษก็ได้
ตัวเลือกทั้งหกใช้ครั้งละหนึ่งข้อพอดี ไม่มีตัวไหนเหลือและไม่มีตัวไหนใช้ซ้ำ

ข้อคู่ทำไม
1. อ่าน .env เข้า environment C godotenv/autoload import แบบ _ แล้วมันทำงานให้ตอน package ถูกโหลด — ก่อน main() เริ่มด้วยซ้ำ
2. แปลง "8080" เป็น int D env.ParseAs[Config]() อ่าน tag env:"APP_PORT" แล้วแปลงชนิดตามที่ field ประกาศไว้ให้เอง
3. ตรวจว่า APP_ENV เป็นค่าที่รู้จัก A validate:"oneof=..." เป็นงานของ validator ไม่ใช่ของ env — "prodction" แปลงเป็น string ได้ แต่ไม่ใช่ค่าที่ยอมรับ
4. กำหนดค่า default F envDefault เขียนติดไว้กับ field เลย ไม่ต้องมี if x == "" { x = ... } กระจายทั่วโปรเจกต์
5. กัน secret หลุดขึ้น Git B .gitignore ข้อเดียวในนี้ที่ไม่ใช่โค้ด Go — เป็นเรื่องของ git ล้วน ๆ และเป็นด่านที่สำคัญที่สุด
6. รวม error ของทุก section E errors.Join อยู่ใน Config.Validate() · คั่นบรรทัดด้วย \n ให้เอง · ตัวไหน nil ก็ข้ามให้

ข้อที่คนตอบผิดบ่อยที่สุดคือข้อ 2 กับข้อ 3 — เพราะสองอันนี้ดูเหมือน "ตรวจค่า" เหมือนกัน
เส้นแบ่งจำง่าย ๆ: env ถามว่า "แปลงเป็นชนิดนี้ได้ไหม" · validator ถามว่า "ค่านี้ใช้ได้จริงไหม"
ผมพิสูจน์เส้นแบ่งนี้ไปแล้วในหัวข้อ 5: APP_PORT=99999 ผ่าน ParseAs สบาย ๆ (มันเป็น int ที่ถูกต้อง) แต่ตกที่ validator เพราะไม่มีพอร์ตหมายเลขนั้นในโลก

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

1. ใน Load() ทำไม error ของ env.ParseAs ถึง wrap ด้วย fmt.Errorf("parse environment: %w", err) แต่ error ของ cfg.Validate() กลับ return err เฉย ๆ?

ผมรันเทียบสองแบบให้ดูในหัวข้อ 4 — แบบ wrap ได้ผลแบบนี้:
validate config: Key: 'App.Port' Error:... 'lte' tag
Key: 'Logging.Level' Error:... 'oneofci' tag ← บรรทัดนี้ไม่มี prefix
ยืนยันด้วยว่า errors.Join คั่นด้วย \n จริง: errors.Join(a,b,c) ของ alpha/beta/gamma ได้ "alpha\nbeta\ngamma" ยาว 16 ตัวอักษรพอดี และไม่มี \n ปิดท้าย
ตัวเลือกที่ 1 ผิดครึ่งเดียว: errors.Join คืน nil เมื่อทุกตัวเป็น nil จริง แต่นั่นไม่เกี่ยวกับการ wrap — และโค้ดเช็ค err != nil ก่อนอยู่แล้ว
ตัวเลือกที่ 2 ผิด: fmt.Errorf รับ error ตัวไหนก็ได้ compile ผ่านสบาย มันแค่อ่านแล้วดูแย่
ตัวเลือกที่ 4 ผิด และผมรันพิสูจน์แล้ว: errors.As ขุดทะลุทั้ง fmt.Errorf และ errors.Join ลงไปเจอ ValidationErrors ครบ 4 ตัว (errors.As through Join -> true 4)
กฎที่ใช้ได้ทั่วไป: wrap error ที่เป็นก้อนเดียว · อย่า wrap error ที่จัดหลายบรรทัดมาให้แล้ว

2. field ShutdownTimeout time.Duration ติด validate:"required" ไว้ แล้วมีคนตั้ง APP_SHUTDOWN_TIMEOUT=-5s — ผลเป็นอย่างไร?

ผลรันจริง: ParseAs : OK -> ... Shutdown=-5s ... แล้วตามด้วย Validate : OK — ผ่านทั้งสองด่าน
เหตุผล: required ของ validator แปลว่า "ห้ามเป็น zero value" เท่านั้น zero value ของ time.Duration คือ 0s ส่วน -5s ไม่ใช่ zero มันจึงผ่านตามนิยาม
ตัวเลือกที่ 2 ผิด — time.ParseDuration("-5s") รับค่าติดลบได้ปกติ
ตัวเลือกที่ 3 คือคำตอบของเคส APP_SHUTDOWN_TIMEOUT=0s ต่างหาก ซึ่งโดนจับจริง (Key: 'App.ShutdownTimeout' Error:... failed on the 'required' tag) — นี่คือจุดที่หลอกที่สุด เพราะเห็นว่า 0s โดนจับแล้วเลยเหมาเอาว่าคุมได้ครบ
ตัวเลือกที่ 4 ผิด ไม่มี panic
ทางแก้ที่ผมรันยืนยันแล้ว: validate:"required,gt=0" — -5s ตกที่ 'gt' · 0s ตกที่ 'required' · 500ms กับ 10s ผ่าน
กับดักเดียวกันนี้โผล่อีกครั้งใน database.go: การเปลี่ยน if ConnMaxLifetime <= 0 มาเป็น validate:"required" ทำให้ DB_CONN_MAX_LIFETIME=-5s หลุดไปเลย

3. ในกล่องตัวอย่างของสไลด์ ผลลัพธ์จบด้วยบรรทัด exit status 1 — บรรทัดนี้ใครพิมพ์?

ผมรันเทียบสองทางกับ env ชุดเดียวกันเป๊ะ: ผ่าน go run ./cmd/api มีบรรทัดนี้ · build เป็น binary แล้วรันตรง ๆ ไม่มีบรรทัดนี้เลย (บรรทัดสุดท้ายกลายเป็น log level=ERROR msg=fatal error="load configuration: ..." แทน)
go run ทำหน้าที่เป็น parent process — พอลูกจบด้วย exit code ไม่ใช่ 0 มันรายงานให้เราทราบ
ตัวเลือกที่ 1 ผิด: os.Exit(1) แค่ทำให้ process จบด้วยรหัส 1 มันไม่พิมพ์อะไรเลย
ตัวเลือกที่ 2 กับ 3 ผิด — ทั้ง slog และ Humanize ไม่มีโค้ดส่วนไหนสร้างข้อความนี้
ทำไมต้องรู้: เวลาไล่ปัญหาบน production คุณรัน binary ไม่ได้รันผ่าน go run — ถ้าไปตามหาบรรทัด exit status 1 ใน log แล้วไม่เจอ อาจเข้าใจผิดว่าโปรแกรมยังไม่ตาย
วิธีเช็ค exit code จริงคือ echo $? หลังคำสั่ง ซึ่งผมวัดได้ 1 ทั้งสองทาง

4. การวาง config ไว้ที่ internal/config/ แทนที่จะเป็น pkg/config/ ให้ผลอะไร?

ผมทดสอบสองทางด้วยโฟลเดอร์เดียวกันเป๊ะ (module นอกใช้ replace ชี้เข้ามา):
· จากใน module เดียวกัน (cmd/api → internal/config): inside module, import ok: wongnok · exit=0
· จาก module อื่น: main.go:6:2: use of internal package wongnok/api/internal/config not allowed · exit=1
ตัวเลือกที่ 1 ผิด — internal เป็นชื่อที่ toolchain ของ Go รู้จักเป็นพิเศษ ต่างจาก pkg ที่เป็นแค่ธรรมเนียมจริง ๆ
ตัวเลือกที่ 3 ผิด ไม่มี flag แบบนั้น กฎนี้เปิดอยู่ตลอดเวลา ปิดไม่ได้
ตัวเลือกที่ 4 ผิดตรงระดับความรุนแรง: มันไม่ใช่คำเตือนของ go vet แต่คือ go build ตกจริง exit code 1 — โค้ดนั้น compile ไม่ออกเลย
ประโยชน์จริงที่ได้: คุณเปลี่ยนชื่อ field ใน Config ได้อย่างอิสระ เพราะรู้แน่ว่าไม่มีใครนอก module นี้ผูกอยู่กับมัน

🛠️ ภารกิจ — สร้าง internal/config ของตัวเอง แล้วทำให้มันพังให้ครบทุกแบบ

เป้าหมายไม่ใช่ "ก๊อปโค้ดในบทนี้ไปวางแล้วรันผ่าน" — เป้าหมายคือ ทำให้มันพังในแบบที่คุณสั่งได้ แล้วอ่าน error ออกว่าเกิดจากอะไร

  1. ตั้งโครง: ในโปรเจกต์ Go ของคุณ สร้าง internal/config/ แล้วแยกเป็น config.go · app.go · logging.go · database.go
    ตอนนี้ยังไม่ต้องมี validate — แค่ให้ env.ParseAs[Config]() ทำงานได้ก่อน
  2. พิสูจน์กฎ internal/ ด้วยตัวเอง: สร้างโฟลเดอร์ใหม่นอกโปรเจกต์ go mod init outsider · เพิ่ม replace ชี้มาที่โปรเจกต์คุณ แล้วลอง import internal/config ดู
    ต้องได้ use of internal package ... not allowed และ exit=1 — ถ้าได้ exit 0 แปลว่า import ผิด path
  3. เติม validate ทีละ section: เริ่มจาก app.go อย่างเดียว แล้วลองยิงสี่เคสนี้ทีละอัน จดข้อความ error ที่ได้ทั้งหมด:
    APP_PORT=99999 · APP_PORT=abc · APP_ENV=prodction · APP_SHUTDOWN_TIMEOUT=0s
    ตอบให้ได้ว่าเคสไหนพังที่ ParseAs และเคสไหนพังที่ Validate (คำใบ้: มีเคสเดียวที่พังที่ ParseAs)
  4. ทำให้ error ออกมาพร้อมกันหลายข้อ: เขียน Config.Validate() ด้วย errors.Join แล้วตั้ง env ให้ผิดสามที่คนละ section พร้อมกัน
    ต้องเห็น error ครบสามบรรทัดในการรันครั้งเดียว — ถ้าเห็นบรรทัดเดียว แปลว่าคุณเผลอ return ทันทีที่เจอตัวแรก
  5. ลองอีกทางแล้วเทียบ: เปลี่ยน return err ของ Validate เป็น return fmt.Errorf("validate: %w", err) แล้วรันซ้ำ
    ดูว่าคำว่า validate: ไปโผล่กี่บรรทัด แล้วตัดสินเองว่าจะเก็บไว้หรือเอาออก
  6. ล่ากับดักค่าติดลบ: ตั้ง APP_SHUTDOWN_TIMEOUT=-5s แล้วรัน
    ยืนยันว่ามันผ่านทั้งสองด่าน · จากนั้นเติม gt=0 เข้า tag แล้วรันซ้ำ
    สังเกตว่าชื่อ tag ใน error เปลี่ยนจาก 'required' เป็น 'gt'
  7. เขียน Humanize() ของตัวเอง: วน validator.ValidationErrors แล้วพิมพ์ fe.Namespace() กับ fe.Tag()
    ท้าทาย: ลองใช้ fe.Param() เพิ่ม เพื่อเขียนข้อความแบบ "App.Env: ต้องเป็นหนึ่งใน development staging production"
  8. เขียนเทสต์ด้วย t.Setenv: อย่างน้อยสองตัว — ตัวหนึ่งเช็คว่า default ทำงาน อีกตัวเช็คว่า config ผิดสามที่แล้วได้ error ครบสามข้อ
    รันเทสต์โดยลบไฟล์ .env ออกก่อน — ถ้ายังผ่าน แปลว่าเทสต์ของคุณ ไม่ได้แอบพึ่งเครื่องตัวเอง ซึ่งเป็นเงื่อนไขที่ต้องมีถ้าจะเอาไปรันบน CI
  9. ท้าทายสุดท้าย: ทำ run() แบบ 5 ขั้นใน main.go แล้วรันด้วย config ที่ผิด · เปิดอีก terminal ยิง curl http://localhost:8080/healthz
    ต้องต่อไม่ติด (curl exit 7) ไม่ใช่ได้ 500 — ถ้าได้ 500 แปลว่าแอปคุณยังเปิดพอร์ตก่อนตรวจ config อยู่

ผลรันจริงทั้งสี่เคส (ตัวโปรแกรมพิมพ์ผลของ ParseAs กับ Validate แยกบรรทัด):

APP_PORT=99999
  ParseAs  : OK  -> App.Port=99999
  Validate : FAILED -> Key: 'App.Port' Error:Field validation for 'Port' failed on the 'lte' tag

APP_PORT=abc
  ParseAs  : FAILED -> env: parse error on field "Port" of type "int": strconv.ParseInt: parsing "abc": invalid syntax

APP_ENV=prodction
  ParseAs  : OK  -> App.Env="prodction"
  Validate : FAILED -> Key: 'App.Env' Error:Field validation for 'Env' failed on the 'oneof' tag

APP_SHUTDOWN_TIMEOUT=0s
  ParseAs  : OK  -> Shutdown=0s
  Validate : FAILED -> Key: 'App.ShutdownTimeout' Error:Field validation for 'ShutdownTimeout' failed on the 'required' tag

มีเคสเดียวที่พังที่ ParseAs คือ APP_PORT=abc — เพราะเป็นเคสเดียวที่ "แปลงเป็นชนิดที่ประกาศไว้ไม่ได้"
อีกสามเคสแปลงได้หมด: 99999 เป็น int ที่ถูกต้อง · "prodction" เป็น string ที่ถูกต้อง · 0s เป็น time.Duration ที่ถูกต้อง
มันแค่ไม่ใช่ค่าที่แอปเราใช้ได้ ซึ่งเป็นคนละคำถามกัน และเป็นงานของ validator

สังเกตข้อความของสองด่านให้ดี มันหน้าตาคนละแบบชัดเจน:
· ParseAs พูดถึง ชนิดข้อมูล: of type "int", strconv.ParseInt
· validator พูดถึง ชื่อกฎ: failed on the 'lte' tag
อ่านสองคำนี้เป็นก็แยกออกทันทีว่าปัญหาอยู่ด่านไหน โดยไม่ต้องไปเปิดโค้ดดู

เกร็ดเล็ก ๆ ที่คุ้มจะสังเกต: บรรทัด ParseAs ข้างบนมีคำว่า env: นำหน้า เพราะเป็นข้อความที่ env.ParseAs คืนมาตรง ๆ แต่ในกล่อง 🔍 ของหัวข้อ 8 กลับไม่มีคำนี้ — ไม่ใช่พิมพ์ตก แต่เพราะตรงนั้นข้อความผ่าน Humanize() ซึ่ง errors.As แกะชั้นนอกออกไปแล้วหยิบ error ชั้นในมาพิมพ์ · ห่อกี่ชั้น อ่านออกมาก็ได้ข้อความคนละแบบ

💬 ข้อ 5 กับข้อ 6 คือสองข้อที่ผมอยากให้ทำจริง ๆ ของบทนี้
ข้อ 5 เพราะการเห็น prefix ไปโผล่บรรทัดเดียวด้วยตาตัวเอง จะทำให้คุณจำกฎ "อย่า wrap error หลายบรรทัด" ได้โดยไม่ต้องท่อง
ข้อ 6 เพราะมันสอนบทเรียนที่ใหญ่กว่าเรื่อง gt=0: กฎที่เราเขียนไว้ ไม่ได้แปลว่าเราคุมได้อย่างที่คิด — ต้องยิงค่าจริงเข้าไปทดสอบถึงจะรู้

ถ้ารันแล้ว output ไม่ตรงกับในบทนี้ ส่งมาได้เลย — ทุกบรรทัดในหน้านี้วัดบน Go 1.26.5 darwin/arm64 · env/v11 v11.4.1 · validator/v10 v10.30.3 · godotenv v1.5.1
จุดที่มีโอกาสต่างกันมากที่สุดคือ validator เวอร์ชันอื่น เพราะข้อความ error ของมันเคยเปลี่ยนรูปแบบมาแล้วในอดีต

อ้างอิง