AlgoMaster Logo

Exception Best Practices

Last Updated: May 17, 2026

17 min read

Exception handling syntax is easy. Knowing when to throw, what to catch, where to wrap, and what to log is the part that separates a healthy codebase from one that swallows bugs and leaks stack traces into log files nobody reads. This chapter pulls together the rules every C# team eventually converges on, with opinions baked in.

Rule 1: Don't Swallow Exceptions

An empty catch block is almost always a bug. It says "if anything goes wrong here, pretend it didn't," which means the next time something does go wrong, you have nothing to work with: no log line, no stack trace, no signal that the operation failed. The order silently fails to ship, the cart silently fails to apply a discount, and three weeks later someone files a support ticket you have no way to diagnose.

That code looks defensive. It is the opposite. The customer sees a success page. The inventory was never updated. The card was never charged. The email never went out. And there is no trace of any of it.

The fix has three options, and which one applies depends on what you actually want to happen on failure.

Option 1: Don't catch. If the caller is better placed to decide what to do, let the exception propagate. This is the right answer most of the time.

Option 2: Catch, log, and rethrow. Use this only at a boundary where you need to add context the caller doesn't have.

Option 3: Catch a specific type and translate it. Wrap the low-level failure in a domain exception so callers can handle it without knowing the implementation detail. We cover this in Rule 7.

What you should never do is the empty catch. There is a vanishingly small list of cases where it's defensible (a fire-and-forget background notification where the only consequence of failure is "the notification didn't go out, and you don't care"), and even then, you should log the exception. If you find yourself writing catch { }, stop and ask: what did the user expect to happen here, and what actually happened? The answer is almost never "nothing."

Rule 2: Catch Specific Types, Not Base Exception

Catching Exception at random points in your code is the close cousin of the empty catch. It works, but it catches too much: a NullReferenceException from a bug in your own code, an OutOfMemoryException from the process running out of memory, an OperationCanceledException from a user clicking cancel, and the actual HttpRequestException you meant to handle. Treating them all the same way is rarely what you want.

That catch (Exception) returns "Description unavailable" whether the call failed because the network is down (a transient error, retryable) or because the code dereferenced a null _client (a bug, not retryable). The user sees a friendly fallback either way, and the bug never surfaces.

The narrower catch tells the truth:

Now an HttpRequestException from a real network failure produces the fallback. A NullReferenceException from a bug propagates up, and the developer sees the stack trace they need to fix it. The point here is the rule: name the exception types you actually expect, and let everything else fly.

There are exactly two places where catching base Exception is the right move:

  1. Program entry points. Main, the top-level handler in a desktop app, the top-level error handler in a web framework. Something has to be there to log the unhandled exception and produce a clean shutdown message instead of a process crash dump.
  2. Message handler loops. A worker that pulls jobs off a queue or processes events one at a time should usually catch Exception around each iteration so a poison message doesn't take down the whole worker. The exception still gets logged, and the message gets routed to a dead-letter queue or marked as failed.

The outer catch is broad because the worker has to keep running. It logs, it routes the bad message somewhere safe, and it moves on. The cancellation case is special-cased with a when filter so a real shutdown still propagates.

Anywhere else, prefer specific types. If the method you're calling can throw three different exceptions and you need to handle them differently, write three catch blocks. If you only care about one of them, catch only that one and let the rest go.

Rule 3: Fail Fast at the Boundary

When invalid input crosses into your code, throw immediately. The further the invalid value travels before you reject it, the more confusing the eventual failure becomes. A null customer parameter that triggers a NullReferenceException six method calls deep is much harder to debug than the same parameter rejected at the top of the entry method with a precise error.

The classic guard pattern was a manual check at the top of every public method:

That works, and you'll see it in many codebases. Since .NET 6, the BCL ships official helpers that do the same thing in one line. These are the modern way to write guards, and most teams have moved to them.

A few details worth knowing:

