Result Pattern in Spring Boot: Elegant Error Handling
The Result pattern is an elegant alternative to traditional exception handling in Spring Boot. Inspired by functional languages like Rust, it allows explicit representation of operations that may fail, improving code readability and robustness.
Why Use the Result Pattern?
Problems with Traditional Exceptions
// ❌ Traditional exception handling
public User findUserById(Long id) throws UserNotFoundException {
User user = userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException("User not found: " + id));
if (!user.isActive()) {
throw new UserInactiveException("User inactive: " + id);
}
return user;
}Problems:
- Checked exceptions pollute method signatures
- Error flow is not explicit
- Difficult to compose operations
Benefits of Result Pattern
// ✅ With Result pattern
public Result<User> findUserById(Long id) {
return userRepository.findById(id)
.map(Result::success)
.orElse(Result.failure(new UserNotFoundError("User not found: " + id)))
.flatMap(user -> user.isActive()
? Result.success(user)
: Result.failure(new UserInactiveError("User inactive: " + id)));
}Base Implementation
Main Result Interface
@FunctionalInterface
public interface Result<T> {
boolean isSuccess();
boolean isFailure();
T get() throws NoSuchElementException;
Error getError() throws NoSuchElementException;
// Functional transformation methods
<U> Result<U> map(Function<T, U> mapper);
<U> Result<U> flatMap(Function<T, Result<U>> mapper);
// Error handling
Result<T> peek(Consumer<T> successConsumer);
Result<T> peekError(Consumer<Error> errorConsumer);
Result<T> recover(Function<Error, T> recoveryFunction);
// Conversion to Java types
Optional<T> toOptional();
Stream<T> toStream();
// Factory methods
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));
}
}
}Concrete Implementations
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 contains no error");
}
@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; // Do nothing in 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 contains no value");
}
@Override
public Error getError() { return error; }
@Override
public <U> Result<U> map(Function<T, U> mapper) {
return failure(error); // Propagate error
}
@Override
public <U> Result<U> flatMap(Function<T, Result<U>> mapper) {
return failure(error); // Propagate error
}
@Override
public Result<T> peek(Consumer<T> successConsumer) {
return this; // Do nothing in 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();
}
}Error System
Base Error Class
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);
}
}Domain Errors
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("Rule '%s': %s", rule, message), null);
}
}
public class SystemError extends Error {
public SystemError(Throwable cause) {
super("SYSTEM_ERROR", "Internal 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 Integration
Service Layer with 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", "Email is required"));
} else if (!emailValidator.isValid(request.getEmail())) {
errors.add(new ValidationError("email", "Invalid email"));
}
if (StringUtils.isBlank(request.getPassword())) {
errors.add(new ValidationError("password", "Password is required"));
} else if (request.getPassword().length() < 8) {
errors.add(new ValidationError("password", "Password must be at least 8 characters"));
}
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",
"Email is already registered"))
: 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;
});
}
}Controller with 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("Unhandled error: " + error);
}
private ResponseEntity<ErrorResponse> handleValidationError(Error error) {
if (error instanceof ValidationError) {
return ResponseEntity.badRequest()
.body(ErrorResponse.from(error));
}
throw new IllegalStateException("Unhandled error: " + error);
}
private ResponseEntity<ErrorResponse> handleNotFoundError(Error error) {
if (error instanceof UserNotFoundError) {
return ResponseEntity.notFound().build();
}
throw new IllegalStateException("Unhandled error: " + 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("Unhandled error: " + error);
}
}Support Classes
ErrorResponse for 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();
}
}Domain Classes
@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;
}Pattern Benefits
- Clarity: Error flow is explicit in code
- Composition: Facilitates operation composition
- Immutability: Result objects are immutable
- Functional: Integrates well with functional programming
- Testability: Makes unit testing easier
Conclusion
The Result pattern provides a more elegant and robust way to handle errors in Spring Boot applications, eliminating the need for checked exceptions and making code more readable and maintainable.