Spring BootにおけるResultパターン:エレガントなエラーハンドリング
Spring Bootのエラーハンドリングといえば@ControllerAdviceと例外の組み合わせが定番ですが、別のやり方もあります。Rustなどに由来するResultパターンをJavaに持ち込む方法です。失敗する可能性のある操作を型で明示的に表現できるので、個人的にはコードの見通しがよくなると感じています。
なぜ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<User>になっているのがポイントです。成功か失敗かが型レベルで見える。mapやflatMapで処理を繋げられるので、エラーチェックのネストが減ります。
基本実装
メイン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));
}
}
}mapは成功値を変換し、flatMapはResultを返す関数を繋げます。recoverでエラーから復帰できる。ofCallableは例外を投げうる処理をResultで包むユーティリティで、Javaの既存コードとの橋渡しになります。
具体的な実装
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();
}
}Failure側ではmapもflatMapも自分自身をそのまま返します。エラーが起きた時点で後続の処理はスキップされる、という挙動です。peekとpeekErrorは副作用を挟むためのもので、ロギングなどに便利です。
エラーシステム
基本エラークラス
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);
}
}codeを持たせているのが実務上のポイントです。APIレスポンスでフロントエンド側がエラーハンドリングを分岐しやすくなります。
ドメインエラー
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);
}
}ドメインごとにエラークラスを分けると、コントローラー側でのinstanceof判定が明確になります。バリデーションエラー、ビジネスルール違反、システムエラー、リソース不在。この4種類くらいに分けておけば、だいたいのユースケースはカバーできます。
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;
});
}
}createUserがflatMapで繋がっているのが見えると思います。バリデーション、メール重複チェック、パスワードエンコード、保存。どれか1つでも失敗したら、そこで止まってエラーが伝播します。途中でif-elseのネストがないのが、個人的に気に入っている点です。
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);
}
}コントローラー側ではrecoverでエラーの型に応じたHTTPレスポンスに変換しています。mapが成功時の変換で、recoverが失敗時の復帰。この2つだけ覚えれば、パイプラインが読めます。
サポートクラス
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がついているだけで、「この処理は失敗するかもしれない」が一目でわかります。処理のチェーンがflatMapで繋がるので、ネストが減って読みやすい。Resultオブジェクトは不変なので、マルチスレッドでも安全です。例外ベースに比べてモックなしでテストしやすいのも地味に嬉しい点です。
一方で、全てのエラーパスをinstanceofで判定しないといけないのは少し冗長です。プロジェクト全体でこれに統一するか、@ControllerAdviceと併用するかは、チームの好みと規模によると思います。個人的には、ドメインロジックが複雑な部分ではResultパターン、インフラ寄りの部分では例外、という使い分けが現実的だと感じています。