DEV Community

Gophernment Co
Gophernment Co

Posted on

Double Report — เมื่อ Error ถูก Log ซ้ำซากใน Go

Double Report — เมื่อ Error ถูก Log ซ้ำซากใน Go

หรือที่ฝรั่งเรียกกันว่า "log and return" anti-pattern

เคยเจอไหม? เปิด log มาดู error เดียวกันซ้ำ 3-4 บรรทัด ทั้งที่มันคือ error จากเหตุการณ์เดียวกัน เขียนโค้ดดี ๆ แต่ log ดูกระจายเหมือนเกิดแผ่นดินไหว

นี่คือ Double Report — anti-pattern ยอดฮิตที่เกิดขึ้นใน Go (และภาษาอื่น) เวลาที่เราไม่แน่ใจว่า "ควร log ตรงไหน" แล้วก็เลย... log ทุกที่


ปัญหาหน้าตาเป็นยังไง

ลองดูตัวอย่าง — REST API ที่ query user จาก database:

// repository/user.go
func (r *UserRepo) FindByID(ctx context.Context, id string) (*User, error) {
    user, err := r.db.QueryRow(ctx, "SELECT * FROM users WHERE id = $1", id)
    if err != nil {
        log.Printf("failed to query user: %v", err) // ❌ log ครั้งที่ 1
        return nil, fmt.Errorf("FindByID: %w", err)
    }
    return user, nil
}

// service/user.go
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
    user, err := s.repo.FindByID(ctx, id)
    if err != nil {
        log.Printf("error getting user %s: %v", id, err) // ❌ log ครั้งที่ 2
        return nil, fmt.Errorf("GetUser: %w", err)
    }
    return user, nil
}

// handler/user.go
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
    id := chi.URLParam(r, "id")
    user, err := h.svc.GetUser(r.Context(), id)
    if err != nil {
        log.Printf("handler GetUser failed: %v", err) // ❌ log ครั้งที่ 3
        http.Error(w, "not found", http.StatusNotFound)
        return
    }
    json.NewEncoder(w).Encode(user)
}
Enter fullscreen mode Exit fullscreen mode

Log ที่ออกมา:

2026/06/29 17:00:01 failed to query user: connection refused
2026/06/29 17:00:01 error getting user 42: FindByID: connection refused
2026/06/29 17:00:01 handler GetUser failed: GetUser: FindByID: connection refused
Enter fullscreen mode Exit fullscreen mode

error จริงมีแค่ connection refused — แต่ log เป็นสามบรรทัด วนซ้ำ context เดิม สิ่งที่เกิดขึ้นคือเราโปะ context เพิ่ม (FindByID:, GetUser:) แล้ว log ทุกระดับ ผลลัพธ์ = noise

ลองนึกภาพ production ที่มี request พร้อมกันเป็นร้อย — log จะอ่านยากขนาดไหน


ทำไมเราถึงเผลอทำ

มันมาจาก mindset นี้:

"ไม่รู้ว่า caller จะ handle error ยังไง — log ไว้ก่อนดีกว่า เผื่อหาย"

แต่ใน Go error คือ value1 — มันถูกส่งกลับขึ้นไปตาม call chain อยู่แล้ว คนเรียกมีสิทธิ์เต็มที่ที่จะตัดสินใจว่าจะ log, wrap, ignore, หรือเปลี่ยนเป็น error type อื่น

พอเรา log ทุกชั้น — เรากำลังแย่งหน้าที่ของ caller โดยไม่จำเป็น


วิธีที่ถูกต้อง — Log หรือ Return อย่างใดอย่างหนึ่ง

กฎง่าย ๆ ที่ community Go ยึดกัน:

Either log the error, or return it — never both2

แปลคือ: ในหนึ่งชั้นของ call chain — คุณเลือกทำอย่างใดอย่างหนึ่ง:

  1. Log แล้ว swallow — เมื่อคุณจัดการ error ได้เรียบร้อยแล้ว และไม่อยากให้มันไปต่อ
  2. Return error — เมื่อคุณยังจัดการไม่ได้ อยากให้คนเรียกตัดสินใจ

แก้ที่ Repository — return เฉย ๆ, ไม่ต้อง log

Repository เป็น layer ต่ำสุด — มันไม่รู้ว่า error เกิดจากอะไร, มีผลกระทบยังไง, ควร log ที่นี่ไหม มันก็แค่ return:

func (r *UserRepo) FindByID(ctx context.Context, id string) (*User, error) {
    user, err := r.db.QueryRow(ctx, "SELECT * FROM users WHERE id = $1", id)
    if err != nil {
        return nil, fmt.Errorf("FindByID: %w", err)
    }
    return user, nil
}
Enter fullscreen mode Exit fullscreen mode

fmt.Errorf("FindByID: %w", err) — อันนี้คือ wrapping ไม่ใช่ logging นะ — มันเติมชื่อ function เข้าไปใน error chain เพื่อให้ debug ได้3 โดยไม่ต้อง log

แก้ที่ Service — business logic ตรงนี้

