wandres.dev
ERRORES · sin excepciones comprobadas

Errores en las fronteras: traducir sin filtrar

Las tres herramientas del nivel solo funcionan si la conversión entre excepción y valor ocurre en el sitio correcto. La lección sitúa la traducción de entrada pegada al adaptador que lanza y la de salida en el borde del proceso, establece qué no debe cruzar jamás una frontera hacia fuera por razones de acoplamiento y de seguridad, fija la disciplina de conservar la causa y registrar una sola vez, y trata la excepción de cancelación como lo que es: una señal del planificador que nunca se captura para continuar.

⏱ 19 min

Todo lo anterior de este nivel es inútil si la traducción entre los dos mundos ocurre en el sitio equivocado, y en la mayoría de los sistemas ocurre en el sitio equivocado por la misma razón: nadie decidió dónde debía ocurrir, así que ocurrió donde alguien tuvo prisa. El síntoma es reconocible y probablemente lo has visto esta semana. Excepciones de la biblioteca de acceso a datos que aparecen tres capas más arriba, en un sitio que no puede saber qué significan. Mensajes de error que llegan al cliente con el nombre de una tabla dentro. La misma incidencia registrada cinco veces con cinco trazas distintas del mismo suceso. Y tareas que siguen corriendo alegremente después de que su ámbito se haya cancelado, porque alguien capturó todo lo capturable y siguió adelante. Ninguno de esos cuatro problemas se arregla eligiendo mejor entre excepciones y valores: se arreglan decidiendo dónde está la frontera y qué se le permite cruzar.

🎯 Al terminar esta lección sabrás
  • Situar la traducción de entrada pegada al adaptador que produce el lanzamiento, y no en la capa que lo recibe por accidente.
  • Determinar qué información no debe cruzar una frontera hacia fuera, por acoplamiento y por seguridad, sin perderla para el diagnóstico.
  • Conservar la cadena de causas y aplicar la regla de registrar una sola vez, en el punto donde el error deja de propagarse.
  • Tratar la excepción de cancelación como señal del planificador y aplicar los idiomas correctos para no interrumpir su propagación.

Dónde vive la traducción

Una frontera es cualquier punto donde el código deja de ser tuyo: la llamada al controlador de base de datos, al cliente de red, al analizador sintáctico, al sistema de ficheros. La regla es que la conversión de lanzamiento a valor ocurra en la función que envuelve esa llamada, y no una capa más arriba. El motivo es informativo: solo ahí se sabe todavía qué significa el fallo concreto, porque solo ahí se conoce a la vez la tecnología que lo produjo y la operación de negocio que se estaba intentando.

// Adaptador: unico sitio que conoce la tecnologia
class RepositorioSql(private val ds: DataSource) : RepositorioPedidos {

    override fun buscar(id: PedidoId): Salida<Pedido, ErrorRepositorio> =
        try {
            Salida.Exito(consultar(id))
        } catch (e: SQLTimeoutException) {
            Salida.Fallo(ErrorRepositorio.NoDisponible)
        } catch (e: SQLException) {
            Salida.Fallo(ErrorRepositorio.Averia(e))   // la causa viaja, no el tipo
        }
}

La dirección contraria tiene su propia regla y su propio sitio. Los valores de error del dominio se convierten en algo comprensible para el mundo exterior en el borde del proceso, es decir, en el controlador que responde una petición, en el consumidor de la cola o en el punto de entrada de la aplicación. Ahí, y solo ahí, un caso del dominio se convierte en un código de estado, un mensaje para el usuario o un reintento.

flowchart LR
A[Biblioteca ajena que lanza] --> B[Adaptador: traduce a valor del dominio]
B --> C[Servicio: solo valores, sin capturas]
C --> D[Borde del proceso: traduce a respuesta o a excepcion]
D --> E[Cliente: mensaje estable sin detalles internos]
B -. registro con causa completa .-> F[Diagnostico]
D -. registro unico del desenlace .-> F

Hay una asimetría entre las dos direcciones que conviene explicitar. Hacia dentro, la traducción reduce: muchos tipos de lanzamiento de la biblioteca se colapsan en unos pocos casos que el dominio sabe distinguir, y esa reducción es una decisión de diseño sobre qué diferencias importan de verdad. Hacia fuera, la traducción vuelve a reducir, esta vez desde los casos del dominio hasta el vocabulario del protocolo. Un sistema sano pierde información en los dos bordes y no pierde ninguna en medio.

