Where Should catch and Logging Go in Exception Handling?
· Updated: · Go Komura · Exception Handling, Logging, Error Handling, Design, C# / .NET
Revision history (1 updates, last updated Sep 1, 2026)
A log of the changes made to this article. Where a pre-update version was archived, it stays readable at a permanent DOI link.
- Retranslated as a full translation of the Japanese original. The previous English version was an abridgement that carried only part of the source, so sections, tables, Mermaid diagrams, figure captions and FAQ entries were missing. All of them have been restored to match the Japanese original, and the technical claims are the same as in the Japanese version. Read the version before this update (DOI: 10.5281/zenodo.21614617)
- First published
Cite this article(DOI: 10.5281/zenodo.21614616)
This article is archived on Zenodo. Below are both the DOI that always resolves to the latest version and the DOI pinned to the version you are reading.
Go Komura (2026). Where Should catch and Logging Go in Exception Handling?. KomuraSoft LLC. https://doi.org/10.5281/zenodo.21614616 https://comcomponent.com/en/blog/2026/04/15/000-exception-catching-logging-error-handling/
- DOI (latest version)
- 10.5281/zenodo.21614616
- DOI (this version)
- 10.5281/zenodo.22220443
Code reviews of exception handling keep surfacing the same three findings.
- The deepest shared function does a
catch (Exception), so the caller cannot tell whether the data simply was not there or whether something broke along the way - One incident, yet the same stack trace appears four times: from the Repository, the Service, the Controller, and the unhandled exception handler
- The user merely canceled, but an
Errorlog is written, and the genuinely dangerous failures get buried in that noise
None of these come from writing try / catch badly. What is missing is the division of roles: where to catch, who writes the log, and where the shape of the failure is decided. Without that division, developers at each layer keep adding a catch and a log just in case, and the code ends up hiding its own causes.
This article is for developers writing line-of-business apps and Web APIs in C# / .NET, and for the people who review those designs. It organizes the boundary that catches exceptions, the place that writes the primary log, and how to divide the responsibility for recovery decisions. Decide up front what happens where in the call hierarchy, and both reviews and incident investigations stop wobbling.
Terms used in this article
Three words recur throughout the article, so let us define them first. These are not general-purpose terms; they carry the meaning below within this article.
| Term | What it means in this article |
|---|---|
| Failure unit | A cohesive unit of work, in business terms, that expresses what failed once. One UI interaction, one HTTP request, one job, one message, one CSV row, and so on. This unit shows up in the logs and in the response alike |
| Primary log | The single Error or Critical record written once per failure. It carries the failure unit and the operational context (requestId, userId, the target ID, and so on). Every other record is a supporting log handled at Debug / Information / Warning |
| Result-mapping | Stopping the chain of rethrown exceptions and turning the failure into a return value instead, such as a Result type or a DTO that represents failure. It refers to putting an expected failure into a form the caller can branch on |
Table of Contents
- The Conclusion First
catch, Logging, and Error Handling Are Different Things- 2.1. Catching
- 2.2. Logging
- 2.3. Error handling
- 2.4. Translating exceptions
- The Decision Table to Check First
- What to Do Where in the Call Hierarchy
- 4.1. The deepest helper / utility / private method
- 4.2. External I/O boundaries: Repository / Gateway / SDK wrappers
- 4.3. Application Service / UseCase
- 4.4. UI / HTTP / Job / Message boundaries
- 4.5. The last-chance unhandled exception handler
- 4.6. Viewed along a single call chain
- Separate Expected Failures from Unexpected Exceptions
- Where and How Many Times Should Logs Be Written?
- Common Anti-Patterns
- A Review Checklist
- A Rough Cheat Sheet
- Summary
- References
- Related Articles
Knowledge map for this article
This article proposes separating the boundary where an exception is caught from the place where the primary log is written in C#/.NET exception handling. It states that the deepest helper and utility layer should not catch broadly, that the Repository/Gateway layer translates exceptions specific to the underlying implementation into meaningful failures, that the Application Service/UseCase layer converts expected failures into results, and that the UI/HTTP/Job boundary tends to be the point where the unit of failure and the operational context line up and the primary log is written exactly once. The unhandled exception handler is not a recovery point but the last place where a record is made, and it prepares the path to shutdown and restart. Duplicate Error logs at each layer, the loss of the stack trace caused by throw ex, and treating OperationCanceledException as a failure log are all positioned as responses to avoid, because every one of them makes tracing the cause harder.
flowchart LR
accTitle: Where to catch exceptions and where to log them
accDescr: Diagram showing how the roles of the exception catch boundary, the primary log, result conversion, and exception translation are distributed from the helper layer to the Repository/Gateway layer, the UseCase layer, the UI/HTTP/Job boundary, and the unhandled exception handler
exception_catch_boundary["Exception Catch Boundary"]
primary_error_log["Primary Error Log Record"]
deep_broad_catch["Broad catch in Deep Layers"]
helper_utility_layer["Helper and Utility Method Layer"]
failure_unit["Unit of Failure"]
diagnosis_difficulty["Difficulty Tracing Failure Causes"]
duplicate_error_logging["Duplicate Error Logs Across Layers"]
repository_gateway_layer["Repository/Gateway/SDK Wrapper Layer"]
exception_translation["Exception Translation at Layer Boundaries"]
limited_retry["Conditional Retry at I/O Boundary"]
usecase_layer["Application Service / Use Case Layer"]
result_conversion["Converting Failures to Result Types"]
expected_failure["Expected Failure"]
ui_http_job_boundary["UI/HTTP/Job/Message Boundary"]
operation_canceled_exception["OperationCanceledException"]
throw_ex_antipattern["throw ex; Stack Trace Overwrite"]
stack_trace_loss["Loss of Stack Trace"]
unhandled_exception_handler["Unhandled Exception Handler"]
process_restart_strategy["Process Exit and Restart Path"]
backgroundservice_unhandled_exception["Unhandled BackgroundService Exception"]
unexpected_exception["Unexpected Exception (Broken Assumption)"]
deep_broad_catch -->|"not recommended for"| helper_utility_layer
failure_unit -->|"recommended for"| exception_catch_boundary
deep_broad_catch -->|"may cause"| diagnosis_difficulty
duplicate_error_logging -->|"may cause"| diagnosis_difficulty
primary_error_log -->|"prevents"| duplicate_error_logging
repository_gateway_layer -->|"implements"| exception_translation
repository_gateway_layer -.->|"uses"| limited_retry
usecase_layer -->|"implements"| result_conversion
result_conversion -->|"recommended for"| expected_failure
ui_http_job_boundary -->|"implements"| primary_error_log
operation_canceled_exception -->|"not recommended for"| primary_error_log
throw_ex_antipattern -->|"may cause"| stack_trace_loss
stack_trace_loss -->|"may cause"| diagnosis_difficulty
unhandled_exception_handler -->|"implements"| process_restart_strategy
process_restart_strategy -.->|"recommended for"| backgroundservice_unhandled_exception
operation_canceled_exception -->|"should come before"| unexpected_exception
primary_error_log -->|"requires"| failure_unit
usecase_layer -->|"uses"| repository_gateway_layer
ui_http_job_boundary -->|"uses"| usecase_layer
primary_error_log -->|"not recommended for"| helper_utility_layer
primary_error_log -.->|"not recommended for"| repository_gateway_layer
helper_utility_layer -->|"not recommended for"| exception_catch_boundary
usecase_layer -->|"recommended for"| exception_catch_boundary
ui_http_job_boundary -->|"recommended for"| exception_catch_boundary
unhandled_exception_handler -->|"not recommended for"| exception_catch_boundary
In the diagram a solid line marks a relation that always holds and a dashed line marks a conditional one (the conditions are given per relation on the detail page). The full list of relations (25 in total, with evidence and certainty) and the definitions of the main concepts are collected on the knowledge map detail page (in Japanese). Data: JSON-LD / Turtle
1. The Conclusion First
- The principle is: do not catch broadly in deep layers. Push
catchtoward the boundaries where a unit of failure can be defined. - For logging, the baseline is one primary log per failure. If every layer keeps logging the same exception at
Error, the reader suffers. - The responsibility of the deepest layer is cleanup, local rollback, exception translation, and, if needed, limited retry. If it rethrows, it normally does not write the primary log there.
- Processing boundaries — a UI interaction, an HTTP request, one job, one message — tend to be the most natural place for the primary log.
- Expected failures should be turned into results at the unit of that use case. Not everything has to keep being thrown upward as an exception.
AppDomain.UnhandledException, WPF’sDispatcherUnhandledException, WinForms’ThreadException, ASP.NET Core’s exception handler, and the host’s final exception handling are less recovery points than last recording points.OperationCanceledExceptioncaused by user cancellation or shutdown is normally not treated as Error.- When in doubt, check in this order:
- Can this location truly make the decision?
- Is the failed unit of work knowable here?
- Can the state be restored or rebuilt here?
- If we log here, will the same exception also get logged above?
In short, the rule is: catch not where you can, but where you can decide with responsibility.
2. catch, Logging, and Error Handling Are Different Things
2.1. Catching
catch means receiving an exception once and changing the flow of processing.
But that, by itself, is not recovery.
For example, even if a lower-level method receives an exception, if it:
- does not know what should be shown to the user
- does not know whether this failure should stop the whole screen or only fail this one operation
- does not know whether the request or job may continue
then that location is usually not a good place to catch.
2.2. Logging
A log is a record not just of the fact that an exception occurred but of which piece of work failed, so it can be traced later.
That is why a good logging location usually has some of the following on hand.
- requestId / traceId
- userId
- orderId / fileId / batchId
- which item number in the input
- which UI interaction
- which queue, which message
Deep helpers and shared functions often know the technical details but lack this context. So the place that knows the technical details and the place that knows the operational context are frequently not the same place.
2.3. Error handling
By error handling, we mean processing like this.
- Showing an error message on the screen
- Returning 4xx / 5xx in HTTP
- Failing just this one item and moving on to the next
- Reinitializing the subsystem
- Exiting the process and leaving it to be restarted
- Releasing resources and bailing out safely
In other words, deciding what the failure looks like from the caller’s or the user’s point of view.
2.4. Translating exceptions
In practice, between catch and handling there is one more important job.
That is translation.
For example, if you let:
HttpRequestExceptionIOExceptionJsonException- DB-driver-specific exceptions
- vendor-SDK-specific exceptions
leak straight to the UI or a Controller, the upper layers start learning the lower implementation’s internal concerns.
So at the boundary, you convert them into failures meaningful at that layer, such as:
- Could not connect to the payment service
- The CSV format was corrupt
- Could not write to the destination
- The device response was invalid
The important point here is that translation and logging are not the same. If you only translate and rethrow, you normally do not write the primary log.
3. The Decision Table to Check First
This article contains three tables of a similar shape. They play different roles, so here is how to tell them apart up front.
| Table | When to look at it | What it contains |
|---|---|---|
| Section 3, the decision table to check first | At design time, when deciding each layer’s responsibilities | The basic policy per location, whether it writes the primary log, and its main responsibilities |
| Section 6, the table of logging locations | At implementation time, when your hand stops over a log line | For each kind of failure, where to record it and at what level |
| Section 9, the rough cheat sheet | At review time, for the final pass | Sections 3 and 6 folded into three columns: catch / logging / error handling |
It is easiest to settle the broad policy with this table first.
| Location | Basic policy | Primary log | Main responsibilities |
|---|---|---|---|
| helper / utility / private method | As a rule, do not catch broadly | No | Cleanup via finally, local rollback, minimal context enrichment |
| Repository / Gateway / SDK wrapper | Catch only specific exceptions | Usually no | Exception translation, limited retry, discarding connections and handles |
| Application Service / UseCase | Turn expected failures into results | If swallowing, log here as needed | Defining the failure unit, partial-failure handling, use-case-level decisions |
| UI / Controller / API / Job / Message boundary | The main receiver for unexpected exceptions | This tends to be the primary log | User-facing responses, HTTP responses, continue-to-next, abort decisions |
| Unhandled exception handler / final host boundary | The last line against leaks | Critical |
Final recording, flush, dump, exit / restart path |
As a diagram, it looks roughly like this.
flowchart TD
A["An exception occurred"] --> B{"Can this location decide retry / result-mapping / whether to continue?"}
B -- "No" --> C["As a rule, don't catch; let it propagate"]
B -- "Yes" --> D{"Is this a layer boundary?"}
D -- "No" --> E["Local cleanup only"]
D -- "Yes" --> F["Translate to a meaningful exception if needed"]
E --> G{"Are the failure unit and operational context known here?"}
F --> G
G -- "No" --> H["Don't write the primary log; pass upward"]
G -- "Yes" --> I["Write the primary log once and decide the response"]
I --> J["If needed: exit / reinitialize / continue to next item"]
This diagram makes two points.
- The first reason to
catchis recovery or cleanup — not logging. - The first reason to log is that the operational context is available — not that you spotted an exception.
4. What to Do Where in the Call Hierarchy
4.1. The deepest helper / utility / private method
Here, the baseline is do not catch broadly.
Places like string conversion, parsing, computation, internal formatting, and shared helpers cannot decide:
- which UI interaction this was
- which request this was
- whether failing only this once is acceptable
- whether the whole screen should close
What this layer is allowed to do is mainly:
- Releasing resources in
finally - Rolling back local state that was half-mutated
- Adding minimal context to the exception message
- Replacing with a more appropriate exception type
- Discarding objects that are no longer reusable
What these have in common is that they are cleanup that runs correctly without knowing who the caller is. Put only the work that requires no decision here and send anything that requires a decision upward, and the line becomes easy to draw.
Conversely, the styles to avoid look like this.
catch (Exception)and returnnull/false/ an empty array- Showing a
MessageBoxhere - Logging at
Errorhere and then rethrowing - Just keeping going when the state cannot be restored
The most dangerous pattern is failing after partially mutating your own state, then continuing to use it as is. In that case, either restore it on the spot if you can, or treat the object as disposable if you cannot.
4.2. External I/O boundaries: Repository / Gateway / SDK wrappers
This is a layer where the reason to catch is clear-cut.
That is because here the implementation concerns of the layer below surface.
- DB driver exceptions
- HTTP communication exceptions
- File I/O exceptions
- COM / P/Invoke / vendor-SDK-specific exceptions
- Exceptions from parsing libraries and serializers
What this layer does is roughly four things.
-
Catch specific exceptions Not a broad
Exception, but specific exceptions that carry meaning. -
Translate into meaningful failures So that the upper layers need not know the lower layers’ internals directly.
- If you retry locally, do it here
But the conditions are strict:
- The failure is known to be transient
- The operation is idempotent
- The retry limit and backoff are defined
- The final behavior on failure is clear Only when all four hold.
- Discard broken connections and handles Rebuilding the connection is often safer than keeping going with the same object.
The logging policy here stays steady if you think of it like this.
- If you rethrow upward, normally do not write the primary log
- If you swallow the exception here and turn it into a result, emit the needed logs and metrics at that point
- Treat each retry attempt within the
Debug/Information/Warningrange, and record only the final failure firmly
This layer is a place to translate, not usually a place to make the final decision.
4.3. Application Service / UseCase
This is the layer that decides how this piece of work fails.
Things like:
- a save operation
- order confirmation
- CSV import
- one batch item’s processing
- applying one message
Units cohesive as use cases like these live here.
This layer can make decisions like these.
- Validation errors fail only this attempt
NotFoundcorresponds to a 404- Business rule violations wait for user correction
- One bad CSV row is logged as
Warningand processing continues - A transient external-service outage fails the whole operation
- Discard intermediate output and start over
In other words, it is the place where the failure unit can be decided.
This layer suits work like:
- Turning expected failures into a
Resultor failure DTO - Aggregating partial failures
- Deciding how many failures to tolerate before stopping
- Converting to error codes or user-facing message keys
Conversely, what this layer should not do is drag in too much UI rendering or HTTP response-body construction. It separates more cleanly if this layer decides up to the use-case-level meaning and leaves the final presentation to the boundary side.
4.4. UI / HTTP / Job / Message boundaries
This is where the primary log location tends to be in most applications.
Units like:
- one press of the Save button in WinForms / WPF
- one HTTP request in ASP.NET Core
- one message in a worker
- one input item in a batch
- one run of a scheduled job
This location knows:
- what the operation was
- whose operation it was
- which item number it was
- which request / batch / message it was
- what to return to the user or caller on failure
In most applications, this layer is the only one that has all five. The layers below know the technical details but hold no operational context, and by the time you reach the unhandled exception handler above, the failure unit is no longer visible. That is why this layer naturally takes on the roles of:
- receiving unexpected exceptions collectively here
- writing the primary log once, with context
- converting into an error dialog, HTTP 500, Problem Details, job failure, continue-to-next, and so on
What matters at this layer is not catching broadly per se, but having defined what is returned after catching broadly.
For batches and queues, thinking in two stages improves clarity.
- Catch at the per-item boundary Decide whether failing just this item and moving on is acceptable
- Do not broadly smother in the parent loop If the parent loop dies, lean on restarting the whole process
Failing item by item and continuing, and the parent loop silently staying alive after an unexpected exception, are completely different things.
4.5. The last-chance unhandled exception handler
This is the last line of defense. It is not a magic recovery point.
The representative ones are:
AppDomain.UnhandledException- WPF’s
Application.DispatcherUnhandledException - WinForms’
Application.ThreadException - ASP.NET Core’s exception-handling middleware and handlers
- Final exception handling in Generic Host / workers /
BackgroundService
The main responsibilities of this layer are at most:
- The final log
- Flush
- A path to dump collection
- Saving session info and recent context
- Setting up exit codes and the restart path
Conversely, there are reasons not to expect too much of it.
- By the time things reach here, it is usually a design gap above
- The state may already be corrupted
- Locks may be held, making heavy work dangerous here
- Even if continuing looks possible, continuing is not necessarily safe
There are also practical .NET-specific cautions worth knowing.
AppDomain.UnhandledExceptionis an event for notification and recording of unhandled exceptions. Packing recovery logic into it is dangerous.- WPF’s
DispatcherUnhandledExceptionoffers the path of settingHandled = trueand apparently continuing, but judging whether recovery is possible comes first. - WinForms’
ThreadExceptionlikewise can leave the application in an unknown state after handling. - ASP.NET Core’s exception-handling middleware must be placed early in the pipeline so it can catch exceptions from what follows.
- An unhandled exception in a
BackgroundServiceis, in .NET 6 and later, logged and by default stops the host. Stopping and riding the restart strategy is sometimes safer than smothering everything in the parent loop.
In desktop apps especially, the path of catching the unhandled exception and continuing exists. But being able to continue and being right to continue are different things.
4.6. Viewed along a single call chain
For example, consider a flow like this.
flowchart LR
A["UI / Controller / Job boundary"] --> B["Application Service / UseCase"]
B --> C["Domain / business logic"]
C --> D["Repository / Gateway / SDK wrapper"]
D --> E["DB / HTTP / File / Vendor SDK"]
The roles then split roughly like this.
Save button → SaveOrderUseCase → PaymentGateway → HTTP
PaymentGateway- Receives communication failures and malformed responses
- Translates them into payment service connection failure and invalid payment service response
- If retrying, does so here, conditionally
- If rethrowing, normally does not write the primary log
SaveOrderUseCase- Turns expected failures like payment rejection into results
- Treats it as only this order confirmation having failed
- Shapes the failure result so the UI or API can return it easily
- UI button handler / Controller
- Receives unexpected exceptions collectively
- Writes the primary log with
orderId,userId,requestId - Converts into a dialog or a 500 / 503 response
- The unhandled exception handler
- Records only what leaked all the way here
- Performs dumps and the final flush
- Prioritizes the exit path, not recovery
With this division, you get the shape of technical details closed off below, operational context attached above, and decisions made at the boundary.
The logs each layer actually writes
Spelling out what each layer actually writes for one and the same failure makes the division concrete. The scenario for this single order: the payment service timed out twice, succeeded on the third attempt, and then an invariant broke during the subsequent inventory allocation.
| Layer | Log written | Level | Example message |
|---|---|---|---|
PaymentGateway |
Each retry attempt | Warning |
Retrying the connection to the payment service. attempt={Attempt}/{MaxAttempts}, orderId={OrderId} |
PaymentGateway |
When translating and rethrowing | None | - (the primary log is the boundary’s job) |
SaveOrderUseCase |
When an expected failure is turned into a result | Information |
The order payment was declined. orderId={OrderId}, reason={DeclineReason} |
SaveOrderUseCase |
An unexpected exception | None | - (passed straight to the boundary) |
| UI button handler / Controller | The primary log for an unexpected exception | Error |
Failed to confirm the order. orderId={OrderId}, userId={UserId} plus the exception object |
| Unhandled exception handler | The final record | Critical |
Terminating the process because of an unhandled exception plus the exception object |
The point is that only one line comes out at Error. Each retry attempt is Warning and the expected failure is dropped to Information, so searching for Error returns this incident as a single hit.
// The primary log. Pass the exception object as the first argument and attach the failure-unit context by name
_logger.LogError(ex, "Failed to confirm the order. orderId={OrderId}, userId={UserId}",
orderId, userId);
If you forget to pass the exception object as the first argument, the stack trace is not recorded. Writing _logger.LogError(ex.Message) and passing only a string leaves you unable to trace the cause afterward.
5. Separate Expected Failures from Unexpected Exceptions
The most important thing in this whole topic is not treating everything as the same exception.
Start by splitting like this.
| Kind of failure | First place to handle | Typical treatment |
|---|---|---|
| Validation defects | UseCase / request boundary | Return as an input error |
NotFound / Conflict |
UseCase / Controller | 404 / 409 or an on-screen message |
| User cancellation / shutdown | Operation boundary | Treat as cancellation. Normally not Error |
| One bad CSV row | Per-row boundary | Record as Warning, move on |
| Transient timeouts that ultimately fail | I/O boundary to request boundary | Return as failure after retries |
NullReferenceException, broken invariants |
request / job boundary | Primary log and a failure response |
AccessViolationException, severe OutOfMemoryException, signs of native-boundary corruption |
Final boundary | Critical, lean toward exiting |
Expected failures are failures that can be decided in advance by design. Unexpected exceptions are failures after which it is doubtful the state can still be trusted.
Just separating these two reduces breakage like:
- Logging
NotFoundatErrorevery time - Treating user cancellation as an outage
- Letting a genuinely dangerous broken invariant slide as a one-off failure
6. Where and How Many Times Should Logs Be Written?
In log design, deciding who writes the primary log matters more than where the catch sits.
There are six basic rules.
- One primary
Error/Criticallog per failure - Lower layers do translation and context enrichment as needed
- The upper boundary writes the primary log with the failure unit and operational context
- Only the layer that swallows a failure on the spot owns the responsibility of recording it
- Do not log expected failures at
Errorevery time - Keep
OperationCanceledExceptionseparate from ordinary failure logs
Here is a rough table of logging locations. Where the table in section 3 was about which responsibility sits in which layer, this one is about where and at what level to record each kind of failure. When you are mid-implementation and stuck on whether this catch should log, look here.
| Situation | Main place to log | Level guideline | Notes |
|---|---|---|---|
| Validation error | request / use case boundary | Information or no log |
A contractual failure, not an outage |
| User cancellation / shutdown | Operation boundary | Debug / Information |
Normally not Error |
| Transient failure during retry | The layer that owns the retry | Debug / Warning |
Don’t make noise before the final failure |
| Retries exhausted, failed | request / job boundary, or the layer swallowing it | Warning / Error |
Record with the failure unit |
| Only one bad row, processing continues | item boundary | Warning |
Attach fileId and rowNumber |
| Unexpected exception failing the whole request | request / UI / job boundary | Error |
Attach requestId, userId, entityId |
| Process-exit class | Unhandled exception boundary | Critical |
Flush, dump, restart path |
A very common pattern in practice is duplicate logging like this.
- The Repository logs
Error - The Service logs the same exception at
Error - The Controller logs
Erroragain - The final unhandled exception handler logs
Criticaltoo
That way, one incident produces multiple copies of the same stack trace side by side. What the reader actually wants is not four copies of the same stack trace, but one primary log plus, if needed, a small number of supporting logs.
In other words, the baseline is: log once, with as much context as needed.
7. Common Anti-Patterns
From here on are the styles that actually come up in reviews. The three most representative ones come with minimal NG and OK code. The code assumes C# 10 / .NET 6 or later with nullable reference types enabled, and uses System.Text.Json and Microsoft.Extensions.Logging.
7.1. catch (Exception) deep down, returning null / false
This easily drops the information about the cause. Worse, the caller can no longer tell whether the data genuinely was not there or whether something broke along the way.
// NG: catch broadly in a deep layer and return null
private static Order? LoadOrder(string path)
{
try
{
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<Order>(json);
}
catch (Exception)
{
// The caller cannot tell whether the file was missing, the JSON was corrupt,
// or the disk could not be read
return null;
}
}
This layer cannot decide how the failure should be handled. Hand the decision to the boundary and stop here at translating into a meaningful failure.
// The exception type this layer throws, representing a meaningful failure
public sealed class OrderFileFormatException : Exception
{
public OrderFileFormatException(string message, Exception? innerException = null)
: base(message, innerException)
{
}
}
// OK: translate only, and hand the decision to the boundary above
private static Order LoadOrder(string path)
{
string json = File.ReadAllText(path);
try
{
return JsonSerializer.Deserialize<Order>(json)
?? throw new OrderFileFormatException($"The order file is empty: {path}");
}
catch (JsonException ex)
{
// JsonException is a lower-implementation concern; turn it into a failure meaningful at this layer
throw new OrderFileFormatException($"The order file has an invalid format: {path}", ex);
}
// IOException and UnauthorizedAccessException are not translated and go straight up.
// Being unable to read the file is not a failure this layer can add meaning to
}
7.2. Logging Error at every layer before rethrowing
The most common source of duplicate logs.
- Lower layers only translate
- The upper boundary writes the primary log
With this division, it drops off considerably.
// NG: the lower layer logs and then rethrows, so the same exception is logged above as well and you get two entries
public async Task<Receipt> ChargeAsync(Payment payment, CancellationToken ct)
{
try
{
return await _gateway.ChargeAsync(payment, ct);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "The payment failed");
throw;
}
}
Have the lower layer translate and pass through, nothing more.
// PaymentGatewayException is a custom exception type of the same shape as
// OrderFileFormatException, representing a failed exchange with the payment service
// OK: the lower layer (PaymentGateway) only translates. It writes no log
public async Task<Receipt> ChargeAsync(Payment payment, CancellationToken ct)
{
try
{
return await _gateway.ChargeAsync(payment, ct);
}
catch (HttpRequestException ex)
{
throw new PaymentGatewayException(
$"Could not connect to the payment service. orderId={payment.OrderId}", ex);
}
}
On top of that, write the primary log exactly once at the boundary where the failure unit and the operational context are both available.
// OK: write the primary log once at the boundary and decide the response to the caller
[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)
{
// This is the only place where the failure unit (one payment for this order)
// and the operational context are both available
_logger.LogError(ex, "Payment for order {OrderId} failed", orderId);
return StatusCode(StatusCodes.Status502BadGateway);
}
}
}
When rethrowing in C#, the rule is to use throw; so the stack trace is not destroyed. Writing throw ex; overwrites the stack trace at that line, and the real origin of the exception is lost.
7.3. Library layers or shared components showing UI directly
When a shared component shows a MessageBox or directly decides an HTTP response body, both reusability and separation of responsibilities collapse.
Lower layers are safer when limited to returning a meaningful failure.
7.4. Logging OperationCanceledException as an outage at Error
Cancellation is part of control flow.
Logging it at Error every time buries the real outages.
// NG: a broad catch that sweeps up cancellation as well and logs it at Error
try
{
await _useCase.ImportAsync(file, ct);
}
catch (Exception ex)
{
// Even when the user just pressed Cancel, control lands here and an Error is written
_logger.LogError(ex, "The import failed");
throw;
}
Because catch clauses are evaluated top to bottom, catch cancellation first, with a more specific type. Adding a when clause keeps you from confusing an abort caused by the token you passed in with an OperationCanceledException raised for some other reason, such as an internal timeout.
// OK: pick up cancellation first and keep it out of the failure logs
try
{
await _useCase.ImportAsync(file, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// A user abort or a shutdown. It is part of control flow, so it is not an Error
_logger.LogInformation("The import was aborted. fileId={FileId}", file.Id);
}
catch (Exception ex)
{
// Only unexpected failures land here. Write the primary log once, with the failure-unit context
_logger.LogError(ex, "The import failed. fileId={FileId}", file.Id);
throw;
}
7.5. Retrying casually despite external side effects
Many operations go wrong if performed twice: sending email, charging payments, device commands, file moves. Retry only when both transience and idempotency are visible.
7.6. Trying to recover everything in the final unhandled exception handler
This is the last insurance policy. It is not the place to put at the center of your design.
The recovery strategy is safer one layer earlier — at the request / job / subsystem boundary.
8. A Review Checklist
When reviewing exception handling, going in this order leaves few gaps.
- Can you state in one sentence what decision this
catchexists to make? - Can this location truly decide retry / result-mapping / continuation / the user response?
- If we log here, will the same failure also be logged at
Errorabove? - Are lower-implementation-specific exceptions translated into meaningful failures at the boundary?
- Can half-broken state be restored here? If not, is it treated as disposable?
- Is
OperationCanceledExceptionkept separate from ordinary failures? - Is it clear whether this is per-item continuation, per-request failure, or process exit?
- Is the final unhandled exception handler expected to record, not to recover?
- Do logs carry the failure-unit context: requestId / userId / batchId / fileId / rowNumber?
- Are expected failures and broken invariants being treated the same?
What pays off most in this checklist is putting what is this catch deciding? into words every single time.
A catch you cannot answer that for is usually unnecessary, or sitting too deep.
9. A Rough Cheat Sheet
Finally, here is a table for checking that folds sections 3 and 6 onto a single sheet. It is meant to be the only thing you need to look at during a review, or when going back over code you have just finished writing.
| Situation | catch |
Log | Error handling |
|---|---|---|---|
| helper / utility | As a rule, no | No | No |
| Repository / Gateway / SDK wrapper | Specific exceptions only | Usually no primary log | Translation, local retry, discarding connections |
| UseCase / Application Service | Receive expected failures | As needed if swallowing | Result-mapping, partial-failure handling |
| UI / Controller / request / item / job boundary | Receive unexpected exceptions broadly | Primary log | Response, message, continue / abort |
| Unhandled exception handler | Only what leaked | Critical |
Final recording, exit path |
When in doubt, these five alone are enough.
- Don’t catch broadly in deep layers
- Catch at boundaries
- One primary log
- The swallowing layer owns the responsibility
- The final unhandled exception means recording and the exit path
10. Summary
Exception handling is not a matter of catching everywhere just because you can catch anywhere.
The order to check is roughly this, and it is enough.
- Can this location truly make the decision?
- Is the failure unit knowable here?
- Can the state be restored or rebuilt here?
- Will logging here cause duplication?
- Is this a recovery point, or the last recording point?
Checking in this order makes organizing the call hierarchy much easier.
The three things that matter most:
- Deep layers: mainly translation and cleanup
- Boundaries: mainly decisions and the primary log
- The final unhandled exception handler: mainly recording and the exit path
Put differently, the baseline is: catch exceptions at boundaries, attach context, and handle them only where recovery is possible.
Once this is settled, both code reviews and incident investigations become far less wobbly.
11. References
- .NET: Best practices for exceptions
- .NET: System.AppDomain.UnhandledException event
- WPF: Application.DispatcherUnhandledException event
- Windows Forms: Application.ThreadException event
- Handle errors in ASP.NET Core
- ASP.NET Core Middleware
- Windows Services using BackgroundService
12. Related Articles
- A Decision Table for Whether to Exit or Continue After an Unexpected Exception
- Minimum Requirements for a Custom Logger, with an Integration Test Checklist
- What Is the .NET Generic Host? - The Foundation for DI, Configuration, and Logging
- Where to Draw the Line Between Unit Tests and Integration Tests
Related Articles
Recent articles sharing the same tags. Deepen your understanding with closely related topics.
A Decision Table for Whether to Exit or Continue After an Unexpected Exception
When an unexpected exception occurs, should the app exit or keep running? We organize the decision from the perspectives of state corrupt...
Stop Using Write-Host — PowerShell Output Streams and Log Design
How to choose between PowerShell's six output streams, the problems with Write-Host and where it genuinely belongs, why function return v...
Designing Windows Apps to Leave Logs and Dumps When They Crash
How to combine regular logging, a final crash marker, WER LocalDumps, and a watchdog process so that even when a Windows app dies from an...
A Minimum Security Checklist for Windows App Development
A checklist-style guide to the security basics for WPF / WinForms / WinUI / C++ / C# business apps: privileges, signing, updates, secrets...
Practical Multithreading Best Practices: Java Edition — Conventions for the Virtual Thread Era
In Java, the established practice for multithreading is never to create threads directly but to build on ExecutorService and virtual thre...
Related Topics
These topic pages place the article in a broader service and decision context.
Windows Technical Topics
Topic hub for KomuraSoft LLC's Windows development, investigation, and legacy-asset articles.
Where This Topic Connects
This article connects naturally to the following service pages.
Windows App Development
We support Windows desktop applications that involve resident processing, device integration, operational logging, and maintainable structure.
Bug Investigation & Root Cause Analysis
We investigate difficult production issues such as intermittent failures, long-run crashes, leaks, and communication stoppages.
Frequently Asked Questions
Common questions about the topic of this article.
- In which layer should exceptions be caught?
- As a rule, do not catch broadly in a deep layer; push the catch toward a boundary where a failure unit can be defined. Processing boundaries such as one UI interaction, one HTTP request, one job, or one message make natural receivers. The baseline is to catch not where you are able to catch, but where you can take responsibility for deciding retry, result-mapping, and whether to continue. Deep helpers and utilities should stop at cleanup in finally, local rollback, and exception translation.
- Should exceptions be logged at every layer?
- The baseline is one primary Error or Critical log per failure. If the Repository logs Error, the Service logs the same exception at Error, and then the Controller logs Error again, a single incident lines up several copies of the same stack trace and the reader suffers. Lower layers should stop at translation and context enrichment, and the primary log belongs at the upper boundary where operational context such as requestId and userId is available. Only the layer that swallows an exception and turns it into a result owns the responsibility of recording that failure.
- How should expected failures be separated from unexpected exceptions?
- Expected failures are failures you can decide in advance by design: validation defects, NotFound, and the like are turned into results at the unit of the use case rather than logged at Error every time. An OperationCanceledException caused by user cancellation is normally not treated as Error either. Broken invariants such as NullReferenceException get the primary log at the request or job boundary plus a failure response, while AccessViolationException and a severe OutOfMemoryException are treated as Critical and lean toward exiting. Just separating these two makes it far less likely that a genuinely dangerous failure gets buried.
- What should the unhandled exception handler do?
- AppDomain.UnhandledException, WPF's DispatcherUnhandledException, WinForms' ThreadException, and the like are the last recording point, not a recovery point. Their main responsibilities go as far as the final log, flushing, a path to dump collection, and setting up exit codes and the restart path. By the time an exception has leaked this far the state may already be corrupted, so being able to continue in appearance does not mean continuing is safe. It is safer to put the recovery strategy at the request or job boundary before it.