บทที่ 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 decisionContract ที่เหมาะ
แสดง not founderrors.Is(err, ErrNotFound)
อ่าน field เช่น retry delayerrors.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