Mini-challenge: Validator chain với exception
Bài thực hành khép lại Module 7 — xây validator kiểm tra User đăng ký, gom nhiều lỗi thành một ValidationException, dùng try-with-resources mô phỏng, và global handler.
Đây là mini-challenge khép lại Module 7. Bạn sẽ xây một validator kiểm tra RegisterRequest (form đăng ký user): tên, email, mật khẩu, tuổi. Nếu có lỗi, gom tất cả lỗi thành một ValidationException rồi ném — chứ không fail ở lỗi đầu tiên. Pattern này phổ biến trong form validation web/mobile.
Mục tiêu: kết hợp custom exception với field context, hierarchy exception, try-with-resources cho resource đọc config, và global handler pattern.
🎯 Đề bài
Thiết kế hệ thống với các thành phần:
1. record RegisterRequest(String username, String email, String password, int age)
- Không validate trong compact constructor (để validator riêng xử lý — pattern "fail slow").
2. class ValidationException extends RuntimeException
- Field
private final List<ValidationError> errors— danh sách mọi lỗi. - Constructor
ValidationException(List<ValidationError>). - Override
getMessage()trả về join của tất cả error message.
3. record ValidationError(String field, String code, String message)
- 3 field immutable — structured logging dễ.
4. class RegisterValidator
- Method
void validate(RegisterRequest req)— throwValidationExceptionnếu có lỗi, ngược lại trả bình thường. - Rules:
username: không null, không blank, 3–20 ký tự.email: không null, chứa@và..password: ≥ 8 ký tự, có ít nhất 1 số.age: ≥ 18.
- Gom tất cả lỗi trước khi throw — không fail fast.
5. class RegisterController (mô phỏng global handler)
- Method
String register(RegisterRequest req)— trả"OK"hoặc response error structured. - Bắt
ValidationException, format lại thành error response kèm list field lỗi.
Output mẫu (với input invalid):
Validating: RegisterRequest[username=ab, email=bad, password=short, age=15]
Validation failed:
- username [LENGTH]: username must be 3-20 chars, got 2
- email [FORMAT]: email must contain '@' and '.'
- password [LENGTH]: password must be >= 8 chars, got 5
- password [COMPLEXITY]: password must contain at least 1 digit
- age [MIN]: age must be >= 18, got 15
Controller response: {"status": "error", "errors": [...]}
Output mẫu (với input valid):
Validating: RegisterRequest[username=alice, [email protected], password=pwd12345, age=25]
Controller response: OK
📦 Concept dùng trong bài
| Concept | Bài | Dùng ở đây |
|---|---|---|
| try/catch/finally | Module 7, bài 1 | RegisterController.register catch ValidationException |
| Checked vs unchecked | Module 7, bài 2 | ValidationException extends RuntimeException |
| Custom exception | Module 7, bài 4 | ValidationException với List<ValidationError> |
| Global handler | Module 7, bài 4 | RegisterController mô phỏng pattern |
| Record + validation | Module 5, bài 6 | RegisterRequest, ValidationError là record |
| Stream | (preview Module 9) | Gom errors rồi join thành message |
▶️ Starter code
import java.util.*;
import java.util.stream.Collectors;
public class RegisterApp {
// TODO: record ValidationError(String field, String code, String message)
// TODO: class ValidationException extends RuntimeException
// - List<ValidationError> errors (final)
// - constructor nhan List<ValidationError>
// - override getMessage() join moi error
// TODO: record RegisterRequest(String username, String email, String password, int age)
// TODO: class RegisterValidator
// public void validate(RegisterRequest req)
// gom tat ca loi, neu > 0 thi throw ValidationException
// TODO: class RegisterController
// public String register(RegisterRequest req)
// try/catch ValidationException, tra ve error response hoac "OK"
public static void main(String[] args) {
RegisterController controller = new RegisterController();
// Case 1: invalid
RegisterRequest bad = new RegisterRequest("ab", "bad", "short", 15);
System.out.println("Validating: " + bad);
System.out.println("Controller response: " + controller.register(bad));
System.out.println();
// Case 2: valid
RegisterRequest good = new RegisterRequest("alice", "[email protected]", "pwd12345", 25);
System.out.println("Validating: " + good);
System.out.println("Controller response: " + controller.register(good));
}
}
javac RegisterApp.java
java RegisterApp
Dành 25–30 phút tự làm.
💡 Gợi ý
ValidationError record:
public record ValidationError(String field, String code, String message) { }
ValidationException:
public static class ValidationException extends RuntimeException {
private final List<ValidationError> errors;
public ValidationException(List<ValidationError> errors) {
super(errors.stream()
.map(e -> e.field() + " [" + e.code() + "]: " + e.message())
.collect(Collectors.joining("\n - ", "Validation failed:\n - ", "")));
this.errors = List.copyOf(errors);
}
public List<ValidationError> getErrors() { return errors; }
}
Validator gom lỗi (fail slow):
public static class RegisterValidator {
public void validate(RegisterRequest req) {
List<ValidationError> errors = new ArrayList<>();
// username
if (req.username() == null || req.username().isBlank()) {
errors.add(new ValidationError("username", "REQUIRED", "username is required"));
} else if (req.username().length() < 3 || req.username().length() > 20) {
errors.add(new ValidationError("username", "LENGTH",
"username must be 3-20 chars, got " + req.username().length()));
}
// email
if (req.email() == null || !req.email().contains("@") || !req.email().contains(".")) {
errors.add(new ValidationError("email", "FORMAT",
"email must contain '@' and '.'"));
}
// password
if (req.password() == null || req.password().length() < 8) {
int len = req.password() == null ? 0 : req.password().length();
errors.add(new ValidationError("password", "LENGTH",
"password must be >= 8 chars, got " + len));
} else if (!req.password().chars().anyMatch(Character::isDigit)) {
errors.add(new ValidationError("password", "COMPLEXITY",
"password must contain at least 1 digit"));
}
// age
if (req.age() < 18) {
errors.add(new ValidationError("age", "MIN",
"age must be >= 18, got " + req.age()));
}
if (!errors.isEmpty()) {
throw new ValidationException(errors);
}
}
}
Controller mô phỏng global handler:
public static class RegisterController {
private final RegisterValidator validator = new RegisterValidator();
public String register(RegisterRequest req) {
try {
validator.validate(req);
// business logic sau validate
return "OK";
} catch (ValidationException e) {
// format error response kieu JSON-like
String errorList = e.getErrors().stream()
.map(err -> String.format(
"{\"field\":\"%s\",\"code\":\"%s\",\"message\":\"%s\"}",
err.field(), err.code(), err.message()))
.collect(Collectors.joining(",", "[", "]"));
return "{\"status\":\"error\",\"errors\":" + errorList + "}";
}
}
}
✅ Lời giải
import java.util.*;
import java.util.stream.Collectors;
public class RegisterApp {
public record ValidationError(String field, String code, String message) { }
public record RegisterRequest(String username, String email, String password, int age) { }
public static class ValidationException extends RuntimeException {
private final List<ValidationError> errors;
public ValidationException(List<ValidationError> errors) {
super(buildMessage(errors));
this.errors = List.copyOf(errors);
}
private static String buildMessage(List<ValidationError> errors) {
return errors.stream()
.map(e -> e.field() + " [" + e.code() + "]: " + e.message())
.collect(Collectors.joining("\n - ", "Validation failed:\n - ", ""));
}
public List<ValidationError> getErrors() { return errors; }
}
public static class RegisterValidator {
public void validate(RegisterRequest req) {
List<ValidationError> errors = new ArrayList<>();
validateUsername(req.username(), errors);
validateEmail(req.email(), errors);
validatePassword(req.password(), errors);
validateAge(req.age(), errors);
if (!errors.isEmpty()) {
throw new ValidationException(errors);
}
}
private void validateUsername(String username, List<ValidationError> errors) {
if (username == null || username.isBlank()) {
errors.add(new ValidationError("username", "REQUIRED", "username is required"));
return;
}
if (username.length() < 3 || username.length() > 20) {
errors.add(new ValidationError("username", "LENGTH",
"username must be 3-20 chars, got " + username.length()));
}
}
private void validateEmail(String email, List<ValidationError> errors) {
if (email == null || !email.contains("@") || !email.contains(".")) {
errors.add(new ValidationError("email", "FORMAT",
"email must contain '@' and '.'"));
}
}
private void validatePassword(String password, List<ValidationError> errors) {
if (password == null || password.length() < 8) {
int len = password == null ? 0 : password.length();
errors.add(new ValidationError("password", "LENGTH",
"password must be >= 8 chars, got " + len));
return;
}
if (password.chars().noneMatch(Character::isDigit)) {
errors.add(new ValidationError("password", "COMPLEXITY",
"password must contain at least 1 digit"));
}
}
private void validateAge(int age, List<ValidationError> errors) {
if (age < 18) {
errors.add(new ValidationError("age", "MIN",
"age must be >= 18, got " + age));
}
}
}
public static class RegisterController {
private final RegisterValidator validator = new RegisterValidator();
public String register(RegisterRequest req) {
try {
validator.validate(req);
return "OK";
} catch (ValidationException e) {
System.out.println(e.getMessage());
String errorList = e.getErrors().stream()
.map(err -> String.format(
"{\"field\":\"%s\",\"code\":\"%s\",\"message\":\"%s\"}",
err.field(), err.code(), err.message()))
.collect(Collectors.joining(",", "[", "]"));
return "{\"status\":\"error\",\"errors\":" + errorList + "}";
}
}
}
public static void main(String[] args) {
RegisterController controller = new RegisterController();
RegisterRequest bad = new RegisterRequest("ab", "bad", "short", 15);
System.out.println("Validating: " + bad);
System.out.println("Controller response: " + controller.register(bad));
System.out.println();
RegisterRequest good = new RegisterRequest("alice", "[email protected]", "pwd12345", 25);
System.out.println("Validating: " + good);
System.out.println("Controller response: " + controller.register(good));
}
}
Giải thích từng phần:
-
ValidationErrorrecord — structured error:field(tên field lỗi),code(mã lỗi machine-readable),message(human-readable). Client web hiển thị theo code hoặc message tuỳ context. -
ValidationException:extends RuntimeException— unchecked, không ép caller try/catch mỗi chỗ.- Field
errorslàList<ValidationError>(immutable quaList.copyOf) — truy cập structured từ handler. super(buildMessage(errors))— concat tất cả error thành message human-readable cho log.buildMessagelà private static — không dùng instance field (vì gọi trongsuper()).
-
RegisterValidator:- Gom lỗi, không fail fast — khác pattern
throwngay lỗi đầu. User thấy tất cả lỗi trong 1 request thay vì submit lại 4 lần. - Tách method
validate*riêng — dễ test, dễ thêm rule mới. - Mỗi rule chỉ
addvàoerrorsnếu fail;returnsớm nếu field null (không tiếp tục check rule phụ thuộc).
- Gom lỗi, không fail fast — khác pattern
-
RegisterController:- Mô phỏng pattern Spring
@RestControllerAdvice: catchValidationException, format response JSON-like. - Log message full để dev thấy stack trace + context.
- Trả JSON structured cho client parse.
- Business logic không biết về HTTP/format — validator throw, controller lo format.
- Mô phỏng pattern Spring
🎓 Mở rộng
Mức 1 — Thêm field context vào ValidationException:
public ValidationException(String operation, List<ValidationError> errors) {
super("[" + operation + "] " + buildMessage(errors));
this.operation = operation;
this.errors = List.copyOf(errors);
}
Mức 2 — Composable validator với builder:
public class ValidatorChain<T> {
private final List<Validator<T>> validators = new ArrayList<>();
public ValidatorChain<T> add(Validator<T> v) { validators.add(v); return this; }
public void validate(T input) {
List<ValidationError> errors = new ArrayList<>();
for (var v : validators) errors.addAll(v.check(input));
if (!errors.isEmpty()) throw new ValidationException(errors);
}
}
interface Validator<T> {
List<ValidationError> check(T input);
}
// Su dung:
var chain = new ValidatorChain<RegisterRequest>()
.add(new UsernameValidator())
.add(new EmailValidator())
.add(new PasswordValidator())
.add(new AgeValidator());
chain.validate(req);
Mỗi validator thành class riêng → dễ test, compose, share giữa nhiều entity.
Mức 3 — Bean Validation API (JSR 380):
Trong dự án Spring/Jakarta EE thực tế, dùng annotation-based:
public record RegisterRequest(
@NotBlank @Size(min = 3, max = 20) String username,
@Email String email,
@Size(min = 8) @Pattern(regexp = ".*\\d.*") String password,
@Min(18) int age
) { }
// Controller:
@PostMapping("/register")
public ResponseEntity<?> register(@Valid @RequestBody RegisterRequest req) { ... }
Spring tự throw MethodArgumentNotValidException, map qua @ExceptionHandler. Zero boilerplate nhưng dựa vào framework — bài này dạy nền tảng để bạn hiểu "magic" đó hoạt động thế nào.
Mức 4 — Unit test:
@Test
void validateRequestWithAllErrors() {
var validator = new RegisterValidator();
var req = new RegisterRequest("ab", "bad", "short", 15);
var ex = assertThrows(ValidationException.class, () -> validator.validate(req));
assertEquals(5, ex.getErrors().size()); // 5 loi
assertTrue(ex.getErrors().stream().anyMatch(e -> e.field().equals("username")));
assertTrue(ex.getErrors().stream().anyMatch(e -> e.field().equals("email")));
assertTrue(ex.getErrors().stream().anyMatch(e -> e.field().equals("password") && e.code().equals("LENGTH")));
assertTrue(ex.getErrors().stream().anyMatch(e -> e.field().equals("password") && e.code().equals("COMPLEXITY")));
assertTrue(ex.getErrors().stream().anyMatch(e -> e.field().equals("age")));
}
@Test
void validRequestPasses() {
var validator = new RegisterValidator();
var req = new RegisterRequest("alice", "[email protected]", "pwd12345", 25);
assertDoesNotThrow(() -> validator.validate(req));
}
✨ Điều bạn vừa làm được
Hoàn thành mini-challenge này, bạn đã:
- Thiết kế custom exception đúng chuẩn: extends
RuntimeException, field context final, constructor build message từ list. - Áp dụng fail slow pattern: gom tất cả lỗi validate trong 1 lần thay vì throw lỗi đầu tiên — UX tốt hơn.
- Dùng structured error với record — mỗi lỗi có field/code/message tách riêng, dễ render UI hoặc log phân tích.
- Tách responsibility: validator (kiểm tra), exception (mang lỗi), controller (map sang response). Mỗi class 1 việc.
- Mô phỏng pattern global exception handler — catch ở biên API, format response structured, business code không biết về HTTP.
- Hiểu lý do unchecked cho ValidationException: không ép caller try/catch mỗi chỗ, handler tập trung xử.
Chúc mừng — bạn đã hoàn thành Module 7! Module 8 sẽ bước sang Generics & Collections: generic types, wildcard, type erasure, và Collections framework (List, Set, Map) — những công cụ bạn dùng hàng ngày mà ít hiểu sâu.
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên