¿Desde dónde comunicar o tratar situaciones de error?

En una aplicación existen situaciones de error que se pueden considerar esperadas y sin embargo hay otras que son anómalas.

Como desarrolladores deberíamos tener en cuenta el máximo "razonable" de cada de estas situaciones y tratarlas cómo espera el usuario.

Vamos a ver algunos ejemplos sencillos utilizando Kotlin con programación a objetos más tradicional y cómo se pueden resolver ya que muchas veces incluso en ejemplos sencillos la forma de resolverlo puede generarnos algún problema adicional que no teníamos.

Los ejemplos que vamos a ver son asumiendo una aplicación usando Clean Architecture, Hexagonal o similar.

Lista de resultados no encontrados

Es bastante común al desarrollar cualquier tipo de aplicación que en algún momento tengamos que mostrar un listado donde podemos hacer búsquedas.

Ahora imaginemos que el usuario realiza una búsqueda sobre el listado y en nuestro origen de datos no existen datos que se ajusten a la combinación de filtros realizada por el usuario.

Esto es una situación de fallo o error pero esperada por el usuario y que encaja dentro de un flujo de trabajo normal.

Lo que espera un usuario es que la lista resultante este vacía, pero no que nuestro software falle por este motivo.

Como mucho podría esperar un mensaje, pero es tan evidente el resultado de la operación al no devolver resultados que el mensaje suele estar de más.

Si estuvieramos desarrollando una API el código http de respuesta debería ser 200 y el body una lista vacía.

Al ser una sitación esperada en nuestro dominio o negocio, no deberiamos tipificar como error este escenario en el dominio de ninguna forma.

Tratando el problema

A la hora de tratar el problema tenemos dos opciones:

  • Devolver null desde el repositorio
class MovieRepository (val context: Application): MovieRepository {

    val baseAddress = context.getString(R.string.base_address)

    var allMovies:MutableList<Movie> = mutableListOf<Movie>()

    ...

    override fun getAllByText (text: String): List<Movie>?{

        if (allMovies.count{ it.title.contains(text) } > 0){
            return allMovies.filter{ it.title.contains(text) }
        } else {
            return null;
        }
    }

    ...
}

Entonces en algún punto de la cadena deberiamos convertir este null a una lista vacia que es lo que espera el usuario.

Esta opción es la que menos me gusta, hay mucha literatura sobre el problema de devolver null.

Devolver null no es una buena idea porque me obliga en las capas anteriores a protegerme contra null cada vez que necesito interactuar con el resultado.

Además como es un error que solo se detecta en ejecución, si me olvido de protegerme en algún caso no lo detectaré en el momento de compilar.

  • Devolver una lista vacía desde el repositorio
class MovieRepository (val context: Application): MovieRepository {

    val baseAddress = context.getString(R.string.base_address)

    var allMovies:MutableList<Movie> = mutableListOf<Movie>()

    ...

    override fun getAllByText (text: String): List<Movie>{
        return allMovies.filter{ it.title.contains(text) }
    }

    ...
}

Esta es la opción que suelo utilizar, es la más simple y no me genera ningún problema adicional.

Evitamos el problema de tener que tratar con nulos y es la opción más natural.

En este caso es la opción más simple porque la función filter en caso de no existir ningún dato ya devuelve una lista vacía. Pero si este no fuera el caso, yo suelo generar una lista vacía a mano para no devolver null en ningún caso.

Resultado simple no encontrado

Otro caso bastante habitual es tener que gestionar cuando un recurso simple no existe.

Por ejemplo al desarrollar una API si nos solicitan un recurso con un identificador y este no existe.

Esto es una situación de error anómala que no entra dentro de un flujo normal o sin fallo.

Al desarrollar una API si se produce este escenario debemos de comunicar un error al usuario devolviendo un código de error http 404 suele ser lo màs adecuado.

En una aplicación estándar es más complicado que se de este problema más allá de un problema en el código porque lo normal es que el usuario nos solicite un recurso eligiendolo previamente en un listado, en cuyo caso la aplicación no debería crasear sino mostrar un mensaje al usuario.

¿Pero donde deberíamos de generar el error? ¿Y cómo deberíamos tratarlo?

Tratando el problema

A la hora de tratar el problema tenemos varias opciones también en este caso:

  • Devolver null desde el repositorio y lanzar excepción desde dominio