Entre esos dos extremos, el núcleo no debería contener ninguna captura. Si aparece una, casi siempre indica que la traducción de entrada se hizo demasiado tarde y que el detalle tecnológico se ha filtrado hacia dentro. Esa comprobación es un buen criterio de revisión, porque es fácil de aplicar sin conocer el dominio.

Lo que no debe cruzar hacia fuera

Un tipo de excepción de una biblioteca es un detalle de implementación tan real como el nombre de una clase interna, y dejarlo salir por la firma o por la propagación acopla a todos los consumidores a esa decisión. Cambiar de controlador de base de datos deja de ser un cambio local en cuanto alguien, en cualquier capa, captura el tipo específico que lanzaba el anterior.

El segundo motivo es de seguridad y suele pesar más. Los mensajes de error filtran con enorme facilidad estructura interna: nombres de tablas y columnas, rutas del sistema de ficheros, nombres de máquinas y puertos, fragmentos de consulta, versiones de dependencias. Y filtran también existencia: un mensaje que distingue entre usuario inexistente y contraseña incorrecta permite enumerar cuentas, de modo que la respuesta hacia fuera debe ser la misma aunque el registro interno distinga perfectamente.

// Fuera: estable, generico, con un identificador para correlacionar
data class RespuestaError(val codigo: String, val mensaje: String, val trazaId: String)

// Dentro: todo el detalle, una sola vez, con la causa completa
log.error("fallo al procesar pedido {} en {}", pedidoId, trazaId, e)

La pareja del ejemplo resume la disciplina entera. Hacia fuera viaja un código estable que el cliente puede tratar programáticamente, un mensaje que no revela nada y un identificador de correlación. Hacia dentro queda todo lo demás, asociado a ese mismo identificador, de modo que quien atiende la incidencia puede reconstruir el caso completo a partir de un dato que el usuario sí puede leerle por teléfono sin que eso suponga ninguna filtración.

🔒

Nunca hacia fuera

Trazas de pila, tipos de excepción de terceros, sentencias, rutas, nombres de máquina y cualquier mensaje que distinga qué dato existía y cuál no.

🧵

Siempre hacia dentro

La causa original encadenada. Envolver sin adjuntar la causa destruye el único hilo que conecta el síntoma con el origen.

📝

Registrar una vez

En el punto donde el error deja de propagarse. Registrar y relanzar en cada capa multiplica el mismo suceso y hace irreconocible el volumen real de incidencias.

⚠️
Envolver sin causa es el defecto más caro de este nivel

Cuando se construye una excepción de dominio a partir de otra y no se le pasa la original como causa, se pierde para siempre la traza que señalaba la línea culpable. El síntoma sobrevive, el origen no. La misma advertencia vale para el caso de error que guarda solo un texto: si el valor de fallo no lleva referencia a lo que ocurrió realmente, la traducción a valor ha sido también una destrucción de evidencia.

La cancelación jamás se traga

En corrutinas, la cancelación se implementa lanzando una excepción específica dentro de la corrutina cancelada. Esa excepción no indica ningún error: es la forma que tiene el planificador de pedir a la tarea que termine de forma ordenada. La consecuencia práctica es contundente: cualquier captura ancha dentro de una función suspendida la atrapa, y al atraparla convierte una cancelación cooperativa en una tarea que sigue viva dentro de un ámbito que ya se dio por muerto.

// Roto: la cancelacion queda atrapada y la tarea continua
suspend fun cargar(): Datos? =
    try { pedir() } catch (e: Exception) { null }

// Correcto: la cancelacion vuelve a su camino antes de tratar nada
suspend fun cargarSeguro(): Datos? =
    try {
        pedir()
    } catch (e: CancellationException) {
        throw e
    } catch (e: IOException) {
        null
    }

La diferencia entre los dos fragmentos es de una sola rama y su efecto es enorme. En el primero, cancelar el ámbito no cancela nada: la tarea absorbe la petición, devuelve un valor de respaldo perfectamente plausible y sigue consumiendo recursos dentro de un ámbito que su padre ya considera terminado. En el segundo, la señal continúa su camino y la jerarquía de corrutinas termina como estaba previsto.

