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)
}
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
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 — คุณเลือกทำอย่างใดอย่างหนึ่ง:
- Log แล้ว swallow — เมื่อคุณจัดการ error ได้เรียบร้อยแล้ว และไม่อยากให้มันไปต่อ
- 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
}
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
}
สังเกตว่า:
-
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)
}
Log หลังแก้:
2026/06/29 17:05:00 ERROR database error user_id=42 error="FindByID: connection refused"
หนึ่งบรรทัด — หนึ่ง 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
}
เพิ่มเติม — 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),
)
ข้อดี: 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 แล้วจะเห็นว่ามันสวย:
- Repository — just return
- Service — ตัดสินใจว่า error คืออะไร แล้ว log ครั้งเดียว
- Handler — map error → HTTP status
แค่นี้ log ก็สะอาด อ่านรู้เรื่อง ดีบั๊กง่าย 🧹
-
"Error คือ value" — หนึ่งใน Go proverbs โดย Rob Pike: error ไม่ใช่ exception ที่ต้อง throw/catch — มันคือค่า return ธรรมดาที่ caller ตรวจสอบด้วย
if err != nil { ... }↩ -
"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 ↩
-
Error wrapping:
fmt.Errorf("context: %w", err)ใช้%wverb เพื่อ wrap error โดยไม่เสีย error chain —errors.Is()และerrors.As()จะ traverse ผ่าน wrapped errors ได้ ↩ -
Handler logging: ถ้าใช้ HTTP middleware (เช่น
chi,echo,gin) — หลายตัวมี request logger ในตัวอยู่แล้ว ไม่ต้อง log ซ้ำใน handler ↩ -
log/slog: structured logging ใน Go 1.21+ — ใช้ key=value pairs แทน format string — ทำให้ log parse ด้วยเครื่องมือ automation ได้ https://go.dev/blog/slog ↩
Top comments (0)