C# & .NET (C# 14, .NET 10 LTS) Module 13 – Exceptions and resource cleanup
Designing exceptions, guard clauses and results
Choose between C# exceptions, guard clauses, Try methods and result types for each failure, and design custom exceptions that carry what a handler needs.
What you will learn
- Choose the built-in exception type and throw helper for each kind of mistake a caller can make
- Decide between an exception, a Try method and a result type for each failure of an operation
- Write a custom exception type that carries the data its handlers need
- Measure what a refusal costs as an exception and as a result, and make an ignored result a build warning
- Document the exceptions a method throws with XML comments, without checked exceptions
Before you start
What you’ll learn · Before you start
What you will learn
- Choose the built-in exception type and throw helper for each kind of mistake a caller can make
- Decide between an exception, a Try method and a result type for each failure of an operation
- Write a custom exception type that carries the data its handlers need
- Measure what a refusal costs as an exception and as a result, and make an ignored result a build warning
- Document the exceptions a method throws with XML comments, without checked exceptions
Before you start
On this page
A transfer of 2,500 rupees from a wallet that holds 1,800 must fail, and so must a transfer of -500 and a wallet paying itself. The negative amount and the self-payment are mistakes in the calling code; the low balance is an everyday outcome that the app must show its user. Designing exceptions means giving each failure the signal that fits it.
You will pick the built-in exception for each caller mistake, choose between an exception, a Try method and a result, and then design custom exceptions, result types and their documentation.
Guard clauses throw the built-in exception that names the caller’s mistake
Wallet.Transfer in guards.cs starts with guard clause: A check at the start of a method that returns or throws at once when an input is bad, so the code after it can assume the input is good and stays unindented. More in “if, switch statements and switch expressions”, one for each mistake a
caller can make, and each throws a type that .NET already has. The program makes every mistake once:
// Each guard throws the built-in exception that names the caller's mistake.
var asha = new Wallet("W-17", 1800m);
var ravi = new Wallet("W-42", 0m);
Report("a transfer that fits", () => asha.Transfer(ravi, 500m));
Report("no wallet to pay", () => asha.Transfer(null!, 500m));
Report("a negative amount", () => asha.Transfer(ravi, -500m));
Report("an amount of zero", () => asha.Transfer(ravi, 0m));
Report("above the limit", () => asha.Transfer(ravi, 150_000m));
Report("a wallet paying itself", () => asha.Transfer(asha, 500m));
asha.Close();
Report("a closed wallet", () => asha.Transfer(ravi, 500m));
static void Report(string what, Action transfer)
{
try
{
transfer();
Console.WriteLine($"{what}: done");
}
catch (ArgumentException e)
{
Console.WriteLine($"{what}: {e.GetType().Name}, parameter {e.ParamName}");
Console.WriteLine($" {e.Message.ReplaceLineEndings(" ")}");
}
catch (InvalidOperationException e)
{
Console.WriteLine($"{what}: {e.GetType().Name}");
Console.WriteLine($" {e.Message}");
}
}
sealed class Wallet(string id, decimal balance)
{
public const decimal Limit = 100_000m;
public string Id { get; } = id;
public decimal Balance { get; private set; } = balance;
public bool IsClosed { get; private set; }
public void Close() => IsClosed = true;
public void Transfer(Wallet to, decimal amount)
{
ArgumentNullException.ThrowIfNull(to);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
ArgumentOutOfRangeException.ThrowIfGreaterThan(amount, Limit);
if (ReferenceEquals(to, this))
{
throw new ArgumentException("A wallet cannot pay itself.", nameof(to));
}
if (IsClosed)
{
throw new InvalidOperationException($"Wallet {Id} is closed; open it again before a transfer.");
}
if (amount > Balance)
{
throw new InvalidOperationException($"The balance of {Balance} is less than {amount}.");
}
Balance -= amount;
to.Balance += amount;
}
} Output
a transfer that fits: done
no wallet to pay: ArgumentNullException, parameter to
Value cannot be null. (Parameter 'to')
a negative amount: ArgumentOutOfRangeException, parameter amount
amount ('-500') must be a non-negative and non-zero value. (Parameter 'amount') Actual value was -500.
an amount of zero: ArgumentOutOfRangeException, parameter amount
amount ('0') must be a non-negative and non-zero value. (Parameter 'amount') Actual value was 0.
above the limit: ArgumentOutOfRangeException, parameter amount
amount ('150000') must be less than or equal to '100000'. (Parameter 'amount') Actual value was 150000.
a wallet paying itself: ArgumentException, parameter to
A wallet cannot pay itself. (Parameter 'to')
a closed wallet: InvalidOperationException
Wallet W-17 is closed; open it again before a transfer.
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run guards.cs
The throw helper: A static method that checks an argument and throws the matching exception with a complete message, such as ArgumentNullException.ThrowIfNull or ArgumentOutOfRangeException.ThrowIfNegativeOrZero. wrote the messages, with the parameter’s name and the refused value:
ThrowIfNegativeOrZero and ThrowIfGreaterThan, new in .NET 8, refused -500, 0 and 150000, and
ArgumentNullException.ThrowIfNull the missing wallet. A wallet paying itself has no helper, so Transfer throws
ArgumentException with nameof(to). A closed wallet is a call at the wrong moment, not a bad argument:
InvalidOperationException.
| The caller | Throw | In guards.cs |
|---|---|---|
passed null |
ArgumentNullException, with ThrowIfNull |
no wallet to pay |
| passed a value out of range | ArgumentOutOfRangeException, with ThrowIfNegativeOrZero, ThrowIfGreaterThan … |
-500, 0 and 150000 |
| passed a value that is wrong in another way | ArgumentException, with nameof |
a wallet paying itself |
| called when the object’s state forbids it | InvalidOperationException |
a closed wallet |
NotSupportedException says that an object does not support the member called, as Write on a read-only stream.
Never throw Exception, SystemException, NullReferenceException or IndexOutOfRangeException on purpose: the first
two tell a handler nothing it can act on, and the runtime throws the other two for its own checks. Because guard
clauses run first, a bad call fails before any money has moved.
Checkpoint
Check: Guard clauses and the built-in exception for each mistake
An expected failure is a result, a caller’s mistake is an exception
guards.cs also throws for a low balance, its one doubtful choice: careful users meet a low balance every day.
three_ways.cs offers the transfer three ways: Transfer throws, TryTransfer returns false, and
TransferOrRefuse returns a result type: A type that a method returns to report an outcome as a value: success with its data, or failure with its reason, so that the caller branches on it instead of catching an exception., TransferResult, done or refused with its
reason:
// One transfer offered three ways: an exception, a Try method and a result.
var asha = new Wallet("W-17", 1800m);
var ravi = new Wallet("W-42", 0m);
// 1. An exception: the caller must catch it, or the program stops.
try
{
asha.Transfer(ravi, 2500m);
}
catch (InvalidOperationException e)
{
Console.WriteLine($"Transfer threw {e.GetType().Name}: {e.Message}");
}
// 2. A Try method: true or false, and nothing more.
bool moved = asha.TryTransfer(ravi, 2500m);
Console.WriteLine($"TryTransfer returned {moved}");
// 3. A result: the outcome and its reason are values the caller reads.
Show(asha.TransferOrRefuse(ravi, 2500m));
Show(asha.TransferOrRefuse(ravi, 500m));
asha.Freeze();
Show(asha.TransferOrRefuse(ravi, 500m));
// Nothing makes a caller look at a Try method's bool or at a result: these two lines compile without a warning.
asha.TryTransfer(ravi, 100m);
asha.TransferOrRefuse(ravi, 100m);
Console.WriteLine($"balances: W-17 {asha.Balance}, W-42 {ravi.Balance}");
static void Show(TransferResult result) =>
Console.WriteLine(result.Succeeded ? $"TransferOrRefuse: done, balance {result.Balance}" : $"TransferOrRefuse: refused, {result.Refusal}");
sealed class Wallet(string id, decimal balance)
{
public string Id { get; } = id;
public decimal Balance { get; private set; } = balance;
public bool IsFrozen { get; private set; }
public void Freeze() => IsFrozen = true;
// The exception form: a refusal becomes InvalidOperationException.
public void Transfer(Wallet to, decimal amount)
{
TransferResult result = TransferOrRefuse(to, amount);
if (!result.Succeeded)
{
throw new InvalidOperationException(result.Refusal);
}
}
// The Try form: the reason is lost.
public bool TryTransfer(Wallet to, decimal amount) => TransferOrRefuse(to, amount).Succeeded;
// The result form. A caller's mistake still throws: only refusals are results.
public TransferResult TransferOrRefuse(Wallet to, decimal amount)
{
ArgumentNullException.ThrowIfNull(to);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
if (IsFrozen)
{
return TransferResult.Refused($"wallet {Id} is frozen", Balance);
}
if (amount > Balance)
{
return TransferResult.Refused($"the balance of {Balance} is less than {amount}", Balance);
}
Balance -= amount;
to.Balance += amount;
return TransferResult.Done(Balance);
}
}
readonly record struct TransferResult(bool Succeeded, decimal Balance, string? Refusal)
{
public static TransferResult Done(decimal balance) => new(true, balance, null);
public static TransferResult Refused(string reason, decimal balance) => new(false, balance, reason);
} Output
Transfer threw InvalidOperationException: the balance of 1800 is less than 2500 TryTransfer returned False TransferOrRefuse: refused, the balance of 1800 is less than 2500 TransferOrRefuse: done, balance 1300 TransferOrRefuse: refused, wallet W-17 is frozen balances: W-17 1300, W-42 500
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run three_ways.cs
All three refused the 2,500, but the callers learned different things. The exception carried the reason, at the
cost of a throw and a catch block; the Try method said only False; the result said why, as a value, and named the
frozen wallet too. The last lines show the risk of the quiet forms: nothing makes a caller read a bool or a result, and
two refusals went by without a word.
| What differs | Exception | Try method | Result |
|---|---|---|---|
| The caller learns | the type, the message, the stack trace | success or failure | what the result type holds |
| If the caller ignores it | the program stops at the top level | the failure passes silently | the failure passes silently |
| A failure costs | a throw and a search for a handler | a branch | a branch |
| It fits | a caller’s mistake, a failing environment | one obvious reason, as in TryParse |
an outcome to show or to act on |
Results suit outcomes a caller must act on. Exceptions keep their place inside a method that returns results:
TransferOrRefuse still throws for a missing wallet or an amount of zero, which are bugs in the caller. That split is
the core of designing exceptions. Answer three questions about a failure of your own:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Signal | A caller's mistake | Happens in normal use | Caller needs the reason | If the caller ignores it |
|---|---|---|---|---|
| An exception Caller writes: try and catch, or nothing at all | Good stops the call at once and names the parameter, as in guards.cs | Poor every refusal pays for a throw and a catch | Good the type and the message say why | Good cannot pass silently: one that nothing catches stops the program |
| A Try method Caller writes: if (!TryTransfer(…)) | Poor a false is easy to miss, and the bug goes on | Good a branch, with no throw | Poor only true or false: TryTransfer said False | Poor an unread false passes silently |
| A result type Caller writes: if (!result.Succeeded) | Poor a refused result is as easy to miss as a false | Good a branch and a small value | Good carries it: wallet W-17 is frozen | Poor an unread result passes silently, as in three_ways.cs |
How the answers decide
- “Is the failure a mistake in the calling code, such as an amount of zero?” Yes, correct code never does this: rules out A Try method and A result type. A bug in the calling code should stop the call before anything changes. A false or a refused result can be ignored, and the bug lives on.
- “Is the failure a mistake in the calling code, such as an amount of zero?” No, it can happen to a careful caller and “Does it happen in normal use, many times a day?” Yes, like a low balance: rules out An exception. An outcome that happens every day would cost a throw and a catch each time, and fill the logs with failures that are not errors.
- “Is the failure a mistake in the calling code, such as an amount of zero?” No, it can happen to a careful caller and “Must the caller learn why it failed, to tell a user or to decide what to do next?” Yes, there is more than one reason: rules out A Try method. A Try method returns only true or false, so the reason is lost: TryTransfer said False for a balance that was too low.
- “Is the failure a mistake in the calling code, such as an amount of zero?” No, it can happen to a careful caller and “Must the caller learn why it failed, to tell a user or to decide what to do next?” No, failure has one obvious meaning: adds a caveat to A result type. A result works, but when failure has one obvious meaning, as in int.TryParse, a bool and an out value say it with less code.
- “Is the failure a mistake in the calling code, such as an amount of zero?” No, it can happen to a careful caller: adds a caveat to A Try method and A result type. Both pass silently when a caller ignores them, so name them so that reading the outcome is the obvious thing to do.
In short, guard clauses throw for the caller’s mistakes, results report what a user must hear, and a Try method serves when a failure has one obvious meaning.
Key idea
Throw for a mistake in the calling code, return a result for an outcome that careful callers meet every day, and use a Try method when the only question is whether it worked.
Checkpoint
Check: An exception, a Try method or a result
Custom exceptions that carry the data a handler needs Pro: opens with a Pro, Premium or Ultimate pass
When no built-in type fits and a handler must act on the details, a custom exception carries them as properties. This part writes one, wraps a cause with it, and builds the serialization members that .NET 8 made obsolete.
In this part:
- Writing InsufficientBalanceException, and a filter that reads its data
- Wrapping the cause, and adding context on the way up
- Serialization members that .NET 8 made obsolete, built both ways
3 runnable examples (C#, Python) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Result types in depth: reasons, retries and the measured cost Pro: opens with a Pro, Premium or Ultimate pass
A result type is only as useful as the reasons it can name and the callers that read it. This part designs one for transfers, measures it against exceptions, and makes the build warn when a result is ignored.
In this part:
- A reason the caller switches on, and a conflict it retries
- 100,000 refusals, measured as exceptions and as results
- Making an ignored result a build warning
3 runnable examples (C#, Python) · about 4 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Documenting what a method throws, without checked exceptions Pro: opens with a Pro, Premium or Ultimate pass
In C# a method’s exceptions are part of its documentation, not of its signature. This part shows why, writes the documentation that the compiler checks, and shows what the callers of task-returning methods receive.
In this part:
- Why C# leaves exceptions out of the signature
- XML exception tags, checked by the compiler
- What callers of task-returning methods receive
3 runnable examples (C#, Python) · about 5 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Practise: the error model of a wallet transfer
transfer.cs checks a TransferOrRefuse that you write, with the rules at the top of the file. As it starts, the
method throws NotImplementedException, so all eight checks fail:
// Practice: write TransferOrRefuse, then run this file until all eight checks pass.
// The rules: a caller's mistake throws the built-in exception for it (no wallet to pay, an amount of zero or less, a
// wallet paying itself); an outcome the user must be told about is a refused result with the reason three_ways.cs
// gives ("wallet W-17 is frozen", or "the balance of 1800 is less than 2500" for a balance that is too low), and it
// leaves both balances as they were; every other transfer moves the money and is done.
(string Check, Func<string> Run, string Expected)[] checks =
[
("a transfer that fits", () => Describe(Pay(1800m, 500m)), "done, balance 1300"),
("more than the balance", () => Describe(Pay(1800m, 2500m)), "refused: the balance of 1800 is less than 2500"),
("a frozen wallet", () => Describe(Pay(1800m, 500m, frozen: true)), "refused: wallet W-17 is frozen"),
("a refusal moves no money", () => BalancesAfterRefusal(), "1800 and 0"),
("no wallet to pay", () => Describe(new Wallet("W-17", 1800m).TransferOrRefuse(null!, 500m)), "throws ArgumentNullException (to)"),
("a negative amount", () => Describe(Pay(1800m, -500m)), "throws ArgumentOutOfRangeException (amount)"),
("an amount of zero", () => Describe(Pay(1800m, 0m)), "throws ArgumentOutOfRangeException (amount)"),
("a wallet paying itself", () => PayItself(), "throws ArgumentException (to)"),
];
int passed = 0;
foreach (var (check, run, expected) in checks)
{
string got;
try
{
got = run();
}
catch (ArgumentException e)
{
got = $"throws {e.GetType().Name} ({e.ParamName})";
}
catch (Exception e)
{
got = $"throws {e.GetType().Name}";
}
bool ok = got == expected;
if (ok)
{
passed++;
}
Console.WriteLine(ok ? $"{check}: ok" : $"{check}: FAIL, {got} instead of {expected}");
}
Console.WriteLine($"{passed} of {checks.Length} checks pass");
return passed == checks.Length ? 0 : 1;
static TransferResult Pay(decimal balance, decimal amount, bool frozen = false)
{
var from = new Wallet("W-17", balance);
if (frozen)
{
from.Freeze();
}
return from.TransferOrRefuse(new Wallet("W-42", 0m), amount);
}
static string BalancesAfterRefusal()
{
var from = new Wallet("W-17", 1800m);
var to = new Wallet("W-42", 0m);
from.TransferOrRefuse(to, 2500m);
return $"{from.Balance} and {to.Balance}";
}
static string PayItself()
{
var wallet = new Wallet("W-17", 1800m);
return Describe(wallet.TransferOrRefuse(wallet, 500m));
}
static string Describe(TransferResult result) =>
result.Succeeded ? $"done, balance {result.Balance}" : $"refused: {result.Refusal}";
sealed class Wallet(string id, decimal balance)
{
public string Id { get; } = id;
public decimal Balance { get; private set; } = balance;
public bool IsFrozen { get; private set; }
public void Freeze() => IsFrozen = true;
public TransferResult TransferOrRefuse(Wallet to, decimal amount)
{
throw new NotImplementedException();
}
}
readonly record struct TransferResult(bool Succeeded, decimal Balance, string? Refusal)
{
public static TransferResult Done(decimal balance) => new(true, balance, null);
public static TransferResult Refused(string reason, decimal balance) => new(false, balance, reason);
} Output (exit status 1)
a transfer that fits: FAIL, throws NotImplementedException instead of done, balance 1300 more than the balance: FAIL, throws NotImplementedException instead of refused: the balance of 1800 is less than 2500 a frozen wallet: FAIL, throws NotImplementedException instead of refused: wallet W-17 is frozen a refusal moves no money: FAIL, throws NotImplementedException instead of 1800 and 0 no wallet to pay: FAIL, throws NotImplementedException instead of throws ArgumentNullException (to) a negative amount: FAIL, throws NotImplementedException instead of throws ArgumentOutOfRangeException (amount) an amount of zero: FAIL, throws NotImplementedException instead of throws ArgumentOutOfRangeException (amount) a wallet paying itself: FAIL, throws NotImplementedException instead of throws ArgumentException (to) 0 of 8 checks pass
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run transfer.cs
Make transfer.cs pass
Copy transfer.cs, and give TransferOrRefuse its guards first, then the two refusals, then the move. Run
dotnet run transfer.cs until it prints 8 of 8 checks pass.
The self-check below covers the two sections above the in-depth parts, and every choice says what it gets right or misses.
Practice that adapts to you
Practise this lesson
7 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · When should a method throw an exception, and when should it return a failure?
Throw when the method cannot do what its name promises because of something that should not happen: a mistake in the
calling code, such as a null argument or an amount of zero, or a failing environment, such as a lost connection. An
exception cannot be ignored, and it stops the work before anything changes. Return a failure, false from a Try method
or a result value, when the outcome is part of normal use: a low balance, a mistyped number, a seat someone just
booked. Those should cost a branch, not a throw. A method may do both: throw for bad arguments and return results for
refusals.
-
Mid to senior
A wallet service throws InsufficientFundsException whenever a balance is too low. At peak it throws 40,000 of them an hour, and the team believes exceptions are slowing it down. Would you change the design? How do you decide, and how do you measure the effect?
-
Senior to staff
Design the error model of a money-transfer API that a mobile app and other internal services call over HTTP. Which failures are exceptions inside the service, which are results, and what does each kind of caller receive?
-
Mid to senior
A team that moved from Java wants every public method of a C# library to declare what it throws, with a build step that fails whenever a method can throw something new. What do you recommend instead, and why?
Key takeaways
- Guard clauses throw the built-in type for each caller mistake, with throw helpers such as
ThrowIfNegativeOrZero, andInvalidOperationExceptionwhen the object’s state forbids the call. - A failure that careful callers meet in normal use, such as a low balance, is a result or a
false, while a mistake in the calling code is an exception. - A Try method loses the reason, a result carries it, and nothing makes a caller read either one.
Terms in this lesson
- result type
- A type that a method returns to report an outcome as a value: success with its data, or failure with its reason, so that the caller branches on it instead of catching an exception.
- throw helper
- A static method that checks an argument and throws the matching exception with a complete message, such as ArgumentNullException.ThrowIfNull or ArgumentOutOfRangeException.ThrowIfNegativeOrZero.
References
- Best practices for exceptions (Microsoft Learn)
- Creating and throwing exceptions (C# fundamentals) (Microsoft Learn)
- ArgumentOutOfRangeException.ThrowIfNegativeOrZero method (Microsoft Learn)
- SYSLIB0051: Legacy serialization support APIs are obsolete (Microsoft Learn)
- CA1806: Do not ignore method results (Microsoft Learn)
- Recommended XML documentation tags (Microsoft Learn)
- NotSupportedException class (Microsoft Learn)
- Task.WhenAll method (Microsoft Learn)
- The Java Tutorials: The Catch or Specify Requirement (Oracle)
- The Trouble with Checked Exceptions: a conversation with Anders Hejlsberg (Artima)
Report a problem with this lesson
Kept only in this browser. Your Learn progress