Đâ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 ý
💡 💡 Gợi ý — đọc khi bị kẹt
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
ℹ️ ✅ Lời giải — xem sau khi đã thử
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.