Hay tres detalles que conviene tener presentes. El primero es que la excepción de agotamiento de tiempo es un subtipo de la de cancelación, de modo que capturar la segunda de forma amplia también silencia los vencimientos; para tratarlos existe la variante de la operación de tiempo límite que devuelve nulo. El segundo es que la limpieza que debe ejecutarse durante una cancelación necesita un contexto no cancelable, porque en una corrutina ya cancelada cualquier suspensión vuelve a fallar de inmediato. El tercero es que, si no quieres escribir la rama de relanzado, existe la comprobación explícita de actividad del contexto, que vuelve a lanzar si el ámbito está cancelado.

try {
    pedir()
} catch (e: Exception) {
    currentCoroutineContext().ensureActive()   // relanza si hubo cancelacion
    registrar(e)
    null
}

withContext(NonCancellable) {
    cerrarConexion()   // limpieza garantizada aunque el ambito este cancelado
}

Existe además un caso que rompe la intuición de quien ya conoce la regla: dentro de un ámbito concurrente, el fallo de una tarea hija cancela a sus hermanas, de modo que una hermana perfectamente sana recibirá la excepción de cancelación aunque su propio trabajo no tuviera ningún problema. Si esa hermana captura de forma ancha y devuelve un valor de respaldo, el ámbito queda en un estado incoherente donde unas tareas se dieron por canceladas y otras se dieron por terminadas con éxito.

coroutineScope {
    val a = async { primera() }
    val b = async { segunda() }   // si primera falla, esta recibe la cancelacion
    a.await() + b.await()
}

La misma advertencia se aplica a la función de captura universal de la biblioteca estándar y a cualquier bloque que capture el supertipo de lo lanzable. En código con corrutinas, capturar de forma más ancha que un tipo concreto es una decisión que hay que justificar cada vez.

La frontera no es una capa del diagrama: es el punto donde cambia quién puede interpretar el fallo

Merece la pena terminar el nivel con la idea que sostiene todo lo demás, porque explica por qué las reglas anteriores no son convenciones intercambiables. Un error, considerado como información, tiene un ciclo de vida muy corto de utilidad plena: en el instante en que se produce contiene todo el contexto —la operación intentada, los parámetros, la tecnología, la línea exacta— y a partir de ahí solo pierde. Cada capa que atraviesa sin ser interpretada elimina contexto que ya no se puede reconstruir, hasta que llega a un sitio donde alguien tiene que decidir qué hacer con un objeto que ya no dice casi nada sobre lo que pasó. Diseñar el manejo de errores es, mirado así, decidir en qué punto exacto del recorrido se gasta esa información antes de que se evapore, y ese punto no lo marca el diagrama de arquitectura sino una pregunta empírica: quién es el último que todavía entiende el fallo. El adaptador es ese último para los detalles de la tecnología, y por eso ahí se traduce. El borde del proceso es ese último para el desenlace del negocio, y por eso ahí se responde y se registra. Entre ambos no hay nadie capaz de interpretar nada, y de ahí sale la regla de que el núcleo no capture. La cancelación es el caso límite que confirma la idea desde el otro lado, porque es lo único que atraviesa todo el sistema sin ser jamás un error: no hay ninguna capa que sea la última en entenderla, ya que solo la entiende el planificador que la envió, y por eso la única disciplina correcta es no tocarla nunca. Un sistema en el que se puede diagnosticar una incidencia a las tres de la madrugada no es el que eligió mejor entre excepciones y valores, sino aquel en el que cada fallo fue interpretado exactamente una vez, por el único que podía hacerlo, y ni una capa más tarde.

⚔️ Coloca tus fronteras
  1. Dibuja el recorrido de un fallo real de tu sistema desde su origen hasta el cliente y marca en qué capa se interpreta por primera vez. Justifica si es la correcta.
  2. Busca un tipo de excepción de una biblioteca de terceros capturado fuera de su adaptador y calcula cuántos ficheros habría que tocar al cambiar esa biblioteca.
  3. Revisa un mensaje de error que llegue al cliente y elimina de él todo lo que revele estructura interna, sustituyéndolo por un identificador de correlación.
  4. Encuentra un registro con relanzado y cuenta cuántas veces aparece el mismo suceso en el sistema de trazas. Deja un solo punto de registro.
  5. Localiza una captura ancha dentro de una función suspendida, cancela su ámbito durante la ejecución y observa el comportamiento. Corrígela y repite la prueba.