Mini-challenge: Bộ kiểm thực mượn ấn phẩm với custom exception
Thực hành thiết kế bộ kiểm thực mượn sách thư viện, gom nhiều lỗi vào ValidationException và tích hợp xử lý lỗi tập trung.
TL;DR: Thử thách này tổng hợp toàn bộ kiến thức của Module 02: try-catch, phân loại checked/unchecked, cấu trúc custom exception, và thiết kế tầng bắt lỗi tập trung. Bạn sẽ xây dựng bộ kiểm thực yêu cầu mượn ấn phẩm (BorrowRequestValidator) áp dụng nguyên tắc gom lỗi toàn diện (fail-slow) thay vì ngắt ngay ở lỗi đầu tiên.
Ở bài trước, bạn đã học cách thiết kế custom exception mang theo dữ liệu ngữ cảnh (payload) để phục vụ cho việc ghi log và trả về thông tin chi tiết cho client. Trong bài thử thách này, ngoại lệ sẽ mang theo cả một danh sách các lỗi vi phạm dữ liệu.
🎯 Đề bài
Khi một bạn đọc gửi yêu cầu mượn ấn phẩm lên hệ thống thư viện trực tuyến, biểu mẫu gửi lên bao gồm mã thẻ bạn đọc, mã ấn phẩm, số ngày đăng ký mượn và ghi chú.
Nếu bạn đọc nhập sai nhiều trường cùng lúc (ví dụ: mã thẻ sai định dạng, số ngày mượn âm, ghi chú quá dài), người dùng sẽ cảm thấy rất phiền toái nếu hệ thống chỉ báo lỗi từng trường một (sửa xong trường này lại báo tiếp trường khác). Hệ thống chuyên nghiệp cần gom toàn bộ các lỗi vi phạm và trả về một lượt thông qua ngoại lệ ValidationException.

