¿Dónde deben ir el catch y el log en el manejo de excepciones?
· Actualizado el: · Go Komura · 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
Errorsolo 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
- Primero, la conclusión
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
- 2.1. Hacer
- La tabla de decisión que conviene mirar primero
- 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
- Separar el fallo previsto de la excepción inesperada
- Dónde y cuántas veces se debe emitir el log
- Errores frecuentes
- Lista de verificación para la revisión
- Guía rápida de uso
- Resumen
- Referencias
- Artículos relacionados
1. Primero, la conclusión
- El principio es no hacer
catchamplio en capas profundas. El lugar donde se hacecatchse 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, elDispatcherUnhandledExceptionde WPF, elThreadExceptionde 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
OperationCanceledExceptionprovocado por la cancelación del usuario o por un apagado, normalmente no se trata como Error. - Ante la duda, conviene revisar en este orden:
- ¿Se puede decidir realmente en este lugar?
- ¿Se conoce aquí la unidad que falló?
- ¿Se puede revertir o recrear el estado aquí?
- 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,
HttpRequestExceptionIOExceptionJsonException- 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í.
flowchart TD
accTitle: Árbol de decisión para catch, log y manejo de errores
accDescr: Diagrama 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 principal
A["Ocurrió una excepción"] --> B{"¿Se puede decidir aquí el retry, la conversión a resultado o si continuar?"}
B -- "No" --> C["En principio, no hacer catch y enviarla hacia arriba"]
B -- "Sí" --> D{"¿Es este un límite de capa?"}
D -- "No" --> E["Solo cleanup local"]
D -- "Sí" --> F["Si hace falta, traducir a una excepción con sentido"]
E --> G{"¿Se conocen aquí la unidad de fallo y el contexto operativo?"}
F --> G
G -- "No" --> H["No emitir el log principal y enviarla al nivel superior"]
G -- "Sí" --> I["Emitir el log principal una vez y decidir la respuesta"]
I --> J["Si hace falta, finalizar, reinicializar o continuar con el siguiente elemento"]
Este diagrama tiene dos puntos clave.
- El primer motivo para hacer
catches la recuperación o el cleanup, no el log - 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 devolvernull/false/ un arreglo vacío - Mostrar un
MessageBoxaquí - Emitir un log
Erroraquí 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.
-
Recibir excepciones concretas Se recibe una excepción concreta y con sentido, no un
Exceptiongenérico. -
Traducir a un fallo con sentido Para que la capa superior no tenga que conocer directamente los detalles de la inferior.
- 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.
- 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
NotFoundequivale 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
Warningy 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
Resulto 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.DispatcherUnhandledExceptionde WPF - El
Application.ThreadExceptionde 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.UnhandledExceptiones un evento para notificar y registrar la excepción no controlada. Es peligroso cargarlo con demasiado procesamiento de recuperación posterior.- En el
DispatcherUnhandledExceptionde WPF existe la posibilidad de ponerHandled = truey continuar en apariencia, pero antes hay que decidir si de verdad es recuperable. - En el
ThreadExceptionde 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.
flowchart LR
accTitle: Jerarquía de llamadas desde el límite de UI hasta el SDK externo
accDescr: Diagrama 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 proveedor
A["Límite de UI / Controller / Job"] --> B["Application Service / UseCase"]
B --> C["Domain / lógica de negocio"]
C --> D["Repository / Gateway / SDK wrapper"]
D --> E["DB / HTTP / File / Vendor SDK"]
En este caso, los roles se reparten aproximadamente así.
Botón Guardar → SaveOrderUseCase → PaymentGateway → 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,userIdyrequestId - 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
NotFoundenError - 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.
- Para un mismo fallo, el log principal de
Error/Criticalse emite una sola vez - La capa inferior, si hace falta, realiza la traducción y añade contexto
- El límite superior emite el log principal con la unidad de fallo y el contexto operativo
- Solo la capa que absorbe la excepción en ese momento asume la responsabilidad de registrar ese fallo
- El fallo previsto no se convierte en
Errorcada vez OperationCanceledExceptionse 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
Erroren 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
OperationCanceledExceptionde 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.
- No absorber de forma amplia en las capas profundas
- Recibir en el límite
- El log principal, una sola vez
- La capa que absorbe asume la responsabilidad
- 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.
- ¿Se puede decidir realmente en este lugar?
- ¿Se conoce aquí la unidad de fallo?
- ¿Se puede revertir o recrear el estado aquí?
- Si se registra aquí, ¿no se duplica?
- ¿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
- .NET: buenas prácticas para excepciones
- .NET: el evento System.AppDomain.UnhandledException
- WPF: el evento Application.DispatcherUnhandledException
- Windows Forms: el evento Application.ThreadException
- Manejar errores en ASP.NET Core
- Middleware de ASP.NET Core
- Servicios de Windows con BackgroundService
12. Artículos relacionados
- Tabla de decisión: finalizar o continuar ante una excepción inesperada
- Cuándo no se puede evitar un logger propio: los requisitos mínimos realmente necesarios, con enfoque práctico y de pruebas de integración
- Qué es .NET Generic Host: la base de la DI, la configuración y el logging
- Cómo trazar el límite entre pruebas unitarias y pruebas de integración
Artículos relacionados
Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.
Tabla de decisión: terminar o continuar ante una excepción inesperada
Analiza si una aplicación debe terminar o continuar tras una excepción inesperada, según el daño al estado, los efectos externos, los hil...
Lista de verificación mínima de seguridad para el desarrollo de aplicaciones de Windows
Lista de verificación para aplicaciones empresariales WPF, WinForms, WinUI, C++ y C#: permisos, firma, actualizaciones, secretos, HTTPS, ...
Buenas prácticas de multithreading en la práctica — Edición Java: el estándar en la era de los hilos virtuales
En Java lo correcto es no crear hilos a mano, sino usar ExecutorService y hilos virtuales. Repasamos synchronized frente a ReentrantLock,...
Buenas prácticas de multihilo en la práctica — Edición C — Programar con seguridad al estilo de la API Win32
En C con Win32 la norma es crear hilos con _beginthreadex, usar bloqueos SRW y variables de condición, Interlocked, y una parada con even...
Buenas prácticas de multithreading en la práctica — Edición C++: eliminando los accidentes desde la estructura con RAII y jthread
En C++, una condición de carrera es directamente comportamiento indefinido. Repasamos la trampa del destructor de std::thread, la parada ...
Temas relacionados
Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.
Temas técnicos de Windows
Portal sobre desarrollo de Windows, investigación de fallos y aprovechamiento de activos existentes.
Servicios relacionados con este tema
El artículo está directamente relacionado con los siguientes servicios.
Desarrollo de aplicaciones para Windows
Aplicaciones empresariales, integración de dispositivos y herramientas de comunicación, de los requisitos al desarrollo.
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.