service ภาษา Go ที่เพิ่งเริ่มเขียนมักไม่ค่อยมีปัญหาเชิงโครงสร้าง ปัญหาจะโผล่มาราว ๆ หนึ่งปีให้หลัง เมื่อมีคนร่วมเขียนโค้ดห้าคน package utils มีฟังก์ชัน 40 ตัว และ log บรรทัดหนึ่งเขียนว่า error: not found โดยไม่บอกเลยว่า อะไร ที่หาไม่เจอ นิสัยสองอย่างที่ป้องกันความเสื่อมโทรมนี้ได้เกือบทั้งหมดคือ การจัดการ error อย่างมีวินัย และการจัดโครงสร้าง package ตามวัตถุประสงค์แทนที่จะจัดตามประเภทไฟล์
error คือ value ดังนั้นจงใส่ context ให้มัน
ใน Go นั้น error เป็น value ธรรมดาที่ถูกคืนค่าออกมาพร้อมกับผลลัพธ์ ข้อดีคือ error ถูกจัดการอย่าง explicit แต่ก็ทำให้เกิดความเคยชินที่จะส่งมันขึ้นไปข้างบนตรง ๆ โดยไม่แก้ไขอะไร:
if err != nil {
return err // where did this come from?
}พอ error นั้นเดินทางไปถึง HTTP handler ข้อมูลทุกอย่างเกี่ยวกับเส้นทางที่มันผ่านมาก็หายไปหมดแล้ว ให้ wrap มันในแต่ละ layer แทน โดยใช้ %w เพื่อให้ยังตรวจสอบ error ต้นทางได้:
func (s *OrderService) Cancel(ctx context.Context, id string) error {
order, err := s.repo.Get(ctx, id)
if err != nil {
return fmt.Errorf("cancel order %s: load: %w", id, err)
}
if err := order.Cancel(); err != nil {
return fmt.Errorf("cancel order %s: %w", id, err)
}
return s.repo.Save(ctx, order)
}ข้อความสุดท้ายจะอ่านได้เหมือนร่องรอยที่บอกเส้นทาง เช่น cancel order 8812: load: sql: no rows in result set และคุณได้ข้อมูลนี้มาโดยไม่ต้องพึ่ง stack trace เลย
หลักง่าย ๆ ในการ wrap: ให้เพิ่ม context ที่อธิบายว่า ฟังก์ชันนี้กำลังพยายามทำอะไร ไม่ใช่อธิบายว่าอะไรพัง เพราะ error ข้างในอธิบายไว้แล้วว่าอะไรพัง
error สามประเภทและควรใช้แต่ละแบบเมื่อไร
1. Sentinel error สำหรับเงื่อนไขที่คาดการณ์ไว้
เมื่อผู้เรียกต้องแยกทางการทำงานตามผลลัพธ์ที่เฉพาะเจาะจงและเป็นที่รู้จัก ให้ export เป็นตัวแปรระดับ package:
var ErrNotFound = errors.New("order not found")
// caller
if errors.Is(err, orders.ErrNotFound) {
return http.StatusNotFound
}errors.Is จะไล่ตรวจไปตาม wrap chain ดังนั้นการเช็กนี้ยังใช้งานได้แม้จะผ่าน fmt.Errorf("...: %w", err) มาหลายชั้นแล้ว
2. Typed error เมื่อผู้เรียกต้องการข้อมูลเพิ่มเติม
ถ้าผู้เรียกต้องการรายละเอียด เช่น field ไหนที่ validate ไม่ผ่าน ให้นิยามเป็น type:
type ValidationError struct {
Field string
Reason string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("invalid %s: %s", e.Field, e.Reason)
}
// caller
var ve *orders.ValidationError
if errors.As(err, &ve) {
respondBadRequest(w, ve.Field, ve.Reason)
}3. Opaque error สำหรับกรณีอื่นทั้งหมด
error ส่วนใหญ่ควรเป็นแบบ opaque (ไม่เปิดเผยรายละเอียดภายใน) ผู้เรียกแค่ต้องรู้ว่ามีบางอย่างล้มเหลว ส่วนข้อความมีไว้ให้คนที่อ่าน log ไม่ควร export sentinel สำหรับเงื่อนไขที่ไม่มีใครใช้แยกทางการทำงาน เพราะทุก error ที่ export ออกไปจะกลายเป็นส่วนหนึ่งของ API contract ของคุณ
แปลง error ที่ขอบเขตของระบบ
โค้ดฝั่ง domain ไม่ควรต้องรู้จัก HTTP status code และ handler ก็ไม่ควรต้องรู้จัก sql.ErrNoRows ให้แปลง error เพียงครั้งเดียวที่ขอบแต่ละด้าน:
- Repository layer: แปลง error เฉพาะของ driver (
sql.ErrNoRows, การละเมิด unique constraint) ให้เป็น domain error (ErrNotFound,ErrDuplicate) - Transport layer: map domain error ไปเป็น status code ในที่เดียว
func statusFor(err error) int {
switch {
case errors.Is(err, domain.ErrNotFound):
return http.StatusNotFound
case errors.Is(err, domain.ErrConflict):
return http.StatusConflict
default:
var ve *domain.ValidationError
if errors.As(err, &ve) {
return http.StatusBadRequest
}
return http.StatusInternalServerError
}
}การรวม mapping นี้ไว้ในฟังก์ชันเดียวทำให้ตรวจสอบได้ง่าย และยากที่จะเกิดความไม่สอดคล้องกัน
log ครั้งเดียวที่ชั้นบนสุด
anti-pattern ที่พบบ่อยคือการ log error แล้วยัง return มันออกไปด้วย ผลคือความล้มเหลวเดียวกันจะโผล่ใน log ถึงสี่ครั้ง ในทุกระดับของ call stack ให้เลือกอย่างใดอย่างหนึ่ง: จะจัดการ error (log, retry หรือ fallback) หรือจะ return มันพร้อม context ก็ได้ แต่อย่าทำทั้งสองอย่าง ใน service ส่วนใหญ่ การ log จะเกิดขึ้นครั้งเดียวใน request middleware พร้อมกับ request ID
อย่า panic ข้ามขอบเขตของ package
panic มีไว้สำหรับความผิดพลาดของโปรแกรมเมอร์ที่กู้คืนไม่ได้จริง ๆ เช่น map ที่เป็น nil ทั้งที่ควรถูก initialize ตั้งแต่ตอน startup ไม่ได้มีไว้สำหรับ validation ที่ไม่ผ่านหรือ network timeout ถ้า library ที่คุณใช้อยู่อาจ panic ได้ ให้ recover ที่ขอบเขตของ goroutine แล้วแปลง panic เป็น error เพื่อไม่ให้ request ที่มีปัญหาเพียงตัวเดียวทำให้ทั้ง process ล่ม
Package layout: จัดตามสิ่งที่โค้ดทำ
ความผิดพลาดที่พบบ่อยที่สุดในการจัดโครงสร้างคือการจัดกลุ่มโค้ดตามหมวดหมู่ทางเทคนิค:
/models
/controllers
/services
/utilsด้วยโครงสร้างแบบนี้ การแก้ฟีเจอร์เดียวต้องแตะถึงสี่ไดเรกทอรี และ utils ก็โตขึ้นเรื่อย ๆ ไม่มีที่สิ้นสุด ให้จัดกลุ่มตาม ความสามารถของ domain แทน:
/cmd/api/main.go # wiring only: config, dependencies, server start
/internal/order/ # order domain: types, service, repository interface
/internal/order/postgres/ # repository implementation
/internal/payment/
/internal/platform/httpx/ # shared HTTP helpers with a real, narrow purpose
/internal/platform/dbx/หลักการเบื้องหลังโครงสร้างนี้:
internal/ป้องกันไม่ให้ module อื่น import package ของคุณ คุณจึง refactor ได้อย่างอิสระcmd/เก็บ entrypoint ที่บางที่สุดmain.goมีหน้าที่สร้าง dependency แล้วเรียกRunโดยไม่มี business logic อยู่เลย- interface อยู่ในที่ที่มันถูกใช้งาน package
orderนิยาม interfaceRepositoryที่มันต้องการ และorder/postgresเป็นตัว implement วิธีนี้ทำให้ domain package ไม่ต้อง import อะไรที่เกี่ยวกับ database และทำ fake ในเทสต์ได้ง่าย - ชื่อ package เป็นคำนามที่บอกว่า package นั้นให้อะไร เช่น
order,paymentหรือratelimitหลีกเลี่ยงชื่ออย่างcommon,helpersและmiscเพราะชื่อที่ไม่ได้อธิบายอะไรเลย สุดท้ายจะกลายเป็นที่รวมของทุกอย่าง
ทำให้ main เรียบง่ายน่าเบื่อไว้
การ wiring แบบ explicit ใน main คือจุดแข็ง ไม่ใช่ boilerplate ที่ต้องซ่อนไว้หลัง framework:
func run(ctx context.Context, cfg Config) error {
db, err := dbx.Open(ctx, cfg.DatabaseURL)
if err != nil {
return fmt.Errorf("open db: %w", err)
}
defer db.Close()
orders := order.NewService(orderpg.NewRepo(db))
router := httpapi.NewRouter(orders)
return httpx.Serve(ctx, cfg.Addr, router)
}ใครก็ตามอ่านฟังก์ชันนี้แล้วจะเห็น dependency graph ทั้งหมดได้ทันที การ return error ออกจาก run แทนที่จะเรียก log.Fatal กระจายไปทั่ว ยังช่วยให้ cleanup ที่ defer ไว้ได้ทำงานจริงด้วย
สรุป
- wrap error ด้วย
%wและอธิบายว่ากำลังพยายามทำ operation อะไร - export sentinel error และ typed error เฉพาะเงื่อนไขที่ผู้เรียกใช้แยกทางการทำงานจริง ๆ
- แปลง error ที่ขอบเขตของ storage และ transport
- log แต่ละ error เพียงครั้งเดียวที่ชั้นบนสุด
- จัด package ตาม domain เก็บไว้ใน
internalและเขียนmainให้ explicit
แนวปฏิบัติเหล่านี้ไม่มีข้อไหนซับซ้อน แต่เมื่อรวมกันแล้ว มันคือสิ่งที่ทำให้ codebase ภาษา Go ยังน่าทำงานด้วย แม้จะผ่านการพัฒนาฟีเจอร์อย่างต่อเนื่องมาเป็นปีแล้วก็ตาม
