Zademy

Patrón Result en Spring Boot: Manejo Elegante de Errores

spring-boot; result-pattern
1158 palabras

El patrón Result es una alternativa elegante al manejo tradicional de excepciones en Spring Boot. Inspirado en lenguajes funcionales como Rust, permite representar explícitamente operaciones que pueden fallar, mejorando la legibilidad y robustez del código.

¿Por Qué Usar el Patrón Result?

Problemas con Excepciones Tradicionales

// ❌ Manejo tradicional con excepciones
public User findUserById(Long id) throws UserNotFoundException {
    User user = userRepository.findById(id)
        .orElseThrow(() -> new UserNotFoundException("Usuario no encontrado: " + id));
    
    if (!user.isActive()) {
        throw new UserInactiveException("Usuario inactivo: " + id);
    }
    return user;
}

Problemas:

  • Excepciones verificadas contaminan la firma del método
  • El flujo de errores no es explícito
  • Dificulta la composición de operaciones

Beneficios del Patrón Result

// ✅ Con patrón Result
public Result<User> findUserById(Long id) {
    return userRepository.findById(id)
        .map(Result::success)
        .orElse(Result.failure(new UserNotFoundError("Usuario no encontrado: " + id)))
        .flatMap(user -> user.isActive()
            ? Result.success(user)
            : Result.failure(new UserInactiveError("Usuario inactivo: " + id)));
}

Implementación Base

Interfaz Result Principal

@FunctionalInterface
public interface Result<T> {
    boolean isSuccess();
    boolean isFailure();
    
    T get() throws NoSuchElementException;
    Error getError() throws NoSuchElementException;
    
    // Métodos de transformación funcional
    <U> Result<U> map(Function<T, U> mapper);
    <U> Result<U> flatMap(Function<T, Result<U>> mapper);
    
    // Manejo de errores
    Result<T> peek(Consumer<T> successConsumer);
    Result<T> peekError(Consumer<Error> errorConsumer);
    Result<T> recover(Function<Error, T> recoveryFunction);
    
    // Conversión a tipos Java
    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));
        }
    }
}

Implementaciones Concretas

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 no contiene 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; // No hacer nada en 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 no contiene valor");
    }
    @Override
    public Error getError() { return error; }
    
    @Override
    public <U> Result<U> map(Function<T, U> mapper) {
        return failure(error); // Propagar error
    }
    
    @Override
    public <U> Result<U> flatMap(Function<T, Result<U>> mapper) {
        return failure(error); // Propagar error
    }
    
    @Override
    public Result<T> peek(Consumer<T> successConsumer) {
        return this; // No hacer nada en 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();
    }
}

Sistema de Errores

Clase Base de Error

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);
    }
}

Errores de Dominio

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("Regla '%s': %s", rule, message), null);
    }
}

public class SystemError extends Error {
    public SystemError(Throwable cause) {
        super("SYSTEM_ERROR", "Error interno del sistema", 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);
    }
}

Integración con Spring Boot

Service Layer con 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", "El email es requerido"));
        } else if (!emailValidator.isValid(request.getEmail())) {
            errors.add(new ValidationError("email", "Email inválido"));
        }
        
        if (StringUtils.isBlank(request.getPassword())) {
            errors.add(new ValidationError("password", "La contraseña es requerida"));
        } else if (request.getPassword().length() < 8) {
            errors.add(new ValidationError("password", "La contraseña debe tener al menos 8 caracteres"));
        }
        
        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",
                "El email ya está registrado"))
            : 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 con 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 no manejado: " + error);
    }
    
    private ResponseEntity<ErrorResponse> handleValidationError(Error error) {
        if (error instanceof ValidationError) {
            return ResponseEntity.badRequest()
                .body(ErrorResponse.from(error));
        }
        throw new IllegalStateException("Error no manejado: " + error);
    }
    
    private ResponseEntity<ErrorResponse> handleNotFoundError(Error error) {
        if (error instanceof UserNotFoundError) {
            return ResponseEntity.notFound().build();
        }
        throw new IllegalStateException("Error no manejado: " + 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 no manejado: " + error);
    }
}

Clases de Soporte

ErrorResponse para 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();
    }
}

Clases de Dominio

@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;
}

Ventajas del Patrón

  1. Claridad: El flujo de errores es explícito en el código
  2. Composición: Facilita la composición de operaciones
  3. Inmutabilidad: Los objetos Result son inmutables
  4. Funcional: Se integra bien con programación funcional
  5. Testabilidad: Facilita la escritura de pruebas unitarias

Conclusión

El patrón Result proporciona una forma más elegante y robusta de manejar errores en aplicaciones Spring Boot, eliminando la necesidad de excepciones verificadas y haciendo el código más legible y mantenible.