Spring BootにおけるResultパターン:エレガントなエラーハンドリング
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;
}パターンの利点
- 明確性:エラーフローがコードで明示的にわかる
- 合成性:操作の合成が容易
- 不変性:Resultオブジェクトは不変
- 関数型:関数型プログラミングと統合しやすい
- テスト容易性:単体テストが容易になる
結論
Resultパターンは、Spring Bootアプリケーションでのエラーハンドリングによりエレガントで堅牢な方法を提供し、チェック例外の必要性を排除し、コードをより読みやすく保守的にします。