Most catch blocks shouldn’t exist.

Not “most catch blocks are wrong” (though many are). Most of them have no reason to be there. Someone felt nervous about an exception propagating and stuck a try/catch around it like a security blanket.

The principle is simple:

Exceptions move up the stack until they reach a layer with enough context to do something meaningful with them.

Every catch block should exist because of a deliberate decision, not because letting an exception fly felt irresponsible.


The Comfort Catch

This one shows up everywhere.

try {
    SendNotification(user);
} catch (Exception) {
    // notifications aren't critical
}

This looks pragmatic. Notifications aren’t critical, so why let them crash anything? Because three weeks from now when notifications silently stop working for all users, nobody will know. The system will look fine. The feature will be gone. You’ll find it eventually, after someone files a ticket that turns into an investigation that turns into someone grepping the codebase for swallowed exceptions.

“Catch and ignore” isn’t handling. It’s hiding.


Five Patterns Worth Knowing

Most catch blocks that deserve to exist fall into a small number of patterns. Having names for them makes code reviews faster and design conversations less circular.

Translate and rethrow. You’re at a boundary between layers. The database threw a SqlException; your caller shouldn’t know you’re using SQL. Catch it, wrap it in something meaningful like DataAccessException, preserve the original as the inner exception, throw. The caller gets a contract they can reason about.

Handle and continue. You’re the layer with context. A 404 from an API call means “this user doesn’t exist yet” and that’s fine in your flow. Catch that specific case, produce a meaningful result. This only works when you catch narrowly; if you’re catching the base Exception type, you don’t understand the failure modes well enough.

Log and rethrow. You’re middleware or a decorator. You want to record what happened without changing the outcome. Use throw; (bare rethrow), never throw ex;. The second version resets the stack trace and destroys the diagnostic trail that makes the log entry useful in the first place.

Respond and stop. You’re the top of the pipeline. Global error handler, outermost middleware, the last line of defence. Convert the exception into a user-facing response. Never expose stack traces, connection strings or SQL to users. If most of your exceptions reach here, your handling everywhere else is too shallow.

Compensate and rethrow. You charged a payment, then the next step failed. Refund the payment, then let the exception propagate. The caller still needs to know the operation failed. And your compensation logic needs to be resilient on its own; if the refund fails, you need a dead letter queue or a manual review flag, not another catch block.

That’s it. Five patterns. If your catch block doesn’t map to one of these (or a deliberate combination), question whether it should exist.


The Redundant Logging Trap

This happens in layers:

// Repository layer
catch (SqlException ex) {
    logger.LogError(ex, "Query failed");          // Log #1
    throw new DataException("Query failed", ex);
}

// Service layer
catch (DataException ex) {
    logger.LogError(ex, "Data operation failed");  // Log #2
    throw;
}

// Controller layer
catch (Exception ex) {
    logger.LogError(ex, "Request failed");         // Log #3
    return StatusCode(500);
}

One failure produces three log entries with slightly different wording, none contributing information the others don’t already have.

The fix: log where you handle the exception, or at one dedicated observation point. Translate without logging at boundaries. If multiple points need to log the same exception, each should contribute context that’s actually different (a correlation ID, a queue message ID, a user session). Not a rephrased version of the same message.


Infrastructure vs. Domain

These are different failure categories and mixing them in a single exception hierarchy creates awkward types.

Infrastructure exceptions are about mechanism. The network failed. The database timed out. The file was locked. Some of these are transient (retry might help). Some aren’t. The IsTransient property makes sense here.

Domain exceptions are about meaning. Insufficient funds. Invalid state transition. Business rule violation. These are never transient in the infrastructure sense; the input is wrong until someone changes it. But they’re often user-facing, so they carry a message safe for display.

Merging these into one base type gives you properties that apply unevenly. IsTransient on a domain exception is conceptually wrong. UserMessage on a low-level infrastructure exception is meaningless. Separate hierarchies, shared interface where needed (IUserFacingException for cases where both need a user message).


When Exceptions Are Wrong

Validation failures aren’t exceptional. A user submitting bad input is one of the three things your application definitely does. Return a result with errors.

“Not found” as a query result: if you’re checking whether something exists, null or an option type is clearer. If you’re fetching something that should be there and it isn’t, that’s an exception.

Using exceptions for control flow is the worst version. The try-get-catch-create pattern reads like a branching statement wearing a disguise. It’s slower (especially in environments with execution limits like Salesforce Apex, where the CPU cost is a governor limit concern). But the bigger problem is readability. catch means “something went wrong.” If nothing went wrong, don’t use catch.


The Checklist

When writing a catch block:

  1. What specific exception am I catching? If “all of them,” stop.
  2. Which of the five patterns is this? If none, the catch block probably shouldn’t exist.
  3. Am I the right layer? If you lack context to make a recovery decision, translate and rethrow.
  4. Is this log entry new information? If another layer already logs this with equivalent context, remove yours.
  5. Am I preserving the original exception? Inner exception. Always.

The instinct to catch everything comes from a good place. You don’t want your application to crash. But an application that silently fails is worse than one that crashes loudly. The crash gets fixed in an hour. The silent failure gets found in a month, if you’re lucky.

Throw where you understand the failure. Catch where you understand the context.