HelperThrows whenAvailable since
ArgumentNullException.ThrowIfNull(value)value is null.NET 6
ArgumentException.ThrowIfNullOrEmpty(value)value is null or "".NET 7
ArgumentException.ThrowIfNullOrWhiteSpace(value)value is null, "", or whitespace.NET 8
ArgumentOutOfRangeException.ThrowIfNegative(value)value is negative.NET 8
ArgumentOutOfRangeException.ThrowIfGreaterThan(value, max)value > max.NET 8
ObjectDisposedException.ThrowIf(condition, instance)condition is true.NET 7

The helpers use [CallerArgumentExpression] to capture the parameter name automatically, so the resulting exception message names the offending argument without you having to spell it out with nameof(). The error message you see in the log will be "Value cannot be null. (Parameter 'customer')", which is exactly what you want.

Constructors get the same treatment. A Cart constructed with a null owner is a zombie object waiting to break something downstream. Reject it in the constructor:

The point isn't that null is uniquely evil. The point is that invalid state should never be allowed to exist. If you accept a malformed Cart and rely on every downstream method to "be careful," sooner or later one of them won't be, and you'll get a NullReferenceException in a place that has nothing to do with the original mistake.

Opinion: most teams favor the ThrowIf* helpers over hand-rolled checks now. They're shorter, they get the parameter name right automatically, and they're easier to skim past as a reader. If your codebase still uses if (x == null) throw new ArgumentNullException(nameof(x)) everywhere, migrating to the helpers is a one-time cleanup that pays for itself in readability.

There's a related .NET 6+ helper, [StackTraceHidden], that's worth knowing if you write your own guard helpers. Attaching the attribute to a method tells the runtime to omit that method's frame from the stack trace when an exception bubbles out of it. That keeps your stack traces pointing at the real caller instead of at your helper.

If Guard.NotEmpty(productName) throws, the stack trace shows the caller of Guard.NotEmpty, not Guard.NotEmpty itself. For trivial wrappers that aren't where the bug is, that's exactly the right behavior.

Rule 4: Don't Use Exceptions for Control Flow

Exceptions are for the exceptional. The expected outcomes of an operation belong in the return value, not in the catch block. Parsing a coupon code the user typed is not exceptional. Looking up a product that may or may not exist is not exceptional. A user clicking "remove" on an item that's already been removed is not exceptional. Code those as normal control flow, and reserve exceptions for genuine failures.

The classic antipattern:

Every malformed coupon entered by a user goes through a thrown-and-caught exception. On a busy site that's many thousands of throws per minute, each one allocating a stack trace and burning CPU. The same code with TryParse does zero allocation on the failure path:

Try* methods are the .NET convention for "may fail predictably." int.TryParse, decimal.TryParse, DateTime.TryParse, Dictionary<,>.TryGetValue, Queue<>.TryDequeue, ConcurrentDictionary<,>.TryAdd, and so on. They return a bool and use an out parameter to deliver the value on success. The expected-failure path is just if (!TryParse) ..., no exception in sight.

When the BCL doesn't give you a Try* method but the operation has an expected failure mode, write your own:

Two methods, two contracts. TryGetProduct returns false when the product isn't there. GetProduct throws. Callers pick the one that matches their situation: a search feature that might return zero results uses TryGetProduct; an order-creation step that requires the product uses GetProduct and lets the absence propagate as an error.

When the expected outcomes are richer than "found or not found," a Result-style discriminated value works well. C# doesn't have a built-in Result<T>, but it's a small type to write:

The caller can pattern match or check the flag, and the failure modes are all part of the normal return contract. No exception types to design, no try-catch around every call, and the validation logic reads top-to-bottom.

Opinion: most C# teams stop short of building full Result<T> infrastructure with monadic chaining like F# or Rust have. The simple version above is enough for the cases where an exception would be overkill but a bool is too thin to carry the failure reason. For really small failure spaces, the Try* pattern is still the idiomatic move.

