บทที่ 13 · Part 4 — APIs and Boundaries
Errors as Public Contracts
ออกแบบ error vocabulary, wrapping, translation และ structured errors ที่สังเกตได้ข้าม boundary
error message ที่ดีช่วย debug ได้ 1 ครั้ง แต่ error contract ที่ดีช่วยให้ทุก caller ตัดสินใจถูกตลอดอายุ
ของ API หาก package wrap sql.ErrNoRows ออกไปโดยไม่ตั้งใจ caller อาจผูก business behavior กับ driver
และการเปลี่ยน storage จะกลายเป็น breaking change Error ใน Go จึงเป็น public vocabulary ไม่ใช่ string
จบบทนี้คุณจะ
- เลือก sentinel, typed error และ opaque error ตามสิ่งที่ caller ต้องทำ
- wrap และ translate error โดยรักษาหรือปิด chain อย่างตั้งใจ
- วาง logging ที่ boundary เดียวและใช้ structured error เมื่อมีเหตุผล
เริ่มจากการตัดสินใจของ Caller
อย่าเริ่มด้วยคำถามว่า error type ควรมี field อะไร ให้เริ่มว่า caller ต้องทำอะไรต่อ:
| Caller decision | Contract ที่เหมาะ |
|---|---|
| แสดง not found | errors.Is(err, ErrNotFound) |
| อ่าน field เช่น retry delay | errors.As ไป typed error |
| แค่หยุดและรายงาน | opaque error พร้อม context |
| หลาย cleanup ล้มพร้อมกัน | errors.Join ถ้าทุก cause มีความหมาย |
var ErrBatchNotFound = errors.New("batch not found")
type ConflictError struct {
BatchID string
State Status
}
func (e *ConflictError) Error() string {
return fmt.Sprintf("batch %q is already %s", e.BatchID, e.State)
}
sentinel เหมาะกับ category คงที่ที่ไม่มีข้อมูลเพิ่ม Typed error เหมาะเมื่อ caller ต้องอ่านข้อมูลจริง แต่ exported field/type คือ compatibility commitment เช่นกัน อย่าสร้าง custom type เพียงเพื่อเก็บ message และ stack ที่ไม่มี caller ใช้
Wrap คือการเปิด Contract
fmt.Errorf("loading batch %q: %w", id, err) เพิ่ม context และรักษา chain ให้ errors.Is/As
เดินถึง cause ได้ นั่นหมายความว่าการเลือก %w เป็น API decision ถ้าไม่ต้องการ expose driver error
ให้ translate ที่ boundary:
func (s *BatchStore) ByID(ctx context.Context, id string) (settlement.Batch, error) {
batch, err := scanBatch(s.db.QueryRowContext(ctx, queryBatch, id))
if errors.Is(err, sql.ErrNoRows) {
return settlement.Batch{}, settlement.ErrBatchNotFound
}
if err != nil {
return settlement.Batch{}, fmt.Errorf("querying batch %q: %w", id, err)
}
return batch, nil
}
ในตัวอย่าง not-found ถูกแปลเป็น vocabulary ของ application แต่ unexpected failure ยัง wrap เพื่อ diagnostics ภายใน process ถ้า public module ไม่ต้องการให้ consumer inspect driver ให้ใช้ opaque error หรือ typed application error แทนการเปิด chain ลงไปทุกชั้น
ใช้ errors.Is แทน == เพราะ chain อาจถูก wrap ใช้ errors.As แทน type assertion บน top-level
error และใช้ errors.Join เมื่อมี failure หลายตัวที่เป็นอิสระ เช่นปิด resource 2 ตัว ไม่ควร Join
primary operation error กับ cleanup errorโดยไม่คิดว่า caller จะจัดลำดับความสำคัญอย่างไร
Error เดินทางข้าม Boundary อย่างไร
HTTP adapter ไม่ควรส่ง err.Error() ให้ client เพราะอาจรั่ว SQL, host หรือ identifier ภายใน
ให้ map ErrBatchNotFound เป็น 404 พร้อม code คงที่, conflict เป็น 409, invalid input เป็น 400
และ unexpected เป็น 500 ข้อความ client กับรายละเอียด log เป็นคนละ contract
หลัก handle once คือ layer กลางเพิ่ม context แล้ว return ส่วน request/job boundary เป็นจุด log ครั้งเดียว ถ้า log แล้ว return ทุกชั้น incident 1 ครั้งจะสร้างข้อความซ้ำและ severity ผิด การ retry ที่จัดการ error สำเร็จอาจ log debug/metric แต่ไม่ควรถูกนับเป็น final failure
Structured Errors และ samber/oops
standard errors + fmt.Errorf เพียงพอสำหรับ service จำนวนมาก เมื่อระบบต้องแนบ stable error code,
stack trace, tenant/request context หรือ structured attributes อย่างสม่ำเสมอ คอร์สแนะนำ
samber/oops เป็นตัวเลือกที่ควรรู้ มันแทน error builder และ
metadata plumbing ของทีม ไม่ได้แทนการออกแบบ error vocabulary หรือ HTTP mapping
ก่อนใช้ต้องกำหนด redaction policy เพราะ error attributes เดินทางเข้า log/APM ได้ ห้ามใส่ token, password, full payload หรือข้อมูลส่วนบุคคลโดยไม่มี policy และอย่าให้ stack trace กลายเป็น API response
Production Toolbox
Default: errors, %w, Is/As และ stable application error vocabulary ใช้ oops เมื่อ structured
context/stack/code มีคุณค่าข้ามหลาย service และทีมกำหนด redaction ได้ ใช้ panic สำหรับ programmer
error หรือ invariant ที่กู้ไม่ได้ใน boundary แคบ ไม่ใช้แทน expected failure
Checklist ของ Error Contract
- caller decision เป็นตัวกำหนด sentinel/type ไม่ใช่ความสะดวกของ producer
%wเปิด chain อย่างตั้งใจและ driver error ไม่รั่วข้าม public boundary- error string lowercase และไม่มีข้อมูลลับ
- error ถูก log หรือ return ไม่ทำทั้ง 2ทุก layer
- HTTP/client response ใช้ stable code และข้อความปลอดภัย
- multi-error ยังบอก primary outcome ได้
- structured error library มี redaction, sampling และ ownership ชัด
อ่านเพิ่ม: Working with Errors in Go 1.13,
errors package และ
Error handling and Go