¿Dónde deben ir el catch y el log en el manejo de excepciones?

· Actualizado el: · · Manejo de excepciones, Logs, Manejo de errores, Diseño, C# / .NET

En las revisiones de código sobre manejo de excepciones, las observaciones que se repiten una y otra vez se reducen, básicamente, a estas tres:

  • La función común más profunda hace catch (Exception), y quien la llama no puede distinguir si «no había datos» o si «algo se rompió en el camino».
  • Para un solo incidente, el Repository, el Service, el Controller y el manejador de excepciones no controladas registran cuatro veces el mismo stack trace.
  • Se emite un log Error solo porque el usuario canceló la operación, y entre esos registros queda enterrado el fallo realmente peligroso.

En ninguno de los tres casos el problema es que el try / catch esté mal escrito. Lo que falta es repartir los roles: dónde se recibe la excepción, quién emite el log y dónde se decide la forma del fallo. Cuando esos roles no están definidos, cada desarrollador de cada capa añade su propio catch y su propio log «por si acaso», y el resultado es un código donde ya no se ve la causa.

Este artículo está dirigido a quienes escriben aplicaciones empresariales o Web API en C# / .NET, y a quienes revisan su diseño. Organiza cómo repartir el límite donde se captura la excepción, el lugar donde se emite el log principal y la responsabilidad de decidir la recuperación. Si se decide de antemano qué corresponde a cada nivel de la jerarquía de llamadas, las decisiones se vuelven mucho más consistentes tanto en la revisión como en la investigación de incidentes.

Terminología usada en este artículo

Antes de continuar, se definen tres términos que aparecen repetidamente en el texto. No son términos generales, sino el sentido que tienen específicamente en este artículo.

Término Significado en este artículo
Unidad de fallo Es la unidad de procesamiento, con sentido a nivel de negocio, que representa «qué fue lo que falló una vez». Corresponde, por ejemplo, a una operación de pantalla, una solicitud HTTP, un job, un mensaje o una fila de un CSV. Esta unidad debe aparecer tanto en el log como en la respuesta
Log principal Es el registro de Error o Critical que se emite una sola vez por cada fallo. Va acompañado de la unidad de fallo y del contexto operativo (requestId, userId, el ID del objeto afectado, etc.). El resto de registros se tratan como logs auxiliares, con Debug / Information / Warning
Conversión a resultado Consiste en dejar de propagar la excepción como tal y transformarla en un valor de retorno, como un tipo Result o un DTO que representa el fallo. Es la operación que deja un fallo previsto en una forma que quien llama puede tratar mediante una bifurcación

Índice

  1. Primero, la conclusión
  2. catch, log y manejo de errores son cosas distintas
    • 2.1. Hacer catch
    • 2.2. Emitir el log
    • 2.3. Hacer el manejo de errores
    • 2.4. Traducir la excepción
  3. La tabla de decisión que conviene mirar primero
  4. Qué hacer en cada nivel de la jerarquía de llamadas
    • 4.1. El helper / utility / método privado más profundo
    • 4.2. El límite de E/S externa: Repository / Gateway / envoltorio de SDK
    • 4.3. Application Service / UseCase
    • 4.4. El límite de UI / HTTP / Job / Message
    • 4.5. El manejador final de excepciones no controladas
    • 4.6. Recorrido de una jerarquía de llamadas completa
  5. Separar el fallo previsto de la excepción inesperada
  6. Dónde y cuántas veces se debe emitir el log
  7. Errores frecuentes
  8. Lista de verificación para la revisión
  9. Guía rápida de uso
  10. Resumen
  11. Referencias
  12. Artículos relacionados

1. Primero, la conclusión

  • El principio es no hacer catch amplio en capas profundas. El lugar donde se hace catch se acerca al límite donde se puede definir la unidad de fallo.
  • La base del log es un solo log principal por cada fallo. Si cada capa sigue registrando la misma excepción como Error, quien lee el log tiene problemas.
  • La responsabilidad de la capa más profunda es la limpieza, el rollback local, la traducción de la excepción y, si hace falta, un retry limitado. Si se vuelve a lanzar (re-throw), normalmente no se emite el log principal en ese punto.
  • Los límites de procesamiento, como una operación de pantalla, una solicitud HTTP, un job o el procesamiento de un mensaje, tienden a ser el punto más natural para el log principal.
  • El fallo previsto se convierte en resultado a nivel del caso de uso correspondiente. No es necesario propagar absolutamente todo como excepción hasta arriba.
  • AppDomain.UnhandledException, el DispatcherUnhandledException de WPF, el ThreadException de WinForms, el manejador de excepciones de ASP.NET Core y el procesamiento final de excepciones del host son, más que un punto de recuperación, el último lugar de registro.
  • Un OperationCanceledException provocado por la cancelación del usuario o por un apagado, normalmente no se trata como Error.
  • Ante la duda, conviene revisar en este orden:
    1. ¿Se puede decidir realmente en este lugar?
    2. ¿Se conoce aquí la unidad que falló?
    3. ¿Se puede revertir o recrear el estado aquí?
    4. Si se registra aquí, ¿no se volverá a registrar la misma excepción más arriba?

