Mở đầu — vì sao bài này quan trọng
Có một sự thật phũ phàng trong nghề PM: phần spec của bạn dài bao nhiêu trang không quan trọng bằng việc bạn có nghĩ tới những lúc "mọi thứ không diễn ra như mơ" hay không. Một feature trông long lanh trên Figma, demo mượt mà trong phòng họp, nhưng đến khi ra production thì người dùng mất mạng giữa chừng, hai người cùng bấm nút một lúc, hoặc tài khoản bị thu hồi quyền — và cả luồng sụp đổ.
Đây chính là lý do tồn tại của bài học này. Ở Bài 5 chúng ta đã nói tổng quan về edge cases và error states; bài này đi sâu vào ba "thủ phạm" gây ra phần lớn các sự cố production thực tế mà PM hay bỏ sót: Offline (mất mạng / mạng yếu), Race condition (đua tranh thời gian), và Permission (quyền truy cập thay đổi). Ba nhóm này khác với edge case thông thường ở chỗ: chúng không phụ thuộc vào dữ liệu người dùng nhập, mà phụ thuộc vào trạng thái của hệ thống và môi trường — thứ mà người thiết kế happy path gần như không bao giờ trải nghiệm khi ngồi văn phòng với Wi-Fi mạnh.
Một PM giỏi spec ba nhóm này không phải vì thích "vẽ vời" cho kỹ. Mà vì nếu bạn không viết, engineer sẽ tự quyết định — và mỗi engineer quyết một kiểu, dẫn đến trải nghiệm chắp vá. Hoặc tệ hơn: không ai quyết, và app crash. Khi bạn viết rõ "khi mất mạng giữa lúc thanh toán thì hiển thị gì", bạn đang tiết kiệm cho team hàng chục giờ tranh luận và cho công ty những đêm trực sự cố.
Khái niệm cốt lõi
Edge case của ba nhóm này có một đặc điểm chung: chúng xảy ra ở ranh giới giữa client và server, hoặc giữa các thao tác chồng lấn về thời gian. Hãy cùng mổ xẻ từng nhóm.
Nhóm 1 — Offline & Network (mạng)
Đây là nhóm phổ biến nhất ở Việt Nam và Đông Nam Á, nơi người dùng thường xuyên ở trên 3G/4G chập chờn, trong thang máy, hầm xe, hoặc vùng phủ sóng yếu. Khi spec, bạn cần phân biệt rõ các trạng thái mạng, vì mỗi trạng thái cần một hành vi khác nhau:
- Offline hoàn toàn (no connection): Thiết bị biết mình không có mạng. Dễ xử lý nhất vì có thể phát hiện trước khi gửi request.
- Slow / flaky (3G yếu, lúc có lúc không): Khó nhất. Request gửi đi nhưng response về rất chậm, hoặc gửi được nhưng không nhận được phản hồi. Người dùng không biết là đang chờ hay đã hỏng.
- Reconnect mid-flow (mất rồi có lại giữa chừng): Người dùng đang điền form bước 3/5 thì rớt mạng, vài giây sau có lại. Dữ liệu đã nhập có còn không?
- Timeout (quá hạn chờ): Mặc định nên đặt ngưỡng, ví dụ 30 giây. Sau ngưỡng này, client bỏ chờ và xử lý như lỗi.
Nhóm 2 — Race condition (đua tranh)
Race condition xảy ra khi hai hay nhiều thao tác chạy chồng lấn về thời gian và kết quả phụ thuộc vào cái nào "về đích" trước. Người dùng cảm nhận đây là "bug ngẫu nhiên, lúc bị lúc không" — và đó là dấu hiệu kinh điển của race.
Các dạng race PM cần spec:
- Double-submit (bấm hai lần): Người dùng bấm nút "Đặt hàng" hai lần vì lần đầu mạng chậm tưởng chưa ăn. Hệ thống tạo hai đơn.
- Concurrent edit (hai người sửa cùng lúc): Hai admin cùng sửa một bài viết. Ai lưu sau ghi đè ai lưu trước (last-write-wins) — và người kia mất công vô ích mà không biết.
- Stale state (trạng thái cũ): Người dùng mở danh sách, một item đã bị người khác xoá, họ vẫn bấm vào item đó.
- Out-of-order response (phản hồi về sai thứ tự): Người dùng gõ tìm kiếm "ip" rồi "iphone"; do mạng, kết quả của "ip" về sau và đè lên kết quả "iphone".
Nhóm 3 — Permission (quyền truy cập)
Đây là nhóm âm thầm nhất nhưng dễ gây sự cố bảo mật và pháp lý nhất. Quyền không phải là thứ kiểm tra một lần lúc đăng nhập rồi xong — quyền có thể thay đổi giữa chừng:
- Quyền bị thu hồi giữa session: Nhân viên bị sa thải, admin xoá quyền, nhưng app vẫn đang mở trên máy họ.
- Token hết hạn: Phiên đăng nhập hết hiệu lực giữa lúc đang dùng.
- Quyền thiếu khi truy cập tài nguyên cụ thể: Người dùng có quyền vào module, nhưng không có quyền với bản ghi cụ thể này (ví dụ xem hồ sơ của phòng ban khác).
- Quyền hệ điều hành bị từ chối: Người dùng từng cho phép camera, sau đó vào Settings tắt đi.
Tình huống thực tế
Ví dụ 1 — MoMo và cú double-charge vì mạng yếu
Hãy hình dung một sản phẩm ví điện tử kiểu MoMo. Một người dùng ở Cần Thơ thanh toán hoá đơn điện 480.000đ qua 3G yếu trong quán cà phê. Bấm "Thanh toán", màn hình xoay tròn 8 giây không nhúc nhích. Anh ấy nghĩ "chắc treo rồi", bấm lại lần nữa. Thực tế: request đầu đã tới server thành công, chỉ là response xác nhận chưa về kịp tới điện thoại. Kết quả nếu không spec kỹ: hai giao dịch 480.000đ, khách bị trừ gần một triệu.
Phần spec thiếu ở đây là idempotency (tính bất biến khi lặp lại). PM cần viết: "Mỗi lần khởi tạo thanh toán, client sinh một idempotency_key duy nhất. Nếu cùng key này được gửi lại trong 24 giờ, server trả về kết quả của giao dịch gốc thay vì tạo giao dịch mới." Đồng thời, nút "Thanh toán" phải bị disable ngay sau lần bấm đầu và hiển thị trạng thái "Đang xử lý...". Nếu timeout 30 giây mà chưa có phản hồi, hiển thị: "Chúng tôi chưa xác nhận được giao dịch. Vui lòng kiểm tra lịch sử trước khi thử lại" — chứ tuyệt đối không tự động retry âm thầm.
Bài học: Với tiền bạc, "thử lại an toàn" (safe retry) phải là yêu cầu bắt buộc trong spec, không phải tuỳ engineer. Và phòng tuyến phải có hai lớp: chặn ở client (disable nút) và chặn ở server (idempotency key).
Ví dụ 2 — App giao hàng và cuộc đua nhận đơn của hai shipper
Một startup giao đồ ăn kiểu ShopeeFood ở TP.HCM gặp tình huống: một đơn hàng nổ ra, hệ thống đẩy thông báo cho 5 shipper gần đó. Hai shipper — một ở Quận 1, một ở Quận 3 — cùng bấm "Nhận đơn" cách nhau 200 mili-giây. Nếu hệ thống xử lý theo kiểu "ai bấm cũng thành công", cả hai cùng chạy tới quán, một người công cốc, quán bối rối, khách chờ lâu.
Đây là race condition kinh điển về tài nguyên độc quyền (chỉ một người được nhận). PM phải spec rõ: "Thao tác nhận đơn dùng cơ chế atomic claim — server chỉ chấp nhận shipper đầu tiên, các request đến sau với cùng order_id nhận về lỗi ORDER_ALREADY_TAKEN." Quan trọng hơn, phải spec cả phía người thua: shipper về sau phải thấy màn hình mượt mà "Đơn này đã có người nhận, đây là các đơn khác gần bạn" — chứ không phải một thông báo lỗi đỏ chói khiến họ tưởng app hỏng. Trải nghiệm của "người thua cuộc trong race" là phần PM hay quên nhất.
Bài học: Với race tranh tài nguyên độc quyền, đừng chỉ spec "ai thắng". Hãy spec trạng thái cụ thể cho người thua, vì đó mới là người có trải nghiệm tệ nếu bạn bỏ qua.
Ví dụ 3 — App nội bộ ngân hàng và nhân viên bị thu hồi quyền
Một ngân hàng tại Việt Nam có app nội bộ cho nhân viên tra cứu thông tin khách hàng. Một chuyên viên tín dụng bị điều chuyển, phòng IT thu hồi quyền truy cập module "Hồ sơ vay" lúc 9h sáng. Nhưng anh này đã mở sẵn màn hình danh sách hồ sơ từ 8h45 và vẫn đang để app mở. Câu hỏi: khi anh bấm vào một hồ sơ lúc 9h05, điều gì xảy ra?
Nếu spec sơ sài, client chỉ kiểm tra quyền lúc load app (8h45) nên vẫn cho xem — đây là lỗ hổng bảo mật và vi phạm quy định bảo mật dữ liệu khách hàng. Spec đúng phải nêu: "Quyền được kiểm tra lại ở mỗi request truy cập tài nguyên nhạy cảm, không chỉ lúc đăng nhập. Nếu server trả về 403 Permission Revoked, client xoá ngay dữ liệu đã cache trên màn hình, đưa người dùng về màn hình chính và hiển thị: 'Quyền truy cập của bạn đã thay đổi. Vui lòng liên hệ quản trị viên.'" Đặc biệt lưu ý: dữ liệu nhạy cảm không được hiển thị trong khoảnh khắc chuyển tiếp — không có cảnh "thấy thoáng qua hồ sơ rồi mới bị đá ra".
Bài học: Quyền là trạng thái sống, không phải con dấu đóng một lần. Trong lĩnh vực tài chính, y tế, mỗi lần truy cập dữ liệu nhạy cảm đều phải tái kiểm tra quyền, và phải spec rõ việc dọn sạch dữ liệu trên màn hình.
Hướng dẫn từng bước
Đây là quy trình thực hành để bạn biến phần edge case từ "cảm tính" thành "có hệ thống" khi viết spec.
Bước 1 — Liệt kê các điểm "đụng mạng" trong luồng. Đi dọc happy path, đánh dấu mọi chỗ client phải nói chuyện với server (load dữ liệu, submit form, thanh toán, upload...). Mỗi điểm này là một ứng viên cho lỗi network. Với mỗi điểm, tự hỏi: nếu mạng chết ngay tại đây thì sao?
Bước 2 — Áp checklist 3 trạng thái mạng cho từng điểm. Với mỗi điểm "đụng mạng", trả lời ba câu: (a) Offline hoàn toàn → hiện gì, cho thử lại không, dữ liệu nhập có giữ lại không? (b) Chậm/timeout → sau bao lâu thì bỏ chờ (mặc định 30s), trong lúc chờ hiện gì? (c) Reconnect giữa chừng → có tự khôi phục không, có cần người dùng làm lại từ đầu không?
Bước 3 — Quét race ở mọi nút "gây thay đổi". Tìm các action ghi dữ liệu (tạo, sửa, xoá, thanh toán, nhận đơn). Với mỗi cái hỏi hai câu: "Nếu bấm hai lần liên tiếp thì sao?" và "Nếu hai người cùng làm trên cùng một bản ghi thì sao?". Quyết định chiến lược: chặn double-submit (disable nút + idempotency key), và chọn last-write-wins hay khoá tài nguyên hay cảnh báo xung đột.
Bước 4 — Đánh dấu các tài nguyên nhạy cảm và spec tái kiểm quyền. Liệt kê những màn hình/dữ liệu cần quyền. Với mỗi cái, viết rõ: quyền được kiểm ở đâu (mỗi request hay chỉ lúc login), và khi quyền bị từ chối giữa chừng thì client làm gì với dữ liệu đang hiển thị.
Bước 5 — Viết thành bảng trong PRD. Đừng để rải rác trong văn xuôi. Lập một bảng ba cột: Tình huống | Hành vi hệ thống | Thông điệp cho người dùng. Bảng này là thứ engineer và QA đọc trực tiếp để code và test.
Bước 6 — Đối chiếu với QA và engineer. Đưa bảng cho engineer review tính khả thi, đưa cho QA để họ thiết kế test case (giả lập slow 3G, ngắt mạng giữa chừng, bấm đúp, hai thiết bị cùng lúc). Nếu QA không test được tình huống bạn viết, tình huống đó coi như chưa được bảo vệ.
Lỗi thường gặp & mẹo
Lỗi 1 — Chỉ spec "happy path + lỗi validation". Nhiều PM viết rất kỹ "email sai định dạng thì báo gì" nhưng quên hẳn "mất mạng lúc submit thì sao". Validation lỗi là edge case dễ; ba nhóm trong bài này mới là chỗ gây sự cố production thật.
Lỗi 2 — Bỏ quên trải nghiệm "người thua" trong race. Như ví dụ shipper: ai cũng nhớ spec người thắng, ít ai spec màn hình cho người đến sau. Mẹo: mỗi khi viết một race, ép mình viết đủ hai trạng thái — winner và loser.
Lỗi 3 — Tự động retry âm thầm với thao tác có hậu quả. Auto-retry rất nguy hiểm với tiền và với các action không idempotent. Mẹo: chia action làm hai loại — đọc (read, retry thoải mái) và ghi (write, chỉ retry khi có idempotency key bảo vệ).
Lỗi 4 — Coi quyền là "kiểm một lần". Như ví dụ ngân hàng. Mẹo: với mọi dữ liệu nhạy cảm, mặc định kiểm quyền ở mỗi lần truy cập, và viết rõ hành vi dọn dẹp dữ liệu khi quyền mất.
Lỗi 5 — Mất dữ liệu người dùng đã nhập khi rớt mạng. Người dùng điền form dài 5 phút, rớt mạng, app reload và xoá sạch — họ sẽ bỏ app. Mẹo: với form dài, spec rõ "giữ lại nội dung đã nhập ở local cho tới khi submit thành công".
Mẹo vàng — Dùng ngôn ngữ con số. Đừng viết "timeout sau một lúc". Viết "timeout sau 30 giây". Đừng viết "chặn bấm nhiều lần". Viết "disable nút ngay sau lần bấm đầu, mở lại sau khi có phản hồi hoặc sau timeout". Con số cụ thể là thứ phân biệt spec dùng được với spec để ngắm.
Bài tập thực hành
Chọn một luồng quen thuộc: đặt vé xem phim trên một app rạp chiếu (chọn ghế → thanh toán → nhận vé).
- Vẽ happy path 4–5 bước, đánh dấu mọi điểm "đụng mạng".
- Với bước chọn ghế, spec một race condition: hai người cùng chọn ghế G7 cách nhau 1 giây. Ai được giữ ghế? Người kia thấy gì? Ghế bị "giữ tạm" trong bao lâu nếu chưa thanh toán?
- Với bước thanh toán, spec tình huống mất mạng ngay sau khi bấm "Thanh toán": cho retry không, làm sao tránh trừ tiền hai lần, hiển thị thông điệp gì.
- Spec một tình huống permission: người dùng đăng nhập ở hai thiết bị, đổi mật khẩu ở thiết bị A; thiết bị B đang giữ giỏ ghế thì sao?
- Trình bày tất cả vào một bảng ba cột: Tình huống | Hành vi hệ thống | Thông điệp người dùng.
Tóm tắt
- Ba nhóm edge case gây phần lớn sự cố production là Offline/Network, Race condition, và Permission — chúng phụ thuộc vào trạng thái hệ thống và môi trường, nên người thiết kế happy path hầu như không gặp.
- Với network: spec đủ ba trạng thái — offline hoàn toàn, chậm/timeout (mặc định 30s), reconnect giữa chừng — và luôn trả lời "dữ liệu người dùng đã nhập đi đâu".
- Với race: chặn double-submit bằng disable nút + idempotency key; với tài nguyên độc quyền, spec rõ cả trạng thái người thắng lẫn người thua; cẩn thận với auto-retry trên action ghi.
- Với permission: coi quyền là trạng thái sống, tái kiểm ở mỗi lần truy cập dữ liệu nhạy cảm, và dọn sạch dữ liệu trên màn hình khi quyền mất — không để lộ trong khoảnh khắc chuyển tiếp.
- Trình bày tất cả thành bảng ba cột (Tình huống | Hành vi | Thông điệp) để engineer code được và QA test được. Spec không test được là spec chưa bảo vệ gì.