Ba bài trước là cơ chế. Bài này là cách dùng chúng trong một dịch vụ thật.

Quy tắc một: hoặc xử lý, hoặc trả lên

if err != nil {
	log.Printf("hỏng: %v", err)   // rồi
	return err                     // lại trả lên
}

Lỗi này đi qua bốn tầng thì bạn có bốn dòng log cho một sự cố. Khi điều tra, bạn không biết đó là bốn lỗi hay một.

Chỉ tầng ngoài cùng log — handler HTTP, worker, main. Mọi tầng dưới chỉ bọc thêm ngữ cảnh và trả lên.

Quy tắc hai: bọc để thêm ngữ cảnh, không lặp lại

Mỗi tầng thêm thứ mà tầng dưới không biết:

// tầng kho
return fmt.Errorf("truy vấn đơn hàng %s: %w", ma, err)

// tầng nghiệp vụ
return fmt.Errorf("xác nhận đơn: %w", err)

// tầng handler — log một lần
log.Printf("POST /don/%s: %v", ma, err)

Kết quả trong log là một dòng đọc như dấu vết từ ngoài vào:

  POST /don/DH-9: xác nhận đơn: truy vấn đơn hàng DH-9: connection refused

Đừng viết fmt.Errorf("lỗi: %w", err) — nó thêm chữ mà không thêm thông tin.

Quy tắc ba: đừng để lộ nội tình ra API

Lỗi từ cơ sở dữ liệu chứa tên bảng, tên cột, đôi khi cả câu truy vấn. Đó là thứ không nên đi ra ngoài.

func (h *Handler) LayDon(w http.ResponseWriter, r *http.Request) {
	don, err := h.svc.Lay(r.Context(), ma)
	if err != nil {
		var kt *LoiKhongTim
		switch {
		case errors.As(err, &kt):
			http.Error(w, "không tìm thấy đơn hàng", 404)
		case errors.Is(err, context.DeadlineExceeded):
			http.Error(w, "quá thời gian xử lý", 504)
		default:
			log.Printf("GET /don/%s: %v", ma, err)   // chi tiết vào LOG
			http.Error(w, "lỗi máy chủ", 500)         // thông điệp chung ra NGOÀI
		}
		return
	}
	...
}

Mẫu này là nơi errors.Iserrors.As trả công: tầng handler quyết định mã HTTP dựa trên loại lỗi, không dựa trên chuỗi thông điệp.

Một cách tổ chức tốt hơn cho dự án lớn: một hàm mapLoi(err) (int, string) duy nhất, dùng lại cho mọi handler.

Quy tắc bốn: lỗi nghiệp vụ khác lỗi hệ thống

Hai loại này cần đối xử khác nhau:

Lỗi nghiệp vụ — đơn hàng không tồn tại, số dư không đủ, mã giảm giá hết hạn. Đây là kết quả bình thường, không phải sự cố. Không cần log ở mức error, không cần cảnh báo.

Lỗi hệ thống — mất kết nối, hết thời gian, đĩa đầy. Đây là sự cố, cần log đầy đủ và cần theo dõi.

Trộn hai loại là cách nhanh nhất để bảng cảnh báo của bạn trở nên vô dụng: hàng nghìn "lỗi" mỗi ngày mà tất cả đều bình thường.

Cách phân biệt gọn nhất là bằng kiểu:

var kt *LoiKhongTim
if errors.As(err, &kt) {
	// nghiệp vụ: trả 404, log ở mức debug hoặc không log
} else {
	// hệ thống: log error
}

Quy tắc năm: đừng dùng chuỗi để kiểm lỗi

if strings.Contains(err.Error(), "not found") {   // ĐỪNG

Chuỗi lỗi đổi khi nâng cấp thư viện, và nó phụ thuộc ngôn ngữ hệ thống. Luôn dùng errors.Is hoặc errors.As.

Nếu thư viện bạn dùng không có sentinel và không có kiểu lỗi, hãy bọc nó ở ranh giới:

func (r *Repo) Lay(ma string) (*Don, error) {
	...
	if errors.Is(err, sql.ErrNoRows) {
		return nil, &LoiKhongTim{Ma: ma}   // đổi sang lỗi của MIỀN của bạn
	}
	return nil, fmt.Errorf("truy vấn %s: %w", ma, err)
}

Giờ tầng trên không cần biết database/sql tồn tại.

Khi nào bỏ qua lỗi là đúng

Hiếm, nhưng có:

defer f.Close()               // tệp mở để ĐỌC
_ = json.NewEncoder(w).Encode(v)   // đã ghi header rồi, không làm gì được nữa

Luôn viết _ = tường minh thay vì bỏ trống, để người đọc biết bạn cố ý. errcheck là công cụ tìm những chỗ lỗi bị bỏ qua mà không cố ý.

Thử ba mươi giây

Lấy log sản xuất của bạn, tìm một mã lỗi bất kỳ, đếm số dòng cho cùng một request:

grep 'req-abc-123' app.log | wc -l

Nếu một request lỗi sinh ra nhiều hơn hai dòng error, bạn đang log ở nhiều tầng. Xoá bớt, giữ tầng ngoài cùng — log sẽ dễ đọc hơn hẳn.

Ngày mai: context — huỷ, hạn chờ, và vì sao nó là tham số đầu tiên của mọi hàm.