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.
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.
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.
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.
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.
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.
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.
throw new Exception("Insufficient funds. Short by 45.50")throw new InsufficientFundsException(balance, requested)catch (InsufficientFundsException ex)ex.ShortfallAmount is a real typed propertyCustom exceptions slot directly into the hierarchy you learned about in the previous lesson — they don't replace it, they extend it:
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).
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;
}
}
Exception — this is a strong, expected .NET convention.ex.Message works exactly as callers expect.public void Withdraw(decimal amount)
{
if (amount > _balance)
throw new InsufficientFundsException(_balance, amount);
_balance -= amount;
}
try
{
account.Withdraw(500m);
}
catch (InsufficientFundsException ex)
{
Console.WriteLine($"Short by {ex.RequestedAmount - ex.AvailableBalance:C}");
}
throw vs. throw ex — the stack trace trapInside a catch block, you can re-throw the caught exception two different-looking ways that behave very differently:
catch (Exception ex)
{
LogError(ex);
throw ex; //
}catch (Exception ex)
{
LogError(ex);
throw; //
}throw statement re-throws the exact same exception object, stack trace untouched.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.
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.
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.
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.
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).
ex is the same object reference.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.
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).
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.
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.
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.
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);
} 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."
ArgumentException, InvalidOperationException) already describes accurately.catch it specifically — otherwise a well-chosen built-in type with a clear message does the job.
innerException when wrapping.throw; over throw ex; — always.
Exception, name the class ending in Exception, and support the standard constructors including an inner exception.throw; preserves the original stack trace; throw ex; resets it — always prefer the bare form when re-throwing.catch (T e) when (...)) let you catch by type and condition, falling through cleanly when the condition doesn't hold.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?
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;
}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?
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?
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?
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.