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

レッスン 6: DTO パターン、検証、およびグローバル例外処理

レコード クラスを使用したデータ転送オブジェクト パターン。 Bean 検証 (@Valid、@NotNull、@Size、カスタム バリデータ)。 @ControllerAdvice、@ExceptionHandler、問題の詳細 RFC 9457。

💻 プログラミング — レッスン 5 レッスン 6: DTO パターン、検証、グローバル 例外処理

Spring Boot 4: 基本から上級まで

パート 2: REST API の構築

xdev.asia

はじめに

運用環境では、エンティティを API に直接公開することはありません。 DTO パターンは、表現層を永続層から分離します。 Bean Validation とグローバル例外処理を組み合わせることで、堅牢でプロフェッショナルな API を構築できます。


1. Java レコードを使用した DTO パターン

1.1 なぜ 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 DTO に Java レコードを使用する

// 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 サービス内のマッピング

@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 の検証

2.1 依存関係

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

2.2 検証アノテーション

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 コントローラーで検証を有効にする

@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 一般的な検証アノテーション

注釈説明
@NotNullnull ではありません
@NotBlanknull ではない、空ではない、単なるスペースではない
@NotEmptynull ではなく、空ではありません (文字列、コレクションの場合)
@Size(min, max)制限長
@Min / @Max数制限
@Email電子メール形式
@Pattern正規表現パターン
@Past / @Future過去/未来の日付
@Positive / @Negative正/負の数

2.5 カスタムバリデーター

// 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. グローバル例外処理

3.1 問題の詳細 (RFC 9457)

Spring Boot 4.x は、ErrorDetail — エラー応答の RFC 9457 標準をサポートしています。

{
    "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 — グローバル例外ハンドラー

@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 カスタム例外クラス

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. Spring Boot で ProblemDetail を有効にする

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

5. 応答エンベロープ パターン (オプション)

// 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));
}

概要

  • DTO パターンは、不変 DTO に Java レコードを使用して、API コントラクトをデータベース エンティティから分離します。
  • Bean Validation (@Valid、@NotBlank、@Email...) はコントローラー層で入力を検証し、カスタムバリデーターをサポートします
  • @RestControllerAdvice + @ExceptionHandler は、標準化されたエラー応答に ProblemDetail (RFC 9457) を使用して、例外を一元的に処理します。

演習

  1. Product エンティティの DTO を作成します: CreateProductRequest (検証あり)、UpdateProductRequest、ProductResponse (原価公開なし)
  2. カスタムバリデータを実装する @ValidSlug スラグに小文字、数字、ダッシュのみが含まれていることを確認してください
  3. 少なくとも 4 つの異なるタイプの例外を処理し、RFC 9457 標準の ProblemDetail を返す GlobalExceptionHandler を作成します。