En definitiva, la base es recibir la excepción no donde se pueda hacer catch, sino donde se pueda decidir con responsabilidad.

2. catch, log y manejo de errores son cosas distintas

2.1. Hacer catch

Hacer catch significa recibir la excepción una vez y cambiar el flujo del procesamiento. Sin embargo, eso en sí mismo no es una recuperación.

Por ejemplo, aunque un método de nivel inferior reciba la excepción,

  • no sabe qué debe mostrarle al usuario
  • no sabe si ese fallo debe detener toda la pantalla o si basta con que falle solo la operación actual
  • no sabe si se puede continuar con ese request o job

En esos casos, ese lugar no suele ser el adecuado para hacer catch.

2.2. Emitir el log

El log no es solo el registro del hecho de que «ocurrió una excepción»: sirve para poder rastrear después qué trabajo fue el que falló.

Por eso, un buen punto de log suele reunir alguno de estos elementos:

  • requestId / traceId
  • userId
  • orderId / fileId / batchId
  • qué número de entrada es
  • qué operación de pantalla fue
  • qué cola y qué mensaje

Los helpers profundos y las funciones comunes, aunque conocen el detalle técnico, con frecuencia no disponen de este contexto. Por eso, el lugar que conoce el detalle técnico y el lugar que conoce el contexto operativo suelen ser distintos.

2.3. Hacer el manejo de errores

Aquí, por manejo de errores se entiende procesamiento como el siguiente.

  • Mostrar un mensaje de error en pantalla
  • Devolver un 4xx / 5xx en HTTP
  • Marcar solo ese elemento como fallido y continuar con el siguiente
  • Reinicializar ese subsistema
  • Finalizar el proceso y dejar el reinicio a cargo de otro mecanismo
  • Liberar los recursos y salir de forma segura

Es decir, se trata de decidir la forma del fallo tal como la ve quien llama o el usuario.

2.4. Traducir la excepción

En la práctica, entre hacer catch y «procesar» hay otra tarea importante. Es la traducción.

Por ejemplo,

  • HttpRequestException
  • IOException
  • JsonException
  • excepciones propias del driver de la base de datos
  • excepciones propias del SDK del proveedor

Si se dejan pasar tal cual hasta la UI o el Controller, la capa superior empieza a conocer los detalles de la implementación inferior.

Por eso, en el límite,

  • «No se pudo conectar con el servicio de pagos»
  • «El formato del CSV estaba dañado»
  • «No se pudo escribir en el destino de almacenamiento»
  • «La respuesta del dispositivo era inválida»

se convierte la excepción en un fallo que tiene sentido en esa capa.

Lo importante aquí es que traducir y registrar no son lo mismo. Si solo se traduce y se relanza hacia arriba, normalmente no se llega a emitir el log principal.

3. La tabla de decisión que conviene mirar primero

En este artículo aparecen tres tablas de forma parecida. Como cada una cumple un rol distinto, antes se explica cómo distinguirlas.

Tabla Cuándo consultarla Qué contiene
Tabla del capítulo 3, la de decisión inicial En el diseño, al decidir la responsabilidad de cada capa La política básica de cada lugar, si emite log principal o no, y la responsabilidad principal
Tabla del capítulo 6, la de puntos de log En la implementación, cuando se duda al escribir una línea de log Dónde y con qué nivel se registra cada tipo de fallo
Tabla del capítulo 9, la guía rápida En la revisión, en la comprobación final Un resumen de los capítulos 3 y 6 en tres columnas: catch / log / manejo de errores

Lo más práctico es fijar primero la política general con esta tabla.

Lugar Política básica Log principal Responsabilidad principal
helper / utility / método privado En principio, no hacer catch amplio No emite Limpieza mediante finally, rollback local, agregar el contexto mínimo necesario
Repository / Gateway / envoltorio de SDK Recibir solo excepciones concretas Normalmente no emite Traducción de excepciones, retry limitado, descarte de conexiones o handles
Application Service / UseCase Convertir en resultado el fallo previsto Si se absorbe aquí, según haga falta Definición de la unidad de fallo, fallo parcial, decisión a nivel de caso de uso
Límite de UI / Controller / API / Job / Message Principal punto de recepción de excepciones inesperadas Este suele ser el log principal Respuesta al usuario, respuesta HTTP, continuar con el siguiente elemento, decisión de abortar
Manejador de excepciones no controladas / límite final del host Última barrera para no dejar nada sin capturar Critical Registro final, flush, dump, vía de finalización y reinicio

Si se representa en un diagrama, queda aproximadamente así.

Árbol de decisión para catch, log y manejo de erroresDiagrama de flujo que muestra cómo decidir, ante una excepción, si se puede resolver retry, conversión a resultado o continuidad en este lugar, si es un límite de capa, si se conoce la unidad de fallo y el contexto operativo, y si corresponde emitir el log principalNoNoNoOcurrió una excepción¿Se puede decidir aquí el retry, la conversión a resultado o si continuar?En principio, no hacer catch y enviarla hacia arriba¿Es este un límite de capa?Solo cleanup localSi hace falta, traducir a una excepción con sentido¿Se conocen aquí la unidad de fallo y el contexto operativo?No emitir el log principal y enviarla al nivel superiorEmitir el log principal una vez y decidir la respuestaSi hace falta, finalizar, reinicializar o continuar con el siguiente elemento

