Làm chủ Error Handling trong Go: Wrap Error, Custom Error và Best Practices cho dự án thực tế

Development tutorial - IT technology blog
Development tutorial - IT technology blog

Go có cách xử lý lỗi riêng biệt — không try/catch như Java, không exception như Python. Hàm trả về error như một return value thông thường và caller tự quyết định xử lý. Nghe đơn giản, nhưng khi project lớn dần, thiếu chiến lược rõ ràng thì debug trở thành ác mộng.

Dự án web app gần nhất mình tham gia có 5 developer. Tuần đầu mỗi người một kiểu: người dùng errors.New, người wrap bằng fmt.Errorf, người tự định nghĩa struct riêng. Kết quả? Trace một lỗi production mất cả buổi sáng vì message kiểu “something went wrong” chẳng nói được gì. Sau khi team ngồi lại thống nhất quy trình, cùng loại lỗi đó chỉ mất khoảng 10 phút để tìm ra.

Ba approach xử lý lỗi phổ biến trong Go

Approach 1: errors.New và fmt.Errorf thuần

Cách đơn giản nhất, phù hợp cho scripts hoặc internal helpers nhỏ:

package main

import (
    "errors"
    "fmt"
)

var ErrNotFound = errors.New("record not found")

func getUserByID(id int) (string, error) {
    if id <= 0 {
        return "", fmt.Errorf("invalid user id: %d", id)
    }
    if id == 999 {
        return "", ErrNotFound
    }
    return "Alice", nil
}

func main() {
    _, err := getUserByID(0)
    if err != nil {
        fmt.Println(err) // invalid user id: 0
    }

    _, err = getUserByID(999)
    if errors.Is(err, ErrNotFound) {
        fmt.Println("User không tồn tại")
    }
}

Sentinel errors như ErrNotFound được định nghĩa ở package level. Caller dùng errors.Is(err, ErrNotFound) để kiểm tra — không nên so sánh trực tiếp bằng == vì sẽ fail khi lỗi bị wrap.

Approach 2: Custom Error Type

Khi cần đính kèm metadata vào lỗi — HTTP status code, error code, tên operation — custom struct là lựa chọn tốt hơn:

type AppError struct {
    Code    int
    Message string
    Op      string // tên operation
}

func (e *AppError) Error() string {
    return fmt.Sprintf("[%s] %d: %s", e.Op, e.Code, e.Message)
}

func getUserByID(id int) (string, error) {
    if id <= 0 {
        return "", &AppError{
            Code:    400,
            Message: "invalid user id",
            Op:      "getUserByID",
        }
    }
    return "Alice", nil
}

func main() {
    _, err := getUserByID(-1)
    if err != nil {
        var appErr *AppError
        if errors.As(err, &appErr) {
            fmt.Printf("HTTP %d: %s\n", appErr.Code, appErr.Message)
        }
    }
}

errors.As traverse toàn bộ error chain để tìm type khớp — khác với type assertion trực tiếp chỉ nhìn ở bề mặt.

Approach 3: Wrap Error với %w

Từ Go 1.13, fmt.Errorf hỗ trợ verb %w để wrap error gốc, giữ nguyên toàn bộ context call stack:

func readConfig(path string) error {
    data, err := os.ReadFile(path)
    if err != nil {
        return fmt.Errorf("readConfig: %w", err)
    }
    _ = data
    return nil
}

func initApp() error {
    if err := readConfig("/etc/app/config.yaml"); err != nil {
        return fmt.Errorf("initApp: %w", err)
    }
    return nil
}

func main() {
    err := initApp()
    if err != nil {
        fmt.Println(err)
        // initApp: readConfig: open /etc/app/config.yaml: no such file or directory

        if errors.Is(err, os.ErrNotExist) {
            fmt.Println("Config file không tồn tại")
        }
    }
}

Error message in ra cả chuỗi call path — nhìn một dòng là biết ngay lỗi đi qua những hàm nào.

Phân tích ưu và nhược điểm

  • errors.New / fmt.Errorf thuần — viết nhanh, ít boilerplate. Nhưng caller không phân biệt được các loại lỗi: không biết đây là 404, 401, hay database timeout. Xử lý có điều kiện gần như không làm được. Hợp lý cho scripts nhỏ hoặc helper nội bộ.
  • Custom Error Type — đính kèm được metadata (HTTP status, error code, tên operation) vào lỗi. Caller dùng errors.As để bắt đúng loại và xử lý từng case. Chi phí là ~10–15 dòng boilerplate mỗi error type — đáng đầu tư cho API layer và business logic.
  • Wrap Error với %w — tự động build chuỗi context như initApp: readConfig: open /etc/app/config.yaml: no such file. Một dòng log là trace được toàn bộ call path. Chain có thể dài nếu wrap nhiều tầng, nhưng đó là thứ mình chấp nhận đánh đổi.

Ba cách này không loại trừ nhau — dự án thực tế thường kết hợp cả ba, mỗi cái đúng vai trò của nó.

Chọn approach phù hợp cho từng tình huống

