Where Should catch and Logging Go in Exception Handling?
· Go Komura · Exception Handling, Logging, Error Handling, Design, C# / .NET
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
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 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:
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
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
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 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.
-
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 “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/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 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
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.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 “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.
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” / “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
NotFoundatErrorevery 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.
- 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.
| 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
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
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
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, 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.
- Don’t grab 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 “we can catch anywhere, so we catch everywhere.”
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 Checklist for When an Unexpected Exception Occurs - Should the App Exit or Continue? The Decision Table to Check First
- When You Can’t Avoid a Custom Logger, What Are the Truly Necessary Minimum Requirements? Practical Requirements and Integration Test Perspectives
- What Is the .NET Generic Host? - DI, Configuration, Logging, and BackgroundService, Organized First
- How Far Should Unit Tests Go, and Where Should Integration Tests Take Over? - Drawing the Boundary and a Practical Decision Table
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...
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...
Minimum Requirements for a Custom Logger, with an Integration Test Checklist
To make a custom app's diagnostic logs trustworthy, we lay out UTF-8 JSON Lines, the required fields, flush, rotation, and failure behavi...
Where to Draw the Line Between Unit Tests and Integration Tests
We organize the boundary between unit tests and integration tests along the axes of pure logic, formats, wiring, environment differences,...
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.
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.
Public links