Service รู้บริบท: "หา user id 42 ไม่เจอมันแปลว่า not found หรือ database down?" — นี่คือที่ที่ควร log:

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
    user, err := s.repo.FindByID(ctx, id)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, fmt.Errorf("user %s not found", id)
        }
        // ⚠️ database connection error — service เห็นว่ามันคือ infra issue
        slog.Error("database error", "user_id", id, "error", err)
        return nil, fmt.Errorf("GetUser: %w", err)
    }
    return user, nil
}
Enter fullscreen mode Exit fullscreen mode

สังเกตว่า:

  • sql.ErrNoRows → ถือเป็นเรื่องปกติ แค่ return not found ไม่ต้อง log
  • connection refused → infrastructure issue → log ที่นี่ครั้งเดียว

แก้ที่ Handler — log เฉพาะที่ไม่คาดคิด

Handler ควรตอบกลับ client — ไม่ต้อง log ทุก error4:

func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
    id := chi.URLParam(r, "id")
    user, err := h.svc.GetUser(r.Context(), id)
    if err != nil {
        // Service จัดการไปแล้ว — handler ก็แค่ map error → HTTP status
        switch {
        case strings.Contains(err.Error(), "not found"):
            http.Error(w, "not found", http.StatusNotFound)
        default:
            slog.Error("unhandled error", "path", r.URL.Path, "error", err)
            http.Error(w, "internal error", http.StatusInternalServerError)
        }
        return
    }
    json.NewEncoder(w).Encode(user)
}
Enter fullscreen mode Exit fullscreen mode

Log หลังแก้:

2026/06/29 17:05:00 ERROR database error user_id=42 error="FindByID: connection refused"
Enter fullscreen mode Exit fullscreen mode

หนึ่งบรรทัด — หนึ่ง error — ครบ context 🎉


สรุปหลักการ

ระดับ บทบาท Log?
Repository Data access — return error
Service Business logic — ตัดสินใจว่า error คืออะไร ✅ ครั้งเดียว
Handler HTTP — map error เป็น status code ❌ (ยกเว้น unexpected)

The Golden Rule

// ✅ ถูก
if err != nil {
    return fmt.Errorf("doThing: %w", err) // return — ให้ caller จัดการ
}

// ✅ ถูก (เมื่อเราจัดการแล้วจริง ๆ)
if err != nil {
    log.Printf("background task failed: %v", err) // log — ไม่ return
    return  // จบที่นี่
}

// ❌ ผิด — อย่าทำเด็ดขาด
if err != nil {
    log.Printf("error: %v", err)  // log แล้ว...
    return err                     // return อีก = double report
}
Enter fullscreen mode Exit fullscreen mode

เพิ่มเติม — Structured Logging แบบ Go 1.21+

Go 1.21 เพิ่ม log/slog เข้ามาใน stdlib5 — ใช้ structured logging แทน log.Printf:

slog.Error("database error",
    slog.String("user_id", id),
    slog.Any("error", err),
)
Enter fullscreen mode Exit fullscreen mode

ข้อดี: log เป็น JSON, filter ด้วย field ได้, ต่อกับ observability tools (Loki, Datadog, Grafana) ง่าย


เชิงอรรถ — Go Naming Philosophy

บทความนี้ตั้งใจใช้ชื่อ function แบบเดียวกับที่ standard library และ community Go ใช้:

  • FindByID — prefix verb (Find) + preposition (By) + noun (ID) — บอกว่า หา ด้วย อะไร
  • GetUser — prefix verb (Get) + noun (User) — บอกว่า เอาอะไร
  • ชื่อแบบนี้มีที่มาจาก Effective Go"the name of a method should say what it does"

Function สั้น ตรงประเด็น อ่านแล้วรู้ว่าทำอะไร — โดยไม่ต้องมีคำว่า Do, Handle, Process มาต่อข้างหน้าโดยไม่จำเป็น


สรุป

Double Report เกิดจากความไม่แน่ใจมากกว่าความผิดพลาด — พอเข้าใจ Go error handling philosophy แล้วจะเห็นว่ามันสวย:

  1. Repository — just return
  2. Service — ตัดสินใจว่า error คืออะไร แล้ว log ครั้งเดียว
  3. Handler — map error → HTTP status

แค่นี้ log ก็สะอาด อ่านรู้เรื่อง ดีบั๊กง่าย 🧹



  1. "Error คือ value" — หนึ่งใน Go proverbs โดย Rob Pike: error ไม่ใช่ exception ที่ต้อง throw/catch — มันคือค่า return ธรรมดาที่ caller ตรวจสอบด้วย if err != nil { ... } 

  2. "Either log or return" — Dave Cheney, ผู้เขียนบทความคลาสสิคเรื่อง Go error handling: "Don't just check errors, handle them gracefully" — https://dave.cheney.net/2016/04/27/dont-just-check-errors-handle-them-gracefully 

  3. Error wrapping: fmt.Errorf("context: %w", err) ใช้ %w verb เพื่อ wrap error โดยไม่เสีย error chain — errors.Is() และ errors.As() จะ traverse ผ่าน wrapped errors ได้ 

  4. Handler logging: ถ้าใช้ HTTP middleware (เช่น chi, echo, gin) — หลายตัวมี request logger ในตัวอยู่แล้ว ไม่ต้อง log ซ้ำใน handler 

  5. log/slog: structured logging ใน Go 1.21+ — ใช้ key=value pairs แทน format string — ทำให้ log parse ด้วยเครื่องมือ automation ได้ https://go.dev/blog/slog 

Top comments (0)