Exception Types and Hierarchy — Choosing the Right Exception

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.

🧠 Test Yourself

A service method is called with a valid ID but the entity does not exist in the database. Which exception is most appropriate to throw?