dotnet/Design Patterns
The Result pattern
intermediateTL;DR
public readonly record struct Result<T>
{
private Result(T? value, string? error) => (Value, Error) = (value, error);
public T? Value { get; private init; }
public string? Error { get; private init; }
[MemberNotNullWhen(true, nameof(Value))]
public bool IsSuccess => Error is null;
public static Result<T> Ok(T value) => new(value, null);
public static Result<T> Fail(string error) => new(default, error);
public TOut Match<TOut>(Func<T, TOut> ok, Func<string, TOut> fail) =>
IsSuccess ? ok(Value) : fail(Error!);
}
// Failures are data, not control flow:
Result<User> result = await GetUserAsync(id);
return result.Match<IResult>(
ok: user => Results.Ok(user),
fail: error => Results.NotFound(error));
How it works
Exceptions in .NET are for the exceptional. Throwing and unwinding the stack
to a handler costs orders of magnitude more than an ordinary return — and a
throw is invisible in the method signature. Nothing about
Task<User> GetUserAsync(Guid id) tells a caller “this can fail”; most
callers won’t guard against it until production proves them wrong.
Result<T> puts the failure back on the signature. GetUserAsync returning
Result<User> instead of User turns “no such user” into data the caller
receives and must act on, not a control-flow event they can forget to catch.
Match is the enforcement mechanism: it takes both an ok and a fail
delegate, so there’s no code path that reads Value without also having
decided what happens on failure — compare that to a nullable return, where
skipping the null check compiles fine and blows up on the first unlucky
input.
The [MemberNotNullWhen(true, nameof(Value))] attribute on IsSuccess is
what lets Value inside Match’s true branch analyze as non-null without an
explicit check or a !: the compiler trusts the annotation and narrows
Value’s nullable state anywhere IsSuccess has already been tested, if
or ternary alike. That’s only sound because the constructor is private —
Ok and Fail are the only paths to a “real” instance. Result<T> is
still a struct, though, so default(Result<T>) exists and bypasses the
constructor entirely: Error is null there too, so IsSuccess reads
true on a value nobody built through Ok. Harmless as long as every
instance comes from the factories — worth remembering if a Result<T> ever
ends up as an uninitialized field or array element.
Chaining without a pyramid of ifs
A single Match at the API boundary is the common case, but a pipeline that
runs a success value through several fallible steps benefits from
Map/Then-style extension methods that skip the transform on failure and
let the failure just flow through. That’s what “railway-oriented
programming” means: two parallel tracks, success and failure, that only
converge at the end.
public static Result<TOut> Map<TIn, TOut>(
this Result<TIn> result, Func<TIn, TOut> map) =>
result.IsSuccess ? Result<TOut>.Ok(map(result.Value)) : Result<TOut>.Fail(result.Error!);
Chained — (await GetUserAsync(id)).Map(u => u.Email).Map(NormalizeEmail) — that
reads as a straight line of happy-path transforms, with the failure handling
factored out entirely instead of an if (user is null) return ... after
every step.
None of this replaces exceptions everywhere. Bugs and broken invariants — a
null argument that should never be null, a switch that hit an “impossible”
default, a violated class invariant — should still throw. Those aren’t
outcomes for a caller to branch on; they’re defects that should crash loudly
and get fixed, not get quietly funneled into an error string.
Gotchas
- Don’t wrap everything in
Result. Reach for it on domain outcomes — “user not found,” “validation failed,” “insufficient stock” — where callers need to react to failure as normal control flow. Infrastructure faults (a dropped connection, a corrupted file, a cancelled operation) are still exceptions; catching around every I/O call to funnel it into aResultjust reinventstry/catchbadly. - A
stringerror channel is a starting point, not the destination. Real applications outgrowstring Errorquickly — a typed error (an enum, or an error record) lets callers switch on the kind of failure instead of pattern-matching on message text. - There’s no compiler exhaustiveness for error kinds — yet. Until C#
gets discriminated unions, the compiler can’t force every arm of a
switchover your error type to be handled the way it does for a sealed hierarchy. Libraries like ErrorOr and OneOf encode “one of N specific outcomes” with generics today, closer to what a union would give you. - Mixing conventions is worse than picking either one. A codebase where
half the services throw and half return
Resultforces every caller to know, ambiguously, which style each dependency uses. Pick a boundary — layer, module, or team convention — and stay consistent on each side of it. - You lose the stack trace at the point of failure.
Result.Fail("not found")doesn’t remember whereGetUserAsyncwas called from the way a thrown exception’s stack trace would. Attach whatever context matters (an ID, the failing step, a correlation ID) to the error itself — you don’t get it for free anymore.