Este diagrama tiene dos puntos clave.

  1. El primer motivo para hacer catch es la recuperación o el cleanup, no el log
  2. El primer motivo para emitir el log es que ya se dispone del contexto operativo, no el simple hecho de haber encontrado la excepción

4. Qué hacer en cada nivel de la jerarquía de llamadas

4.1. El helper / utility / método privado más profundo

Aquí, la base es, en principio, no recibir de forma amplia.

Por ejemplo, en lugares como la conversión de cadenas, el parseo, los cálculos, el formateo interno o los helpers comunes, no se puede determinar:

  • qué operación de pantalla fue
  • qué request fue
  • si basta con que falle solo esta vez
  • si se debe cerrar toda la pantalla

Lo que se puede hacer en esta capa se limita, principalmente, a esto:

  • Liberar recursos mediante finally
  • Revertir el estado local que quedó a medio modificar
  • Añadir el contexto mínimo al mensaje de la excepción
  • Sustituir por un tipo de excepción más adecuado
  • Descartar objetos que ya no se pueden reutilizar

Lo que tienen en común es que son tareas de limpieza que se pueden ejecutar correctamente sin saber quién es el llamador. Resulta más fácil trazar la línea si se piensa así: aquí solo van los procesos que no requieren decisión, y lo que sí la requiere se envía hacia arriba.

Por el contrario, conviene evitar escribir código como este.

  • Hacer catch (Exception) y devolver null / false / un arreglo vacío
  • Mostrar un MessageBox aquí
  • Emitir un log Error aquí y luego relanzar
  • «Continuar de todos modos» aunque no se pueda revertir el estado

Es especialmente peligroso el patrón en el que se sigue usando el objeto después de que falló tras haber modificado parcialmente su propio estado. En ese caso, hay dos opciones: revertirlo en el momento si es posible, o, si no lo es, asumir que debe descartarse.

4.2. El límite de E/S externa: Repository / Gateway / envoltorio de SDK

Esta es una capa donde el motivo para hacer catch está claro.

Porque aquí es donde salen a la superficie los detalles de implementación de la capa inferior.

  • Excepciones del driver de la base de datos
  • Excepciones de comunicación HTTP
  • Excepciones de E/S de archivos
  • Excepciones propias de COM / P/Invoke / SDK del proveedor
  • Excepciones de bibliotecas de parseo o de serializadores

En esta capa hay, básicamente, cuatro tareas.

  1. Recibir excepciones concretas Se recibe una excepción concreta y con sentido, no un Exception genérico.

  2. Traducir a un fallo con sentido Para que la capa superior no tenga que conocer directamente los detalles de la inferior.

  3. Si se hace retry localmente, hágalo aquí No obstante, las condiciones son bastante estrictas.
    • Se sabe que el fallo es temporal
    • Existe idempotencia
    • Están definidos el número máximo de intentos y la forma de espera
    • El comportamiento final en caso de fallo es claro Solo cuando se cumplen estas cuatro condiciones a la vez.
  4. Descartar conexiones o handles dañados En muchos casos es más seguro «volver a crear la conexión» que «seguir usando el mismo objeto la próxima vez».

La política de log aquí se mantiene consistente si se piensa así:

  • Si se vuelve a lanzar hacia arriba, normalmente no se emite el log principal
  • Si aquí se absorbe la excepción y se convierte en un resultado, se emiten en ese momento el log y las métricas necesarias
  • Cada intento durante el retry se trata dentro del rango Debug / Information / Warning, y solo el fallo final se registra con mayor severidad

Esta capa es el lugar donde se traduce, no, normalmente, el lugar donde se toma la decisión final.

4.3. Application Service / UseCase

Esta es la capa que decide «cómo debe fallar este trabajo en concreto».

Por ejemplo,

  • el procesamiento de guardado
  • la confirmación de un pedido
  • la importación de un CSV
  • el procesamiento de un elemento de un batch
  • la aplicación de un mensaje

son, entre otros, unidades coherentes a nivel de caso de uso que residen aquí.

En esta capa se pueden tomar decisiones como las siguientes.

  • Un error de validación falla solo esta vez
  • Un NotFound equivale a un 404
  • Una violación de una regla de negocio queda a la espera de que el usuario la corrija
  • Una fila inválida del CSV se registra como Warning y se continúa
  • Un fallo temporal de un servicio externo hace fallar todo el procesamiento
  • Se descarta el resultado parcial y se reinicia desde el principio

Es decir, es el lugar donde se puede decidir la unidad de fallo.

Esta capa es adecuada para tareas como estas:

  • Convertir el fallo previsto en la forma de un Result o de un DTO de fallo
  • Agregar los fallos parciales
  • Decidir hasta cuántos fallos se permiten antes de detener la continuación
  • Convertir a un código de error o a una clave de mensaje para el usuario

