Chuyển đến nội dung chính

Lesson 6: DTO Pattern, Validation & Global Exception Handling

Data Transfer Object pattern with record classes. Bean Validation (@Valid, @NotNull, @Size, custom validator). @ControllerAdvice, @ExceptionHandler, ProblemDetail RFC 9457.

💻 Programming — Lesson 5 Lesson 6: DTO Pattern, Validation & Global Exception Handling

Spring Boot 4: From Basics to Advanced

Part 2: Building REST API

xdev.asia

Introduction

In production, you never expose Entities directly to the API. The DTO pattern separates the representation layer from the persistence layer. Combined with Bean Validation and Global Exception Handling, you will build a robust and professional API.


1. DTO Pattern with Java Records

1.1 Why do we need DTO?

Client ←→ Controller ←→ Service ←→ Repository ←→ Database
           │                          │
         DTO/Request              Entity/Model
         DTO/Response

Lý do:
- Không expose fields nhạy cảm (password hash, internal IDs)
- Decouple API contract khỏi database schema
- Validate input tại boundary
- Versioning API dễ dàng hơn

1.2 Use Java Records for DTO

// Entity - ánh xạ database
@Entity
@Table(name = "users")
public class User {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private String email;
    private String passwordHash;
    private String role;
    private boolean active;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
    // getters, setters...
}

// Request DTO - nhận input từ client
public record CreateUserRequest(
    String name,
    String email,
    String password
) {}

public record UpdateUserRequest(
    String name,
    String email
) {}

// Response DTO - trả về cho client
public record UserResponse(
    Long id,
    String name,
    String email,
    String role,
    LocalDateTime createdAt
) {
    // Factory method từ Entity
    public static UserResponse from(User user) {
        return new UserResponse(
            user.getId(),
            user.getName(),
            user.getEmail(),
            user.getRole(),
            user.getCreatedAt()
        );
    }
}

1.3 Mapping in Service

@Service
public class UserService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
        this.userRepository = userRepository;
        this.passwordEncoder = passwordEncoder;
    }

    public UserResponse create(CreateUserRequest request) {
        User user = new User();
        user.setName(request.name());
        user.setEmail(request.email());
        user.setPasswordHash(passwordEncoder.encode(request.password()));
        user.setRole("USER");
        user.setActive(true);
        user.setCreatedAt(LocalDateTime.now());

        User saved = userRepository.save(user);
        return UserResponse.from(saved);
    }

    public List<UserResponse> findAll() {
        return userRepository.findAll().stream()
            .map(UserResponse::from)
            .toList();
    }
}

2. Bean Validation

2.1 Dependency

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

2.2 Validation Annotations

public record CreateUserRequest(
    @NotBlank(message = "Tên không được để trống")
    @Size(min = 2, max = 100, message = "Tên phải từ 2-100 ký tự")
    String name,

    @NotBlank(message = "Email không được để trống")
    @Email(message = "Email không hợp lệ")
    String email,

    @NotBlank(message = "Mật khẩu không được để trống")
    @Size(min = 8, message = "Mật khẩu phải ít nhất 8 ký tự")
    @Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).*$",
             message = "Mật khẩu phải có chữ hoa, chữ thường và số")
    String password,

    @Min(value = 0, message = "Tuổi phải >= 0")
    @Max(value = 150, message = "Tuổi phải <= 150")
    Integer age,

    @NotNull(message = "Ngày sinh không được null")
    @Past(message = "Ngày sinh phải là ngày trong quá khứ")
    LocalDate dateOfBirth
) {}

2.3 Enable Validation in Controller

@PostMapping
public ResponseEntity<UserResponse> createUser(
        @Valid @RequestBody CreateUserRequest request) {
    // @Valid trigger validation
    // Nếu invalid, Spring tự động throw MethodArgumentNotValidException
    UserResponse user = userService.create(request);
    return ResponseEntity.status(HttpStatus.CREATED).body(user);
}

2.4 Common validation annotations

AnnotationDescription
@NotNullNot null
@NotBlankNot null, not empty, not just spaces
@NotEmptyNot null, not empty (for String, Collection)
@Size(min, max)Limit length
@Min / @MaxNumber limit
@EmailEmail format
@PatternRegex patterns
@Past / @FutureDate in the past/future
@Positive / @NegativePositive/negative numbers

2.5 Custom Validator

// Tạo custom annotation
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = UniqueEmailValidator.class)
public @interface UniqueEmail {
    String message() default "Email đã được sử dụng";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

// Implement validator
public class UniqueEmailValidator implements ConstraintValidator<UniqueEmail, String> {

    private final UserRepository userRepository;

