Using the Result Monad for Composable Failure Handling in C#
The Result monad turns expected failures into explicit, composable values instead of relying on exceptions or guard clauses. By chaining operations withâŻBind and finalising withâŻMatch, developers can write linear, shortâcircuiting pipelines that stay readable and testable even when dealing with I/O, validation, and domain rules. This article explains how to model failures with Result<TSuccess, TError>, contrasts it with traditional patterns, and provides a concrete example of deactivating a user via an HTTP API.
### The Problem with Traditional Failure Handling
In many C# codebases, fallible work is handled in one of three ways:
1. **Exceptions** â implicit control flow that jumps out of a method as soon as an error occurs. Method signatures often hide the fact that a failure is possible. While exceptions preserve stack traces and surface truly exceptional conditions, they can quickly become noisy when the failure is an expected, businessârule scenario.
2. **Guard clauses / Try patterns** â explicit branching that returns an enum or boolean to represent success or failure. This linear approach is readable but must be repeated after each step; the âcheckâandâcontinueâ pattern spreads throughout the code.
3. **Nullable or Option types** â useful for absent values, but they do not encode an explicit error reason.
When a failure can legitimately occurâparsing user input, validating data, or performing a business ruleâusing exceptions forces the caller to catch and translate them, whereas guard clauses push the error handâoff to the caller and often leave the error context incomplete.
### Enter the Result Monad
`Result` is a simple, twoâcase type that mirrors the shape of `Option` but carries an explicit error payload. The two factory methodsâ`Ok(value)` and `Fail(error)`ârepresent success and failure, respectively. The monadic operations `Bind`, `Map`, and `Match` encode sequencing, transformation, and final handling.
```csharp
public sealed class Result
{
private readonly TSuccess _value;
private readonly TError _error;
private readonly bool _isSuccess;
private Result(TSuccess value, TError error, bool isSuccess)
{
_isSuccess = isSuccess;
_value = value;
_error = error;
}
public static Result Ok(TSuccess value) =>
new Result(value, default!, true);
public static Result Fail(TError error) =>
new Result(default!, error, false);
public Result Map(Func f) =>
_isSuccess ? Ok(f(_value)) : Fail(_error);
public Result Bind(Func> f) =>
_isSuccess ? f(_value) : Fail(_error);
public TResult Match(Func ok, Func err) =>
_isSuccess ? ok(_value) : err(_error);
}
```
*Bind* shortâcircuits: when the current result is a failure, the delegate is not invoked and the failure propagates unchanged. *Match* is the boundary adapter that turns a `Result` into whatever the caller expectsâan HTTP response, a CLI exit code, or a UI state.
### When to Use Result
| Scenario | Preferred Approach | Reason |
|---|---|---|
| Validation or business rule checks that are expected and recoverable | `Result` | Failures carry a domainâspecific error; the pipeline can continue as long as all steps succeed. |
| Parsing user input | `Result` | Failure is common; `Result` conveys exactly which step failed without throwing. |
| I/O or infrastructure operations that might crash the process | Exception | Unexpected errors should surface stack traces; `Result` can be used for recoverable infra errors. |
| Situations where multiple independent failures must be collected | `Validation` or applicative pattern | `Result` is shortâcircuiting by nature; `Validation` accumulates errors. |
### A Concrete Example: Deactivating a User
Below is a compact service that accepts a string ID from an HTTP request, parses it, loads a user, applies a domain rule, and persists the change. All domain logic returns a `Result`; the HTTP boundary maps the result into a friendly message.
```csharp
public sealed record Error(string Code, string Message);
public interface IUserRepo
{
User? Find(int id);
void Save(User user);
}
public sealed class User
{
public int Id { get; init; }
public bool IsActive { get; set; }
}
public sealed class UserService
{
private readonly IUserRepo _repo;
public UserService(IUserRepo repo) => _repo = repo;
// Domain pipeline â returns a Result.
public Result DeactivateUser(string inputId) =>
ParseId(inputId)
.Bind(FindUser)
.Bind(DeactivateDecision);
// Endpoint adapter â translates to a consumerâfacing string.
public string HandleDeactivateRequest(string inputId)
{
Result result = DeactivateUser(inputId);
return result.Match(
ok: _ => { _repo.Save(_); return "User deactivated"; },
err: e => $"Deactivate failed: {e.Code} - {e.Message}" );
}
private static Result ParseId(string inputId) =>
int.TryParse(inputId, out var id)
? Result.Ok(id)
: Result.Fail(new Error("Parse", "Invalid ID format"));
private Result FindUser(int id)
{
var user = _repo.Find(id);
return user is null
? Result.Fail(new Error("NotFound", $"User {id} not found"))
: Result.Ok(user);
}
private static Result DeactivateDecision(User user)
{
if (!user.IsActive)
return Result.Fail(new Error("Domain", "User is already inactive"));
user.IsActive = false;
return Result.Ok(user);
}
}
```
The service composes three independent, pure steps. A failure in any step shortâcircuits the rest, and the final `Match` handles success or propagates a meaningful error string.
### Serializing Results vs. Unwrapping
Publishing a `Result` through a public API can expose internal implementation details. Instead, always `Match` to a DTO, `ProblemDetails`, or HTTP status code. Serializing the raw type would surface fields such as `isSuccess` or `error`, thereby coupling clients to internal control flow.
### Async & Task
Adding asynchronous operations introduces `Task>`. Most FP libraries supply `BindAsync`, `MapAsync`, or LINQ helpers (`SelectManyAsync`) so that you can continue to compose without awaiting at every step. Using a wellâtested library (e.g., LanguageExt or CSharpFunctionalExtensions) is recommended over reinventing these combinators.
### Recap
-âŻ`Result` makes expected failures explicit, composable, and testable.
-âŻ`Bind` constructs shortâcircuiting pipelines; `Match` adapts the final outcome for callers.
-âŻPrefer `Result` for validation, user input, and recoverable business rules; reserve exceptions for invariants and truly exceptional failures.
-âŻAlways unwrap `Result` at the boundary; do not serialize the monad itself.
-âŻUse asyncâaware combinators or existing libraries when mixing `Task` and `Result`.
By adopting this pattern, teams can keep business logic pure and linear while retaining precise error information up to the contract layer.