The rough test for "should this be an exception?" is: would I be willing to put this in a catch (Exception) at the top of the app and log it as a bug? If yes, throw. If no (because it's a normal user-facing condition like "coupon doesn't exist"), don't.

Rule 5: Mind the Cost

The throw itself is expensive. The try-catch is not. That asymmetry is the single most important performance fact about exceptions in .NET, and most "exceptions are slow" advice gets it wrong.

The cost of a thrown exception comes from capturing the stack trace. The runtime walks the stack, records the frames, allocates strings for type and method names, and produces a snapshot the caller can inspect. That's not free, and on a deep call stack it's not even close to free.

The happy path traverses three small nodes and stops. The throw path adds two heavy nodes (stack trace capture and unwinding) plus the catch handler. Each of those costs nanoseconds on a shallow stack and microseconds on a deep one.

A rough rule of thumb: throwing and catching an exception is somewhere around a thousand times more expensive than a normal method return. The exact factor varies by stack depth, runtime version, and whether [StackTraceHidden] is involved, but a thousandfold is a reasonable mental model and matches benchmark results you'll see floating around. A code path that returns in 50 nanoseconds when it succeeds takes 50 microseconds when it throws.

That difference doesn't matter for the genuine failure case. A network call failing, a deadlock breaking, a database constraint rejecting a write: these are rare enough that the cost is invisible. It does matter when you start using exceptions as control flow, and especially when you do it in a hot loop.

Output (illustrative, varies by machine):

Half the inputs throw, and the runtime spends most of the time capturing stack traces. The TryParse version walks the same data, branches on the boolean, and finishes essentially instantly. This is the "never throw in a hot loop" rule made concrete.

The good news is that just having a try-catch doesn't cost anything significant when nothing throws. The JIT emits a small amount of metadata so the runtime knows where the protected region is, but the generated code on the happy path is the same as code without the try. Wrap a method body in try-catch and benchmark a hundred million calls against the same method without the try, and the difference is in the noise. The try-catch itself isn't the part you should worry about. The throw is.

The practical guidance:

  • Use try-catch freely where it makes the code clearer.
  • Avoid throw in inner loops and on the per-row path of data-processing code.
  • For predictable failures inside loops, use Try* patterns or Result types.
  • For genuine errors at the boundary, throw without thinking about cost.

Rule 6: Don't Throw These Types

Some exception types are reserved for the runtime, and others are too generic to communicate anything useful. Throwing them from your own code is a code smell that makes the failure harder to diagnose and harder to catch precisely.

The list of types you should not throw yourself:

TypeWhy you shouldn't throw it
ExceptionToo generic. Callers have nothing to catch specifically, so they either catch everything or nothing.
SystemExceptionReserved by the BCL for system-level errors. Throwing it from app code is semantically wrong.
ApplicationExceptionOriginally intended as the base class for app exceptions, but Microsoft now recommends against it. Inherit from Exception instead.
NullReferenceExceptionThe runtime throws this for null dereferences. Throwing it manually is misleading. Use ArgumentNullException for null parameters.
IndexOutOfRangeExceptionSame story as NullReferenceException. Throw ArgumentOutOfRangeException for invalid indices passed to your API.
StackOverflowExceptionReserved. Cannot be caught in normal handlers since .NET 2.0.
OutOfMemoryExceptionReserved. The runtime throws it.
ExecutionEngineExceptionReserved. The runtime throws it.
AccessViolationExceptionReserved. The runtime throws it.

Three of these come up often enough to flag specifically.

Don't throw bare `Exception`. The whole point of the type hierarchy is to let callers catch a narrow class of failure and ignore the rest. Throwing Exception defeats that.

The caller has to write catch (Exception) to handle this, which catches everything else too. Use a specific built-in or write a custom type:

Don't throw `NullReferenceException`. The runtime owns this one. When a caller sees NullReferenceException in a stack trace, they assume there's a bug in the code that did the dereferencing, not that someone passed a null parameter. If you want to signal "this argument was null," ArgumentNullException is the right type.

Don't inherit from `ApplicationException` for your custom exceptions. It used to be the recommendation, but the BCL itself doesn't follow it consistently, and Microsoft now explicitly recommends inheriting from Exception directly.

The same idea applies in reverse: don't catch types you can't meaningfully handle. Catching OutOfMemoryException to "recover" is almost always wishful thinking, because by the time you've caught it the process is already in a bad state. Catching StackOverflowException doesn't work at all since .NET 2.0. Don't write defensive code against failures the runtime owns; let them propagate and crash the process cleanly.

Rule 7: Wrap Low-Level Exceptions at the Boundary

When a low-level exception bubbles up from an infrastructure call, it usually carries the wrong vocabulary for the caller above. A SqlException with "Cannot insert duplicate key" tells the database access layer what happened, but the order service that called it doesn't speak SQL. The caller cares whether the order was placed, not which constraint fired.

Wrap the low-level exception in a domain-specific type at the seam between layers. Pass the original exception as the inner exception so the original stack trace and message are still there for diagnostics. The _Inner Exceptions_ lesson is the deep dive on this pattern. Here's the rule in shorthand.

Each layer translates the failure into terms its caller can act on. The chain of inner exceptions preserves the diagnostic detail. The top layer can catch OrderPlacementFailed without ever having seen SqlException, and a developer reading the log can still drill down to the original.

The repository catches the SQL-specific failure, translates it into a domain exception that the order service understands, and preserves the original. The order service can react to DuplicateOrderException without knowing anything about SQL Server error codes.

Two things to avoid when wrapping:

  • Don't wrap without preserving the inner exception. throw new MyException("...") without passing ex to the constructor loses the original stack trace. Always pass it.
  • Don't wrap in a layer that genuinely is the same layer. If the data layer wraps a SQL exception in another data-layer exception with the same vocabulary, you've added a frame without adding information. Wrap when you cross a boundary, not just because you saw a catch.

The boundaries worth wrapping at:

  • Database / persistence layer to application layer.
  • Network / HTTP client layer to application layer.
  • File-system / OS layer to application layer.
  • Any third-party SDK's exception types to your own domain types.

Inside a single layer, let exceptions propagate as-is unless you're adding context that genuinely helps the caller.

Rule 8: Log Once, At the Top

The temptation to "log and rethrow" at every layer feels defensive but produces noise. The same failure shows up four times in the log file, each entry from a different layer, each with a slightly different stack trace. When something goes wrong in production, the engineer on call has to read four stack traces to figure out which one is the original. Worse, log-and-rethrow at every layer makes it impossible to tell from a single log line whether a given failure was actually handled or just observed and re-raised.

The cleaner pattern is: log once, at the layer that is responsible for the user-visible outcome. That's usually the top-level handler in a web app, the message-loop handler in a worker, or the entry point in a console tool. Inner layers throw or wrap. They don't log.

The controller is the boundary between the request and everything inside. It's the layer that decides what response the user sees, and it's the layer that owns the log line. OrderService.PlaceOrder does no logging at all. It throws specific exceptions, and the controller decides which ones become warnings, which become information, and which become unhandled errors with a 500 response.

The rule has a few useful corollaries:

  • Catch-log-rethrow is fine if you're adding genuinely new context. "Order 1234 failed during placement" is more useful than just the underlying exception, because it ties the failure to the operation. But if you're going to log a wrapper line, don't also log the same exception at the next layer up. Pick one.
  • Don't log inside utility methods. A method that runs deep inside a request shouldn't write log lines. It throws, and the boundary handles it.
  • Structured logging beats string interpolation. Use the logger's placeholder syntax ("Order {OrderId} failed") instead of $"Order {orderId} failed". The former preserves orderId as a structured field for search and aggregation; the latter loses it in a string.

Include enough context in the message to make the log entry searchable. The order ID, the customer ID (or hashed ID), the operation name, the input parameters that drove the failure. Never include personally identifiable information (PII) like full names, email addresses, addresses, or payment data, and never include secrets like API keys or tokens.

Most logging frameworks will redact known sensitive fields if you tell them which ones, but the safer default is: log the IDs, not the values. An audit trail can correlate the IDs back to the customer record without ever exposing the customer's data in a log line.

Don't Throw from Dispose, Finalizers, or Event Handlers

Three corners of the language have stricter rules than the general best-practices list above.

Dispose is expected to be safe to call any number of times and to never throw. It's frequently called from finally blocks that are themselves cleaning up after a previous exception, and if Dispose throws on top of an existing exception, the original is lost. Catch and log inside Dispose instead of letting an exception escape.

Finalizers are even stricter. Unhandled exceptions from a finalizer terminate the process since .NET 2.0. Most code doesn't need a finalizer at all; the IDisposable pattern covers the cleanup cases.

Event handlers that throw cause the remaining subscribers not to run, and in UI frameworks they can take down the dispatcher. Either each handler catches its own exceptions, or the raiser invokes handlers one at a time inside a try-catch. The _try-catch Block_ lesson covers the mechanics.

The common thread: exception handling in these places isn't optional. Throwing from Dispose, a finalizer, or an unguarded event handler creates failures that are much worse than the original problem.

Rules at a Glance

The rules above as a single reference table. Pin this somewhere visible.

RuleDoDon'tWhy
Don't swallowLog, rethrow, or wrapEmpty catch { } blocksSilent failure makes production bugs impossible to diagnose.
Catch specificCatch the type you can actually handleCatch base Exception everywhereBroad catches hide bugs you don't mean to handle.
Fail fastValidate at the boundary with ArgumentNullException.ThrowIfNullLet invalid input travel deep into the call stackThe further bad input travels, the harder the eventual failure is to read.
No exceptions for control flowTryParse, TryGetValue, Result-style return valuestry { Parse(...); } catch (FormatException) { ... } in a loopThrowing is ~1000x slower than returning. Expected failures aren't exceptional.
Mind the costThrow freely at the boundary, never in a hot loopUse throw as a way to break out of nested codeStack trace capture allocates and walks frames. The throw is the cost, not the try.
Don't throw these typesThrow ArgumentNullException, InvalidOperationException, or a custom typeThrow Exception, NullReferenceException, ApplicationExceptionReserved or too generic. Callers can't catch them precisely.
Wrap at the boundaryTranslate low-level exceptions into domain types with the inner exception preservedLet SqlException leak into a web controllerEach layer should speak the right vocabulary, while keeping the chain for diagnostics.
Log once, at the topLog at the boundary that owns the user-visible outcomeLog-and-rethrow at every layerDuplicate log lines drown the signal in noise.
Don't throw from Dispose, finalizers, event handlersCatch, log, and continueLet exceptions escape these special methodsThe runtime or framework can't recover, and the original cause gets lost.

Wrapping It Up

The mechanics of try, catch, finally, throw, custom exceptions, and inner exceptions are mostly straightforward once you've used them a few times. What turns them into a workable strategy is the discipline around when to use which. Catch what you can handle, throw at the boundary, validate fast, don't throw in loops, wrap as you cross layers, log once at the top, and keep the special methods (Dispose, finalizers, event handlers) on a tight leash.

If a single mental model helps, try this one: exceptions are for things that broke a contract. A null parameter where the contract says non-null broke the contract. A network call that failed when the contract said it should succeed broke the contract. A user typing a malformed coupon code did not break any contract, because users typing garbage is a normal part of the system. The first two get exceptions. The last gets a bool and a friendly message.

Summary

  • An empty catch is almost always a bug. Either don't catch, catch and rethrow, or catch and translate into a domain exception. Silent failure is worse than a crash because it hides the real problem.
  • Catch the narrowest exception type you can handle. Broad catches belong only at program entry points and inside message-loop workers, where they protect against unhandled failures taking the process down.
  • Validate inputs at the boundary using ArgumentNullException.ThrowIfNull, ArgumentException.ThrowIfNullOrWhiteSpace, and the other ThrowIf* helpers added in .NET 6 through .NET 8. They capture the parameter name automatically and produce cleaner stack traces.
  • Don't use exceptions for control flow. TryParse, TryGetValue, custom Try* methods, and Result-style return values cover the cases where failure is an expected outcome instead of a contract violation.
  • The throw is what costs, not the try. Stack-trace capture allocates and walks frames, making a thrown exception roughly 1000x more expensive than a normal return. Never throw inside an inner loop or on the per-row path of data processing.
  • Don't throw Exception, SystemException, ApplicationException, NullReferenceException, or IndexOutOfRangeException from your own code. They're either reserved for the runtime or too generic to catch precisely.
  • Wrap low-level exceptions in domain types at layer boundaries, preserving the original as the inner exception. Log once at the layer that owns the user-visible outcome, not at every layer in between.
  • Dispose, finalizers, and event handlers are special. Don't let exceptions escape them, because the runtime or framework above can't recover, and the original cause gets lost.