Where Should catch and Logging Go in Exception Handling?

· Updated: · · 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 Error log 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

  1. The Conclusion First
  2. catch, Logging, and Error Handling Are Different Things
    • 2.1. Catching
    • 2.2. Logging
    • 2.3. Error handling
    • 2.4. Translating exceptions
  3. The Decision Table to Check First
  4. 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
  5. Separate Expected Failures from Unexpected Exceptions
  6. Where and How Many Times Should Logs Be Written?
  7. Common Anti-Patterns
  8. A Review Checklist
  9. A Rough Cheat Sheet
  10. Summary
  11. References
  12. 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.

Where to catch exceptions and where to log themDiagram 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 handlernot recommended forrecommended formay causemay causepreventsimplementsusesimplementsrecommended forimplementsnot recommended formay causemay causeimplementsrecommended forshould come beforerequiresusesusesnot recommended fornot recommended fornot recommended forrecommended forrecommended fornot recommended forException Catch BoundaryPrimary Error Log RecordBroad catch in Deep LayersHelper and Utility Method LayerUnit of FailureDifficulty Tracing Failure CausesDuplicate Error Logs Across LayersRepository/Gateway/SDK Wrapper LayerException Translation at Layer BoundariesConditional Retry at I/O BoundaryApplication Service / Use Case LayerConverting Failures to Result TypesExpected FailureUI/HTTP/Job/Message BoundaryOperationCanceledExceptionthrow ex; Stack Trace OverwriteLoss of Stack TraceUnhandled Exception HandlerProcess Exit and Restart PathUnhandled BackgroundService ExceptionUnexpected Exception (Broken Assumption)

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 catch toward 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’s DispatcherUnhandledException, WinForms’ ThreadException, ASP.NET Core’s exception handler, and the host’s final exception handling are less recovery points than last recording points.
  • OperationCanceledException caused by user cancellation or shutdown is normally not treated as Error.
  • When in doubt, check in this order:
    1. Can this location truly make the decision?
    2. Is the failed unit of work knowable here?
    3. Can the state be restored or rebuilt here?
    4. 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:

  • HttpRequestException
  • IOException
  • JsonException
  • 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.

NoYesNoYesNoYesAn exception occurredCan this location decide retry / result-mapping / whether to continue?As a rule, don't catch; let it propagateIs this a layer boundary?Local cleanup onlyTranslate to a meaningful exception if neededAre the failure unit and operational context known here?Don't write the primary log; pass upwardWrite the primary log once and decide the responseIf needed: exit / reinitialize / continue to next item

This diagram makes two points.

  1. The first reason to catch is recovery or cleanup — not logging.
  2. 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 return null / false / an empty array
  • Showing a MessageBox here
  • Logging at Error here 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.

  1. Catch specific exceptions Not a broad Exception, but specific exceptions that carry meaning.

  2. Translate into meaningful failures So that the upper layers need not know the lower layers’ internals directly.

  3. 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.
  4. 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 / Warning range, 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
  • NotFound corresponds to a 404
  • Business rule violations wait for user correction
  • One bad CSV row is logged as Warning and 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 Result or 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.UnhandledException is an event for notification and recording of unhandled exceptions. Packing recovery logic into it is dangerous.
  • WPF’s DispatcherUnhandledException offers the path of setting Handled = true and apparently continuing, but judging whether recovery is possible comes first.
  • WinForms’ ThreadException likewise 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 BackgroundService is, 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.

UI / Controller / Job boundaryApplication Service / UseCaseDomain / business logicRepository / Gateway / SDK wrapperDB / HTTP / File / Vendor SDK

The roles then split roughly like this.

Save button → SaveOrderUseCasePaymentGateway → 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 NotFound at Error every 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.

  1. One primary Error / Critical log per failure
  2. Lower layers do translation and context enrichment as needed
  3. The upper boundary writes the primary log with the failure unit and operational context
  4. Only the layer that swallows a failure on the spot owns the responsibility of recording it
  5. Do not log expected failures at Error every time
  6. Keep OperationCanceledException separate 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 Error again
  • The final unhandled exception handler logs Critical too

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 catch exists 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 Error above?
  • 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 OperationCanceledException kept 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.

  1. Don’t catch broadly in deep layers
  2. Catch at boundaries
  3. One primary log
  4. The swallowing layer owns the responsibility
  5. 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.

  1. Can this location truly make the decision?
  2. Is the failure unit knowable here?
  3. Can the state be restored or rebuilt here?
  4. Will logging here cause duplication?
  5. 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

Recent articles sharing the same tags. Deepen your understanding with closely related topics.

These topic pages place the article in a broader service and decision context.

This article connects naturally to the following service pages.

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.

Author Profile

Profile page for the article author.

Go Komura

Representative of KomuraSoft LLC

Focused on Windows software development, technical consulting, and investigations into failures that are difficult to reproduce.

Back to the Blog