กลับไปยังบทความ

Backend

Error Handling และโครงสร้างโปรเจกต์ Go (Golang) ที่ดูแลง่ายแม้ระบบจะโตขึ้น

error แบบ explicit และ package แบบแบน ๆ ของ Go ดูง่ายในช่วงแรก แต่จะเริ่มสร้างปัญหาเมื่อโค้ดโตถึง 50,000 บรรทัด บทความนี้รวบรวมแนวปฏิบัติที่ช่วยให้ service ภาษา Go ที่กำลังเติบโตยังอ่านง่าย ทั้งการ wrap error พร้อม context, sentinel error และ typed error รวมถึงการจัดโครงสร้าง package ตามหน้าที่การทำงาน

24 กันยายน 25695 นาทีในการอ่าน

Error Handling and Project Layout for Go Services That Age Well

service ภาษา Go ที่เพิ่งเริ่มเขียนมักไม่ค่อยมีปัญหาเชิงโครงสร้าง ปัญหาจะโผล่มาราว ๆ หนึ่งปีให้หลัง เมื่อมีคนร่วมเขียนโค้ดห้าคน package utils มีฟังก์ชัน 40 ตัว และ log บรรทัดหนึ่งเขียนว่า error: not found โดยไม่บอกเลยว่า อะไร ที่หาไม่เจอ นิสัยสองอย่างที่ป้องกันความเสื่อมโทรมนี้ได้เกือบทั้งหมดคือ การจัดการ error อย่างมีวินัย และการจัดโครงสร้าง package ตามวัตถุประสงค์แทนที่จะจัดตามประเภทไฟล์

error คือ value ดังนั้นจงใส่ context ให้มัน

ใน Go นั้น error เป็น value ธรรมดาที่ถูกคืนค่าออกมาพร้อมกับผลลัพธ์ ข้อดีคือ error ถูกจัดการอย่าง explicit แต่ก็ทำให้เกิดความเคยชินที่จะส่งมันขึ้นไปข้างบนตรง ๆ โดยไม่แก้ไขอะไร:

go
if err != nil {
    return err // where did this come from?
}

พอ error นั้นเดินทางไปถึง HTTP handler ข้อมูลทุกอย่างเกี่ยวกับเส้นทางที่มันผ่านมาก็หายไปหมดแล้ว ให้ wrap มันในแต่ละ layer แทน โดยใช้ %w เพื่อให้ยังตรวจสอบ error ต้นทางได้:

go
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:

go
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:

go
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 ในที่เดียว
go
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: จัดตามสิ่งที่โค้ดทำ

ความผิดพลาดที่พบบ่อยที่สุดในการจัดโครงสร้างคือการจัดกลุ่มโค้ดตามหมวดหมู่ทางเทคนิค:

code
/models
/controllers
/services
/utils

ด้วยโครงสร้างแบบนี้ การแก้ฟีเจอร์เดียวต้องแตะถึงสี่ไดเรกทอรี และ utils ก็โตขึ้นเรื่อย ๆ ไม่มีที่สิ้นสุด ให้จัดกลุ่มตาม ความสามารถของ domain แทน:

code
/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 นิยาม interface Repository ที่มันต้องการ และ order/postgres เป็นตัว implement วิธีนี้ทำให้ domain package ไม่ต้อง import อะไรที่เกี่ยวกับ database และทำ fake ในเทสต์ได้ง่าย
  • ชื่อ package เป็นคำนามที่บอกว่า package นั้นให้อะไร เช่น order, payment หรือ ratelimit หลีกเลี่ยงชื่ออย่าง common, helpers และ misc เพราะชื่อที่ไม่ได้อธิบายอะไรเลย สุดท้ายจะกลายเป็นที่รวมของทุกอย่าง

ทำให้ main เรียบง่ายน่าเบื่อไว้

การ wiring แบบ explicit ใน main คือจุดแข็ง ไม่ใช่ boilerplate ที่ต้องซ่อนไว้หลัง framework:

go
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 ยังน่าทำงานด้วย แม้จะผ่านการพัฒนาฟีเจอร์อย่างต่อเนื่องมาเป็นปีแล้วก็ตาม

พร้อมสร้างสิ่งที่ยิ่งใหญ่หรือยัง?

เล่าโปรเจคของคุณให้เราฟัง — เราจะพาคุณไปถึง

เลือกคุกกี้ที่เราใช้ได้ คุกกี้ที่จำเป็นช่วยให้เว็บไซต์ทำงานได้และเปิดใช้งานเสมอ