    public UniqueEmailValidator(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public boolean isValid(String email, ConstraintValidatorContext context) {
        if (email == null) return true; // @NotBlank sẽ handle null
        return !userRepository.existsByEmail(email);
    }
}

// Sử dụng
public record CreateUserRequest(
    @NotBlank String name,
    @Email @UniqueEmail String email,
    @NotBlank @Size(min = 8) String password
) {}

3. Global Exception Handling

3.1 ProblemDetail (RFC 9457)

Spring Boot 4.x supports ProblemDetail — RFC 9457 standard for error responses:

{
    "type": "https://api.example.com/errors/validation",
    "title": "Validation Error",
    "status": 400,
    "detail": "Request validation failed",
    "instance": "/api/v1/users",
    "errors": [
        {"field": "email", "message": "Email không hợp lệ"},
        {"field": "password", "message": "Mật khẩu phải ít nhất 8 ký tự"}
    ]
}

3.2 @ControllerAdvice — Global Exception Handler

@RestControllerAdvice
public class GlobalExceptionHandler {

    // Validation errors
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ProblemDetail handleValidationErrors(
            MethodArgumentNotValidException ex,
            HttpServletRequest request) {

        ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
        problem.setTitle("Validation Error");
        problem.setDetail("Request validation failed");
        problem.setInstance(URI.create(request.getRequestURI()));

        List<Map<String, String>> errors = ex.getBindingResult()
            .getFieldErrors().stream()
            .map(error -> Map.of(
                "field", error.getField(),
                "message", error.getDefaultMessage() != null
                    ? error.getDefaultMessage() : "Invalid value"
            ))
            .toList();

        problem.setProperty("errors", errors);
        return problem;
    }

    // Resource not found
    @ExceptionHandler(ResourceNotFoundException.class)
    public ProblemDetail handleNotFound(
            ResourceNotFoundException ex,
            HttpServletRequest request) {

        ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
        problem.setTitle("Resource Not Found");
        problem.setDetail(ex.getMessage());
        problem.setInstance(URI.create(request.getRequestURI()));
        return problem;
    }

    // Duplicate resource
    @ExceptionHandler(DuplicateResourceException.class)
    public ProblemDetail handleDuplicate(
            DuplicateResourceException ex,
            HttpServletRequest request) {

        ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.CONFLICT);
        problem.setTitle("Duplicate Resource");
        problem.setDetail(ex.getMessage());
        problem.setInstance(URI.create(request.getRequestURI()));
        return problem;
    }

    // Catch-all for unexpected errors
    @ExceptionHandler(Exception.class)
    public ProblemDetail handleGeneral(
            Exception ex,
            HttpServletRequest request) {

        ProblemDetail problem = ProblemDetail.forStatus(
            HttpStatus.INTERNAL_SERVER_ERROR);
        problem.setTitle("Internal Server Error");
        problem.setDetail("An unexpected error occurred");
        problem.setInstance(URI.create(request.getRequestURI()));
        // Không expose exception details cho client trong production
        return problem;
    }
}

3.3 Custom Exception Classes

public class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String resource, String field, Object value) {
        super(String.format("%s not found with %s: '%s'", resource, field, value));
    }
}

public class DuplicateResourceException extends RuntimeException {
    public DuplicateResourceException(String resource, String field, Object value) {
        super(String.format("%s already exists with %s: '%s'", resource, field, value));
    }
}

// Sử dụng trong service
@Service
public class UserService {
    public UserResponse findById(Long id) {
        return userRepository.findById(id)
            .map(UserResponse::from)
            .orElseThrow(() -> new ResourceNotFoundException("User", "id", id));
    }
}

4. Enable ProblemDetail in Spring Boot

# application.yaml
spring:
  mvc:
    problemdetails:
      enabled: true # Bật ProblemDetail cho tất cả exceptions

5. Response Envelope Pattern (Optional)

// API Response wrapper
public record ApiResponse<T>(
    boolean success,
    T data,
    String message,
    LocalDateTime timestamp
) {
    public static <T> ApiResponse<T> ok(T data) {
        return new ApiResponse<>(true, data, null, LocalDateTime.now());
    }

    public static <T> ApiResponse<T> ok(T data, String message) {
        return new ApiResponse<>(true, data, message, LocalDateTime.now());
    }

    public static <T> ApiResponse<T> error(String message) {
        return new ApiResponse<>(false, null, message, LocalDateTime.now());
    }
}

// Sử dụng
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<UserResponse>> getUser(@PathVariable Long id) {
    UserResponse user = userService.findById(id);
    return ResponseEntity.ok(ApiResponse.ok(user));
}

Summary

  • DTO pattern separates API contracts from database entities, using Java Records for immutable DTOs
  • Bean Validation (@Valid, @NotBlank, @Email...) validates input at the controller layer, supports custom validators
  • @RestControllerAdvice + @ExceptionHandler handles exceptions centrally, using ProblemDetail (RFC 9457) for standardized error responses

Exercises

  1. Create DTO for Product entity: CreateProductRequest (with validation), UpdateProductRequest, ProductResponse (without exposing cost price)
  2. Implement custom validator @ValidSlug check the slug only contains lowercase letters, numbers and dashes
  3. Create a GlobalExceptionHandler that handles at least 4 different types of exceptions, returning RFC 9457 standard ProblemDetail