Skip to content

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-rolled Result<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.

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

return r.Match(
    ok => Results.Created(/* ... */, ok),
    err => Results.Problem(/* ... */));

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.Value without 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.

Further reading