1. record BorrowRequest(String memberId, String assetId, int durationDays, String note)
- Không thực hiện validate trong compact constructor mà để
BorrowRequestValidatorkiểm tra riêng biệt (mô hình kiểm thực theo tầng).
2. record ValidationError(String field, String code, String message)
- Đại diện cho một lỗi vi phạm cụ thể của từng trường dữ liệu.
3. class ValidationException extends RuntimeException
- Trường dữ liệu:
private final List<ValidationError> errors. - Constructor:
ValidationException(List<ValidationError> errors). - Ghi đè phương thức
getMessage()để nối toàn bộ các thông điệp lỗi thành một chuỗi tổng hợp dễ đọc.
4. class BorrowRequestValidator
- Phương thức:
public void validate(BorrowRequest request). - Quy tắc kiểm tra (Rules):
memberId: Không được null, không được rỗng, phải bắt đầu bằng tiền tố"MEM-"và có độ dài từ 6 đến 12 ký tự.assetId: Không được null, không được rỗng, phải bắt đầu bằng tiền tố"ITEM-".durationDays: Phải từ 1 đến 30 ngày (quy định thời hạn mượn của thư viện).note: Nếu có giá trị thì độ dài không được vượt quá 100 ký tự.
- Quy tắc thực thi: Gom tất cả lỗi vi phạm vào một danh sách. Chỉ ném
ValidationExceptionkhi danh sách có từ 1 lỗi trở lên.
5. class BorrowController (Mô phỏng Global Handler)
- Phương thức:
public String submitBorrow(BorrowRequest request). - Sử dụng khối
try-catchđể bắtValidationExceptionvà trả về chuỗi JSON thông báo lỗi có cấu trúc; nếu hợp lệ, trả về"SUCCESS".
Kết quả mong đợi khi dữ liệu vi phạm nhiều lỗi:
Kiem tra yeu cau: BorrowRequest[memberId=LIB12, assetId=B-01, durationDays=-3, note=Ghi chu...]
Phat hien 3 loi vi pham:
- memberId [FORMAT]: Ma ban doc phai bat dau bang 'MEM-' va tu 6-12 ky tu
- assetId [FORMAT]: Ma an pham phai bat dau bang 'ITEM-'
- durationDays [RANGE]: So ngay muon phai tu 1 den 30 ngay, nhan duoc: -3
Ket qua phan hoi tu Controller:
{"status": "BAD_REQUEST", "errorCount": 3, "errors": [...]}
📦 Kiến thức trọng tâm áp dụng
| Khái niệm | Bài học liên quan | Cách áp dụng trong thử thách |
|---|---|---|
| try-catch semantic | Bài 01 | Bắt ngoại lệ tại controller và xử lý phản hồi |
| Checked vs Unchecked | Bài 04 | ValidationException kế thừa RuntimeException |
| Custom Exception Context | Bài 06 | Ngoại lệ chứa danh sách đối tượng ValidationError |
| Global Handler Pattern | Bài 06 | Tách biệt tầng kiểm thực (Service) và tầng định dạng lỗi (Controller) |
▶️ Khung mã khởi động (Starter Code)
import java.util.*;
import java.util.stream.Collectors;
public class BorrowValidatorApp {
// TODO 1: Khai bao record ValidationError(String field, String code, String message)
// TODO 2: Khai bao class ValidationException extends RuntimeException
// Chua List<ValidationError> errors
// TODO 3: Khai bao record BorrowRequest(String memberId, String assetId, int durationDays, String note)
// TODO 4: Xay dung class BorrowRequestValidator gom loi toan dien
// public void validate(BorrowRequest request)
// TODO 5: Xay dung class BorrowController bat ValidationException va tra ve JSON
// public String submitBorrow(BorrowRequest request)
public static void main(String[] args) {
BorrowController controller = new BorrowController();
// Kich ban 1: Du lieu sai pham nhieu loi
BorrowRequest invalidReq = new BorrowRequest("LIB-12", "BOOK-99", -5, "Muon gap");
System.out.println("--- KICH BAN 1: DU LIEU KHONG HOP LE ---");
System.out.println("Controller Response:\n" + controller.submitBorrow(invalidReq));
System.out.println();
// Kich ban 2: Du lieu hop le
BorrowRequest validReq = new BorrowRequest("MEM-10023", "ITEM-5021", 14, "Nghien cuu khoa hoc");
System.out.println("--- KICH BAN 2: DU LIEU HOP LE ---");
System.out.println("Controller Response:\n" + controller.submitBorrow(validReq));
}
}
Hãy dành khoảng 20 đến 25 phút tự viết mã trước khi xem lời giải chi tiết dưới đây.
💡 Gợi ý giải pháp
- Gom lỗi vào List trong Validator:
Java
List<ValidationError> errors = new ArrayList<>(); if (req.memberId() == null || !req.memberId().startsWith("MEM-")) { errors.add(new ValidationError("memberId", "FORMAT", "Ma ban doc phai bat dau bang 'MEM-'")); } if (!errors.isEmpty()) { throw new ValidationException(errors); } - Nối thông điệp trong getMessage():
Java
@Override public String getMessage() { return errors.stream() .map(e -> e.field() + " [" + e.code() + "]: " + e.message()) .collect(Collectors.joining("\n - ", "Xac thuc that bai:\n - ", "")); }
✅ Lời giải hoàn chỉnh
import java.util.*;
import java.util.stream.Collectors;
public class BorrowValidatorApp {
public record ValidationError(String field, String code, String message) {
public ValidationError {
Objects.requireNonNull(field, "field must not be null");
Objects.requireNonNull(code, "code must not be null");
Objects.requireNonNull(message, "message must not be null");
}
}
public static class ValidationException extends RuntimeException {
private final List<ValidationError> errors;
public ValidationException(List<ValidationError> errors) {
super("Yeu cau chua du lieu khong hop le: " + errors.size() + " loi");
this.errors = List.copyOf(errors); // Bao ve tinh bat bien
}
public List<ValidationError> getErrors() {
return errors;
}
@Override
public String getMessage() {
return errors.stream()
.map(err -> String.format("%s [%s]: %s", err.field(), err.code(), err.message()))
.collect(Collectors.joining("\n - ", "Kiem tra du lieu that bai:\n - ", ""));
}
}
public record BorrowRequest(String memberId, String assetId, int durationDays, String note) { }
public static class BorrowRequestValidator {
public void validate(BorrowRequest request) {
Objects.requireNonNull(request, "request must not be null");
List<ValidationError> errors = new ArrayList<>();
// 1. Kiem tra memberId
if (request.memberId() == null || request.memberId().isBlank()) {
errors.add(new ValidationError("memberId", "REQUIRED", "Ma the ban doc khong duoc de trong"));
} else if (!request.memberId().startsWith("MEM-") || request.memberId().length() < 6 || request.memberId().length() > 12) {
errors.add(new ValidationError("memberId", "FORMAT", "Ma the ban doc phai bat dau bang 'MEM-' va dai 6-12 ky tu"));
}
// 2. Kiem tra assetId
if (request.assetId() == null || request.assetId().isBlank()) {
errors.add(new ValidationError("assetId", "REQUIRED", "Ma an pham khong duoc de trong"));
} else if (!request.assetId().startsWith("ITEM-")) {
errors.add(new ValidationError("assetId", "FORMAT", "Ma an pham phai bat dau bang tien to 'ITEM-'"));
}
// 3. Kiem tra durationDays
if (request.durationDays() < 1 || request.durationDays() > 30) {
errors.add(new ValidationError("durationDays", "RANGE", "Thoi gian muon phai tu 1 den 30 ngay, nhan duoc: " + request.durationDays()));
}
// 4. Kiem tra note
if (request.note() != null && request.note().length() > 100) {
errors.add(new ValidationError("note", "MAX_LENGTH", "Ghi chu khong duoc vuot qua 100 ky tu"));
}
// Neu co loi, nem ngoai le gom tat ca
if (!errors.isEmpty()) {
throw new ValidationException(errors);
}
}
}
public static class BorrowController {
private final BorrowRequestValidator validator = new BorrowRequestValidator();
public String submitBorrow(BorrowRequest request) {
try {
validator.validate(request);
return "{\"status\": \"SUCCESS\", \"message\": \"Yeu cau muon sach da duoc ghi nhan thanh cong\"}";
} catch (ValidationException ex) {
// Mo phong format JSON tra ve cho client
String errorsJson = ex.getErrors().stream()
.map(err -> String.format("{\"field\": \"%s\", \"code\": \"%s\", \"detail\": \"%s\"}",
err.field(), err.code(), err.message()))
.collect(Collectors.joining(",\n ", "[\n ", "\n ]"));
return String.format("{\n \"status\": \"BAD_REQUEST\",\n \"errorCount\": %d,\n \"errors\": %s\n}",
ex.getErrors().size(), errorsJson);
}
}
}
public static void main(String[] args) {
BorrowController controller = new BorrowController();
BorrowRequest invalidReq = new BorrowRequest("LIB-12", "BOOK-99", -5, "Muon gap");
System.out.println("--- KICH BAN 1: DU LIEU KHONG HOP LE ---");
System.out.println("Controller Response:\n" + controller.submitBorrow(invalidReq));
System.out.println();
BorrowRequest validReq = new BorrowRequest("MEM-10023", "ITEM-5021", 14, "Nghien cuu khoa hoc");
System.out.println("--- KICH BAN 2: DU LIEU HOP LE ---");
System.out.println("Controller Response:\n" + controller.submitBorrow(validReq));
}
}
🎓 Bài kiểm thử tự động với JUnit 5
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class BorrowRequestValidatorTest {
private final BorrowValidatorApp.BorrowRequestValidator validator =
new BorrowValidatorApp.BorrowRequestValidator();
@Test
void testValidRequestPassesWithoutException() {
var req = new BorrowValidatorApp.BorrowRequest("MEM-12345", "ITEM-9901", 7, "Hop le");
assertDoesNotThrow(() -> validator.validate(req));
}
@Test
void testCollectsMultipleErrorsSimultaneously() {
var req = new BorrowValidatorApp.BorrowRequest("BAD_ID", "BAD_ASSET", 50, "Ghi chu");
var ex = assertThrows(BorrowValidatorApp.ValidationException.class, () ->
validator.validate(req));
assertEquals(3, ex.getErrors().size());
assertTrue(ex.getErrors().stream().anyMatch(e -> e.field().equals("memberId")));
assertTrue(ex.getErrors().stream().anyMatch(e -> e.field().equals("assetId")));
assertTrue(ex.getErrors().stream().anyMatch(e -> e.field().equals("durationDays")));
}
@Test
void testThrowsNullPointerWhenRequestIsNull() {
assertThrows(NullPointerException.class, () -> validator.validate(null));
}
}
✨ Tổng kết Module 02: Exception Handling
Bạn đã hoàn thành trọn vẹn Module 02. Nhìn lại kiến thức vừa chinh phục:
- Cơ chế Stack Unwinding: JVM leo ngược Call Stack và thu dọn biến cục bộ khi tìm kiếm khối catch.
- Bản đồ Throwable: Phân biệt bản chất giữa Error, Checked Exception và Unchecked Exception.
- Stack Trace & Cause Chain: Đọc hiểu log trong 10 giây và kỹ thuật giữ gìn dấu vết lỗi gốc qua tham số cause.
- Bytecode Exception Table: Hiểu nguyên lý Zero-cost try block và định lượng chi phí của
fillInStackTrace(). - try-with-resources: Tự động giải phóng tài nguyên theo thứ tự LIFO và bảo toàn suppressed exception.
- Custom Exception & Validation Pattern: Thiết kế phân cấp ngoại lệ domain kết hợp gom lỗi có cấu trúc.
Bài tiếp theo: Generics cơ bản — kiểu tham số hoá và an toàn type-safe
Bài này có giúp bạn hiểu bản chất không?
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Custom exception — thiết kế phân cấp exception nghiệp vụ
Bài tiếp Generics & CollectionsGenerics cơ bản — class và method nhận kiểu làm tham số
Lộ trình Junior Java Backend Developer
✓ Bạn vừa đi hết chặng Exception handling (4/21)
Chặng tiếp: Generics & Collections — “Generics cơ bản — class và method nhận kiểu làm tham số”
Thảo luận & góp ý
Hỏi đáp về bài này