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. Hình dung một lỗi đi lên như một sự việc leo thang qua các cấp trong một tổ chức: mỗi cấp ghi thêm cái nó biết rồi chuyển lên, và chỉ cấp trên cùng lập một biên bản duy nhất rồi đưa ra một câu trả lời cho khách.

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ố — bốn cấp mỗi cấp lập một biên bản cho cùng một chuyện. 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 — đừng để anh nhân viên đọc to cả tập hồ sơ nội bộ cho khách nghe.

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.Is và errors.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. "Hết hàng" không phải là cháy kho.

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ố ý.

Nếu chỉ soi một thứ sau bài này, đếm số dòng log cho cùng một request lỗi, trong ba mươi giây:

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.

Mẫu số chung

Một lỗi đi lên, và kỷ luật thì giống nhau ở mọi ngôn ngữ: mỗi tầng thêm cái ngữ cảnh nó biết riêng rồi chuyển tiếp, chỉ ranh giới ngoài cùng mới log — một lần — và quyết định câu trả lời. Hoặc xử lý, hoặc trả lên, không bao giờ cả hai (Go bọc bằng %w, Java xâu chuỗi cause, Python raise ... from đều sinh ra cho việc này), vì một dòng log ở mọi tầng biến một sự cố thành bốn và giết sạch tín hiệu. Và dịch ở mỗi ranh giới để một thất bại bên trong — lỗi SQL, lỗi của bên thứ ba — thành từ vựng của miền bạn trước khi nó đi lên: tầng trên thôi phụ thuộc chi tiết tầng dưới, và client không bao giờ thấy nội tình. Đúng cái sợi chỉ ranh-giới-dịch-đừng-để-lộ như DTO và bài tải tệp.

Điều thứ hai: không phải thất bại nào cũng là sự cố. Một kết quả dự kiến (không tìm thấy, số dư không đủ) là kết quả nghiệp vụ bình thường; một hỏng hóc thật (mất kết nối, đĩa đầy) là sự cố hệ thống — và log hay cảnh báo cả hai như nhau sẽ giết chết kênh cảnh báo: một cái chuông reo cả với chuyện thường ngày là cái chuông không ai buồn nghe, nên đám cháy thật bị bỏ lỡ. Mức nghiêm trọng là một quyết định thiết kế: để dành mức-error và cảnh báo cho thứ mà một con người phải ra tay, đẩy kết quả-dự-kiến về một mã trạng thái bình thường và log rất ít hoặc không log. Và đừng bao giờ rẽ nhánh theo chuỗi lỗi — thông điệp là cho con người đọc, còn cái kiểu mới là cho mã kiểm (errors.Is/As, lớp exception), vì câu chữ đổi là phép so âm thầm gãy.

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

Bài tập làm thử

Bài 1 (đọc hiểu). Một request lỗi đi qua bốn tầng (kho → nghiệp vụ → handler → main), mỗi tầng đều viết log.Printf(...) rồi return err. Giải thích tại sao đây là vấn đề khi điều tra sự cố, và nêu quy tắc đúng theo bài viết.

Đáp án

Bốn tầng cùng log cho một sự cố duy nhất tạo ra bốn dòng log riêng biệt trong hệ thống log — người điều tra không biết đây là bốn lỗi khác nhau hay chỉ một lỗi lan qua bốn tầng, gây nhiễu và khó truy vết. Quy tắc đúng theo bài viết: chỉ tầng ngoài cùng log (handler HTTP, worker, hoặc main) — mọi tầng dưới chỉ bọc thêm ngữ cảnh bằng fmt.Errorf("...: %w", err) và trả lỗi lên, không log.

Bài 2 (sửa lỗi). Handler sau để lộ chi tiết nội bộ (tên bảng, câu SQL) ra ngoài API, vi phạm nguyên tắc bảo mật của bài viết. Sửa lại đúng mẫu:

func (h *Handler) LayDon(w http.ResponseWriter, r *http.Request) {
	don, err := h.svc.Lay(r.Context(), ma)
	if err != nil {
		http.Error(w, err.Error(), 500) // lộ chi tiết ra ngoài
		return
	}
	...
}
Đáp án
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
	}
	...
}

