Mở đầu — vì sao bài này quan trọng
Nếu bạn từng tự hỏi vì sao một tính năng "đã làm xong" lại liên tục bị engineer hỏi ngược lại, vì sao QA tìm ra hàng tá bug mà bạn chưa từng nghĩ tới, hay vì sao một feature trông đẹp trong demo nhưng vỡ trận khi ra production — thì 80% câu trả lời nằm ở chỗ: bạn chỉ viết spec cho happy path (đường đi suôn sẻ) mà quên mất các edge cases (trường hợp biên) và error states (trạng thái lỗi).
Happy path là kịch bản lý tưởng: người dùng có mạng tốt, nhập đúng dữ liệu, có đủ quyền, server phản hồi nhanh, không ai bấm nút hai lần. Nhưng thực tế ở Việt Nam và Đông Nam Á còn khắc nghiệt hơn nhiều: người dùng đi thang máy mất sóng, dùng máy Android đời cũ, chuyển từ Wi-Fi sang 4G giữa chừng, có người dùng tiếng Việt có dấu lẫn không dấu, có người dùng đọc màn hình. Một PM giỏi không phải là người nghĩ ra được giải pháp — phần lớn engineer làm được điều đó. PM giỏi là người nghĩ ra được những gì có thể sai trước khi nó xảy ra, và viết rõ trong spec hệ thống phải hành xử thế nào.
Bài này không dạy bạn viết user story hay acceptance criteria (đó là Bài 4), cũng không đi sâu vào từng loại trạng thái màn hình như empty/loading (đó là Bài 23) hay từng kỹ thuật chi tiết offline-sync (Bài 28). Bài 5 cho bạn một tư duy hệ thống và một checklist tổng quát để không bao giờ bỏ sót những lớp edge case quan trọng nhất: offline, race condition, permission denied, localization và accessibility. Đây là tấm lưới an toàn đầu tiên bạn quăng ra trước khi giao spec cho team.
Khái niệm cốt lõi
Edge case và error state khác nhau thế nào?
Hai khái niệm này hay bị gộp làm một, nhưng phân biệt rõ giúp bạn viết spec sạch hơn.
- Edge case là một điều kiện đầu vào hoặc bối cảnh nằm ở rìa của những gì bình thường: danh sách rỗng, danh sách 10.000 phần tử, người dùng bấm nút hai lần liên tiếp, mất mạng giữa chừng, tên dài 200 ký tự. Edge case mô tả "chuyện gì xảy ra".
- Error state là trạng thái hệ thống và giao diện hiển thị khi một edge case (hoặc một sự cố) khiến luồng bình thường không hoàn thành được: thông báo "Không có kết nối mạng", màn hình "Bạn không có quyền truy cập", toast "Thanh toán thất bại, vui lòng thử lại".
Năm lớp edge case thường bị bỏ sót nhất
Đây là khung xương của bài học — năm "vùng mù" mà PM hay quên. Hãy ghi nhớ chúng như một checklist mặc định cho mọi feature.
1. Offline / mạng kém. Người dùng mất mạng hoàn toàn, hoặc mạng chập chờn (timeout, request treo). Câu hỏi spec: Khi không có mạng, người dùng thấy gì? Dữ liệu họ đã nhập có bị mất không? Có cho thao tác offline rồi đồng bộ sau không, hay chặn hẳn? Request đang chạy dở bị ngắt thì sao?
2. Race condition (điều kiện tranh chấp). Nhiều thao tác xảy ra gần như đồng thời và đụng nhau. Ví dụ kinh điển: người dùng bấm nút "Đặt hàng" hai lần trong nửa giây vì lần đầu lag; hai thiết bị cùng sửa một bản ghi; callback thanh toán về sau khi người dùng đã đóng app. Câu hỏi spec: Nếu cùng một hành động được kích hoạt hai lần thì sao? Ai thắng khi có xung đột? Có cần khóa (lock) hay idempotency key không?
3. Permission denied (từ chối quyền). Người dùng không có quyền — quyền hệ điều hành (camera, vị trí, thông báo) hoặc quyền nghiệp vụ (chưa đăng nhập, không phải admin, hết hạn gói). Câu hỏi spec: Nếu user từ chối cấp quyền camera thì luồng đi đâu? Nếu một nhân viên cấp thấp mở link của tính năng dành cho quản lý thì hiện gì? Khác biệt giữa "chưa đăng nhập" và "đã đăng nhập nhưng không đủ quyền" được xử lý ra sao?
4. Localization (bản địa hóa). Nội dung và định dạng thay đổi theo ngôn ngữ/khu vực. Tiếng Việt có dấu khiến chuỗi dài hơn và có ký tự đặc biệt; ngày tháng, tiền tệ, số điện thoại có định dạng riêng; chuỗi dịch sang ngôn ngữ khác có thể dài gấp đôi và vỡ layout. Câu hỏi spec: Nút "Submit" khi dịch thành "Gửi đi" hay một chuỗi dài hơn thì layout còn vừa không? Tên người dùng tiếng Việt có dấu có hiển thị đúng và tìm kiếm được không?
5. Accessibility (khả năng tiếp cận). Người dùng khiếm thị dùng trình đọc màn hình, người dùng phóng to chữ 200%, người dùng chỉ điều hướng bằng bàn phím. Câu hỏi spec: Mỗi icon-only button có nhãn cho screen reader không? Thông báo lỗi có được đọc lên không? Độ tương phản màu có đạt chuẩn không?
Bài 22, 24, 25, 28 sẽ đi sâu vào từng lớp. Ở đây, nhiệm vụ của bạn là không bỏ sót lớp nào ngay từ vòng spec đầu tiên.
Nguyên tắc "Fail loudly, recover gracefully"
Một error state tốt tuân theo hai nguyên tắc. Fail loudly (báo lỗi rõ ràng): đừng nuốt lỗi âm thầm khiến người dùng tưởng đã thành công. Recover gracefully (phục hồi nhẹ nhàng): luôn cho người dùng một lối ra — nút thử lại, dữ liệu được giữ lại, hướng dẫn cụ thể thay vì mã lỗi vô nghĩa. Một thông báo "Đã có lỗi xảy ra (Error 500)" vi phạm cả hai: nó vừa không nói rõ lỗi gì, vừa không cho người dùng biết phải làm gì tiếp.
Tình huống thực tế
Ví dụ 1 — Ví điện tử và cú bấm đúp tai hại (race condition)
Một ví điện tử tại Việt Nam (tạm gọi là VPay) ra mắt tính năng chuyển tiền. Spec ban đầu mô tả rất gọn: người dùng nhập số tiền, bấm "Chuyển", hệ thống trừ tiền và gửi đi. Demo chạy ngon. Nhưng sau hai tuần ra production, đội vận hành nhận hàng loạt khiếu nại: người dùng bị trừ tiền hai lần cho cùng một giao dịch.
Nguyên nhân là một race condition cổ điển. Trên mạng 3G ở tỉnh, sau khi bấm "Chuyển", nút mất 2–3 giây mới phản hồi. Người dùng tưởng app đơ nên bấm lần hai. Hai request gần như giống hệt nhau cùng đi tới server, và cả hai đều được xử lý. Với một giao dịch trung bình 300.000 đồng, mỗi ca khiếu nại là một lần hoàn tiền thủ công cộng với một khách hàng mất niềm tin.
Bài học: Spec gốc thiếu đúng một dòng: "Nếu cùng một yêu cầu chuyển tiền được gửi hai lần (cùng người gửi, người nhận, số tiền trong vòng 60 giây), hệ thống chỉ thực hiện một lần — dùng idempotency key sinh từ phía client." Kèm theo đó là error state phía giao diện: ngay khi bấm "Chuyển", nút phải chuyển sang trạng thái loading và bị vô hiệu hóa để không thể bấm lần hai. Một dòng spec phòng race condition đã có thể tiết kiệm hàng trăm giờ xử lý và cứu lấy uy tín sản phẩm.
Ví dụ 2 — App giao đồ ăn và chiếc thang máy (offline)
Một startup giao đồ ăn ở khu vực Đông Nam Á làm tính năng cho tài xế cập nhật trạng thái đơn: "Đã nhận món", "Đang giao", "Đã giao". Spec mô tả luồng happy path hoàn hảo. Vấn đề là tài xế thường xuyên ra vào các tòa chung cư cao tầng — vào thang máy, vào hầm gửi xe — nơi sóng 4G chập chờn hoặc mất hẳn.
Khi tài xế bấm "Đã giao" lúc mất sóng, app cũ hiển thị vòng quay loading vô tận rồi báo "Lỗi, vui lòng thử lại". Tài xế bấm đi bấm lại trong tuyệt vọng, đôi khi bỏ cuộc và đi tiếp, khiến đơn không bao giờ được đánh dấu hoàn thành. Hệ thống phía khách hàng thì vẫn hiển thị "đang giao", dẫn đến khách gọi tổng đài phàn nàn dù đã nhận được đồ ăn.
Bài học: Đội PM viết lại spec với một lớp offline rõ ràng. Trạng thái mới: khi mất mạng, thao tác "Đã giao" được lưu vào hàng đợi cục bộ (local queue), giao diện báo "Đã ghi nhận, sẽ đồng bộ khi có mạng" thay vì báo lỗi. Khi có sóng trở lại, app tự động gửi. Spec ghi rõ ba trường hợp: (a) mất mạng hoàn toàn — cho thao tác offline và xếp hàng; (b) mạng chậm/timeout — giữ trạng thái loading tối đa 10 giây rồi chuyển sang xếp hàng; (c) đồng bộ thất bại do server từ chối — báo lỗi cụ thể cho tài xế. Chỉ riêng việc cho phép thao tác offline đã giảm đáng kể số đơn "kẹt" và số cuộc gọi tổng đài.
Ví dụ 3 — Form đăng ký và cái tên có dấu (localization)
Một nền tảng học trực tuyến triển khai form đăng ký với trường "Họ và tên". Spec chỉ ghi "trường text, bắt buộc nhập". Khi ra mắt, đội ngũ phát hiện hai vấn đề. Thứ nhất, một số người dùng tên "Nguyễn Thị Hồng Ánh" bị lỗi hiển thị ô vuông ở chữ "Ánh" trên một màn hình email xác nhận do encoding không phải UTF-8. Thứ hai, tính năng tìm kiếm học viên không tìm ra "Đặng" khi admin gõ "Dang" — vì hệ thống không xử lý so khớp có dấu/không dấu, một nhu cầu rất phổ biến của người Việt khi gõ nhanh.
Bài học: Localization không chỉ là dịch giao diện. Với người dùng Việt Nam, spec cho bất kỳ trường nhập liệu nào nên ghi rõ: hệ thống phải hỗ trợ đầy đủ Unicode UTF-8 cho tiếng Việt có dấu ở mọi nơi (lưu, hiển thị, email, PDF); tìm kiếm nên hỗ trợ so khớp không phân biệt dấu; và khi có kế hoạch đa ngôn ngữ, layout phải chịu được chuỗi dài hơn 30–40%. Đây là loại edge case rẻ tiền khi xử lý từ đầu nhưng rất đắt khi phải vá sau.
Hướng dẫn từng bước
Đây là quy trình bạn có thể áp dụng cho mọi feature để rà soát edge case và error state, ngay sau khi đã viết xong happy path.
Bước 1 — Viết happy path trước. Mô tả luồng lý tưởng từ đầu đến cuối. Bạn cần biết "đúng" trông như thế nào trước khi liệt kê những gì có thể "sai".
Bước 2 — Đi qua từng bước của happy path và hỏi "Nếu... thì sao?". Tại mỗi thao tác có gọi mạng, đụng quyền, hoặc nhận đầu vào từ người dùng, dừng lại và hỏi: Nếu mất mạng ở đây thì sao? Nếu user bấm hai lần thì sao? Nếu user không có quyền thì sao? Nếu dữ liệu rỗng/cực lớn/ký tự lạ thì sao?
Bước 3 — Quét qua checklist 5 lớp. Với mỗi feature, chiếu lần lượt qua: Offline → Race condition → Permission → Localization → Accessibility. Đánh dấu lớp nào áp dụng. Không phải lớp nào cũng liên quan, nhưng việc chủ động loại trừ (ghi "không áp dụng vì...") an toàn hơn nhiều so với việc quên mất.
Bước 4 — Với mỗi edge case quan trọng, định nghĩa error state. Trả lời ba câu: (1) Hiển thị gì cho người dùng? (2) Dữ liệu họ đã nhập có được giữ lại không? (3) Lối ra là gì — retry, quay lại, hay liên hệ hỗ trợ? Viết đúng câu chữ thông báo lỗi, đừng để engineer tự bịa.
Bước 5 — Ưu tiên theo rủi ro × tần suất. Bạn không thể xử lý mọi edge case trong v1. Hãy chấm điểm mỗi case theo mức độ nghiêm trọng (mất tiền, mất dữ liệu là cao nhất) nhân với tần suất xảy ra. Xử lý ngay những case điểm cao, ghi những case điểm thấp vào mục "known limitations" hoặc để dành cho phiên bản sau.
Bước 6 — Trình bày trong spec dưới dạng bảng. Một bảng ba cột — Edge case | Hành vi hệ thống | Thông báo cho người dùng — giúp engineer và QA đọc nhanh và không bỏ sót. Đây là định dạng được nhiều team tin dùng vì nó vừa đầy đủ vừa dễ rà.
Lỗi thường gặp & mẹo
Lỗi 1: Chỉ viết happy path rồi giao spec. Đây là lỗi phổ biến nhất của PM mới. Hậu quả là engineer phải tự quyết định cách xử lý lỗi — và họ sẽ quyết theo cách dễ làm nhất, không phải cách tốt nhất cho người dùng. Mẹo: coi việc rà edge case là một bước bắt buộc, không phải tùy chọn, trước khi spec được xem là "xong".
Lỗi 2: Liệt kê edge case nhưng không định nghĩa error state. Viết "xử lý trường hợp mất mạng" là chưa đủ — đó chỉ là nêu vấn đề. Mẹo: mỗi edge case phải đi kèm hành vi cụ thể và câu chữ thông báo. "Xử lý" là một từ cấm trong spec; hãy thay bằng mô tả chính xác.
Lỗi 3: Thông báo lỗi vô nghĩa. "Đã có lỗi xảy ra" hay "Error 500" đẩy gánh nặng sang người dùng. Mẹo: mỗi thông báo lỗi nên trả lời được "Chuyện gì xảy ra?" và "Tôi nên làm gì bây giờ?". Ví dụ tốt: "Không thể kết nối máy chủ. Vui lòng kiểm tra mạng và thử lại."
Lỗi 4: Quên rằng "không có dữ liệu" cũng là một trạng thái. Danh sách rỗng, kết quả tìm kiếm không có gì — đây là edge case bị bỏ quên nhiều nhất. Mẹo: mọi danh sách, bảng, kết quả tìm kiếm đều phải có spec cho trường hợp rỗng.
Lỗi 5: Cố gắng xử lý 100% edge case trong v1. Điều này khiến scope phình to và ngày ship lùi vô hạn. Mẹo: phân biệt rõ "phải có" và "chấp nhận được giới hạn". Ghi minh bạch các giới hạn đã biết vào spec để cả team đồng thuận, thay vì giả vờ chúng không tồn tại.
Mẹo bổ sung — học từ chính khiếu nại của khách. Logs lỗi production và ticket hỗ trợ là kho edge case quý giá. Trước khi spec một feature mới, hãy xem feature tương tự trước đó đã sinh ra những loại lỗi nào. Edge case của ngày mai thường chính là bug của hôm qua.
Bài tập thực hành
Chọn một trong hai feature sau (hoặc một feature bạn đang làm) và thực hành quy trình 6 bước ở trên:
Feature A — Nút "Thêm vào giỏ hàng" trên một app thương mại điện tử.
Feature B — Tính năng "Tải lên ảnh đại diện" trong phần hồ sơ người dùng.
Với feature đã chọn, hãy hoàn thành các yêu cầu:
- Viết happy path trong 3–5 dòng.
- Lập một bảng edge case với ít nhất 8 dòng, bắt buộc phủ đủ cả 5 lớp: ít nhất 1 case offline, 1 race condition, 1 permission, 1 localization, 1 accessibility (3 dòng còn lại tùy chọn — ví dụ dữ liệu rỗng, đầu vào cực lớn, định dạng file sai).
- Mỗi dòng gồm ba cột: Edge case | Hành vi hệ thống | Thông báo cho người dùng (viết đúng câu chữ thông báo).
- Đánh dấu mức ưu tiên (Cao/Trung bình/Thấp) cho từng dòng dựa trên rủi ro × tần suất, và chọn ra 3 case bạn sẽ làm trong v1.
Tóm tắt
- Happy path chỉ là một nửa công việc. Một spec chuyên nghiệp dành phần lớn giá trị ở việc mô tả những gì có thể sai và hệ thống phải phản ứng ra sao.
- Phân biệt edge case (nguyên nhân) và error state (cách hệ thống phản ứng và hiển thị). Liệt kê edge case là chưa đủ — mỗi case quan trọng phải có error state cụ thể kèm câu chữ thông báo.
- Ghi nhớ checklist 5 lớp dễ bị bỏ sót: Offline, Race condition, Permission denied, Localization, Accessibility. Chiếu qua từng lớp cho mọi feature, kể cả khi chỉ để loại trừ.
- Áp dụng nguyên tắc "fail loudly, recover gracefully": báo lỗi rõ ràng và luôn cho người dùng một lối ra.
- Ưu tiên theo rủi ro × tần suất và ghi minh bạch các giới hạn đã biết — bạn không cần xử lý mọi case trong v1, nhưng phải xử lý đúng những case quan trọng.
- Trình bày bằng bảng ba cột để engineer và QA đọc nhanh, không bỏ sót. Edge case bạn đầu tư hôm nay chính là những cuộc gọi tổng đài và những lần hoàn tiền bạn tránh được ngày mai.