Zademy

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

spring-boot; result-pattern
words palabras

Llevo años viendo código Spring Boot donde las excepciones hacen de todo: controlan flujo, señalan errores de validación, comunican fallos de negocio. Funciona, sí, pero la firma del método te miente. Dice User findUserById(Long id) como si siempre fuera a devolver un User, y luego en cualquier momento te suelta una excepción que no aparece por ninguna parte.

El patrón Result, que viene de lenguajes como Rust, cambia eso. En vez de lanzar, devuelves un tipo que dice explícitamente "esto puede salir bien o mal, y aquí está el resultado o el error". No es magia, es honestidad en la firma.

¿Por qué usar el patrón Result?

El problema con las excepciones

Mira este código, que seguro has escrito alguna variante:

// ❌ 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;
}

Tres cosas me molestan de este patrón. La excepción verificada contamina la firma del método: cualquier cosa que llame a findUserById tiene que declarar throws UserNotFoundException o atraparla, y así sucesivamente hasta el controlador. El flujo de errores queda oculto: no sabes qué puede fallar sin leer la implementación entera. Y encadenar operaciones se vuelve un infierno de try-catch anidados.

Lo que cambia con Result

La misma operación, con 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)));
}

La firma ya no miente. Result<User> te dice claramente: esto puede devolver un User o puede devolver un error, y el compilador te obliga a lidiar con ambos casos. Además, ahora puedes componer operaciones con map y flatMap en vez de anidar try-catch. Si vienes de programación funcional o has usado Optional, la curva de aprendizaje es mínima.

Implementación base

La interfaz Result

La interfaz central es más sencilla de lo que parece. Necesitas saber si fue éxito o fracaso, obtener el valor o el error, y poder transformar el resultado funcionalmente:

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

Los métodos map y flatMap son el alma del patrón. map transforma el valor de éxito sin tocar el error. flatMap es para cuando la propia transformación puede fallar y devolver otro Result. El método ofCallable es un comodín útil para envolver código que lanza excepciones y que no controlas.

Implementaciones concretas

Dos clases sellan el trato: Success y Failure. Son inmutables y no dejan que construyas un Success sin valor ni un Failure sin error.

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

El detalle clave es que Failure.map y Failure.flatMap simplemente propagan el error sin ejecutar nada. Eso es lo que te permite encadenar transformaciones sin preocuparte por los errores en cada paso: se propagan solos hasta que decides manejarlos con recover o peekError.

El sistema de errores

La clase base Error

En vez de lanzar excepciones genéricas, defino una jerarquía de errores de dominio. Cada error lleva un código identificatorio, un mensaje, la causa original y un timestamp:

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

El campo code vale oro cuando construyes APIs. Le das al cliente un código estable (USER_NOT_FOUND, VALIDATION_ERROR) que no cambia aunque ajustes el mensaje. El cliente puede programar contra el código, no contra el texto.

Errores de dominio

A partir de esa base, defino los errores específicos que mi dominio necesita:

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

La distinción entre ValidationError, BusinessRuleError y SystemError importa porque el controlador va a mapear cada tipo a un código HTTP distinto. Una validación es un 400, un recurso no encontrado es un 404, un error de sistema es un 500.

Integración con Spring Boot

Service layer con Result

Aquí es donde el patrón brilla de verdad. El servicio de creación de usuario queda como una cadena de transformaciones que lee casi como una receta:

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

El método createUser son cuatro líneas. Cada paso valida o transforma algo, y si cualquiera falla, el error se propaga sin que tengas que hacer nada. No hay un solo try-catch en todo el servicio. Si necesitas agregar un paso nuevo, lo insertas en la cadena con otro flatMap. El código se lee de arriba abajo y queda claro qué hace cada operación.

Controller con Result

El controlador es donde conviertes los errores de dominio en respuestas HTTP. El método recover te permite manejar cada tipo de error por separado:

@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 la API

El DTO que devuelve la API cuando algo falla. Incluye el código de error para que el cliente pueda programar contra él:

@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

Los DTOs de entrada y salida, sin lógica:

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

Qué ganas con esto

Después de usar este patrón en varios proyectos, lo que más valoro es la transparencia. Cuando abres un método que devuelve Result<User>, sabes inmediatamente que puede fallar y tienes que lidiar con eso. No hay sorpresas, no hay excepciones que aparecen de la nada desde tres capas más abajo.

La composición es el otro gran beneficio. Encadenar validaciones, transformaciones y operaciones de persistencia con flatMap produce código que se lee como una secuencia lineal, sin el ruido visual del control de errores imperativo. Y porque los objetos Result son inmutables, no hay efectos secundarios ocultos ni condiciones de carrera que temer.

Los tests también mejoran. Para probar el camino de error, construyes un Result.failure y verificas que el comportamiento downstream es el correcto. No necesitas mockear excepciones ni pelear con frameworks de mocking para simular fallos.

El precio es algo de boilerplate al principio: la interfaz, las implementaciones, la jerarquía de errores. Pero es código que escribes una vez y reutilizas en toda la aplicación. A partir de ahí, cada nuevo servicio, cada nuevo caso de uso, sale más limpio.