err.Error() có thể chứa tên bảng, tên cột, thậm chí câu truy vấn SQL — thông tin nội bộ không nên lộ ra client. Tầng handler nên quyết định mã HTTP dựa trên loại lỗi (errors.As/errors.Is), log chi tiết kỹ thuật, và trả về client một thông điệp chung chung.

Bài 3 (vận dụng). Viết một hàm repository LayDon(ma string) (*Don, error) dùng database/sql, tuân theo quy tắc "bọc để thêm ngữ cảnh, không lặp lại" — đảm bảo thông điệp lỗi cuối cùng (khi log ở tầng ngoài) đọc như một dấu vết từ ngoài vào trong.

Đáp án
// tầng kho
func (r *Repo) LayDon(ma string) (*Don, error) {
	var d Don
	err := r.db.QueryRow("SELECT ... WHERE ma=?", ma).Scan(&d.ID)
	if err != nil {
		return nil, fmt.Errorf("truy vấn đơn hàng %s: %w", ma, err)
	}
	return &d, nil
}

// tầng nghiệp vụ
func (s *Svc) XacNhan(ma string) error {
	_, err := s.repo.LayDon(ma)
	if err != nil {
		return fmt.Errorf("xác nhận đơn: %w", err)
	}
	return nil
}

// tầng handler — log một lần duy nhất
if err := svc.XacNhan(ma); err != nil {
	log.Printf("POST /don/%s: %v", ma, err)
	// kết quả: "POST /don/DH-9: xác nhận đơn: truy vấn đơn hàng DH-9: connection refused"
}

Mỗi tầng chỉ thêm phần ngữ cảnh mà nó biết (không lặp lại thông tin tầng dưới đã có), tạo thành một chuỗi dấu vết dễ đọc khi log ở tầng ngoài cùng.

Bài 4 (bẫy/đánh đổi). Một hệ thống giám sát báo động "hàng nghìn lỗi mỗi ngày" nhưng khi kiểm tra, phần lớn là các trường hợp "đơn hàng không tồn tại" hoàn toàn bình thường (khách gõ sai mã). Giải thích nguyên nhân gốc rễ theo bài viết, và cách khắc phục.

Đáp án

Nguyên nhân: hệ thống đang trộn lẫn lỗi nghiệp vụ (đơn hàng không tồn tại — kết quả bình thường, khách gõ sai mã xảy ra hàng ngày) với lỗi hệ thống (mất kết nối, hết thời gian, đĩa đầy — sự cố thật cần chú ý), log cả hai ở cùng mức độ nghiêm trọng (error) và đưa cả hai vào cùng kênh cảnh báo. Hậu quả: "một cái chuông reo cả với chuyện thường ngày là cái chuông không ai buồn nghe" — cảnh báo trở nên vô dụng, và một sự cố hệ thống thật sự có thể bị bỏ lỡ giữa hàng nghìn cảnh báo giả.

Khắc phục: phân biệt bằng kiểu lỗi (errors.As(err, &loiKhongTim) → lỗi nghiệp vụ, trả 404, log ở mức debug hoặc không log; còn lại → lỗi hệ thống, log ở mức error và đưa vào cảnh báo).

Bài 5 (đọc hiểu/vận dụng). Đoạn mã sau dùng chuỗi để kiểm tra loại lỗi — bài viết cảnh báo đây là cách làm sai. Giải thích lý do, và sửa lại đúng cách:

if strings.Contains(err.Error(), "not found") {
	http.Error(w, "không tìm thấy", 404)
}
Đáp án

Chuỗi thông điệp lỗi (err.Error()) có thể đổi bất cứ lúc nào khi nâng cấp thư viện phụ thuộc, và nó cũng phụ thuộc vào ngôn ngữ/locale của hệ thống hoặc của thư viện đang dùng — kiểm tra bằng strings.Contains là một phép so sánh mong manh có thể gãy âm thầm mà không có cảnh báo nào. Cách đúng là dùng errors.Is (với sentinel error) hoặc errors.As (với kiểu lỗi tuỳ chỉnh):

var kt *LoiKhongTim
if errors.As(err, &kt) {
	http.Error(w, "không tìm thấy", 404)
}

Nếu thư viện bên ngoài không cung cấp sentinel/kiểu lỗi, phải tự bọc và chuyển đổi ngay tại ranh giới (ví dụ chuyển sql.ErrNoRows thành *LoiKhongTim của miền bạn) để tầng trên không bao giờ phải kiểm tra chuỗi.