Result Pattern vs Exceptions
Key Points
- Exceptions for exceptional (unexpected) conditions: bugs, infrastructure failures, programmer errors.
- Result types for expected outcomes: not-found, validation failures, business-rule rejections.
- Result pattern is more cost-effective on hot paths — exceptions are slow when frequent (microseconds each).
- Hybrid is realistic: exceptions at boundaries (controller catches
NotFoundException→ 404), Results in inner core. - Popular libraries:
FluentResults,OneOf,ErrorOr,LanguageExt. Or hand-rolledResult<T>. - Don't lose the error: Result forces callers to acknowledge failure; exceptions can be silently swallowed.
Concepts (deep dive)
Hand-rolled Result
public abstract record Result<T>
{
public sealed record Ok(T Value) : Result<T>;
public sealed record Err(string Code, string Message) : Result<T>;
public bool IsSuccess => this is Ok;
public TR Match<TR>(Func<T, TR> ok, Func<Err, TR> err)
=> this is Ok o ? ok(o.Value) : err((Err)this);
}
Or with a popular library (FluentResults):
public Result<Order> Place(...)
{
if (...) return Result.Fail<Order>("validation error");
return Result.Ok(order);
}
var result = Place(...);
if (result.IsFailed) return /* handle */;
var order = result.Value;
When to use which
| Condition | Use |
|---|---|
| "Customer not found" (expected) | Result |
| "Database connection lost" (infra) | Exception |
| "Invalid email format" (validation) | Result |
| "Null reference in our own code" (bug) | NRE; let it propagate |
| "Concurrency conflict" (expected) | Either; depends on layer |
| "Authorization denied" (expected) | Result or 403 directly |
The line: if the caller will likely handle this case in normal flow, return a Result. If it's a bug or infra failure, exception.
Performance comparison
A throw + catch in .NET is ~1-5µs. In a hot path that fires "user not found" exceptions for 30% of lookups, that's significant CPU.
Result types compile to plain conditional flow — nanoseconds.
For high-RPS services with frequent expected-failure outcomes, Results dominate exceptions on perf.
Hybrid pattern at boundaries
// Inner: Result-based
public Result<Order> PlaceOrder(...) { /* returns Ok/Err */ }
// Boundary: convert to HTTP
app.MapPost("/orders", (Req req) =>
{
var result = handler.PlaceOrder(req);
return result switch
{
Result<Order>.Ok ok => Results.Created($"/orders/{ok.Value.Id}", ok.Value),
Result<Order>.Err { Code: "validation" } e => Results.ValidationProblem(/* ... */),
Result<Order>.Err { Code: "not-found" } e => Results.NotFound(),
Result<Order>.Err e => Results.Problem(detail: e.Message)
};
});
Inner code reads naturally. Boundary translates Result → HTTP. Best of both.
Popular libraries
| Library | Style |
|---|---|
| FluentResults | Result<T> with errors as objects; chainable |
| OneOf | Discriminated-union-style: OneOf<User, NotFound, Error> |
| ErrorOr | Lightweight Result + ProblemDetails-friendly |
| LanguageExt | Full functional library; Either<L, R>, Option<T>, etc. |
| Hand-rolled | ~50 lines for typical projects |
Result<T> and async
public async Task<Result<Order>> PlaceAsync(...) { /* ... */ }
var r = await handler.PlaceAsync(...);
if (r.IsFailed) return /* handle */;
Just wrap in Task<Result<T>>. Async story is unchanged.
Validation results
public record ValidationError(string Field, string Message);
public record ValidationResult
{
public List<ValidationError> Errors { get; init; } = new();
public bool IsValid => Errors.Count == 0;
}
public ValidationResult Validate(Cmd c)
{
var r = new ValidationResult();
if (string.IsNullOrEmpty(c.Email)) r.Errors.Add(new("Email", "required"));
if (c.Amount <= 0) r.Errors.Add(new("Amount", "must be positive"));
return r;
}
This is just a specialization of the Result idea. FluentValidation has the same shape with more features.
When NOT to use Result
- Public-API contract that throws by convention — e.g.,
IDictionary<TKey, TValue>.Item[get]throws on missing key. Don't fight the framework. - Truly exceptional conditions — null arguments where caller violated the contract.
- In a constructor where you can't return a value — exceptions are the only choice.
Combining results
// FluentResults
var combined = result1.WithReasons(result2.Reasons);
// OneOf-style
var either = await GetUser(id); // OneOf<User, NotFound, ServerError>
return await either.Match(
user => Results.Ok(user),
notFound => Results.NotFound(),
error => Results.Problem(detail: error.Message));
Pattern-matching on the result type cleanly handles each case.
Code: correct vs wrong
❌ Wrong: throw for "not found"
public Order GetOrThrow(Guid id)
{
var o = db.Orders.Find(id);
if (o is null) throw new NotFoundException($"Order {id}");
return o;
}
// Caller must remember to catch; on miss, exception cost paid every time.
✅ Correct: Result
public Result<Order> Find(Guid id) =>
db.Orders.Find(id) is { } o ? Result.Ok(o) : Result.NotFound("order missing");
❌ Wrong: returning null and hoping callers check
public Order? Find(Guid id) => db.Orders.Find(id);
var o = Find(id);
o.Confirm(); // NRE if not found
NRT helps but doesn't enforce. Result with explicit failure is clearer.
❌ Wrong: ignoring the failure case
var r = Place(...);
return Results.Ok(r.Value); // ❌ if r.IsFailed, throws InvalidOperationException
✅ Correct: pattern match
Design patterns for this topic
Pattern 1 — "Inner Result; outer HTTP translation"
- Intent: Result in core; translate at boundary.
Pattern 2 — "Discriminated-union-style for explicit cases"
- Intent: OneOf/discriminated unions force exhaustive matching.
Pattern 3 — "Validation pipeline returns Result"
- Intent: validation failures as data, not exceptions.
Pattern 4 — "FluentResults for chainability"
- Intent: when you have multi-step operations with cumulative errors.
Pattern 5 — "Hybrid: Result for expected, exception for unexpected"
- Intent: match the tool to the case.
Pros & cons / trade-offs
| Aspect | Pros | Cons |
|---|---|---|
| Exceptions | Familiar; built-in | Expensive; easy to swallow |
| Result | Explicit; performant | Verbose; library or hand-roll |
| Hybrid | Right tool per layer | Two patterns coexist |
| OneOf | Exhaustive matching | Newer; less familiar |
When to use / when to avoid
- Use Result for expected outcomes (not found, validation, business rules).
- Use exceptions for unexpected (bugs, infra).
- Avoid Result everywhere — too verbose; exceptions still right for some cases.
- Avoid swallowing exceptions with a Result wrapper — defeats the point.
Interview Q&A
Q1. When use Result vs exceptions? Result for expected outcomes that the caller will likely handle. Exception for unexpected (bugs, infra failures).
Q2. Why is the throw cost relevant? Each throw + catch is microseconds — fine for rare events, expensive in hot paths.
Q3. What's a discriminated union and how does it help? A type that's exactly one of N variants. C# doesn't have first-class unions yet, but OneOf library and sealed record hierarchies emulate it. Forces exhaustive handling.
Q4. Should Result types be exception-aware? Generally Result represents expected failures; exceptions are for the unexpected. If an exception fires inside Result-returning code, log + propagate or convert to Result depending on context.
Q5. How do you translate Result to HTTP? Pattern match at the boundary; map error code/type to HTTP status (404, 400, 422, 500). Use ProblemDetails for the body.
Q6. What if a failure in a validator is critical infra (DB down)? That's not a validation result — that's an infra exception. Let it propagate. Validation should be pure.
Q7. Is Maybe<T> / Option<T> the same as Result<T>? Different. Option<T> represents presence/absence (Some / None). Result<T> represents success/failure with reason. Both have value.
Q8. Why does NRT not replace Result? NRT signals "could be null"; Result signals "and here's why". The latter is richer.
Q9. Is there a pattern-matching language feature for this? C# pattern matching + sealed record hierarchy gets you most of the way. C# is moving toward first-class discriminated unions.
Q10. How do tests look with Result? Assert on the variant: Assert.True(result.IsSuccess) or Assert.IsType<Result<Order>.Ok>(result). Cleaner than asserting "it didn't throw".
Gotchas / common mistakes
- ⚠️ Both throw and return Result in the same method — confusing.
- ⚠️
result.Valuewithout checking — runtime exception if failed. - ⚠️ Wrapping infrastructure exceptions in Result — masks real failures.
- ⚠️ Inconsistent error shape — some calls return string codes; others Exception objects.
- ⚠️ Too many Result libraries in one solution — pick one.