← Open in the full interactive course (progress tracking, search & more)

A generic "Exception" tells you something broke. A custom exception tells you exactly what — and gives the caller a fighting chance to react.

In the last lesson, you saw a payment method that can fail because an account doesn't have enough money. You could report that with a plain InvalidOperationException("Not enough funds"). It would work. But now imagine the caller wants to handle this specific failure differently from every other possible failure — maybe by prompting the user to add funds, rather than showing a generic "something went wrong" error.

With a generic exception type, the caller has no reliable way to distinguish "insufficient funds" from "account frozen" from "network timeout" without fragile string-matching on the message. What's needed is a type that says exactly what happened — an InsufficientFundsException, distinguishable at compile time from anything else.

In this lesson, you'll learn how to design and throw your own exception types, the crucial difference between throw and throw ex, and how exception filters let you catch based on more than just type.

What Is It?

The Simple Explanation

A custom exception is just a class you write yourself that derives from System.Exception (or a more specific built-in exception type). Once it exists, you can throw an instance of it exactly like any built-in exception — and callers can catch it by that specific type.

The Technical Definition

Creating a custom exception means declaring a class — conventionally named ending in Exception — that inherits from System.Exception or an appropriate subclass. By convention it exposes the standard constructors (parameterless, message-only, message-plus-inner-exception), and it may add extra properties to carry structured data about the failure beyond what a plain string message can hold.

Why a type, and not just a message string?

A message is meant for a human to read. A type is meant for code to branch on. catch (InsufficientFundsException ex) is something the compiler checks and the runtime dispatches reliably. if (ex.Message.Contains("insufficient")) is fragile — it breaks the moment someone rewords the message.

Why Does It Exist?

The Problem

Built-in exception types are deliberately generic — InvalidOperationException, ArgumentException — because they're meant to cover countless unrelated scenarios across the entire .NET ecosystem. If every failure in your domain used the same generic type, callers trying to handle one specific case would have no clean way to distinguish it from every other case that also happens to throw InvalidOperationException.

The Need

Your application's domain has its own failure modes — insufficient funds, an out-of-stock product, an expired session — that deserve their own identity. Callers need to be able to say "catch this exact kind of failure" without resorting to inspecting message text.

The Solution — Domain-Specific Exception Types

By defining InsufficientFundsException as its own class, you give it a permanent, compiler-checked identity. Callers write catch (InsufficientFundsException ex) and know precisely what they're handling. You can also attach structured data — like the shortfall amount — as real typed properties instead of parsing it back out of a sentence.

Generic Exception

Custom Exception

Big Picture

Custom exceptions slot directly into the hierarchy you learned about in the previous lesson — they don't replace it, they extend it:

WHERE A CUSTOM EXCEPTION FITS IN THE HIERARCHY
System.Exception
    └── System.SystemException (built-in framework failures)
    └── System.ApplicationException (rarely used base — most teams skip it)
    └── InsufficientFundsException (your custom type, usually derived straight from Exception)
        └── AccountFrozenInsufficientFundsException (optionally, even more specific)
A catch (Exception ex) still catches your custom type too — because it's still an Exception. But a catch (InsufficientFundsException ex) catches only that type (and any type derived from it).

How It Works

DEFINING AND THROWING A CUSTOM EXCEPTION
Step 1 — Derive from Exception
public class InsufficientFundsException : Exception
{
    public decimal AvailableBalance { get; }
    public decimal RequestedAmount { get; }

    public InsufficientFundsException(decimal availableBalance, decimal requestedAmount)
        : base($"Cannot withdraw {requestedAmount:C}; only {availableBalance:C} available.")
    {
        AvailableBalance = availableBalance;
        RequestedAmount = requestedAmount;
    }
}
Step 2 — Throw it where the failure actually occurs
public void Withdraw(decimal amount)
{
    if (amount > _balance)
        throw new InsufficientFundsException(_balance, amount);

    _balance -= amount;
}
Step 3 — Catch it precisely, and use the extra data
try
{
    account.Withdraw(500m);
}
catch (InsufficientFundsException ex)
{
    Console.WriteLine($"Short by {ex.RequestedAmount - ex.AvailableBalance:C}");
}

