Result Pattern in Spring Boot: Elegant Error Handling
Exceptions for control flow is something Java developers just accept. The Result pattern, borrowed from Rust and other functional languages, offers a different deal: make failure a value, not a stack trace. Method signatures tell the truth about what can go wrong.
Why the Result pattern
The problem with 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;
}What goes wrong here:
- Checked exceptions clutter every method signature they touch
- You can't tell from reading the call site whether a method can fail, or how
- Chaining operations gets messy when each one throws
What Result gives you instead
// ✅ 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)));
}Building it from scratch
The 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));
}
}
}Success and Failure 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();
}
}Errors as values
Error base 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-specific 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);
}
}Wiring it into Spring Boot
Service layer
@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 layer
@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);
}
}Supporting types
API error response
@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();
}
}Request and response DTOs
@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;
}Where it pays off
The signature change is the biggest win. When a method returns Result<User>, the caller knows it can fail and has to deal with it. No surprises from a throws clause buried three calls up the stack.
Composition falls out naturally. map, flatMap, and recover chain together the same way Optional and Stream do. If you've worked with those, the mental model transfers directly.
Result objects are immutable by design, so you never worry about a failure being silently mutated. Testing gets simpler too: you assert on the Result value, no try-catch gymnastics needed.
Is it worth the up-front cost of building this infrastructure? For a throwaway CRUD endpoint, no. But once you're in a service layer with real domain rules, where operations fail in five different ways for five different reasons, Result earns its keep. The type system enforces error handling. Method signatures become documentation. You stop catching exceptions you never expected in the first place.