Make failures clear so your team (and your future self) can fix problems in minutes, not hours.
I remember one night, around 2 a.m., PagerDuty went off. A payment job had failed, with only a vague log: operation failed. No argument name, no range, no state. After digging through logs, I found that a negative quantity had triggered an InvalidOperationException
, which our retry logic mistook for a temporary server issue. We spent an hour retrying bad data.
I've learned that what you throw is as important as when you throw it. The right exception names give you the truth fast.
Quick Guide (save this):
- Caller's mistake? Use
Argument*
.- Null reference? Use
ArgumentNullException
. - Value outside the allowed range? Use
ArgumentOutOfRangeException
. - Wrong format (not null, but still invalid)? Use
ArgumentException
.
- Null reference? Use
- Object or environment not ready? Use
InvalidOperationException
.- This means legal arguments were used, but at the wrong time (e.g., not initialized, disposed of, or closed).
Always name the parameter and the rule you're enforcing. Use the built-in ThrowIf*
helpers in .NET 8+ if you can.
Check early, fail fast. Validate data at the entry points, like controllers or public APIs. Don't use exceptions for normal program flow, only for truly unexpected situations.
Name the Real Issue
Why is this complicated? Because we often lump two different problems into something went wrong:
- Bad request: The caller provided invalid input.
- Bad state: The system isn't ready to handle the request, even if it's valid.
Both can cause a crash, but they need different fixes, alerts, and retry strategies. Your exception type acts as a clear message, telling support teams, retry mechanisms, and code reviewers who needs to do what.
Use This Two-Step Test
Did the caller provide illegal input?
* If yes, use `Argument*`.
* Null reference? Use `ArgumentNullException` (`ThrowIfNull`).
* Number or text outside the allowed range? Use `ArgumentOutOfRangeException`.
* Correct type, but an invalid format? Use `ArgumentException`.
If the input is okay, but the object can't do it right now?
* If yes, use `InvalidOperationException`.
Examples: not initialized, already disposed of, connection closed, concurrency conflict.
If neither of these fit, stop and think. You might need a custom exception (like PaymentDeclinedException
) or a more specific built-in exception (like IOException
or TimeoutException
).
Here's the process:
[Failure occurs]
├─ Illegal input? ──► Argument* (Null / OutOfRange / Exception)
│
└─ Input legal, wrong state? ──► InvalidOperation
Applying Guard Clauses (C# 12 / .NET 8+)
These examples assume you're using nullable references, .NET 8 SDK, and C# 12.
using System;
using System.Collections.Generic;
public static class Pricing
{
// Expects sku non-empty, quantity > 0, priceIndex contains sku.
public static decimal PriceOrder(string sku, int quantity, IReadOnlyDictionary<string, decimal=""> priceIndex)
{
ArgumentNullException.ThrowIfNull(priceIndex);
if (string.IsNullOrWhiteSpace(sku))
throw new ArgumentException(SKU cannot be empty or whitespace.,</string,> nameof(sku));
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(quantity, 0, nameof(quantity));
if (!priceIndex.TryGetValue(sku, out var unit))
throw new InvalidOperationException($SKU '{sku}' is unknown in the current price index.);
return unit * quantity;
}
}
Why these choices?
- An empty
sku
isn't a range issue, it's a format problem, so useArgumentException
. - A
quantity
less than or equal to 0 violates a numeric rule, so useArgumentOutOfRangeException
. - A missing
sku
inpriceIndex
isn't the caller's fault if they provided thesku
and a dictionary. It means something's wrong with our environment, so we go withInvalidOperationException
.
Should you ever throw Argument*
for the unknown SKU
? Only if your API says the caller needs to check if the SKU exists before calling. Ask who is in charge.
Try It Out
Create a new console app:
dotnet new console -n ExceptionRubricDemo -f net8.0
cd ExceptionRubricDemo
Replace the code in Program.cs
with this:
using System;
using System.Collections.Generic;
var prices = new Dictionary<string, decimal=""> { [ABC] = 9.99m };
decimal</string,> ok = Pricing.PriceOrder(ABC, 2, prices);
Console.WriteLine(ok); // 19.98
try { Pricing.PriceOrder(, 2, prices); }
catch (ArgumentException ex) { Console.WriteLine(ex.GetType().Name + : + ex.ParamName); }
try { Pricing.PriceOrder(ABC, 0, prices); }
catch (ArgumentOutOfRangeException ex) { Console.WriteLine(ex.GetType().Name + : + ex.ParamName); }
try { Pricing.PriceOrder(MISSING, 1, prices); }
catch (InvalidOperationException ex) { Console.WriteLine(ex.GetType().Name + : + ex.Message); }
public static class Pricing
{
public static decimal PriceOrder(string sku, int quantity, IReadOnlyDictionary<string, decimal=""> priceIndex)
{</string,>
ArgumentNullException.ThrowIfNull(priceIndex);
if (string.IsNullOrWhiteSpace(sku))
throw new ArgumentException(SKU cannot be empty or whitespace., nameof(sku));
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(quantity, 0, nameof(quantity));
if (!priceIndex.TryGetValue(sku, out var unit))
throw new InvalidOperationException($SKU '{sku}' is unknown in the current price index.);
return unit * quantity;
}
}
Run the app:
dotnet run
You should see the three exception types, each with helpful details.
Avoid Simple Mistakes
-
Calling caller errors InvalidOperation.
- If
pageSize = -1
, that's the caller's fault. Don't hide it as invalid operation or something failed. Put yourself in a 2 AM situation and consider what you would want to see.
- If
-
Forgetting the parameter name.
- Always use
nameof(arg)
to point stack traces to the exact parameter.
- Always use
-
Throwing too late.
- Validate at the entry point. If an invalid value gets deep into the system, it will throw a misleading message far from where the real problem started.
-
Using exceptions as if/else.
- Exceptions are heavy and slow. If you expect a missing key often, use
TryGet…
patterns orOneOf<result, error="">
-style results. Reserve exceptions for truly rare states.
- Exceptions are heavy and slow. If you expect a missing key often, use
-
Re-throwing incorrectly.
- Use
throw;
to keep the original stack trace.throw ex;
resets it and destroys evidence.
- Use
Top comments (0)