Por el contrario, lo que no debería hacer esta capa es cargar demasiado con la presentación en UI o con el armado del cuerpo de la respuesta HTTP. Aquí conviene decidir hasta el sentido a nivel de caso de uso, y dejar la forma final de presentarlo al límite exterior; así resulta más fácil mantener la separación.

4.4. El límite de UI / HTTP / Job / Message

En muchas aplicaciones, este suele ser el punto del log principal.

Por ejemplo, unidades como estas:

  • Una pulsación del botón «Guardar» en WinForms / WPF
  • Una solicitud HTTP en ASP.NET Core
  • Un mensaje de un worker
  • Una entrada de un batch
  • Una ejecución de un job programado

Este lugar conoce cosas como estas:

  • qué operación fue
  • quién la realizó
  • qué número de elemento era
  • qué request / batch / message fue
  • qué devolver al usuario o al llamador en caso de fallo

En muchas aplicaciones, solo esta capa reúne estos cinco elementos. Las capas inferiores conocen el detalle técnico pero no tienen el contexto operativo, y al llegar hasta el manejador de excepciones no controladas ya no se conoce la unidad de fallo. Por eso, esta capa suele encargarse de:

  • recibir aquí, de forma conjunta, las excepciones inesperadas
  • emitir una vez el log principal con contexto
  • convertirlas en un diálogo de error, un HTTP 500, un Problem Details, un fallo de job o la continuación con el siguiente elemento, entre otros

Lo importante en esta capa no es el hecho de recibir de forma amplia, sino que esté definido qué se devuelve después de recibirla.

Por ejemplo, en un batch o una queue conviene pensarlo en dos etapas para verlo con más claridad.

  • Recibir en el límite de cada elemento Decidir si se puede continuar con el siguiente marcando solo ese elemento como fallido
  • No absorber de forma amplia en el bucle padre Si el bucle padre muere, orientarlo hacia el reinicio de todo el proceso

«Hacer fallar elemento por elemento y continuar» y «que el bucle padre siga vivo en silencio aunque caiga por una excepción inesperada» son cosas completamente distintas.

4.5. El manejador final de excepciones no controladas

Este es el último bastión. No es un punto de recuperación mágico.

Los representantes típicos son estos:

  • AppDomain.UnhandledException
  • El Application.DispatcherUnhandledException de WPF
  • El Application.ThreadException de WinForms
  • El middleware o el manejador de procesamiento de excepciones de ASP.NET Core
  • El procesamiento final de excepciones del Generic Host / worker / BackgroundService

La responsabilidad principal de esta capa se limita, como mucho, a esto:

  • Log final
  • flush
  • Vía para capturar el dump
  • Preservar la información de sesión o el contexto inmediatamente anterior
  • Preparar el código de salida o la vía de reinicio

Por el contrario, hay cosas que es mejor no esperar demasiado de este lugar.

  • Si la excepción llegó hasta aquí, con frecuencia es un descuido de diseño en un nivel superior
  • El estado puede estar ya corrompido
  • Puede haber un lock retenido, así que un procesamiento pesado es peligroso
  • Aunque en apariencia se pueda continuar, eso no garantiza que sea seguro hacerlo

También hay algunas precauciones prácticas que conviene tener presentes en torno a .NET.

  • AppDomain.UnhandledException es un evento para notificar y registrar la excepción no controlada. Es peligroso cargarlo con demasiado procesamiento de recuperación posterior.
  • En el DispatcherUnhandledException de WPF existe la posibilidad de poner Handled = true y continuar en apariencia, pero antes hay que decidir si de verdad es recuperable.
  • En el ThreadException de WinForms también existe la posibilidad de que, después de tratarlo ahí, la aplicación quede en un estado desconocido.
  • El middleware de procesamiento de excepciones de ASP.NET Core debe colocarse en una etapa temprana del pipeline, para poder recibir las excepciones que ocurran después.
  • Las excepciones no controladas de BackgroundService, desde .NET 6 en adelante, tienden a quedar registradas y, por defecto, detener el host. En algunos casos es más seguro detenerse y aplicar una estrategia de reinicio que absorberlo todo en el bucle padre.

En particular, en las aplicaciones de escritorio existe el camino de «capturar la excepción no controlada y continuar». Sin embargo, poder continuar y que esté bien continuar son cosas distintas.

4.6. Recorrido de una jerarquía de llamadas completa

Consideremos, por ejemplo, un flujo como este.

Jerarquía de llamadas desde el límite de UI hasta el SDK externoDiagrama que muestra el flujo desde el límite de UI, Controller o Job, pasando por Application Service o UseCase, el Domain o lógica de negocio y el Repository, Gateway o envoltorio de SDK, hasta la base de datos, HTTP, archivos o el SDK del proveedorLímite de UI / Controller / JobApplication Service / UseCaseDomain / lógica de negocioRepository / Gateway / SDK wrapperDB / HTTP / File / Vendor SDK

En este caso, los roles se reparten aproximadamente así.