Mình dùng quy tắc phân tầng trong team như sau:

  • Repository / Database layer: Wrap error gốc bằng %w, prefix tên function. Ví dụ: fmt.Errorf("UserRepo.GetByID: %w", err). Lỗi từ database driver giữ nguyên nhưng có thêm context.
  • Service / Business logic layer: Định nghĩa Custom Error Type cho lỗi nghiệp vụ cụ thể. Ví dụ: ErrUserSuspended, ErrQuotaExceeded. Caller tầng trên dùng errors.As để xử lý từng loại.
  • Handler / API layer: Dùng errors.As bắt Custom Error, chuyển thành HTTP response phù hợp. Lỗi không xác định thì trả 500 và log đầy đủ error chain.

Với Sentinel errors, mình chỉ tạo khi caller thực sự cần so sánh bằng errors.Is. Tránh tạo hàng chục biến Err* mà không ai dùng — package level pollution.

Hướng dẫn triển khai trong dự án thực tế

Bước 1: Tạo package apperrors riêng

// internal/apperrors/errors.go
package apperrors

import "fmt"

type ErrorCode int

const (
    ErrCodeNotFound     ErrorCode = 404
    ErrCodeUnauthorized ErrorCode = 401
    ErrCodeInternal     ErrorCode = 500
)

type AppError struct {
    Code    ErrorCode
    Message string
    Err     error
}

func (e *AppError) Error() string {
    if e.Err != nil {
        return fmt.Sprintf("%s: %v", e.Message, e.Err)
    }
    return e.Message
}

// Unwrap cho phép errors.Is và errors.As traverse qua
func (e *AppError) Unwrap() error {
    return e.Err
}

func NotFound(msg string) *AppError {
    return &AppError{Code: ErrCodeNotFound, Message: msg}
}

func Unauthorized(msg string) *AppError {
    return &AppError{Code: ErrCodeUnauthorized, Message: msg}
}

func Wrap(err error, msg string) *AppError {
    return &AppError{Code: ErrCodeInternal, Message: msg, Err: err}
}

Bước 2: Dùng trong service layer

// internal/service/user.go
package service

import (
    "errors"
    "fmt"
    "myapp/internal/apperrors"
    "myapp/internal/repository"
)

func (s *UserService) GetUser(id int) (*User, error) {
    user, err := s.repo.FindByID(id)
    if err != nil {
        if errors.Is(err, repository.ErrNotFound) {
            return nil, apperrors.NotFound(fmt.Sprintf("user %d not found", id))
        }
        return nil, apperrors.Wrap(err, "UserService.GetUser")
    }
    return user, nil
}

Bước 3: Xử lý trong HTTP handler

// internal/handler/user.go
package handler

import (
    "errors"
    "net/http"
    "myapp/internal/apperrors"
)

func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
    id := parseID(r)
    user, err := h.service.GetUser(id)
    if err != nil {
        var appErr *apperrors.AppError
        if errors.As(err, &appErr) {
            http.Error(w, appErr.Message, int(appErr.Code))
            return
        }
        http.Error(w, "Internal Server Error", 500)
        return
    }
    writeJSON(w, user)
}

Best practices cần nhớ

  • Chỉ log ở tầng cao nhất — nếu mỗi hàm đều log error, cùng một lỗi xuất hiện 5 lần trong log, rất khó đọc. Log một lần duy nhất ở handler hoặc main.
  • Không bỏ context khi wrapreturn fmt.Errorf("something went wrong: %w", err) vô nghĩa. Luôn ghi tên hàm hoặc thông tin cụ thể: fmt.Errorf("UserService.Create: %w", err).
  • Implement Unwrap() nếu muốn hỗ trợ error chain — không có Unwrap(), errors.Iserrors.As sẽ không traverse qua custom struct của bạn.
  • Sentinel errors phải exported và có prefix Err — theo Go convention: var ErrNotFound = errors.New(...). Không đặt tên kiểu notFoundError vì caller sẽ không import được.
  • Đừng im lặng với error_ = someFunc() hoặc bắt lỗi rồi return ngay không log gì là pattern nguy hiểm. Ít nhất phải wrap rồi return để tầng trên quyết định.

Một quy ước nhỏ mà team mình thấy rất hiệu quả: mỗi error message bắt đầu bằng PackageName.FunctionName:. Đọc log production là biết ngay vào file nào, hàm nào để debug. Không cần tool hay framework gì — chỉ cần team thống nhất và giữ kỷ luật.

Error handling trong Go không khó về mặt cú pháp. Khó ở chỗ giữ nhất quán khi codebase lớn dần. Cách tiếp cận mình hay gợi ý: bắt đầu với fmt.Errorf + %w cho đơn giản, thêm Custom Error Type khi cần phân nhánh xử lý, rồi tách apperrors package riêng khi team trên 3 người. Tiêu chí cuối cùng — người mới join nhìn vào 5 phút là phải hiểu được: gặp lỗi thì làm gì, log ở đâu, và tìm nguồn gốc lỗi như thế nào.

Share: