Where to `catch`, log, and handle exceptions — sorting out call-hierarchy boundaries and responsibilities for real-world code
Introduction
Three problems show up over and over in exception handling:
- broad
catchdeep inside helpers that returnsnullorfalseand erases the cause - every layer logging the same exception, turning the log into noise
- expecting too much recovery from the last-resort unhandled-exception handler
1. Short version
- don’t broadly catch in deep layers (helpers, etc.)
- catch at the boundary where you can actually decide what to do with the failure
- one failure, one primary log — that’s the baseline
- the deepest layer’s job is cleanup and translating exceptions. if you rethrow, don’t emit the primary log
- “processing boundaries” — a screen action, an HTTP request, one job item — are the right place for the primary log
- expected failures should become results, not exceptions you keep throwing
2. catching, logging, and error handling are different things
| action | meaning |
|---|---|
| catch | receiving an exception and changing the flow. not recovery |
| log | a record so you can trace later which work failed |
| error handling | deciding the shape of the failure (screen message, HTTP response, skip and continue, etc.) |
| translating exceptions | turning a low-level concrete exception into a failure that means something at the current layer |
3. Responsibilities by call-hierarchy level
| location | default policy | log | main responsibility |
|---|---|---|---|
| helper / utility | don’t broadly catch | none | cleanup via finally, local rollback |
| Repository / Gateway | catch concrete exceptions only | usually none | exception translation, limited retry, tearing down connections |
| UseCase / Service | turn expected failures into results | only if you swallow, and only as needed | defining the unit of failure, partial-failure handling |
| UI / Controller / Job boundary | the catch-all for unexpected exceptions | primary log goes here | response, HTTP 500, continue to the next item |
| unhandled-exception handler | only what slipped through | Critical | last-resort record, shutdown path |
4. What each layer actually does
4.1 Deep layers (helper / utility)
- okay: releasing resources in
finally, local rollback, adding minimal context to an exception message - not okay:
catch(Exception)and returningnull/false/ an empty array, logging here, showing UI here
4.2 External I/O boundary (Repository / Gateway)
- catches concrete exceptions like
HttpRequestExceptionorIOException - translates them into something meaningful (“payment service connection failed,” “CSV format invalid”) and rethrows
- if you retry, do it here — but only when the operation is idempotent
- if you rethrow, don’t emit the primary log
4.3 UseCase / Application Service
- turns expected failures (validation, NotFound, business-rule violations) into a result type or a failure DTO
- decides the “meaning at the use-case level” and returns it upward
- doesn’t assemble UI strings or HTTP response bodies here
4.4 Boundary (UI / Controller / Job)
- the place that catches unexpected exceptions wholesale
- emits one primary log with requestId, userId, orderId, etc.
- converts to an error dialog, HTTP 500, job failure, and so on
4.5 Unhandled-exception handler
- final log, flush, dump collection, exit-code setting
- don’t expect to recover here. if it reached this point, there’s a design gap
- WPF’s
DispatcherUnhandledExceptionand friends let you setHandled = trueand continue, but “continuing” isn’t the same as “safe”
5. Classify the kinds of failure
| kind of failure | where to handle | typical treatment |
|---|---|---|
| validation error | UseCase / boundary | return as input error |
| NotFound / Conflict | UseCase / Controller | 404 / 409 etc. |
| user cancellation | the operation boundary | treat as cancel, not Error |
| one bad CSV row | per-row boundary | log Warning and move on |
| transient timeout that ultimately fails | I/O through request boundary | return as failure after retry |
| NullReferenceException | request / job boundary | primary log, failure response |
| AccessViolationException and similar | final boundary | Critical, lean toward shutdown |
6. Logging rules
- one primary log (Error / Critical) per failure
- lower layers only translate and add context — no primary log
- the upper boundary writes the primary log with full context
- only the layer that swallows owns the recording
- don’t log every expected failure as Error
- keep
OperationCanceledExceptionseparate from incident logs (Debug / Information is plenty)
Classic duplicate-logging pattern
Repository logs Error -> Service logs the same exception as Error -> Controller logs Error again -> unhandled handler logs Critical
-> four copies of the same stack trace. what you want is one primary log plus the minimum supporting logs.
7. C# notes
// rethrow without breaking the stack trace
throw; // OK
throw ex; // NG: stack trace gets reset
8. Wrap-up
- deep layers do translation and cleanup. no broad catch
- boundaries do the deciding and the primary log. catch where you can actually take responsibility
- log once, and add only as much context as you need
- catch at boundaries, and only handle where you can recover
When in doubt, walk through these in order:
- can this layer actually make the decision
- is the unit of failure visible here
- can state be rolled back here
- will logging here cause duplication
- is this a recovery point, or the last place to record
Related Articles
Recent articles sharing the same tags. Deepen your understanding with closely related topics.
Checklist for Unexpected Exceptions - A Quick Decision Table for Whether to Exit or Keep Running
A practical guide for deciding whether an app should exit or keep running when an unexpected exception occurs. Three options and a decisi...
When You Can't Avoid Building Your Own Logger: Practical Minimum Requirements and Integration Test Checks
When you have no choice but to build a custom application logger, here are the minimum requirements to lock down first and the integratio...
How to Reliably Capture Logs When a Windows App Crashes from a Programming Bug - Designs That Don't Bet on In-Process Logging, Plus Best Practices for WER, Final Markers, and Watchdogs
How to keep evidence when a Windows app crashes from an unexpected exception: how routine logs, a final crash marker, WER LocalDumps, and...
Why Windows Code Should Prefer Event Waits Over Timer Polling - Avoiding ~15.6 ms Granularity
Sleep and timed waits on Windows are bound to a clock granularity of about 15.6 ms, so they are rarely as precise as they look. Here is w...
A Minimum Security Checklist for Windows Application Development
A minimum security checklist for Windows app development (WPF, WinForms, WinUI, C++, C#) covering privileges, signing, secrets, communica...
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.