Where Should catch and Logging Go in Exception Handling?

· · Exception Handling, Logging, Error Handling, Design, C# / .NET

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

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 restart to the supervisor
  • 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

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

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 keep 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 “Rebuild the connection” is often safer than “keep 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 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

So it 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

“Fail item by item and continue” and “the parent loop silently stays 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 “catch the unhandled exception and continue” 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” / “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 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.

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 accidents like:

  • Logging NotFound at Error every time
  • Treating user cancellation as an outage
  • Letting a genuinely dangerous broken invariant slide as “failed just this once”

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.

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
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

7.1. catch (Exception) deep down, returning null / false

This easily drops the information about the cause. Worse, the caller can no longer tell “the data genuinely wasn’t there” from “something broke along the way.”

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.

When rethrowing in C#, the rule is to use throw; so the stack trace is preserved.

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.

7.5. Retrying casually despite external side effects

Many operations are accidents waiting to happen 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, compressed hard, it comes down to this table.

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 grab 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 “we can catch anywhere, so we catch everywhere.”

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.

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