This document outlines best practices for managing exceptions in your C# code, emphasizing clarity, performance, and maintainability. These principles are designed to work in tandem with the CheckedExceptions Analyzer.
- Understand What a Bug Is
- What Exceptions Represent
- When to Avoid Exceptions
- How to Handle Exceptions Well
- Limiting and Containing Exceptions
- Exception Type Guidelines
- Alternatives to Exceptions
- Conclusion
A bug is unexpected behavior. It defies user or developer expectations and can surface at any stage in development or production.
Example:
public void Foo() { throw new InvalidOperationException(); // Unhandled => Bug }
Unhandled exceptions are bugs. Every exception that escapes a method and isn't accounted for should be treated as a failure to anticipate or contain an error condition.
Exceptions are not just errors—they are exceptional. They signal that something unexpected or unlikely has happened that can't be handled by regular logic.
Examples include:
- Hardware faults (e.g., I/O errors, memory access violations)
- Arithmetic overflows
- System limitations
Note: Exceptions differ from expected domain errors, such as validation failures, which are better modeled using
Resultobjects or alternative mechanisms.
Use ThrowsAttribute if needed, but avoid over-declaring exceptions—especially in public APIs. It communicates unpredictability and complexity to callers.
Use non-throwing APIs when available:
Preferred:
if (int.TryParse(input, out var result)) { ... }Avoid:
int result = int.Parse(input); // Throws on failure
Catching exceptions near where they are thrown reduces the cost of stack unwinding and improves clarity.
Example:
try { SaveToDisk(); } catch (IOException ex) { LogError(ex); Retry(); }
Catching all exceptions globally hides bugs and encourages ignoring faults.
Anti-pattern:
try { RunApplication(); } catch (Exception) { // Swallowed! Don't do this unless you're logging & exiting }
Too many exceptions make it hard to reason about possible failure paths.
Aggregate multiple exception scenarios under a clearly named DomainException:
catch (IOException ex) when (ex.Message.Contains("disk")) { throw new StorageUnavailableException("Disk error", ex); }
Still, prefer Result objects for recoverable, expected issues.
InvalidOperationExceptionArgumentNullExceptionArgumentOutOfRangeException
These are conventional and understood by most developers.
Do not throw:
NullReferenceExceptionStackOverflowExceptionAccessViolationException
These are meant for the runtime.
Use a custom, descriptive wrapper instead. AggregateException is difficult to pattern match and leads to boilerplate.
Stick to flat, explicitly named exception classes when custom exceptions are needed.
Avoid:
public class MyBaseException : Exception { } public class FooException : MyBaseException { } public class BarException : MyBaseException { }Prefer:
public class UserAccountLockedException : Exception { }
Too broad and may mask unexpected issues:
catch (Exception) { ... } // Try to avoid
Example:
public record Result(bool Success, string? Error); public Result RegisterUser(string name) { if (string.IsNullOrWhiteSpace(name)) return new(false, "Name is required"); return new(true, null); }
Leverage the compiler’s nullable analysis to prevent ArgumentNullExceptions.
Exceptions should remain what they were intended to be—exceptional. By minimizing their use, limiting their propagation, and favoring explicit, predictable error signaling mechanisms like Result, you write code that’s easier to maintain, safer to extend, and more aligned with user expectations.
Together with the CheckedExceptions Analyzer, these practices help ensure that exceptions remain manageable and that your software becomes less exception-prone and less buggy.