Zademy

Spring BootにおけるResultパターン:エレガントなエラーハンドリング

spring-boot; result-pattern
1348 単語

Resultパターンは、Spring Bootにおける従来の例外処理のエレガントな代替案です。Rustなどの関数型言語にインスパイアされ、失敗する可能性のある操作を明示的に表現し、コードの可読性と堅牢性を向上させます。

なぜResultパターンを使用するのか?

従来の例外の問題点

// ❌ 従来の例外処理
public User findUserById(Long id) throws UserNotFoundException {
    User user = userRepository.findById(id)
        .orElseThrow(() -> new UserNotFoundException("ユーザーが見つかりません: " + id));
    
    if (!user.isActive()) {
        throw new UserInactiveException("ユーザーが非アクティブです: " + id);
    }
    return user;
}

問題点:

  • チェック例外がメソッドシグネチャを汚染する
  • エラーフローが明示的でない
  • 操作の合成が難しい

Resultパターンの利点

// ✅ Resultパターンを使用
public Result<User> findUserById(Long id) {
    return userRepository.findById(id)
        .map(Result::success)
        .orElse(Result.failure(new UserNotFoundError("ユーザーが見つかりません: " + id)))
        .flatMap(user -> user.isActive()
            ? Result.success(user)
            : Result.failure(new UserInactiveError("ユーザーが非アクティブです: " + id)));
}

基本実装

メインResultインターフェース

@FunctionalInterface
public interface Result<T> {
    boolean isSuccess();
    boolean isFailure();
    
    T get() throws NoSuchElementException;
    Error getError() throws NoSuchElementException;
    
    // 関数型変換メソッド
    <U> Result<U> map(Function<T, U> mapper);
    <U> Result<U> flatMap(Function<T, Result<U>> mapper);
    
    // エラーハンドリング
    Result<T> peek(Consumer<T> successConsumer);
    Result<T> peekError(Consumer<Error> errorConsumer);
    Result<T> recover(Function<Error, T> recoveryFunction);
    
    // Java型への変換
    Optional<T> toOptional();
    Stream<T> toStream();
    
    // ファクトリーメソッド
    static <T> Result<T> success(T value) {
        return new Success<>(value);
    }
    
    static <T> Result<T> failure(Error error) {
        return new Failure<>(error);
    }
    
    static <T> Result<T> ofCallable(Callable<T> callable) {
        try {
            return success(callable.call());
        } catch (Exception e) {
            return failure(new SystemError(e));
        }
    }
}

具体的な実装

public final class Success<T> implements Result<T> {
    private final T value;
    
    public Success(T value) {
        this.value = Objects.requireNonNull(value);
    }
    
    @Override
    public boolean isSuccess() { return true; }
    @Override
    public boolean isFailure() { return false; }
    @Override
    public T get() { return value; }
    @Override
    public Error getError() {
        throw new NoSuchElementException("Successにはエラーが含まれていません");
    }
    
    @Override
    public <U> Result<U> map(Function<T, U> mapper) {
        try {
            return success(mapper.apply(value));
        } catch (Exception e) {
            return failure(new SystemError(e));
        }
    }
    
    @Override
    public <U> Result<U> flatMap(Function<T, Result<U>> mapper) {
        try {
            return mapper.apply(value);
        } catch (Exception e) {
            return failure(new SystemError(e));
        }
    }
    
    @Override
    public Result<T> peek(Consumer<T> successConsumer) {
        successConsumer.accept(value);
        return this;
    }
    
    @Override
    public Result<T> peekError(Consumer<Error> errorConsumer) {
        return this; // Successでは何もしない
    }
    
    @Override
    public Optional<T> toOptional() {
        return Optional.of(value);
    }
    
    @Override
    public Stream<T> toStream() {
        return Stream.of(value);
    }
}

public final class Failure<T> implements Result<T> {
    private final Error error;
    
    public Failure(Error error) {
        this.error = Objects.requireNonNull(error);
    }
    
