Stop Catching Exceptions You Cannot Handle

by Remy van Duijkeren | Apr 19, 2026 | Blog

Catching exceptions is not the same as handling them. Most developers do not know the difference, and it costs them.

I was reviewing a pull request last week. Standard stuff: integration code, talking to an external API, processing some data. The code was wrapped in try...catch. Not a specific exception. Not a recovery path. Just catch (Exception ex), a log line, and then execution continuing as if nothing happened.

I left a comment: "Why are you catching this here?"

The reply: "Otherwise the app would crash."

I wrote back: "That is what we want."

The silence on the other end was familiar. I have had this exact exchange more times than I can count. Junior developers, seniors, code reviews, architecture sessions. The reaction is always the same. Disbelief. Sometimes mild offense. Like I suggested something reckless.

I did not. Letting your app crash on an unhandled exception is not reckless. It is correct.

The Misunderstanding

Developers learn try...catch early. It is in every tutorial, every beginner guide, every "handling errors in C#" example. Learn the pattern. Apply the pattern. Safety achieved.

The problem is that catching exceptions and handling exceptions are not the same thing.

Catching means you intercepted it. Handling means you did something meaningful in response. Most try...catch blocks I see in production code do one but not the other. They catch, they maybe log, and they let execution continue. The app is now running in a state it never expected to be in. But at least it did not crash.

That is not stability. That is a slow disaster with better optics.

What An Exception Actually Is

When your application throws an exception, it is not a bug report waiting to be filed. It is the runtime saying: I have encountered a state I cannot reason about. I do not know what to do from here.

The correct response to that is not "keep going." It is: stop, surface the problem, understand the state that caused it, and fix it.

Consider System.OutOfMemoryException. Your application has just been told it cannot allocate any more memory. What is the recovery plan? Log a message and continue processing the order queue? There is no meaningful path forward from that state. Catching exceptions like this and continuing is not resilience. It is pretending the problem did not happen.

Most exceptions are not that extreme, but the principle holds. An exception means your application is in unexpected territory. The fastest way to learn what that territory looks like is to let it crash and read the full picture: the stack trace, the state at the time, the exact path that led there.

Swallowing the exception hides that picture. You fix it later. If you notice it at all.

What To Do Instead: Global Exception Handler

The first step is not more try...catch. It is a global exception handler.

This is a single place in your application that catches every unhandled exception, logs it thoroughly, and then lets the crash happen. You get full visibility. You lose nothing by not catching.

In .NET, the AppDomain.UnhandledException event is the hook for this. It looks something like:

AppDomain.CurrentDomain.UnhandledException += (sender, args) =>
{
    var ex = (Exception)args.ExceptionObject;
    logger.Fatal(ex, "Unhandled exception. Application is terminating.");
    Log.CloseAndFlush(); // flush buffered logs before the process exits
};

Most logging frameworks buffer writes for performance. If your process exits before the buffer flushes, you lose the log. Force the flush. Make sure the crash is loud.

With a global handler in place, you do not need to scatter try...catch across your codebase to preserve observability. The handler does that job once, at the top, for everything.

The Real Work: Prevent The State

Once you have seen the exception and know what state your application was in when things went wrong, the goal is not to catch that exception. It is to prevent the state that caused it.

That means validating before you act. For argument validation, .NET 6 and up gives you ArgumentNullException.ThrowIfNull — a clean one-liner that replaces the old manual null check pattern.

// validate arguments at the entry point
ArgumentNullException.ThrowIfNull(serviceProvider);

// validate state before using it
if (string.IsNullOrWhiteSpace(customerId))
    throw new ArgumentException("Customer ID is required.", nameof(customerId));

// check before access
if (items.Contains(key))
{
    // now it is safe to use
}

Think about every method as a box. Something comes in. Something goes out. What are the valid inputs? What makes an output valid? Be explicit about those assumptions, especially at boundaries your code does not control.

External system boundaries are where defensive programming is non-negotiable. API calls, database queries, file I/O, network operations. Never assume the state you need. Always check. The moment you cross into territory you do not own, validate everything before you act on it.

For internal assumptions you are guaranteeing about your own code, Debug.Assert is the right tool, not a try...catch. But that is a separate conversation.

When Catching Exceptions Actually Makes Sense

It does exist. Here are the legitimate cases.

You are at a true external boundary and cannot validate state before the call. The call might fail in a way you cannot predict, and you know specifically which exception that produces and what to do when it happens.

You need to add context before rethrowing. You caught a low-level exception and want to wrap it in something that makes sense to your caller, without losing the original.

You need to clean up resources. The finally block matters here more than the catch.

The rules that apply in every case (these also align with Microsoft's own exception handling guidelines):

  • Never catch System.Exception broadly. It catches everything, including bugs you need to see.
  • Catch only the specific exception type, and even the specific description, you understand and have a response to.
  • If you do not know what to do after catching it, do not catch it.
  • Never swallow it silently. A catch block that does nothing is an invisible bug factory.
  • Do not catch locally just to log. That is what the global handler is for.
  • If you catch and cannot fully handle, rethrow.

Rethrow Correctly: This One Detail Matters

Two ways to rethrow. One is right. One destroys your stack trace.

Wrong:

catch (Exception ex)
{
    throw ex; // resets the stack trace, you lose the original call location
}

Right:

catch (Exception)
{
    throw; // preserves the original stack trace
}

When adding context:

catch (IOException ex)
{
    throw new InvalidOperationException(
        $"Failed to process order {orderId} due to I/O error.", ex);
}

The original exception becomes the inner exception. The full chain is preserved. When you are debugging at 11pm trying to figure out what broke, you will want that chain.

throw ex; resets everything. You get a stack trace that starts at the rethrow point, not the actual origin. Never do it.

Closing

Stability does not come from catching everything. It comes from surfacing problems fast, understanding the state that caused them, and eliminating that state.

The apps I have seen with the most try...catch blocks are always the hardest to debug. Not because the developers were careless. Because every catch block is a decision to suppress information. Enough suppressions and the system is opaque. You cannot tell what it is doing or why.

There is an Agile principle hiding in here. Agile is about short feedback loops: ship small, learn fast, improve continuously. Letting your app crash is exactly that applied to error handling. You crash, you see the problem, you fix the root state, you ship again. Repeat. Over time the app becomes genuinely stable because you have eliminated real failure paths, one by one, with evidence.

The try...catch-everything approach breaks that loop. Problems get swallowed. Feedback is delayed or lost. You never learn because the system never tells you. The app looks stable until it does not, and by then the cause is buried under months of suppressed exceptions.

Let things crash when they should crash. Log aggressively. Fix the root state. That is how you build software that actually stays stable over time.

Related