Botón Guardar → SaveOrderUseCasePaymentGateway → HTTP

  • PaymentGateway
    • Recibe los fallos de comunicación o las respuestas con formato anómalo
    • Los traduce a «fallo de conexión con el servicio de pagos» o «respuesta inválida del servicio de pagos»
    • Si se hace retry, se realiza aquí de forma condicional
    • Si se vuelve a lanzar, normalmente no se registra el log principal
  • SaveOrderUseCase
    • Convierte en resultado los fallos previstos, como un rechazo de pago
    • Lo trata como «solo falló esta confirmación de pedido»
    • Deja el resultado del fallo en una forma fácil de devolver a la UI o a la API
  • UI, manejador del botón / Controller
    • Recibe de forma conjunta las excepciones inesperadas
    • Registra el log principal con orderId, userId y requestId
    • Lo convierte en la muestra de un diálogo o en una respuesta 500 / 503
  • Manejador de excepciones no controladas
    • Registra únicamente lo que llegó filtrado hasta ahí
    • Realiza el dump y el flush final
    • Prioriza la vía de finalización, no la recuperación

Con este reparto, el resultado es que el detalle técnico se cierra abajo, el contexto operativo se añade arriba y la decisión se toma en el límite.

Los logs que emite realmente cada capa

Si se concreta hasta qué escribe realmente cada capa para un mismo fallo, el reparto queda mucho más claro. El supuesto para este pedido es que «el servicio de pagos hizo timeout dos veces, tuvo éxito en el tercer intento, y después, al reservar el stock, ocurrió una ruptura de precondición».

Capa Log que emite Nivel Ejemplo de mensaje
PaymentGateway Cada intento del retry Warning Reintentando la conexión con el servicio de pagos. attempt={Attempt}/{MaxAttempts}, orderId={OrderId}
PaymentGateway Al traducir y relanzar No emite — (el log principal es responsabilidad del límite)
SaveOrderUseCase Al convertir en resultado un fallo previsto Information Se rechazó el pago del pedido. orderId={OrderId}, reason={DeclineReason}
SaveOrderUseCase Excepción inesperada No emite — (se envía tal cual al límite)
UI, manejador del botón / Controller Log principal de la excepción inesperada Error Falló la confirmación del pedido. orderId={OrderId}, userId={UserId} + el objeto de la excepción
Manejador de excepciones no controladas Registro final Critical Se finaliza el proceso por una excepción no controlada + el objeto de la excepción

El punto clave es que Error se emite en una sola línea. Como cada intento del retry queda en Warning y el fallo previsto en Information, al buscar por Error este incidente aparece como un único resultado.

// Log principal: se pasa el objeto de la excepción como primer argumento y se añade, con nombre, el contexto de la unidad de fallo
_logger.LogError(ex, "Falló la confirmación del pedido. orderId={OrderId}, userId={UserId}",
    orderId, userId);

Si se olvida pasar el objeto de la excepción como primer argumento, no queda registrado el stack trace. Si solo se pasa una cadena, como en _logger.LogError(ex.Message), después ya no se puede rastrear la causa.

5. Separar el fallo previsto de la excepción inesperada

Lo más importante en este tema es no tratar todo como si fuera la misma “excepción”.

Para empezar, se puede separar así.

Tipo de fallo Primer lugar donde se trata Tratamiento típico
Deficiencia de validación UseCase / límite de request Se devuelve como error de entrada
NotFound / Conflict UseCase / Controller 404 / 409 o mensaje en pantalla
Cancelación del usuario / apagado Límite de la operación Se trata como cancelación. Normalmente no se convierte en Error
Una fila inválida del CSV Límite de la fila Se registra como Warning y se continúa
Timeout temporal que termina en fallo Límite de E/S hasta límite de request Se devuelve como fallo después del retry
NullReferenceException, ruptura de precondición Límite de request / job Se registra el log principal y se responde con fallo
AccessViolationException, un OutOfMemoryException grave, indicios de corrupción en el límite nativo Límite final Se trata como Critical, orientado a la finalización

El fallo previsto es un fallo que el diseño puede decidir de antemano. La excepción inesperada es un fallo en el que resulta dudoso si se puede confiar en el estado a partir de ese momento.

Con solo separar estos dos casos se reducen incidentes como estos.

  • Convertir siempre un NotFound en Error
  • Tratar la cancelación del usuario como un incidente
  • Dejar pasar como «solo falló esta vez» una ruptura de precondición realmente peligrosa

6. Dónde y cuántas veces se debe emitir el log

En el diseño del log, es más importante decidir primero quién emite el log principal que decidir la posición del catch.

Hay seis reglas básicas.

  1. Para un mismo fallo, el log principal de Error / Critical se emite una sola vez
  2. La capa inferior, si hace falta, realiza la traducción y añade contexto
  3. El límite superior emite el log principal con la unidad de fallo y el contexto operativo
  4. Solo la capa que absorbe la excepción en ese momento asume la responsabilidad de registrar ese fallo
  5. El fallo previsto no se convierte en Error cada vez
  6. OperationCanceledException se separa del log habitual de incidentes

