Mở đầu — vì sao bài này quan trọng
Hãy hình dung bạn vừa chuyển khoản 5 triệu đồng qua một ứng dụng ngân hàng. Màn hình hiện lên dòng chữ đỏ: Error 500: Internal Server Error. Transaction reference: NULL. Trong khoảnh khắc đó, bạn không biết tiền đã trừ chưa, giao dịch thành công hay thất bại, có nên thử lại không. Sự hoảng loạn này không phải do lỗi kỹ thuật — hệ thống có thể đã xử lý đúng — mà do cách hệ thống nói chuyện với người dùng khi có sự cố.
Đây chính là lãnh địa mà nhiều Technical BA bỏ quên. Chúng ta dành hàng tuần để viết spec cho luồng "happy path" — luồng mọi thứ diễn ra suôn sẻ — rồi để mặc phần xử lý lỗi cho lập trình viên tự quyết. Kết quả là mỗi developer trả về một kiểu lỗi khác nhau, mỗi màn hình hiển thị một thông điệp khác nhau, và đội Customer Support nhận hàng trăm ticket chỉ vì người dùng không hiểu chuyện gì đang xảy ra.
Error handling không phải là "phần phụ" của spec. Trên thực tế, trong các hệ thống tài chính, y tế hay thương mại điện tử, phần lớn độ phức tạp nằm ở các nhánh lỗi, không phải nhánh thành công. Một BA giỏi phân loại lỗi rõ ràng, định nghĩa HTTP status code chính xác, và quan trọng nhất là biến thông báo kỹ thuật khô khan thành thông điệp mà người dùng thật sự hiểu và hành động được. Bài này sẽ trang bị cho bạn khung tư duy đó.
Khái niệm cốt lõi
Phân biệt hai tầng: System Error và User Message
Điều đầu tiên cần khắc cốt ghi tâm: lỗi hệ thống và thông báo người dùng là hai thứ tách biệt.
- System Error là ngôn ngữ máy nói với máy: HTTP status code, error code nội bộ, stack trace, log. Phục vụ developer, hệ thống giám sát, và việc gỡ lỗi.
- User Message là ngôn ngữ máy nói với người: câu chữ thân thiện, hướng dẫn hành động tiếp theo, không lộ chi tiết kỹ thuật.
500 ở backend có thể hiển thị thành "Hệ thống đang bận, vui lòng thử lại sau ít phút" cho người dùng. Nhiệm vụ của BA là spec cả hai tầng và ánh xạ (mapping) giữa chúng.Phân loại lỗi (Error Category)
Nền tảng của mọi spec error handling là một bảng phân loại lỗi rõ ràng. Dưới đây là các nhóm chính dựa trên chuẩn HTTP, là khung mà bạn sẽ dùng đi dùng lại.
1. User Error (Lỗi do người dùng) — nhóm 4xx
Người dùng nhập sai, thiếu dữ liệu, hoặc gửi định dạng không hợp lệ. Đây là lỗi "có thể sửa được" từ phía người dùng.
- 400 Bad Request — dữ liệu đầu vào sai (validation). Ví dụ: email không đúng định dạng, số tiền âm, ngày sinh trong tương lai. Thông điệp phải chỉ rõ trường nào sai và sửa thế nào.
- 422 Unprocessable Entity — dữ liệu đúng định dạng nhưng vi phạm quy tắc nghiệp vụ. Ví dụ: chuyển khoản vượt hạn mức ngày, đặt lịch vào khung giờ đã đầy.
- 409 Conflict — xung đột trạng thái. Ví dụ: hủy đơn hàng đã giao, đăng ký email đã tồn tại.
- 401 Unauthorized — chưa đăng nhập hoặc phiên hết hạn. Hành động chuẩn: redirect về trang đăng nhập, lưu lại URL gốc để quay lại sau khi login.
- 403 Forbidden — đã đăng nhập nhưng không có quyền. Không redirect login (vì có đăng nhập lại cũng không có quyền), mà hiển thị thông báo "Bạn không có quyền truy cập tính năng này".
3. Not Found — 404
Tài nguyên không tồn tại: URL sai, ID không có trong database, trang đã bị xóa. Thông điệp nên cung cấp lối thoát: nút về trang chủ, ô tìm kiếm, hoặc gợi ý trang liên quan.
4. System Error (Lỗi hệ thống) — nhóm 5xx
- 500 Internal Server Error — lỗi không lường trước ở backend.
- 502 / 503 / 504 — lỗi gateway, dịch vụ quá tải, hoặc timeout.
Cấu trúc một Error Response chuẩn
Với mỗi API, BA nên spec một định dạng lỗi nhất quán. Một mẫu phổ biến và thực dụng:
{
"error": {
"code": "INSUFFICIENT_BALANCE",
"message": "Số dư không đủ để thực hiện giao dịch",
"field": "amount",
"trace_id": "req-7f3a9c21",
"timestamp": "2026-06-27T10:15:30+07:00"
}
}
Bốn thành phần đáng chú ý: code (mã máy đọc, không đổi theo ngôn ngữ, dùng để frontend ánh xạ thông điệp), message (mô tả mặc định), field (trường gây lỗi, phục vụ form), và trace_id (mã truy vết để Support và developer lần ra log).
Bốn nguyên tắc viết User Message
- Nói rõ chuyện gì đã xảy ra — không mơ hồ kiểu "Đã xảy ra lỗi".
- Chỉ ra cách khắc phục — người dùng cần biết bước tiếp theo.
- Giữ giọng điệu con người, không đổ lỗi — tránh "Bạn đã nhập sai", thay bằng "Số điện thoại cần có 10 chữ số".
- Không lộ thông tin kỹ thuật nhạy cảm — stack trace, tên bảng database, query SQL là lỗ hổng bảo mật khi lộ ra ngoài.
Tình huống thực tế
Tình huống 1 — Ví điện tử và cú "double charge" do thông báo mơ hồ
Một ví điện tử lớn tại Việt Nam (giả định gọi là PayQuick) gặp làn sóng khiếu nại: người dùng bị trừ tiền hai lần khi nạp thẻ điện thoại. Khi đội BA điều tra, hóa ra giao dịch đầu đã thành công ở backend, nhưng do mạng chậm, response không kịp về app trong 15 giây nên app hiển thị: "Giao dịch thất bại. Vui lòng thử lại." Người dùng bấm thử lại, và lần thứ hai trừ tiền thật.
Gốc rễ vấn đề là sự nhầm lẫn giữa timeout (504) và failure thật sự. Một timeout có nghĩa là "tôi không biết kết quả", chứ không phải "thất bại". Spec ban đầu của BA chỉ viết: "Nếu lỗi, hiển thị thông báo thử lại."
Đội BA viết lại spec theo hướng phân loại rõ:
- Khi timeout (chưa rõ kết quả): hiển thị "Giao dịch đang được xử lý. Vui lòng kiểm tra lịch sử giao dịch trước khi thử lại" — và vô hiệu hóa nút thử lại trong 60 giây.
- Bổ sung yêu cầu idempotency để dù người dùng có bấm lại, giao dịch trùng cũng không được tính thêm.
Tình huống 2 — Sàn thương mại điện tử và thông báo "Error code: E_VALIDATION_43"
Một sàn TMĐT khu vực Đông Nam Á phát hiện tỷ lệ bỏ giỏ hàng (cart abandonment) ở bước thanh toán cao bất thường — khoảng 23% người dùng rời đi ngay sau khi bấm "Đặt hàng". Phân tích session recording cho thấy thủ phạm là một thông báo: Error code: E_VALIDATION_43. Hệ thống trả về đúng lỗi 400, nhưng frontend chỉ in nguyên error code ra màn hình.
Sự thật phía sau E_VALIDATION_43 là: địa chỉ giao hàng thiếu phường/xã. Một thông tin cực kỳ dễ sửa, nhưng người dùng không hề biết cần sửa gì, nên họ bỏ cuộc.
Đội BA xây một bảng ánh xạ error code → user message (error message catalog), yêu cầu mỗi mã lỗi 4xx đều phải có:
- Thông điệp tiếng Việt rõ ràng, ví dụ: "Vui lòng chọn Phường/Xã để hoàn tất địa chỉ giao hàng."
- Trường liên quan để frontend tô đỏ và cuộn tới đúng ô.
Bài học: Một error code thô hiển thị cho người dùng là tiền rơi khỏi túi. Error message catalog là tài sản spec quan trọng, không phải việc làm cho có.
Tình huống 3 — Hệ thống bệnh viện và lỗi 403 gây nguy hiểm
Một hệ thống quản lý bệnh viện (HIS) tại TP.HCM gặp tình huống tế nhị. Một bác sĩ trực đêm cần xem hồ sơ bệnh án của một bệnh nhân cấp cứu, nhưng bệnh nhân thuộc khoa khác. Hệ thống trả 403 Forbidden với thông báo cộc lốc: "Truy cập bị từ chối." Bác sĩ tưởng hệ thống lỗi, gọi điện cho IT lúc 2 giờ sáng, mất 20 phút quý giá.
Đội BA nhận ra: với hệ thống mà sai sót có thể ảnh hưởng tính mạng, thông điệp 403 không chỉ cần lịch sự mà còn cần chỉ đường cho hành động khẩn cấp. Họ spec lại:
- Thông điệp: "Bạn không có quyền xem hồ sơ này vì bệnh nhân thuộc Khoa Nội. Trong trường hợp cấp cứu, bấm 'Yêu cầu truy cập khẩn cấp' — hành động sẽ được ghi log và gửi tới Trưởng khoa."
- Bổ sung cơ chế "break-the-glass": cho phép truy cập khẩn cấp có kiểm soát, kèm audit trail đầy đủ.
Hướng dẫn từng bước
Đây là quy trình để bạn spec error handling cho một tính năng bất kỳ:
Bước 1 — Liệt kê toàn bộ điểm có thể lỗi (failure points). Với mỗi bước trong luồng nghiệp vụ, hỏi "Điều gì có thể sai ở đây?". Input sai, không có quyền, dữ liệu không tồn tại, dịch vụ ngoài chết, mạng timeout. Liệt kê tất cả.
Bước 2 — Phân loại mỗi lỗi vào đúng nhóm HTTP. Gắn mỗi failure point với một status code (400/401/403/404/409/422/500/503...). Việc này buộc bạn suy nghĩ rõ "đây là lỗi của ai".
Bước 3 — Định nghĩa error code nội bộ và cấu trúc response.
Đặt mã máy đọc cho từng lỗi (ví dụ INSUFFICIENT_BALANCE, SESSION_EXPIRED). Chốt định dạng JSON error response thống nhất cho cả hệ thống.
Bước 4 — Viết User Message cho từng lỗi. Áp dụng bốn nguyên tắc: nói rõ chuyện gì, chỉ cách sửa, giọng con người, không lộ kỹ thuật. Soạn cả tiếng Việt và tiếng Anh nếu hệ thống đa ngôn ngữ.
Bước 5 — Định nghĩa hành động hệ thống đi kèm. Lỗi này có redirect không? Có retry tự động không? Có vô hiệu hóa nút không? Có gửi log/alert cho đội vận hành không? Có hiển thị trace_id không?
Bước 6 — Lập Error Message Catalog. Tổng hợp tất cả thành một bảng duy nhất: error code, HTTP status, user message (đa ngôn ngữ), field, system action, ghi chú. Đây là deliverable chính, vừa cho dev vừa cho QA và Support.
Bước 7 — Định nghĩa logging và trace. Mỗi lỗi 5xx phải log gì? Mức độ (INFO/WARN/ERROR)? Có sinh correlation ID gửi về cho người dùng không? (Phần chi tiết về logging thuộc Bài 48, ở đây ta chỉ định nghĩa điểm giao tiếp.)
Lỗi thường gặp & mẹo
Lỗi: Gộp tất cả vào "Đã xảy ra lỗi, vui lòng thử lại". Đây là thông báo vô dụng nhất. Người dùng không biết lỗi do mình hay do hệ thống, không biết thử lại có ích không. Mỗi nhánh lỗi xứng đáng một thông điệp riêng.
Lỗi: Dùng sai status code. Trả 200 OK kèm {"success": false} trong body là cơn ác mộng cho hệ thống giám sát — mọi thứ trông như thành công. Ngược lại, trả 500 cho lỗi validation khiến đội vận hành báo động giả. Status code phải phản ánh đúng bản chất.
Lỗi: Lộ thông tin kỹ thuật. Hiển thị SQLException: table users column password cho người dùng cuối là vừa khó hiểu vừa là lỗ hổng bảo mật nghiêm trọng. Chi tiết kỹ thuật chỉ thuộc về log, không thuộc về màn hình.
Lỗi: Quên ngôn ngữ và đa vùng. Spec error message bằng tiếng Anh rồi để dev tự dịch dẫn đến thông điệp ngô nghê. BA nên cung cấp bản tiếng Việt chuẩn ngay từ đầu.
Mẹo 1 — Phân biệt lỗi "transient" và "permanent". Lỗi tạm thời (503, timeout) thì gợi ý thử lại hoặc retry tự động. Lỗi vĩnh viễn (400, 403) thì thử lại vô ích, đừng mời người dùng thử lại.
Mẹo 2 — Luôn kèm trace_id cho lỗi 5xx. Một dòng "Mã sự cố: req-7f3a9c21" giúp Support tra log trong vài giây thay vì hỏi đi hỏi lại người dùng.
Mẹo 3 — Tận dụng inline validation. Với lỗi 400 trên form, hiển thị thông báo ngay cạnh trường sai thay vì một banner đỏ ở đầu trang. Trải nghiệm tốt hơn hẳn.
Bài tập thực hành
Bạn được giao spec tính năng "Đặt vé xe khách online" cho một hãng xe tại Việt Nam. Luồng chính: người dùng chọn tuyến, chọn ghế, nhập thông tin, thanh toán.
Yêu cầu:
- Liệt kê ít nhất 6 failure points trong luồng này (ví dụ: ghế vừa bị người khác đặt, thẻ thanh toán bị từ chối, phiên hết hạn...).
- Với mỗi failure point, lập một dòng trong Error Message Catalog gồm các cột:
error_code,HTTP status,user_message (tiếng Việt),field (nếu có),system_action.
- Xử lý riêng tình huống khó: người dùng đã thanh toán nhưng hệ thống mất kết nối với cổng thanh toán giữa chừng (kết quả không xác định). Viết thông điệp và hành động hệ thống cho trường hợp này.
- Chỉ ra một thông báo mà bạn cố tình không mời người dùng thử lại, và giải thích vì sao.
Tóm tắt
- Error handling không phải phần phụ — phần lớn độ phức tạp của hệ thống nằm ở các nhánh lỗi, và đây là trách nhiệm spec rõ ràng của Technical BA.
- Luôn tách hai tầng: System Error (status code, error code, log — cho máy và dev) và User Message (câu chữ thân thiện — cho người).
- Nắm vững bảng phân loại: 4xx là lỗi người dùng (400 validation, 401 chưa đăng nhập, 403 không có quyền, 404 not found, 409 conflict, 422 vi phạm nghiệp vụ); 5xx là lỗi hệ thống, tuyệt đối không đổ lỗi cho người dùng.
- Viết User Message theo bốn nguyên tắc: nói rõ chuyện gì, chỉ cách sửa, giọng con người, không lộ kỹ thuật.
- Phân biệt timeout/kết quả không xác định với thất bại thật — nhầm lẫn ở đây gây thiệt hại tài chính (bài học từ ví điện tử).
- Sản phẩm đầu ra cốt lõi là Error Message Catalog: bảng ánh xạ error code, status, thông điệp đa ngôn ngữ, trường, và hành động hệ thống — dùng chung cho dev, QA và Support.
- Mẹo vàng: kèm trace_id cho lỗi 5xx, phân biệt lỗi tạm thời với vĩnh viễn, và đừng bao giờ mời người dùng thử lại một lỗi mà thử lại cũng vô ích.