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ùngerrors.Asđể xử lý từng loại. - Handler / API layer: Dùng
errors.Asbắ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 wrap —
return 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.Isvàerrors.Assẽ 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ểunotFoundErrorvì 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.