En este escenario devolveríamos null desde el repositorio en caso de no existir el recurso.

class MovieRepository (val context: Application): MovieRepository {

    val baseAddress = context.getString(R.string.base_address)

    var allMovies:MutableList<Movie> = mutableListOf<Movie>()

    ...

    override fun getById(id: Long): Movie? {
        val movie = getAll().firstOrNull { it.id == id }
        return movie
    }

    ...
}

FirstOrNull nos devuelve null sino existe el dato según el filtro.

Y posteriormente verificamos en el caso de uso o servicio de aplicación si es null y en ese caso invocamos la función subscrita correspondiente para comunicar el error a la capa de presentación en el hilo de interfaz.

class GetMovieByIdUseCase (private val movieRepository: MovieRepository,
executor: Executor): UseCase(executor) {

    ...

    fun run() {
        try {
            val movie = movieRepository.getById(id)

            if (movie == null){
                uiExecute {onMovieNotFoundError()}
            }else {
                uiExecute { onMovieLoaded(movie!!) }
            }
        } catch (ex: Exception) {
            uiExecute {onConnectionError()}
        }
    }
}

Esta opción puede parecer que tiene sentido porque estamos gestionando cuando se produce un escenario de error desde el dominio a través del caso de uso o servicio de aplicación.

Pero la pega es que esto nos obliga a devolver null desde el repositorio y yo prefiero no devolver null en ningún caso.

  • Generar la excepción de dominio desde el repositorio y tratarla en el caso de uso

En este escenario lanzamos una excepción desde el repositorio.

class MovieRepository (val context: Application): MovieRepository {

    val baseAddress = context.getString(R.string.base_address)

    var allMovies:MutableList<Movie> = mutableListOf<Movie>()

    ...

   override fun getById (id: Long) = getAll().firstOrNull{it.id == id} ?: throw MovieNotFoundException()
    }

    ...
}

Y en el caso de uso recogemos la excepción y en ese caso invocamos la función subscrita correspondiente para comunicar el error a la capa de presentación en el hilo de interfaz.

class GetMovieByIdUseCase (private val movieRepository: MovieRepository,
executor: Executor): UseCase(executor) {

    ...

    fun run() {
        try {
            val movie = movieRepository.getById(id)

            uiExecute {onMovieLoaded(movie)}
        } catch (ex: MovieNotFoundException) {
            uiExecute {onMovieNotFoundError()}
        } catch (ex: Exception) {
            uiExecute {onConnectionError()}
        }
    }
}

Resolviendo el problema de esta forma conseguimos evitar tratar con null en cualquier punto de la aplicación donde se utilice el repositorio.

Este enfoque mantiene las reponsabilidades que en teoria debe tener un repositorio, como adaptador, que es convertir de lenguaje de infraestructura en lenguaje de dominio, ya que la excepción estaría definida en dominio.

El uso de excepciones puede ser delicado porque históricamente han sido mal usadas y se ha abusado de ellas para modificar el flujo de ejecución, creando excepciones para modelar casos que no son excepcionales.

En algunos contextos, como importaciones masivas donde puede ser frecuente que se produzcan casos excepcionales, el uso de excepciones puede ser un problema de rendimiento por que son más costosas.

Tambien el uso de excepciones es un problema si trabajas con concurrencia ya que las excepciones no atraviesan hilos, solo viven en el hilo donde son creadas y hay que utilizar callbacks o algún patrón de diseño tipo Observer para comunicar la excepción entre hilos.

Otro problema adicional con el uso de excepciones es que no expone claramente al cliente que situaciones excepcionales se pueden producir.

Conclusiones

En este artículo hemos visto como podemos realizar una gestión de situaciones de fallo normales o no en una aplicación utilizando Clean Architecture, Hexagonal o similar mediante conceptos de programación orientada a objetos como listas vacías o excepciones, evitando devolver null desde el repositorio.

En una aplicacion de usuario no muy compleja puede ser opciones válidas pero las excepciones tienen alguna limitaciones que hemos visto.

Es posible gestionar los casos excepcionales con un enfoque de programación funcional sin el uso de excepciones, podríamos utilizar un valor de retorno tipo option o either desde el repositorio que contiene el objeto devuelto en caso de éxito o el de fallo.

Cómo podríamos implementarlo de esta forma lo dejo para una segunda parte que este ya se ha quedado un poco largo :)