The .NET exception hierarchy is a class tree rooted at System.Exception. Understanding which exceptions exist, what they signal, and which ones your code should handle versus let propagate is a key skill. Catching too broadly (catching Exception or even SystemException) hides bugs; catching too narrowly misses legitimate error cases. The rule of thumb: catch what you can recover from, let the rest propagate to a global handler.
The Exception Hierarchy
// System.Exception
// ├── System.SystemException (runtime errors — usually don't catch these)
// │ ├── NullReferenceException — accessed null reference
// │ ├── IndexOutOfRangeException — array index out of bounds
// │ ├── InvalidCastException — bad type cast
// │ ├── StackOverflowException — infinite recursion (cannot catch)
// │ ├── OutOfMemoryException — no memory (usually fatal)
// │ ├── DivideByZeroException — integer division by zero
// │ └── IOException
// │ ├── FileNotFoundException
// │ ├── DirectoryNotFoundException
// │ └── EndOfStreamException
// │
// └── ApplicationException (user-defined app exceptions — base class for custom)
// (though in practice many just inherit from Exception directly)
// Common exceptions YOU interact with:
// ArgumentException — invalid argument value
// ArgumentNullException — null argument (ArgumentException subclass)
// ArgumentOutOfRangeException — argument outside valid range
// InvalidOperationException — method call invalid for current state
// NotSupportedException — operation not supported
// NotImplementedException — not yet implemented (use in stubs)
// KeyNotFoundException — key not in dictionary
// FormatException — bad string format (int.Parse)
// OverflowException — arithmetic overflow
// TimeoutException — operation timed out
// UnauthorizedAccessException — permission denied
Note:
ArgumentNullException and ArgumentOutOfRangeException are the programmer’s fault — they indicate that a method was called incorrectly. You should throw these from your own methods to enforce preconditions, but you should not normally need to catch them (if your caller is calling your method incorrectly, the bug is in their code). InvalidOperationException is different — it indicates that the method was called at the wrong time or in the wrong state, which may be a legitimate user-facing condition to handle.Tip: Use
ArgumentNullException.ThrowIfNull(param, nameof(param)) (.NET 6+) and ArgumentOutOfRangeException.ThrowIfNegativeOrZero(id, nameof(id)) (.NET 8+) for concise parameter validation at the top of methods. These static helpers are much more readable than manually writing if (param is null) throw new ArgumentNullException(nameof(param)). They also carry the parameter name automatically for the exception message, making debugging easier.Warning: Never catch
StackOverflowException, OutOfMemoryException, or ExecutionEngineException — the CLR will not reliably call your catch block for these, and the process is already in a state where you cannot safely continue. These are terminal exceptions. Similarly, catching Exception broadly and then doing nothing (or just logging) is dangerous because it can mask bugs like null references and logic errors that should fail loudly so developers fix them.Choosing Which Exception to Throw
public Post GetById(int id)
{
// Wrong argument type — ArgumentException family
if (id <= 0)
throw new ArgumentOutOfRangeException(nameof(id), id, "ID must be positive.");
// Method called in wrong state — InvalidOperationException
if (!_isInitialised)
throw new InvalidOperationException(
"Repository must be initialised before querying.");
// Entity not found — KeyNotFoundException or custom NotFoundException
var post = _db.Posts.Find(id);
if (post is null)
throw new KeyNotFoundException($"Post with ID {id} was not found.");
return post;
}
// Validation in constructors — ArgumentException family
public User(string email)
{
ArgumentException.ThrowIfNullOrWhiteSpace(email, nameof(email));
if (!email.Contains('@'))
throw new ArgumentException("Invalid email format.", nameof(email));
Email = email;
}
// Not yet implemented — NotImplementedException in stubs
public Task<Report> GenerateReportAsync() =>
throw new NotImplementedException("Report generation coming in v2.");
Exception Hierarchy for Common Scenarios
| Scenario | Exception to Throw | Exception to Catch |
|---|---|---|
| Null argument passed in | ArgumentNullException | Usually don’t catch — fix caller |
| Argument value out of valid range | ArgumentOutOfRangeException | Usually don’t catch — fix caller |
| Object not in valid state for call | InvalidOperationException | May catch and return 400/409 |
| File or resource not found | FileNotFoundException | Catch — return 404 or fallback |
| Network / DB timeout | TimeoutException | Catch — retry or return 503 |
| Duplicate entry (domain rule) | Custom DuplicateException | Catch — return 409 |
| Entity not found (domain) | Custom NotFoundException | Catch — return 404 |
Common Mistakes
Mistake 1 — Catching Exception to handle a specific type (too broad)
❌ Wrong — catches everything including memory errors:
catch (Exception) { return "Not found"; } // hides ALL errors!
✅ Correct — catch the specific type you know about:
catch (KeyNotFoundException) { return NotFound(); }
Mistake 2 — Throwing Exception directly (too vague)
❌ Wrong — callers cannot distinguish your error from a system error:
throw new Exception("Something went wrong"); // which something?
✅ Correct — throw the most specific type available, or define a custom exception.