Where to `catch`, log, and handle exceptions — sorting out call-hierarchy boundaries and responsibilities for real-world code

· · Exception Handling, Logging, Architecture, Reliability, Windows Development

Introduction

Three problems show up over and over in exception handling:

  • broad catch deep inside helpers that returns null or false and 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 returning null / false / an empty array, logging here, showing UI here

4.2 External I/O boundary (Repository / Gateway)

  • catches concrete exceptions like HttpRequestException or IOException
  • 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 DispatcherUnhandledException and friends let you set Handled = true and 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

  1. one primary log (Error / Critical) per failure
  2. lower layers only translate and add context — no primary log
  3. the upper boundary writes the primary log with full context
  4. only the layer that swallows owns the recording
  5. don’t log every expected failure as Error
  6. keep OperationCanceledException separate 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:

  1. can this layer actually make the decision
  2. is the unit of failure visible here
  3. can state be rolled back here
  4. will logging here cause duplication
  5. 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.

Related Topics

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

Where This Topic Connects

This article connects naturally to the following service pages.

Back to the Blog