    @Override
    public boolean isSuccess() { return false; }
    @Override
    public boolean isFailure() { return true; }
    @Override
    public T get() {
        throw new NoSuchElementException("Failureには値が含まれていません");
    }
    @Override
    public Error getError() { return error; }
    
    @Override
    public <U> Result<U> map(Function<T, U> mapper) {
        return failure(error); // エラーを伝播
    }
    
    @Override
    public <U> Result<U> flatMap(Function<T, Result<U>> mapper) {
        return failure(error); // エラーを伝播
    }
    
    @Override
    public Result<T> peek(Consumer<T> successConsumer) {
        return this; // Failureでは何もしない
    }
    
    @Override
    public Result<T> peekError(Consumer<Error> errorConsumer) {
        errorConsumer.accept(error);
        return this;
    }
    
    @Override
    public Optional<T> toOptional() {
        return Optional.empty();
    }
    
    @Override
    public Stream<T> toStream() {
        return Stream.empty();
    }
}

エラーシステム

基本エラークラス

public abstract class Error {
    private final String code;
    private final String message;
    private final Throwable cause;
    private final LocalDateTime timestamp;
    
    protected Error(String code, String message, Throwable cause) {
        this.code = code;
        this.message = message;
        this.cause = cause;
        this.timestamp = LocalDateTime.now();
    }
    
    public String getCode() { return code; }
    public String getMessage() { return message; }
    public Throwable getCause() { return cause; }
    public LocalDateTime getTimestamp() { return timestamp; }
    
    @Override
    public String toString() {
        return String.format("[%s] %s", code, message);
    }
}

ドメインエラー

public class ValidationError extends Error {
    private final String field;
    
    public ValidationError(String field, String message) {
        super("VALIDATION_ERROR", message, null);
        this.field = field;
    }
    
    public String getField() { return field; }
}

public class BusinessRuleError extends Error {
    public BusinessRuleError(String rule, String message) {
        super("BUSINESS_RULE_VIOLATION",
              String.format("ルール '%s': %s", rule, message), null);
    }
}

public class SystemError extends Error {
    public SystemError(Throwable cause) {
        super("SYSTEM_ERROR", "内部システムエラー", cause);
    }
}

public class UserNotFoundError extends Error {
    public UserNotFoundError(String message) {
        super("USER_NOT_FOUND", message, null);
    }
}

public class UserInactiveError extends Error {
    public UserInactiveError(String message) {
        super("USER_INACTIVE", message, null);
    }
}

Spring Boot統合

Resultを使用するサービス層

@Service
@RequiredArgsConstructor
public class UserService {
    
    private final UserRepository userRepository;
    private final EmailValidator emailValidator;
    private final PasswordEncoder passwordEncoder;
    
    public Result<User> createUser(CreateUserRequest request) {
        return validateCreateRequest(request)
            .flatMap(this::checkEmailExists)
            .flatMap(this::encodePassword)
            .flatMap(this::saveUser);
    }
    
    private Result<CreateUserRequest> validateCreateRequest(CreateUserRequest request) {
        List<ValidationError> errors = new ArrayList<>();
        
        if (StringUtils.isBlank(request.getEmail())) {
            errors.add(new ValidationError("email", "メールアドレスは必須です"));
        } else if (!emailValidator.isValid(request.getEmail())) {
            errors.add(new ValidationError("email", "メールアドレスが無効です"));
        }
        
        if (StringUtils.isBlank(request.getPassword())) {
            errors.add(new ValidationError("password", "パスワードは必須です"));
        } else if (request.getPassword().length() < 8) {
            errors.add(new ValidationError("password", "パスワードは8文字以上である必要があります"));
        }
        
        return errors.isEmpty()
            ? Result.success(request)
            : Result.failure(new ValidationError(errors.get(0).getField(),
                errors.stream().map(Error::getMessage).collect(Collectors.joining(", "))));
    }
    
