leanmind logo leanmind text logo

Blog

BDD

Behaviour-driven Development es una técnica para tomar mejores requisitos de producto.

Evita el try catch hell simplificando el either

Por Eric Driussi

Recientemente, he estado colaborando con un equipo en una base de código Java bastante grande y compleja, con más de un par de años en producción.

La base de código ha pasado por varios cambios de negocio y tiene bastantes casos límite a considerar.

La mayor parte del código fue hecho por personas que ya no están en la empresa. Como consecuencia, la programación defensiva es la norma.

Como podrías esperar, se lanzan Excepciones en todas partes, y el código está plagado de bloques try/catch donde los bloques catch actúan efectivamente como sentencias if/else. Esto, a su vez, hace que el mantenimiento y el desarrollo de funcionalidades sea un proceso lento y doloroso.

El problema con las Excepciones

Reconozco que manejar casos de error con Excepciones (o constructos similares en otros lenguajes), no lleva necesariamente a una situación desastrosa de try/catch, pero siendo honesto, siento que es tan fácil cometer errores que casi espero que suceda.

El peligro aquí es, en resumen, que puedes terminar fácilmente con dos flujos de ejecución semi-paralelos en tu mente, mientras lees el código. Me explico.

Al analizar el bloque try, toda esa lógica depende no solo de lo que estás leyendo, sino también de cada posible punto de fallo a lo largo del camino.

Cada línea que lees puede detener el flujo de ejecución principal, y enviarte quién sabe cuántas líneas más abajo (o archivos arriba en la pila).

Es como una declaración GOTO menos elegante (y menos peligrosa), o un if de múltiples ramas que no se puede refactorizar en Clausulas Guarda, y cuyo correspondiente else podría estar en un archivo diferente.

Te obliga a razonar de manera diferente sobre lo que una función retorna y lo que puede lanzar, lo cual es extraño, ya que conceptualmente ambos son formas de comunicar el resultado de una operación en la pila.

Una “solución” común a este problema (especialmente en equipos que usan Java), es algo como “deja que @ExceptionHandler de Spring lo maneje”, lo cual, por supuesto, es (en mi opinión) significativamente peor.

No ver el problema no significa que no esté allí, solo significa que no lo ves.

Saliendo del lío

Se plantearon muchas preguntas sobre cómo mejorar el diseño, cómo minimizar estos bloques try/catch, o cómo hacer que los casos límite sean más claros.

Así como una base de código llena de verificaciones de nulos suele pedir a gritos el uso de Optional, una con muchos try/catch complejos podría mejorarse generalmente agregando el tipo Either a la mezcla.

Sin embargo, estos dos no son (en mi experiencia), igual de fáciles de incorporar al conjunto de herramientas de un desarrollador, si no se han iniciado en paradigmas funcionales antes.
Esto es especialmente cierto en Java, ya que los Optional están integrados, pero los Either no lo están, y en Java se suele ser mucho más cauteloso con las dependencias que, por ejemplo, en JavaScript.

Además, los Optional son conceptualmente fáciles de entender: o hay algo o no lo hay. Está lo suficientemente cerca de un null (con el que todos estamos dolorosamente familiarizados), es un paso fácil de dar.

Los Either, por otro lado, te obligan a pensar de manera bastante diferente sobre la gestión de errores, aún más si no estás acostumbrado a manejar errores devolviéndolos. También, suelen venir con un montón de funciones que suenan inteligentes, y conceptos que necesitas entender.
No está claro de inmediato qué deberías hacer con ellos, y al principio pueden parecer esotéricos.

Un compromiso cómodo

Veamos si podemos construir una solución personalizada, a medio camino entre la POO y lo funcional.

A un nivel muy básico, necesitaremos algo que represente el resultado de una operación.
Este código debería contener (envolver) tanto el éxito como el fallo de la operación.

Dado que esto no evolucionará necesariamente en un Either completamente desarrollado, podríamos llamarlo Result.
Este nombre está más cerca de su caso de uso previsto, y debería (con suerte), disuadir a cualquier purista funcional de informarnos educadamente, que no es técnicamente un tipo Either correcto.

Podría verse algo así:

public class Result<S, E> {
    private final S success;
    private final E error;
    
    private Result(S success, E error) {
        this.success = success;
        this.error = error;
    }
    
    public static <S, E> Result<S, E> success(S success) {
        return new Result<>(success, null);
    }
    
    public static <S, E> Result<S, E> error(E error) {
        return new Result<>(null, error);
    }
}

Simple y sencillo.

En algún momento, necesitaremos comprobar si el resultado es un error. También necesitaremos desempacar el resultado para tal vez mostrárselo al usuario, o incluirlo en una respuesta de API, por ejemplo.

