Java — Từ Zero đến Senior/Exception Handling/Mini-challenge: Validator chain với exception
5/5
~30 phútException Handling

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) — throw ValidationException nế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 @..
    • 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

ConceptBàiDùng ở đây
try/catch/finallyModule 7, bài 1RegisterController.register catch ValidationException
Checked vs uncheckedModule 7, bài 2ValidationException extends RuntimeException
Custom exceptionModule 7, bài 4ValidationException với List<ValidationError>
Global handlerModule 7, bài 4RegisterController mô phỏng pattern
Record + validationModule 5, bài 6RegisterRequest, 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:

  • ValidationError record — 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 errorsList<ValidationError> (immutable qua List.copyOf) — truy cập structured từ handler.
    • super(buildMessage(errors)) — concat tất cả error thành message human-readable cho log.
    • buildMessage là private static — không dùng instance field (vì gọi trong super()).
  • RegisterValidator:

    • Gom lỗi, không fail fast — khác pattern throw ngay 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ỉ add vào errors nếu fail; return sớm nếu field null (không tiếp tục check rule phụ thuộc).
  • RegisterController:

    • Mô phỏng pattern Spring @RestControllerAdvice: catch ValidationException, 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ở 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.