A continuación, una tabla resumida de los puntos de log. Mientras que la tabla del capítulo 3 trataba «qué responsabilidad corresponde a cada capa», esta es una tabla de dónde y con qué nivel se deja registro, según el tipo de fallo. Si durante la implementación duda sobre si debería registrar un log en este catch, consulte esta tabla.

Situación Lugar principal donde se registra Nivel orientativo Notas
Error de validación Límite de request / use case Information o sin log No es un incidente, sino un fallo contractual
Cancelación del usuario / shutdown Límite de la operación Debug / Information Normalmente no se convierte en Error
Fallo temporal durante el retry Capa que tiene el retry Debug / Warning No hacer demasiado ruido antes del fallo final
Fallo tras agotar los reintentos Límite de request / job, o la capa que lo absorbe en ese momento Warning / Error Se registra con la unidad de fallo
Continuar con solo una fila inválida Límite del elemento Warning Se añaden fileId y rowNumber
Excepción inesperada que tumba todo el request Límite de request / UI / job Error Se añaden requestId, userId y entityId
Del nivel de finalizar el proceso Límite de excepciones no controladas Critical flush, dump, vía de reinicio

En la práctica es bastante frecuente encontrar logs duplicados como estos.

  • El Repository registra Error
  • El Service registra la misma excepción como Error
  • El Controller vuelve a registrar Error
  • El manejador final de excepciones no controladas registra además Critical

Así, un solo incidente termina generando varias veces el mismo stack trace. Lo que quien lee el log necesita no son cuatro copias del mismo stack trace, sino un único log principal y, si hace falta, unos pocos logs auxiliares.

Dicho de otra forma, la base es un log una sola vez, y contexto solo el necesario.

7. Errores frecuentes

A partir de aquí se enumeran patrones de código que aparecen a menudo en las revisiones. En los tres casos más representativos se incluye el código mínimo de la versión incorrecta (NG) y de la correcta (OK). El código asume C# 10 / .NET 6 o posterior, con los tipos de referencia nullable habilitados, y usa System.Text.Json y Microsoft.Extensions.Logging.

7.1. Hacer catch (Exception) en una capa profunda y devolver null / false

Esto tiende a perder la información sobre la causa. Además, quien llama deja de poder distinguir si «realmente no había datos» o si «algo se rompió en el camino».

// NG: recibir de forma amplia en una capa profunda y devolver null
private static Order? LoadOrder(string path)
{
    try
    {
        var json = File.ReadAllText(path);
        return JsonSerializer.Deserialize<Order>(json);
    }
    catch (Exception)
    {
        // Quien llama no puede distinguir si el archivo no existía, si el JSON
        // estaba dañado o si no se pudo leer el disco
        return null;
    }
}

Esta capa no puede decidir cómo se debe tratar el fallo. La decisión se traslada al límite, y aquí el trabajo se limita a traducir hasta obtener un fallo con sentido.

// Tipo de excepción con sentido que lanza esta capa
public sealed class OrderFileFormatException : Exception
{
    public OrderFileFormatException(string message, Exception? innerException = null)
        : base(message, innerException)
    {
    }
}

// OK: solo se traduce, y la decisión se traslada al límite superior
private static Order LoadOrder(string path)
{
    string json = File.ReadAllText(path);

    try
    {
        return JsonSerializer.Deserialize<Order>(json)
            ?? throw new OrderFileFormatException($"El archivo del pedido está vacío: {path}");
    }
    catch (JsonException ex)
    {
        // Convierte el JsonException, propio de la implementación inferior, en un fallo con sentido para esta capa
        throw new OrderFileFormatException($"El formato del archivo del pedido es inválido: {path}", ex);
    }

    // IOException y UnauthorizedAccessException no se traducen y se envían tal
    // cual hacia arriba: "no se puede leer el archivo" no es un fallo al que esta capa pueda añadirle sentido
}

7.2. Registrar Error en cada capa y luego relanzar

Es la causa más frecuente de logs duplicados.

Repartiendo así:

  • la capa inferior solo traduce
  • el límite superior emite el log principal

se puede reducir bastante.

// NG: la capa inferior registra el log y luego relanza. La capa superior también registra la misma excepción, y el resultado son dos logs
public async Task<Receipt> ChargeAsync(Payment payment, CancellationToken ct)
{
    try
    {
        return await _gateway.ChargeAsync(payment, ct);
    }
    catch (HttpRequestException ex)
    {
        _logger.LogError(ex, "Falló el pago");
        throw;
    }
}

La capa inferior se limita a traducir y dejar pasar.

// PaymentGatewayException tiene la misma forma que OrderFileFormatException:
// es un tipo de excepción propio que representa que "falló la comunicación con el servicio de pagos"

// OK: la capa inferior (PaymentGateway) solo traduce. No emite log
public async Task<Receipt> ChargeAsync(Payment payment, CancellationToken ct)
{
    try
    {
        return await _gateway.ChargeAsync(payment, ct);
    }
    catch (HttpRequestException ex)
    {
        throw new PaymentGatewayException(
            $"No se pudo conectar con el servicio de pagos. orderId={payment.OrderId}", ex);
    }
}

