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.
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.
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.
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.
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).
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.
¿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?
Pero espera 🖐 que tenemos un conflicto interno. A nosotros las newsletter nos parecen 💩👎👹 Por eso hemos creado la LEAN LISTA, la primera lista zen, disfrutona y que suena a rock y reggaeton del sector de la programación. Todos hemos recibido newsletters por encima de nuestras posibilidades 😅 por eso este es el compromiso de la Lean Lista