public boolean isSuccess() {
    return success != null;
}
    
public boolean isError() {
    return error != null;
}
    
public S getSuccess() {
    return success;
}
    
public E getError() {
    return error;
}

Dependiendo de cómo estén diseñadas las capas de nuestro software, podríamos necesitar mapear un error/éxito en otro. Nuestros errores podrían ser específicos de la capa, y nuestro concepto de éxito, podría significar algo diferente en la capa de persistencia, en comparación con la capa de dominio.

public class Result<S, E> {
    private final S success;
    private final E error;
    
    private Result(S success, E error) {
        this.success = success;
        this.error = error;
    }
    
    public static <S, E> Result<S, E> success(S success) {
        return new Result<>(success, null);
    }
    
    public static <S, E> Result<S, E> error(E error) {
        return new Result<>(null, error);
    }
    
    public boolean isSuccess() {
        return success != null;
    }
    
    public boolean isError() {
        return error != null;
    }
    
    public S getSuccess() {
        return success;
    }
    
    public E getError() {
        return error;
    }
    
    public <T> Result<T, E> mapSuccess(Function<S, T> fn) {
        return Result.success(fn.apply(success));
    }
    
    public <T> Result<S, T> mapError(Function<E, T> fn) {
        return Result.error(fn.apply(error));
    }
}

Aquí tenemos que usar funciones como parámetros, pero aparte de eso, no hay nada sofisticado en este código. El código aburrido es bueno, a Grug le gusta el código aburrido.

No tienes que obligarte (o a otros), a seguir estrictamente el camino funcional para obtener los beneficios de usar las partes que realmente necesitas.
Simplemente añade más capacidades a tu tipo Result según las necesites y, si sientes que se está volviendo inmanejable, deséchalo por completo en favor de una solución de terceros.

Añade a esto algún tipo de interfaz Error para asegurar la seguridad de tipos y el mapeo adecuado, y estarás bien encaminado para minimizar las Excepciones lanzadas. O no… también puedes empezar simplemente con cadenas de texto en ambos lados, y agregar tipos más complejos según sea necesario.

Se levantarán errores

Por supuesto, esto no evitará que las bibliotecas de terceros, o incluso, la biblioteca estándar de tu lenguaje lance Excepciones.

Podemos decidir no lanzarlas dentro del código que escribimos, lo cual ya es más de la mitad de la batalla, pero un lenguaje que proporciona estos constructos siempre te obligará a defenderte de ellos.

Si un software está razonablemente diseñado, esto es relativamente fácil de manejar:
Envuelve tu código de I/O en un try/catch, construye un Result en consecuencia y maneja eso desde ese punto en adelante.

Un ejemplo simple, sería un fragmento de código de la capa de persistencia, que obtiene datos de una base de datos.
Obviamente, la conexión podría fallar, los datos podrían no encontrarse, podrías esperar un resultado y obtener más de uno, etc.

Toma el código que se comunica con la base de datos y envuélvelo en un try/catch.
Construye tu Result a partir de las Excepciones que captures (o podrías capturar), devuélvelo a quien llame y olvídate de las Excepciones a partir de ese punto.

Este enfoque también permite mayor flexibilidad, cuando el fallo y el éxito no son tan blanco y negro.
Por ejemplo, supongamos que tu código de persistencia no encontró los datos. Eso es solo un no encontrado para la capa de persistencia, pero en la capa de aplicación la historia podría ser diferente.
Dependiendo del caso de uso, eso podría implicar que se proporcionó información no válida para la búsqueda, o que los datos fueron movidos, o que deberían crearse sin la intervención del usuario.

Mapea el Result según sea necesario para transmitir esta información entre las capas de tu software.
Podrías agregar una función map más sofisticada para mapear libremente errores a éxitos, o viceversa, si es necesario.

Cuando llegues al fragmento de código encargado de manejar estos errores y/o presentarlos al usuario, simplemente desempaca el Result y muestra la información relevante, (o construye la respuesta de la API, o inicia tu política de reintentos, tú decides).

Conclusión

Las Excepciones son difíciles de manejar correctamente, y a veces es más fácil simplemente evitarlas tanto como sea posible.
No tienes que reescribir toda la base de código o enseñar a tu equipo “el camino de la función” para lograr esto.

Simplifica, añade solo el código que necesites cuando lo necesites.

Haz que el código funcione para tu equipo, no al revés.

Publicado el 09/10/2024 por

¿Quieres más? te invitamos a suscribirte a nuestro boletín para avisarte cada vez que recopilemos contenido de calidad que compartir.

Si disfrutas leyendo nuestro blog, ¿imaginas lo divertido que sería trabajar con nosotros? ¿te gustaría?

Impulsamos el crecimiento profesional de tu equipo de developers