A partir de ahí, se emite una sola vez el log principal en el límite donde ya se dispone de la unidad de fallo y del contexto operativo.

// OK: se emite el log principal una sola vez en el límite, y se decide la respuesta hacia quien llama
[ApiController]
public sealed class PaymentController : ControllerBase
{
    private readonly ILogger<PaymentController> _logger;
    private readonly SaveOrderUseCase _useCase;

    public PaymentController(ILogger<PaymentController> logger, SaveOrderUseCase useCase)
    {
        _logger = logger;
        _useCase = useCase;
    }

    [HttpPost("orders/{orderId}/pay")]
    public async Task<IActionResult> PayAsync(string orderId, CancellationToken ct)
    {
        try
        {
            Receipt receipt = await _useCase.ExecuteAsync(orderId, ct);
            return Ok(receipt);
        }
        catch (PaymentGatewayException ex)
        {
            // Aquí, y solo aquí, se dispone de la unidad de fallo (este pago de este pedido) junto con el contexto operativo
            _logger.LogError(ex, "Falló el pago del pedido {OrderId}", orderId);
            return StatusCode(StatusCodes.Status502BadGateway);
        }
    }
}

En C#, si se vuelve a lanzar, la base es usar throw; para no dañar el stack trace. Si se escribe throw ex;, el stack trace se sobrescribe en esa línea y se pierde la ubicación real donde ocurrió.

7.3. Que una capa de biblioteca o un componente común muestre la UI directamente

Si un componente común muestra un MessageBox o decide directamente el cuerpo de la respuesta HTTP, se pierden tanto la reutilización como la separación de responsabilidades. Es más seguro que la capa inferior se limite a devolver un fallo con sentido.

7.4. Registrar OperationCanceledException como Error tratándolo como un incidente

La cancelación forma parte del flujo de control. Si se convierte en Error cada vez, el incidente real queda enterrado.

// NG: un catch amplio arrastra incluso la cancelación y la convierte en Error
try
{
    await _useCase.ImportAsync(file, ct);
}
catch (Exception ex)
{
    // Incluso si el usuario solo pulsó "Cancelar", llega aquí y se emite un Error
    _logger.LogError(ex, "Falló la importación");
    throw;
}

Las cláusulas catch se evalúan de arriba hacia abajo, así que conviene recibir primero, y con un tipo más específico, solo la cancelación. Añadir una cláusula when evita confundir la interrupción provocada por el token que se pasó con un OperationCanceledException originado por otro motivo, como un timeout interno.