throw vs. throw ex — the stack trace trap

Inside a catch block, you can re-throw the caught exception two different-looking ways that behave very differently:

throw ex; — resets the trace

catch (Exception ex) { LogError(ex); throw ex; // }

throw; — preserves the trace

catch (Exception ex) { LogError(ex); throw; // }
This is one of the most common real-world exception mistakes. throw ex; compiles fine and looks harmless — but every time you use it, you're throwing away the exact information (the original stack trace) that would have told you where the bug actually is. Always prefer a bare throw; when re-throwing the exception you just caught.

Exception filters — catching based on more than type

Sometimes you want to catch a specific type only under certain conditions. A when clause on a catch block lets you add that condition without giving up the ability to fall through to a later catch block if it doesn't match:

try { account.Withdraw(requestedAmount); } catch (InsufficientFundsException ex) when (ex.RequestedAmount > 10_000m) { // Only catches insufficient-funds failures for large withdrawals — // maybe these get escalated to a fraud-review queue. NotifyFraudTeam(ex); } catch (InsufficientFundsException ex) { // Smaller shortfalls are handled the normal way. Console.WriteLine($"Insufficient funds: short by {ex.RequestedAmount - ex.AvailableBalance:C}"); }

Unlike an if statement inside the catch block, a filter that evaluates to false means this catch block is skipped entirely — the runtime moves on to check the next catch block, exactly as if the type hadn't matched at all. This also matters for debugging: filters run before the stack unwinds, so a debugger attached at the throw site can still inspect the original call stack even while the filter condition is being evaluated.

Simple Example

A minimal custom exception with just a message — the simplest form:

public class ProductOutOfStockException : Exception { public ProductOutOfStockException(string sku) : base($"Product '{sku}' is out of stock.") { Sku = sku; } public string Sku { get; } } // ─── Usage ─── public void ReserveStock(string sku, int quantity) { var available = GetAvailableQuantity(sku); if (available < quantity) throw new ProductOutOfStockException(sku); // reserve the stock... } try { ReserveStock("SKU-4471", 3); } catch (ProductOutOfStockException ex) { Console.WriteLine($"Sorry, {ex.Sku} is currently unavailable."); }

Notice the standard shape: a class ending in Exception, deriving from Exception, building a clear message via the base constructor, and exposing one extra property (Sku) that a caller can inspect programmatically instead of parsing the message.

Real-World Example

A payment-processing method in an e-commerce checkout flow, using a custom exception with an inner exception to preserve the original cause:

public class PaymentDeclinedException : Exception { public string DeclineCode { get; } public PaymentDeclinedException(string declineCode, string message, Exception? innerException = null) : base(message, innerException) { DeclineCode = declineCode; } } public class PaymentProcessor { public void ChargeCard(Order order, CardDetails card) { try { _gatewayClient.Charge(card, order.Total); } catch (GatewayTimeoutException gatewayEx) { // Wrap the low-level gateway failure in our own domain exception, // but keep the original exception attached as the "inner" cause. throw new PaymentDeclinedException( declineCode: "GATEWAY_TIMEOUT", message: $"Payment gateway timed out while charging order {order.Id}.", innerException: gatewayEx); } } } // ─── Usage ─── try { processor.ChargeCard(order, card); Console.WriteLine("Payment successful."); } catch (PaymentDeclinedException ex) when (ex.DeclineCode == "GATEWAY_TIMEOUT") { Console.WriteLine("The payment gateway is temporarily unavailable — please retry shortly."); } catch (PaymentDeclinedException ex) { Console.WriteLine($"Payment was declined ({ex.DeclineCode}): {ex.Message}"); }

The innerException parameter (available on Exception's standard constructors, and worth including on your own custom types) is important here — it means ex.InnerException still points back to the original GatewayTimeoutException, so nothing about the root cause is lost even though the caller only needs to catch PaymentDeclinedException.

Under the Hood

Why does throw; preserve the stack trace while throw ex; resets it? Every exception object carries a StackTrace property that the CLR populates. Specifically, the runtime records the trace starting from where the exception was thrown — not where it was created (those are usually the same statement, but not always).

throw; VS throw ex; — WHAT THE CLR ACTUALLY DOES
bare throw;
throw ex;

This is exactly why production logging that relies on ex.StackTrace becomes much less useful the moment throw ex; is used anywhere in the call chain — the trace no longer points at the actual bug.

Common Confusion

1. "I should derive from ApplicationException"

Older guidance suggested deriving custom exceptions from System.ApplicationException to distinguish "your" exceptions from framework ones. Microsoft has since reversed that advice — there's no reliable value in the distinction, and modern .NET guidance is simply to derive directly from Exception (or a more specific existing type when your failure genuinely is a special case of one, e.g. deriving from ArgumentException for a domain-specific argument problem).

2. Exception filters vs. an if statement inside catch

You could write catch (Exception ex) { if (!condition) throw; ... } to approximate a filter — but it's not the same. That version still enters the catch block (and any debugger breakpoint set on it) even when the condition doesn't hold, and it re-throws using... well, hopefully a bare throw;. A when filter skips the catch block entirely when the condition is false, which is both cleaner and lets a later, more appropriate catch block take over immediately.

3. "throw ex is only a style preference"

It looks stylistic, but it's a real functional difference — it changes what information is available afterward. Treat this as a hard rule, not a preference: use bare throw; when re-throwing the same exception, always.

Common Mistakes

Mistake 1 — Creating a custom exception type for every tiny variation

A separate exception class for CardExpiredException, CardNumberInvalidException, CardCvvInvalidException... when a single InvalidCardException with a Reason enum property would serve callers just as well with far less ceremony. Reserve a brand-new type for cases callers genuinely need to handle differently — not for every distinct message.

Mistake 2 — Losing the inner exception when wrapping

Wrong:

catch (SqlException sqlEx) { throw new OrderSaveException("Could not save the order."); // original cause is gone }

Correct: always pass the caught exception as the inner exception.

catch (SqlException sqlEx) { throw new OrderSaveException("Could not save the order.", sqlEx); }

Mistake 3 — Using throw ex out of habit

Many developers write throw ex; without realizing it resets the stack trace, simply because it "reads more naturally" as re-throwing the variable they just caught. Train yourself to reach for the bare throw; whenever the intent is "let this exception continue on its way, unchanged."

When Should I Use It?

Create a custom exception when:

A built-in exception is enough when:

Rule of thumb: Don't create a custom exception type "just in case." Create one when you can point to an actual caller that needs to catch it specifically — otherwise a well-chosen built-in type with a clear message does the job.

Mental Model

Custom exception = a named, typed failure with room for structured data.
throw; = "keep going, unchanged" — preserves where it really happened.
throw ex; = "this is a brand new throw" — erases where it really happened.
catch (T e) when (condition) = "only mine if this extra condition is also true."

Remember:
· Name it clearly, derive from Exception, expose structured properties.
· Always pass the original exception as innerException when wrapping.
· Prefer bare throw; over throw ex; — always.

Key Takeaway


Check Your Understanding

You've learned how to create your own exception types and re-throw them safely. Let's test how well it stuck.

1. What is the main advantage of a custom exception type like InsufficientFundsException over throwing a plain Exception with a descriptive message?

Show answer

Correct: B

Why B is correct: A named type is something the compiler checks and code can branch on reliably with catch (InsufficientFundsException ex), and extra properties like ex.RequestedAmount give the caller structured, typed data instead of forcing them to parse a sentence.

Why A is incorrect: There's no meaningful performance difference between a custom exception type and the base Exception class.

Why C is incorrect: Nothing about .NET automatically logs exceptions just because they're custom — logging is something you set up explicitly.

Why D is incorrect: A custom exception still derives from Exception, so a catch (Exception ex) block still catches it — that's normal type hierarchy behavior.

Reinforcement: The value of a custom exception type is precision — both in what callers can catch, and in what data they can retrieve.

2. What's wrong with this code?

catch (SqlException ex) { LogError(ex); throw ex; }
Show answer

Correct: C

Why C is correct: throw ex; re-throws the same exception object but resets its recorded stack trace to start at this line, erasing the information about where the exception was originally thrown — making it much harder to debug later. The fix is a bare throw;.

Why A is incorrect: The code compiles and runs fine — the problem is a subtle behavioral one, not a compile error.

Why B is incorrect: Logging before re-throwing is a completely normal and reasonable order; that isn't the issue here.

Why D is incorrect: This is exactly the mistake the lesson warns about — throw ex; is a well-known anti-pattern precisely because of the stack trace problem.

Reinforcement: Whenever you're re-throwing the exception you just caught unchanged, use a bare throw;.

3. What does an exception filter like catch (PaymentDeclinedException ex) when (ex.DeclineCode == "GATEWAY_TIMEOUT") do when a PaymentDeclinedException is thrown with a different decline code?

Show answer

Correct: C

Why C is correct: A when filter is evaluated as part of deciding whether this catch block matches at all. If the condition is false, the runtime behaves exactly as if the type didn't match either — it moves on to check subsequent catch blocks for a match.

Why A is incorrect: This is normal runtime behavior, not a compile-time error — the code is perfectly valid.

Why B is incorrect: The catch block's body never even executes when the filter is false — it's skipped entirely, not entered and then no-op'd.

Why D is incorrect: Exception filters don't change the severity or nature of the exception — they only decide whether that particular catch block applies.

Reinforcement: A filter's condition being false is equivalent to the catch block's type not matching — the search for a handler continues.

4. You're wrapping a low-level SqlException in a custom OrderSaveException before re-throwing it up to the caller. What should you do to avoid losing information about the original failure?

Show answer

Correct: B

Why B is correct: Passing the original exception as innerException keeps it fully accessible via ex.InnerException — including its own stack trace and type — while still letting the caller catch the more meaningful, domain-specific OrderSaveException.

Why A is incorrect: A message string loses the exception's type, stack trace, and any other structured data — it's much less useful than keeping the actual object.

Why C is incorrect: Swallowing the exception without re-throwing means the caller never learns the save failed at all — this is the "silent failure" problem from the previous lesson.

Why D is incorrect: This loses the chance to give the caller a more meaningful, domain-specific exception type — sometimes appropriate, but it defeats the purpose of wrapping described in this scenario.

Reinforcement: When wrapping one exception in another, always attach the original as the inner exception so no diagnostic information is lost.

5. A team is building an app and starts creating a brand-new exception subclass for every single validation message their forms produce (dozens of them), even though every one of these failures is handled identically by the UI — just displayed as a generic error banner. Is this a good use of custom exceptions?

Show answer

Correct: B

Why B is correct: The rule of thumb is to create a new exception type when a caller genuinely needs to handle it differently. If every one of these failures is caught and displayed identically, dozens of near-identical exception classes add ceremony without adding any real value — one type with a message or a reason code accomplishes the same thing more simply.

Why A is incorrect: More specific types have real costs (more classes to maintain, more cognitive overhead) that should be justified by an actual need to handle cases differently.

Why C is incorrect: Custom exceptions can be created for any application-specific failure, not just extensions of built-in types.

Why D is incorrect: Plenty of user-facing error messages are perfectly well served by a single exception type carrying a message, without needing a unique class per message.

Reinforcement: Custom exception types are a tool for callers who need to react differently — not a requirement for every distinct failure message.

You can now design your own exception types, throw and re-throw them safely, and filter catch blocks with precision. Next, we shift from errors to another everyday reality of real applications: reading and writing files.


dotnetmadeeasy.com — Learn C# and .NET, the right way.