DEV Community

Cover image for Stop Guessing Your Exceptions
Sukhpinder Singh
Sukhpinder Singh

Posted on

Stop Guessing Your Exceptions

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.
  • 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`.
Enter fullscreen mode Exit fullscreen mode

If the input is okay, but the object can't do it right now?

*   If yes, use `InvalidOperationException`. 
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

Why these choices?

  • An empty sku isn't a range issue, it's a format problem, so use ArgumentException.
  • A quantity less than or equal to 0 violates a numeric rule, so use ArgumentOutOfRangeException.
  • A missing sku in priceIndex isn't the caller's fault if they provided the sku and a dictionary. It means something's wrong with our environment, so we go with InvalidOperationException.

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
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

Run the app:

dotnet run
Enter fullscreen mode Exit fullscreen mode

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.
  • Forgetting the parameter name.

    • Always use nameof(arg) to point stack traces to the exact parameter.
  • 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 or OneOf<result, error="">-style results. Reserve exceptions for truly rare states.
  • Re-throwing incorrectly.

    • Use throw; to keep the original stack trace. throw ex; resets it and destroys evidence.

Top comments (0)