// OK: se captura primero la cancelación, separándola del log de incidentes
try
{
    await _useCase.ImportAsync(file, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
    // Interrupción del usuario o apagado. Forma parte del flujo de control, así que no se convierte en Error
    _logger.LogInformation("Se interrumpió la importación. fileId={FileId}", file.Id);
}
catch (Exception ex)
{
    // Aquí solo llegan los fallos inesperados. Se emite el log principal una vez, con el contexto de la unidad de fallo
    _logger.LogError(ex, "Falló la importación. fileId={FileId}", file.Id);
    throw;
}

7.5. Reintentar sin cuidado cuando hay efectos secundarios externos

Hay muchas operaciones, como el envío de correo, el cobro, un comando a un dispositivo o el movimiento de un archivo, en las que repetir la misma operación provoca un problema. El retry solo se debe aplicar cuando están claros a la vez el carácter temporal del fallo y la idempotencia.

7.6. Intentar recuperarlo todo en el manejador final de excepciones no controladas

Este es el último seguro. No es el lugar donde debe centrarse el diseño.

Es más seguro ubicar la estrategia de recuperación en la capa anterior, es decir, en el límite de request / job / subsistema.

8. Lista de verificación para la revisión

En la revisión del manejo de excepciones, seguir este orden deja pocos puntos sin revisar.

  • ¿Se puede decir en una frase para qué decisión existe este catch?
  • ¿Se puede decidir realmente en este lugar el retry, la conversión a resultado, si continuar o no, o la respuesta al usuario?
  • Si se registra aquí, ¿no se volverá a registrar el mismo fallo como Error en una capa superior?
  • ¿Se traducen en el límite las excepciones propias de la implementación inferior a un fallo con sentido?
  • ¿Se puede revertir aquí el estado que quedó dañado a medio camino? Si no se puede, ¿está previsto descartarlo?
  • ¿Se separa OperationCanceledException de los incidentes habituales?
  • ¿Queda claro si es continuación a nivel de elemento, fallo a nivel de request o finalización del proceso?
  • ¿Se espera del manejador final de excepciones no controladas el registro, y no la recuperación?
  • ¿Lleva el log el contexto de la unidad de fallo, como requestId, userId, batchId, fileId o rowNumber?
  • ¿No se está tratando por igual «el fallo previsto» y «la ruptura de precondición»?

Lo que más rinde de esta lista es poner en palabras, cada vez, «qué decide este catch». Un catch que no se puede explicar así suele ser innecesario, o está en un lugar demasiado profundo.

9. Guía rápida de uso

Para terminar, se incluye una tabla de comprobación que resume en una sola hoja los capítulos 3 y 6. Está pensada para que baste con mirar esta hoja durante la revisión o al repasar el código ya escrito.

Situación catch Log Manejo de errores
helper / utility En principio, no No No
Repository / Gateway / envoltorio de SDK Recibir solo excepciones concretas Normalmente no registra el log principal Traducción, retry local, descarte de la conexión
UseCase / Application Service Recibir el fallo previsto Si se absorbe, según haga falta Conversión a resultado, fallo parcial
Límite de UI / Controller / request / item / job Recibir de forma amplia la excepción inesperada Log principal Respuesta, mensaje, continuar / abortar
Manejador de excepciones no controladas Solo lo que se filtró Critical Registro final, vía de finalización

Ante la duda, basta con estas cinco reglas.

  1. No absorber de forma amplia en las capas profundas
  2. Recibir en el límite
  3. El log principal, una sola vez
  4. La capa que absorbe asume la responsabilidad
  5. La excepción no controlada final: registro y vía de finalización

10. Resumen

El manejo de excepciones no consiste en «hacer catch en cualquier parte solo porque se puede hacer catch en cualquier parte».

El orden en el que conviene mirarlo es, básicamente, este.

  1. ¿Se puede decidir realmente en este lugar?
  2. ¿Se conoce aquí la unidad de fallo?
  3. ¿Se puede revertir o recrear el estado aquí?
  4. Si se registra aquí, ¿no se duplica?
  5. ¿Es este un punto de recuperación, o el último lugar de registro?

Si se revisa en este orden, ordenar la jerarquía de llamadas resulta bastante más fácil.

Hay tres puntos especialmente importantes.

  • La capa profunda se ocupa, sobre todo, de traducir y hacer cleanup
  • El límite se ocupa, sobre todo, de decidir y del log principal
  • El manejador final de excepciones no controladas se ocupa, sobre todo, del registro y de la vía de finalización

Dicho de otra forma, la base es recibir la excepción en el límite, añadirle contexto y procesarla solo en el lugar donde se puede recuperar.

Una vez que esto queda definido, tanto la revisión de código como la investigación de incidentes se vuelven mucho más consistentes.

11. Referencias

12. Artículos relacionados

Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.

Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.

El artículo está directamente relacionado con los siguientes servicios.

Preguntas frecuentes

Preguntas habituales en las consultas sobre el tema del artículo.

¿En qué capa se debe hacer catch de las excepciones?
El principio es no hacer catch amplio en capas profundas, sino acercarlo al límite donde se puede definir la unidad de fallo. Límites de procesamiento como una operación de pantalla, una solicitud HTTP, un job o un mensaje suelen ser el punto de recepción natural. La base no es recibir donde se pueda hacer catch, sino donde se pueda decidir con responsabilidad el retry, la conversión a resultado y si continuar o no. En los helpers y utilidades profundos, limítese a la limpieza en finally, al rollback local y a la traducción de excepciones.
¿Se debe registrar la excepción en cada capa?
La base es que, para un mismo fallo, el log principal de Error / Critical se emita una sola vez. Si el Repository registra Error, el Service registra Error para la misma excepción y el Controller vuelve a registrar Error, un solo incidente termina generando varias veces el mismo stack trace, lo que dificulta la lectura. Las capas inferiores deben limitarse a traducir la excepción y añadir contexto, mientras que el log principal se emite en el límite superior, donde ya se dispone del contexto operativo como requestId o userId. Solo la capa que absorbe la excepción y la convierte en un resultado asume la responsabilidad de registrar ese fallo.
¿Cómo se distingue un fallo previsto de una excepción inesperada?
Un fallo previsto es aquel que el diseño puede anticipar de antemano: errores de validación o un NotFound, por ejemplo, se convierten en resultado a nivel de caso de uso y no se registran como Error cada vez. Un OperationCanceledException por cancelación del usuario tampoco suele tratarse como Error. En cambio, una ruptura de precondición como NullReferenceException debe registrarse como log principal en el límite de request / job y traducirse en una respuesta de fallo, mientras que AccessViolationException o un OutOfMemoryException grave se tratan como Critical, orientados a la finalización. Con solo separar estos dos casos se reducen los incidentes en los que un fallo realmente peligroso queda enterrado.
¿Qué se debe hacer en el manejador de excepciones no controladas?
AppDomain.UnhandledException, el DispatcherUnhandledException de WPF, el ThreadException de WinForms y similares no son un punto de recuperación, sino el último lugar de registro. Su responsabilidad principal se limita al log final, el flush, la vía para capturar volcados (dump) y la preparación del código de salida o del reinicio. En el momento en que una excepción llega hasta aquí, el estado puede estar ya corrompido, así que aunque en apariencia se pueda continuar, eso no significa que sea seguro hacerlo. La estrategia de recuperación es más segura si se ubica en el límite anterior, el de request o job.

Perfil del autor

Página de presentación del autor del artículo.

Go Komura

Representante de KomuraSoft LLC

Especializado en desarrollo de software para Windows, consultoría técnica e investigación de fallos, sobre todo en proyectos con sistemas existentes y errores difíciles de reproducir.

Volver al blog