Result pattern or exceptions? Handling business errors in C# without surprises
Short answer: throw exceptions for things that should not happen (a bug, the database is down, a null that breaks an invariant). Return a Result for outcomes that are a normal part of the business (insufficient funds, wallet frozen, coupon expired). The caller can't ignore a Result by accident, and your API can turn each error into a clear 4xx response.
A real example: the 500 that was really a 422
A withdrawal endpoint calls wallet.Withdraw(amount), which throws InvalidOperationException("Insufficient funds"). In one controller someone remembered to catch it and return 400. In the new mobile endpoint nobody did, so users with a low balance see "Something went wrong" and an error is logged as a server failure. The monitoring dashboard now counts every poor customer as an incident.
The root cause is that "not enough money" was modelled as a crash. The method signature void Withdraw(long) says it always succeeds, and the compiler can't remind anyone that it doesn't.
The rule of thumb
| Situation | Use |
|---|---|
| Insufficient funds, frozen wallet, duplicate national ID | Result |
| Validation of user input | Result (or validation filters) |
| Database unreachable, timeout, bug, broken invariant | Exception |
| Programmer misuse (null argument, negative count) | Exception (ArgumentException) |
If the product owner can describe the case in a user story, it is a Result.
Runnable code
A small Result type:
public sealed record Error(string Code, string Message);
public sealed class Result<T>
{
private Result(T? value, Error? error) => (Value, Error) = (value, error);
public T? Value { get; }
public Error? Error { get; }
public bool IsSuccess => Error is null;
public static Result<T> Ok(T value) => new(value, null);
public static Result<T> Fail(Error error) => new(default, error);
public static implicit operator Result<T>(T value) => Ok(value);
public static implicit operator Result<T>(Error error) => Fail(error);
}
Domain errors live next to the domain:
public static class WalletErrors
{
public static readonly Error Frozen = new("wallet.frozen", "This wallet is frozen.");
public static readonly Error InsufficientFunds =
new("wallet.insufficient_funds", "Not enough balance for this withdrawal.");
}
public Result<LedgerEntry> Withdraw(long amountRials, TimeProvider clock)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amountRials); // a bug, not a business case
if (IsFrozen) return WalletErrors.Frozen;
if (amountRials > Balance) return WalletErrors.InsufficientFunds;
var entry = LedgerEntry.Withdrawal(Id, amountRials, clock.GetUtcNow());
Entries.Add(entry);
return entry;
}
Mapping to HTTP in one place:
public static class ResultHttp
{
public static IResult ToHttp<T>(this Result<T> result) => result.IsSuccess
? Results.Ok(result.Value)
: Results.Problem(
statusCode: StatusCodes.Status422UnprocessableEntity,
title: result.Error!.Message,
extensions: new Dictionary<string, object?> { ["code"] = result.Error.Code });
}
app.MapPost("/wallets/{id:guid}/withdraw",
async (Guid id, WithdrawRequest req, WalletService wallets, CancellationToken ct) =>
(await wallets.WithdrawAsync(id, req.AmountRials, ct)).ToHttp());
The client gets a stable code it can translate ("wallet.insufficient_funds"), and logs only show real failures.
Pros and cons
Pros
- Expected failures are visible in the method signature and hard to forget.
- Error codes are stable and testable; web and mobile clients can localise them.
- Monitoring stops counting business outcomes as server errors.
Cons
- More ceremony: every caller checks
IsSuccessor chains results. - Without discipline you end up with both: some methods throw, some return, for the same kind of error.
- C# has no built-in discriminated unions yet, so the type is a convention, not a language guarantee.
Cleaning up error handling in an ASP.NET Core codebase? See how I can help.
Frequently asked questions
Are exceptions too slow to use for business errors?
Throwing costs microseconds, which rarely matters for a web request. The real problem is that an exception hides an expected outcome from the method signature, so callers forget to handle it. Result makes the failure part of the contract.
Should I use a library like ErrorOr, FluentResults or OneOf?
They are fine and save a little code. A hand-written Result<T> of 30 lines is also fine and has no dependency. Pick one approach per codebase and stick to it; mixing three styles is worse than any single choice.
Do I still need a global exception handler?
Yes. Results cover the outcomes you expect. Bugs, timeouts and database failures still throw, and a global handler should turn them into a 500 ProblemDetails with a correlation id, without leaking stack traces.