Error Handling & Edge Cases: Thiết kế cho những khoảnh khắc \"xấu xí\" nhất của ứng dụng
7/19/2026 · 17p đọc
title: "Error Handling & Edge Cases: Thiết kế cho những khoảnh khắc "xấu xí" nhất của ứng dụng"
series: "UX Architect: 40 bài Expert-Deep Dive"
part: "Phần 2 — Kiến trúc Thông tin & Tư duy Logic"
order: 18
audience: "Product Designer & UX Architect"
reading_time: "13 phút"
tags: ["Error Handling", "Edge Cases", "Empty State", "Error Taxonomy", "Error Code", "System Design"]
Error Handling & Edge Cases: Thiết kế cho những khoảnh khắc "xấu xí" nhất của ứng dụng
Một khách hàng mới đăng ký tài khoản, đăng nhập lần đầu vào module "Báo cáo doanh thu" của CRM — và nhìn thấy một màn hình trắng tinh, không một dòng chữ, không một nút bấm. Họ ngồi im ba giây, thử refresh trang, rồi mở ticket hỗ trợ với tiêu đề "Phần mềm bị lỗi, không hiển thị gì cả". Thực ra không có lỗi nào cả — đơn giản là họ chưa tạo báo cáo nào, và màn hình rỗng đó chính xác là trạng thái mong đợi. Nhưng vì không ai thiết kế cho trạng thái này, người dùng buộc phải tự suy luận, và họ suy luận sai theo hướng tồi tệ nhất: sản phẩm bị hỏng.
Ở một tình huống khác, cùng khách hàng đó, vài tuần sau, đang thao tác nhập liệu hàng loạt thì nhận được thông báo đỏ chót: "Error 429: Too Many Requests". Họ không biết 429 nghĩa là gì, không biết mình đã làm sai điều gì, và cũng không biết phải chờ bao lâu trước khi thử lại. Họ chỉ biết là "lại lỗi nữa" — và ticket hỗ trợ thứ hai ra đời, lần này kèm theo một dòng chữ nản lòng hơn: "Phần mềm này lỗi liên tục, không dùng được".
Cả hai tình huống trên đều xảy ra ở đúng những khoảnh khắc mà đội thiết kế thường bỏ qua nhiều nhất — không phải vì thiếu năng lực, mà vì toàn bộ năng lượng thiết kế trong một sprint thường dồn vào happy path: luồng chính, khi mọi dữ liệu đầy đủ, mọi request thành công, mọi điều kiện thuận lợi. Nhưng chính những khoảnh khắc "xấu xí" — màn hình rỗng, thông báo lỗi, trạng thái đang tải kéo dài, hay bị từ chối quyền truy cập — mới là nơi người dùng cảm thấy bất an nhất, và vì vậy cũng là nơi họ cần được trấn an nhất. Bài này đi sâu vào cách thiết kế có hệ thống cho các trạng thái này, và quan trọng hơn, vì sao việc thiết kế đó không thể tách rời khỏi cách backend cấu trúc lỗi.
The UX Problem
Pain point cốt lõi: khoảng 90% công sức thiết kế trong hầu hết dự án dồn vào happy path — giao diện đẹp, mượt, đầy đủ dữ liệu mẫu bắt mắt trong file Figma — trong khi các trạng thái không-hoàn-hảo (rỗng, lỗi, đang tải, hết quyền) bị bỏ mặc cho giải pháp mặc định: một thông báo lỗi kỹ thuật khô khan do framework hoặc backend tự sinh ra, hoặc tệ hơn, một màn hình trắng không nội dung.
Vấn đề này phổ biến vì một số lý do mang tính hệ thống, không phải ngẫu nhiên:
Thứ nhất, dữ liệu demo trong quá trình thiết kế luôn "đẹp". Khi designer dựng mockup, họ gõ tay hoặc dán vào dữ liệu mẫu lý tưởng: tên đầy đủ, avatar rõ nét, danh sách có đúng 12 dòng vừa khít một trang. Không ai chủ động hình dung "nếu danh sách này chưa có dòng nào thì sao", "nếu tên khách hàng dài 80 ký tự thì sao", "nếu request bị timeout thì sao". Trạng thái edge case không xuất hiện tự nhiên trong quá trình sáng tạo trực quan — nó chỉ xuất hiện khi ai đó chủ động đặt câu hỏi "còn nếu...?".
Thứ hai, trạng thái lỗi bị coi là trách nhiệm của kỹ thuật, không phải của thiết kế. Trong nhiều quy trình, khi PM hỏi "nếu API lỗi thì hiển thị gì?", câu trả lời ngầm định là "thì hiển thị message lỗi backend trả về" — như thể đó là một chi tiết triển khai, không phải một quyết định trải nghiệm. Đây là sự nhầm lẫn về phạm vi trách nhiệm: nội dung và cách trình bày của một trạng thái lỗi là quyết định UX y hệt như nội dung và cách trình bày của một trạng thái thành công — chỉ là nó xảy ra ít thường xuyên hơn và vì vậy dễ bị coi nhẹ.
Thứ ba — và đây là gốc rễ khiến vấn đề khó sửa nếu không có sự phối hợp kiến trúc — thiết kế không thể tạo ra thông báo lỗi hữu ích nếu backend không cung cấp đủ ngữ cảnh để phân biệt các loại lỗi. Nếu tầng backend chỉ trả về một chuỗi message tự do bất kỳ lúc nào có exception xảy ra, UI không có cách nào biết đây là lỗi do người dùng nhập sai, do hệ thống quá tải, hay do họ không có quyền — và vì vậy buộc phải rơi về phương án an toàn nhất nhưng vô dụng nhất: "Đã có lỗi xảy ra, vui lòng thử lại sau". Đây chính là điểm mà bài này sẽ khai triển sâu ở phần Architect's Insight, vì nó là nơi quyết định UX và quyết định kiến trúc backend gắn chặt vào nhau nhất trong toàn bộ series.
Hệ quả tổng hợp: người dùng gặp phải những khoảnh khắc mất phương hướng nhất trong hành trình sử dụng sản phẩm mà không nhận được bất kỳ sự trấn an nào — không biết chuyện gì đang xảy ra, không biết đó có phải lỗi của họ hay không, không biết phải làm gì tiếp theo. Với một sản phẩm B2B như CRM, nơi người dùng thường đang xử lý dữ liệu khách hàng thật hoặc quy trình phê duyệt có tính pháp lý, cảm giác mất phương hướng này trực tiếp làm giảm niềm tin vào toàn bộ hệ thống, không chỉ vào tính năng đang gặp lỗi.
Design Framework
Nguyên tắc nền tảng: mỗi trạng thái "không phải happy path" cần được coi là một màn hình cần thiết kế riêng, với mục tiêu trải nghiệm riêng, không phải một biến thể phụ được xử lý bằng default của framework. Có bốn nhóm trạng thái chính cần thiết kế tường minh, mỗi nhóm có mục tiêu tâm lý khác nhau.
1. Empty state (trạng thái rỗng). Xảy ra khi chưa có dữ liệu — lần đầu dùng tính năng, hoặc đã xóa hết dữ liệu, hoặc bộ lọc không khớp kết quả nào. Mục tiêu tâm lý: xác nhận với người dùng rằng "đây là trạng thái bình thường, không phải lỗi" và hướng dẫn hành động tiếp theo. Một empty state tốt gồm ba phần: (a) một minh họa hoặc icon nhẹ nhàng xác nhận "màn hình này vốn trông như vậy khi chưa có gì", (b) một câu giải thích ngắn gọn tại sao đang rỗng, (c) một call-to-action rõ ràng dẫn tới hành động tạo dữ liệu đầu tiên. Cần phân biệt rõ hai loại empty state khác nhau về nguyên nhân: "rỗng vì chưa từng có dữ liệu" (cần CTA tạo mới, giọng điệu khích lệ) và "rỗng vì bộ lọc/tìm kiếm không khớp" (cần gợi ý nới lỏng điều kiện lọc, không phải CTA tạo mới).
2. Error state (trạng thái lỗi). Xảy ra khi một thao tác thất bại — request lỗi, validate không qua, hệ thống ngoài không phản hồi. Nguyên tắc viết thông báo lỗi hữu ích gồm ba lớp bắt buộc, thiếu bất kỳ lớp nào cũng khiến thông báo trở nên vô dụng:
- Nói rõ điều gì đã sai — bằng ngôn ngữ người dùng hiểu, không phải thuật ngữ kỹ thuật hay mã HTTP trần trụi.
- Nói rõ vì sao (nếu có thể) — lỗi do dữ liệu nhập sai, do quyền hạn, do hệ thống bên ngoài, hay do tải quá cao.
- Nói rõ cách khắc phục — hành động cụ thể tiếp theo: sửa trường nào, chờ bao lâu, liên hệ ai. Một thông báo lỗi không đi kèm hành động khắc phục chỉ là một tiếng chuông báo động không kèm hướng dẫn thoát hiểm.
3. Loading state (trạng thái đang tải). Xảy ra trong khoảng thời gian chờ giữa hành động và kết quả. Nguyên tắc: thời gian chờ ngắn (dưới khoảng 1 giây) không cần chỉ báo rõ rệt vì có thể gây nhấp nháy khó chịu; thời gian chờ trung bình cần skeleton screen hoặc spinner để xác nhận hệ thống đang xử lý chứ không đứng hình; thời gian chờ dài (vài giây trở lên, ví dụ export báo cáo lớn) cần thanh tiến trình có ước lượng hoặc thông báo rõ "việc này có thể mất một lúc" kèm khả năng tiếp tục thao tác khác trong lúc chờ.
4. Permission-denied state (trạng thái bị từ chối quyền truy cập). Xảy ra khi người dùng cố truy cập một tài nguyên hoặc hành động họ không có quyền. Đây là trạng thái dễ bị nhầm với error state thông thường nhất, nhưng cần xử lý khác biệt: không nên hiển thị như một lỗi hệ thống (khiến người dùng nghĩ sản phẩm hỏng), mà cần nói rõ đây là giới hạn quyền hạn có chủ đích, kèm hướng dẫn liên hệ ai (quản trị viên, chủ tài khoản) để xin cấp quyền nếu cần. Nhầm lẫn giữa hai loại này là một trong những nguyên nhân phổ biến khiến người dùng nghĩ hệ thống "bị bug" trong khi thực ra hệ thống đang hoạt động đúng thiết kế bảo mật.
Bốn trạng thái này, cùng với happy path, tạo thành một ma trận đầy đủ cần được thiết kế cho mọi màn hình có tải dữ liệu động — không phải một danh sách tùy chọn, mà là một checklist bắt buộc trước khi coi một màn hình là "thiết kế xong".
The "Architect's" Insight
Đây là phần phân định rõ nhất giữa một UX Architect và một UI Designer thuần thẩm mỹ, và cũng là phần dễ bị bỏ qua nhất trong thực hành: chất lượng của mọi error state mà designer có thể thiết kế bị giới hạn tuyệt đối bởi cấu trúc dữ liệu lỗi mà backend trả về. Nếu backend không thiết kế một error taxonomy (hệ thống phân loại lỗi) có cấu trúc rõ ràng với error code chuẩn hóa, UI không có cách nào — dù designer giỏi đến đâu — hiển thị được thông báo lỗi hữu ích tương ứng từng loại lỗi cụ thể.
Hãy phân tích cơ chế kỹ thuật cụ thể tại sao điều này đúng.
Trong một kiến trúc backend không có error taxonomy, mọi exception — dù nguyên nhân là gì — thường được catch chung và trả về client dưới dạng một message tự do, ví dụ message trực tiếp từ exception gốc hoặc một chuỗi chung chung "Internal Server Error". Khi đó, ở tầng frontend, developer nhận được một response có hình dạng gần như:
{
"statusCode": 500,
"message": "Cannot read property 'quota' of undefined"
}
Với response này, UI hoàn toàn bất lực: không có trường nào cho biết đây là lỗi hết hạn mức, lỗi dữ liệu sai định dạng, hay lỗi bug thực sự trong code. Frontend chỉ còn một lựa chọn duy nhất: hiển thị một thông báo an toàn nhưng vô nghĩa cho mọi trường hợp — "Đã có lỗi xảy ra, vui lòng thử lại sau". Đây không phải lỗi của designer hay của frontend developer; đây là hệ quả tất yếu của một quyết định kiến trúc ở tầng backend.
Ngược lại, khi backend được thiết kế với một error taxonomy chuẩn hóa — mỗi loại lỗi nghiệp vụ có một errorCode riêng biệt, ổn định, không đổi theo message hiển thị — frontend có đủ thông tin để render đúng UI tương ứng cho từng loại lỗi, hoàn toàn tách biệt khỏi câu chữ message (vốn có thể cần dịch đa ngôn ngữ hoặc thay đổi theo thời gian mà không ảnh hưởng tới logic hiển thị):
{
"statusCode": 429,
"errorCode": "API_QUOTA_EXCEEDED",
"message": "Bạn đã vượt hạn mức 1000 request/ngày",
"meta": { "retryAfterSeconds": 3600, "currentUsage": 1000, "limit": 1000 }
}
so với:
{
"statusCode": 422,
"errorCode": "VALIDATION_INVALID_FORMAT",
"message": "Trường 'email' không đúng định dạng",
"meta": { "field": "email" }
}
Hai response này có cùng cấu trúc bao ngoài nhưng errorCode khác nhau hoàn toàn, và chính trường này — không phải message — là thứ frontend dùng để quyết định render UI nào. Với API_QUOTA_EXCEEDED, UI có thể hiển thị một banner kèm đồng hồ đếm ngược tới retryAfterSeconds và nút "Nâng cấp gói" nếu phù hợp. Với VALIDATION_INVALID_FORMAT, UI highlight đúng field email trong form và hiển thị lỗi inline ngay dưới ô nhập — một trải nghiệm hoàn toàn khác, và chỉ có thể thực hiện được nếu backend đã phân loại lỗi tới mức đủ chi tiết.
Hệ quả kiến trúc cụ thể mà quyết định này kéo theo ở tầng backend NestJS:
- Một exception filter tập trung (
@Catch()global filter) chuẩn hóa mọi response lỗi về cùng một shape{ statusCode, errorCode, message, meta }, thay vì để mỗi module tự ý trả về format riêng. - Một enum hoặc const tập trung liệt kê toàn bộ
errorCodehợp lệ trong hệ thống, được document trong Swagger để frontend biết trước toàn bộ danh sách cần xử lý UI tương ứng — đây chính là "hợp đồng lỗi" (error contract) tương tự API contract đã bàn ở bài Wireframing, nhưng áp dụng riêng cho nhánh thất bại thay vì nhánh thành công. - Custom exception class kế thừa theo domain, ví dụ
QuotaExceededException,InvalidFormatException, mỗi class map cứng tới mộterrorCodecố định — tránh tình trạng cùng một loại lỗi nghiệp vụ nhưng được throw ở nhiều nơi với message khác nhau, dẫn tới UI phải đoán bằng cách so khớp chuỗi message (một antipattern cực kỳ dễ vỡ vì message có thể đổi bất cứ lúc nào, kể cả khi chỉ sửa câu chữ cho rõ nghĩa hơn). - Migration/thiết kế dữ liệu bị ảnh hưởng gián tiếp: để trả được
meta.currentUsagevàmeta.limittrong ví dụ trên, hệ thống cần một bảng hoặc cơ chế đếm quota theo thời gian thực (ví dụ dùng Redis counter với TTL) — nghĩa là chính quyết định UX "phải hiển thị được số lượt còn lại" kéo theo yêu cầu hạ tầng cụ thể ở tầng dữ liệu, không chỉ là chuyện viết câu chữ.
Nói cách khác: thiết kế error state không bắt đầu từ Figma, mà bắt đầu từ việc UX Architect ngồi cùng backend lead để thống nhất error taxonomy trước khi bất kỳ màn hình lỗi nào được vẽ. Nếu thiếu bước này, mọi nỗ lực thiết kế thông báo lỗi "hay" ở tầng UI đều chỉ là trang trí cho một chuỗi message không ổn định phía dưới — và sẽ vỡ ngay khi backend đổi câu chữ message vì lý do hoàn toàn không liên quan tới UX.
Practical Example
Tình huống: module "Tự động hóa gửi email hàng loạt" trong CRM, nơi người dùng có thể gặp nhiều loại lỗi khác nhau khi nhấn "Gửi chiến dịch" — nhưng nếu không phân loại, tất cả đều trông giống nhau trên UI.
Bảng error taxonomy tối thiểu cần thống nhất giữa BA/UX Architect và backend trước khi thiết kế UI:
| errorCode | Nguyên nhân nghiệp vụ | UI tương ứng cần hiển thị |
|---|---|---|
EMAIL_QUOTA_EXCEEDED |
Đã dùng hết hạn mức gửi email trong ngày/tháng theo gói dịch vụ | Banner vàng: "Đã dùng hết X/X email tháng này" + nút "Nâng cấp gói" + không cho phép gửi tiếp |
RECIPIENT_LIST_EMPTY |
Danh sách người nhận rỗng sau khi lọc | Thông báo inline ngay trên bảng người nhận: "Không có người nhận nào khớp bộ lọc hiện tại" + nút "Chỉnh bộ lọc" |
TEMPLATE_MISSING_REQUIRED_FIELD |
Template email thiếu biến bắt buộc (ví dụ {{customer_name}}) |
Highlight đúng vị trí biến thiếu trong editor template, không phải banner chung |
EXTERNAL_MAIL_PROVIDER_DOWN |
Nhà cung cấp SMTP/API email bên thứ ba không phản hồi | Banner: "Dịch vụ gửi email đang tạm gián đoạn, chiến dịch đã được lưu và sẽ tự gửi lại sau" + không mất dữ liệu người dùng đã nhập |
PERMISSION_CAMPAIGN_SEND_DENIED |
Người dùng không có quyền gửi chiến dịch (chỉ có quyền soạn nháp) | Trạng thái permission-denied riêng: "Bạn cần quyền 'Gửi chiến dịch' để thực hiện, liên hệ quản trị viên" — không hiển thị như lỗi hệ thống |
Minh họa exception filter chuẩn hóa (NestJS, rút gọn để làm rõ ý tưởng):
export class DomainException extends HttpException {
constructor(
public readonly errorCode: string,
message: string,
statusCode: number,
public readonly meta?: Record<string, unknown>,
) {
super({ errorCode, message, meta }, statusCode);
}
}
export class EmailQuotaExceededException extends DomainException {
constructor(currentUsage: number, limit: number) {
super(
'EMAIL_QUOTA_EXCEEDED',
`Đã dùng hết ${limit}/${limit} email tháng này`,
HttpStatus.TOO_MANY_REQUESTS,
{ currentUsage, limit },
);
}
}
Ở tầng frontend, thay vì if (error.message.includes('quota')) (so khớp chuỗi — dễ vỡ), logic rẽ nhánh dựa hoàn toàn vào errorCode ổn định:
switch (error.errorCode) {
case 'EMAIL_QUOTA_EXCEEDED':
return <QuotaExceededBanner meta={error.meta} />;
case 'RECIPIENT_LIST_EMPTY':
return <EmptyRecipientInlineHint />;
case 'PERMISSION_CAMPAIGN_SEND_DENIED':
return <PermissionDeniedState />;
default:
return <GenericErrorFallback />; // vẫn cần, nhưng chỉ là lưới an toàn cuối cùng
}
Điểm mấu chốt: nhánh default vẫn tồn tại — không bao giờ loại bỏ hoàn toàn — nhưng nó chỉ nên được kích hoạt cho những lỗi thực sự chưa lường trước (bug thật), không phải trở thành nhánh xử lý chính cho mọi loại lỗi nghiệp vụ đã biết trước.
Common Pitfalls
1. Hiển thị nguyên văn message lỗi kỹ thuật từ backend cho người dùng cuối. Đây là lỗi phổ biến và nguy hiểm nhất: khi frontend không biết phải hiển thị gì, cách "nhanh nhất" là render thẳng error.message từ response — dẫn tới người dùng nhìn thấy những dòng như "ECONNREFUSED 127.0.0.1:5432" hay stack trace rút gọn. Điều này không chỉ gây hoang mang mà còn có thể rò rỉ thông tin hạ tầng nội bộ (tên service, cấu trúc bảng) ra ngoài — một rủi ro bảo mật thực sự, không chỉ là vấn đề thẩm mỹ. Nguyên tắc bắt buộc: message kỹ thuật từ exception gốc chỉ nên xuất hiện trong log phía backend (Sentry), không bao giờ đi thẳng ra response gửi cho client trừ khi đã được ánh xạ tường minh qua error taxonomy.
2. Không thiết kế empty state, khiến người dùng mới tưởng sản phẩm bị hỏng. Vì demo và testing luôn dùng dữ liệu mẫu sẵn có, đội thiết kế hiếm khi tự trải nghiệm cảm giác "tài khoản hoàn toàn mới, chưa có gì". Hậu quả là màn hình rỗng mặc định của framework (thường chỉ là một bảng trống không tiêu đề) trở thành trải nghiệm đầu tiên của người dùng mới với tính năng đó — đúng thời điểm ấn tượng ban đầu quan trọng nhất. Cách phòng tránh: luôn test mọi màn hình danh sách/dashboard với một tài khoản hoàn toàn sạch trước khi coi là hoàn thiện.
3. Gộp chung permission-denied với error state thông thường. Khi cả hai đều hiển thị dưới dạng banner đỏ "Có lỗi xảy ra", người dùng không thể phân biệt được "tôi làm gì đó sai/hệ thống gặp sự cố" với "tôi không có quyền làm việc này". Hệ quả thực tế: người dùng liên tục thử lại một hành động họ vốn dĩ không bao giờ được phép thực hiện, tạo ra ticket hỗ trợ không cần thiết và cảm giác thất vọng vì "thử mãi vẫn báo lỗi". Permission-denied cần một thiết kế trực quan và văn phong khác biệt rõ rệt — thường là giọng điệu trung tính, giải thích "đây là giới hạn có chủ đích", không phải giọng điệu xin lỗi như lỗi hệ thống.
Checklist
- Mọi màn hình có tải dữ liệu động đã có thiết kế riêng cho: happy path, empty state, error state, loading state, permission-denied state
- Đã phân biệt rõ empty state "chưa từng có dữ liệu" và empty state "bộ lọc không khớp kết quả", với CTA khác nhau tương ứng
- Đã thống nhất error taxonomy (
errorCodechuẩn hóa) với backend TRƯỚC khi thiết kế bất kỳ màn hình error state nào - Mỗi thông báo lỗi hiển thị cho người dùng cuối đều có đủ ba lớp: điều gì sai, vì sao (nếu phù hợp), cách khắc phục cụ thể
- Không có message kỹ thuật thô (stack trace, tên service nội bộ, mã lỗi framework) hiển thị trực tiếp cho người dùng cuối
- Permission-denied state có thiết kế và văn phong tách biệt rõ ràng khỏi error state thông thường
- Đã test mọi màn hình chính với một tài khoản/dữ liệu hoàn toàn sạch để xác nhận empty state hiển thị đúng như thiết kế
🔗 Bài viết liên quan
Bài trước: Form Design Psychology · Bài tiếp theo: Designing for Complexity