    private Result<CreateUserRequest> checkEmailExists(CreateUserRequest request) {
        return userRepository.existsByEmail(request.getEmail())
            ? Result.failure(new BusinessRuleError("EMAIL_UNIQUE",
                "メールアドレスは既に登録されています"))
            : Result.success(request);
    }
    
    private Result<UserData> encodePassword(CreateUserRequest request) {
        return Result.ofCallable(() -> {
            String encodedPassword = passwordEncoder.encode(request.getPassword());
            return UserData.builder()
                .email(request.getEmail())
                .password(encodedPassword)
                .name(request.getName())
                .active(true)
                .build();
        });
    }
    
    private Result<User> saveUser(UserData userData) {
        return Result.ofCallable(() -> {
            User saved = userRepository.save(userData);
            return saved;
        });
    }
}

Resultを使用するコントローラー

@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
    
    private final UserService userService;
    
    @PostMapping
    public ResponseEntity<?> createUser(@RequestBody @Valid CreateUserRequest request) {
        return userService.createUser(request)
            .map(user -> ResponseEntity.status(HttpStatus.CREATED)
                .body(UserResponse.from(user)))
            .recover(this::handleBusinessError)
            .recover(this::handleValidationError)
            .recover(this::handleSystemError)
            .get();
    }
    
    @GetMapping("/{id}")
    public ResponseEntity<?> getUser(@PathVariable Long id) {
        return userService.findUserById(id)
            .map(user -> ResponseEntity.ok(UserResponse.from(user)))
            .recover(this::handleNotFoundError)
            .recover(this::handleSystemError)
            .get();
    }
    
    private ResponseEntity<ErrorResponse> handleBusinessError(Error error) {
        if (error instanceof BusinessRuleError) {
            return ResponseEntity.badRequest()
                .body(ErrorResponse.from(error));
        }
        throw new IllegalStateException("未処理のエラー: " + error);
    }
    
    private ResponseEntity<ErrorResponse> handleValidationError(Error error) {
        if (error instanceof ValidationError) {
            return ResponseEntity.badRequest()
                .body(ErrorResponse.from(error));
        }
        throw new IllegalStateException("未処理のエラー: " + error);
    }
    
    private ResponseEntity<ErrorResponse> handleNotFoundError(Error error) {
        if (error instanceof UserNotFoundError) {
            return ResponseEntity.notFound().build();
        }
        throw new IllegalStateException("未処理のエラー: " + error);
    }
    
    private ResponseEntity<ErrorResponse> handleSystemError(Error error) {
        if (error instanceof SystemError) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(ErrorResponse.from(error));
        }
        throw new IllegalStateException("未処理のエラー: " + error);
    }
}

サポートクラス

APIエラーレスポンス

@Data
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ErrorResponse {
    private String code;
    private String message;
    private LocalDateTime timestamp;
    private String path;
    
    public static ErrorResponse from(Error error) {
        return ErrorResponse.builder()
            .code(error.getCode())
            .message(error.getMessage())
            .timestamp(error.getTimestamp())
            .build();
    }
}

ドメインクラス

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CreateUserRequest {
    private String email;
    private String password;
    private String name;
}

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserResponse {
    private Long id;
    private String email;
    private String name;
    private boolean active;
    
    public static UserResponse from(User user) {
        return UserResponse.builder()
            .id(user.getId())
            .email(user.getEmail())
            .name(user.getName())
            .active(user.isActive())
            .build();
    }
}

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserData {
    private String email;
    private String password;
    private String name;
    private boolean active;
}

パターンの利点

  1. 明確性:エラーフローがコードで明示的にわかる
  2. 合成性:操作の合成が容易
  3. 不変性:Resultオブジェクトは不変
  4. 関数型:関数型プログラミングと統合しやすい
  5. テスト容易性:単体テストが容易になる

結論

Resultパターンは、Spring Bootアプリケーションでのエラーハンドリングによりエレガントで堅牢な方法を提供し、チェック例外の必要性を排除し、コードをより読みやすく保守的にします。