# Stop Writing Boilerplate Conversions: The Result Pattern in C#

Every error type looked identical. Same two properties: error and description. Different type name.

I stared at the handler converting `ValidationError` to `ProcessingError`, copying those same two fields into a new type. Eight lines of boilerplate, for what? Simply because C# couldn't express "same error, different context."

Every handler looked almost the same: validate the request, then explicitly pattern-match the result. If validation failed, destructure the error and reconstruct an identical error with a different type name from another hierarchy of types. If validation succeeded, process the request. Don't forget the default case throwing `UnexpectedTypeException`, a runtime bomb that could detonate if anyone added a new result type and forgot to update every handler.

There had to be a better way.

The refactoring changed how I think about error handling in OAuth 2.0 and [OpenID Connect](https://www.abblix.com/en/docs/glossary-overview#openid-connect), where one mishandled case can compromise security or break an auth flow. I'll share what stuck: the patterns I kept using afterwards, and the mistakes that taught them.

## TL;DR

I migrated our OAuth 2.0/OIDC library from per-endpoint inheritance-based unions to a single generic Result type. The problem? About twenty duplicate error types, handlers bloated with boilerplate type conversions, and runtime type checks waiting for the day someone added a result type and missed a handler.

Here's what was at stake: Every new endpoint meant copy-pasting error handling code across four layers. Every handler risked `UnexpectedTypeException` if I forgot to update pattern matching. Code reviews became "spot the missing default case" exercises. Even I, as the author, had to re-trace the error hierarchy every time I touched a new endpoint. The mechanical work was dragging velocity down.

The Result pattern changed the economics. About twenty duplicate error types vanished, and every handler in the validate-then-process pipeline collapsed from 10-15 lines to just 2. The runtime type checks along that pipeline became compile-time guarantees. The work shipped as the core of our v2.0 release, a change that swept more than 300 files; the test suite stayed green throughout, and v2 went on to pass the same OpenID Foundation conformance profiles v1 had passed.

The trade-offs were real. Your team faces a learning curve with functional concepts. Type signatures grow longer in some places. The migration itself takes time and effort.

Would I do it again? Without hesitation. If you're working with rich error semantics - OAuth 2.0, payment systems, complex validation flows - this pattern transforms how you handle errors.

## Where I Started

To understand the impact of my migration, you need to see how Abblix OIDC Server is designed under the hood.

Each OAuth 2.0/OIDC endpoint follows a consistent pipeline:

Validators examine incoming requests for OAuth 2.0/OIDC compliance. Invalid credentials, missing parameters, expired tokens: validators catch these and return either a validated request or validation errors.

Processors execute the business logic - generating tokens, retrieving user info, managing sessions, revoking credentials. They consume validated requests and return either success results or processing errors.

Handlers orchestrate the flow: they coordinate validators and processors and manage the request-response lifecycle for each endpoint.

Formatters convert results into HTTP responses. Success usually becomes 200 OK with a JSON payload. Errors become 400 Bad Request (client errors), 401 Unauthorized, or 403 Forbidden (auth failures), with standardized error details.

```mermaid
flowchart TD
    Req([HTTP Request])
    Req --> V

    subgraph Handler["Handler (orchestrates)"]
        V["Validator<br/>returns: ValidRequest | ValidationError"]
        P["Processor<br/>returns: Success | Error"]
        Fmt["Formatter<br/>Converts to HTTP Response"]

        V -->|"ValidRequest"| P
        P -->|"Success"| Fmt
        V -->|"ValidationError → Error"| Fmt
        P -->|"Error"| Fmt
    end

    Fmt --> Res([HTTP Response: 200 OK or 4xx])
```

Clean. Testable. Hexagonal.

What no diagram shows: the mechanical work of passing errors through each stage. Validators returned `ValidationError`. Processors needed `ProcessingError`. Same two fields, copied manually at every boundary, not because the architecture was wrong, but because C# forced separate types in each hierarchy.

## The Problem I Was Solving

Every component needed to communicate success or failure between stages, and my discriminated union implementation created massive duplication at every level.

OAuth 2.0 and OpenID Connect servers are full of complex error scenarios across multiple endpoints. My original implementation used inheritance-based discriminated unions - each endpoint had a base response type with success and error subtypes.

But here's what made it painful in my case: every endpoint had two separate type hierarchies. One for validation (ValidRequest/ValidationError) and another for processing (SuccessResponse/ErrorResponse). This meant:

- About twenty duplicate error types, all containing identical properties
- Forced type conversions: handlers manually converted `ValidationError` to `ProcessingError`, pure boilerplate
- 10-15 line handlers: just to pass errors through validation, processing, and formatting
- Runtime exceptions: pattern matching required default cases that threw `UnexpectedTypeException`

The root cause? The C# we were building on had no native discriminated unions. Unlike F#, TypeScript, or Rust, it had no sum types built into the language, so the codebase simulated discriminated unions with inheritance, a verbose workaround. (That gap is closing in C# 15, which a later section takes up.)

### The Inheritance-Based Approach

```csharp
// Base type for discriminated union
public abstract record UserInfoResponse;

// Success case
public record UserInfoFoundResponse(JsonObject User, ClientInfo ClientInfo, string Issuer)
    : UserInfoResponse;

// Error case - identical structure across all endpoints!
public record UserInfoErrorResponse(string Error, string ErrorDescription)
    : UserInfoResponse;

// Handler signature - doesn't reveal this can fail
Task<UserInfoResponse> HandleAsync(UserInfoRequest request);
```

This pattern repeated across every endpoint.

You might wonder: why not one base type for all endpoints? Because OAuth 2.0 endpoints return fundamentally different success types: UserInfo returns claims, Token returns access and refresh tokens, Revocation returns nothing, Introspection returns token metadata. Separate base types weren't a design mistake; they reflected the domain. The same goes for the split between validation and processing stages, which are genuinely different concerns.

In inheritance-based unions, the base type defines the union's scope: you can't mix types from different hierarchies.

The next idea is usually to share one base type across all pipeline stages. It would work only if the type system distinguished between stages, but C# doesn't.

```csharp
public abstract record PipelineResult;
public record ValidRequest(...) : PipelineResult;
public record OidcError(string Error, string ErrorDescription) : PipelineResult;
public record SuccessResponse(...) : PipelineResult;

Task<PipelineResult> ValidateAsync(...);            // Should be: ValidRequest | OidcError
Task<PipelineResult> ProcessAsync(ValidRequest);    // Should be: SuccessResponse | OidcError
```

Both methods return the same `PipelineResult`, so the compiler can't catch a processor that accidentally returns a `ValidRequest`. Sharing base types produces unions that are too loose, including combinations that should be impossible. The same problem appears if you try to share a base type across different endpoints: `UserInfoSuccess` and `TokenSuccess` under a common parent means the UserInfo handler is now type-compatible with the Token handler's return, and vice versa.

I needed precise unions:

- Validation union: `ValidRequest | ValidationError`
- Processing union: `SuccessResponse | ProcessingError`

Two different unions with different valid combinations. C# has no way to express "semantically identical types, different unions." The single-inheritance constraint forces separate error types per stage, even though they carry identical properties (`Error`, `ErrorDescription`). The handler sat in the middle, copying those properties from `ValidationError` to `ProcessingError`.

I spent significant time trying to make shared base types work before I accepted this constraint.

### The Handler Complexity

A typical handler:

```csharp
public async Task<UserInfoResponse> HandleAsync(
    UserInfoRequest userInfoRequest,
    ClientRequest clientRequest)
{
    var validationResult = await _validator.ValidateAsync(userInfoRequest, clientRequest);

    return validationResult switch
    {
        ValidUserInfoRequest validRequest => await _processor.ProcessAsync(validRequest),

        UserInfoRequestError { Error: var error, ErrorDescription: var description }
            => new UserInfoErrorResponse(error, description),

        _ => throw new UnexpectedTypeException(nameof(validationResult), validationResult.GetType()),
    };
}
```

More than 10 lines of code to express a simple flow: validate, then either process or return an error.

The middle case is pure boilerplate - destructuring `Error` and `ErrorDescription` from `UserInfoRequestError` just to construct an identical `UserInfoErrorResponse`.

And that `default` case with `UnexpectedTypeException`? It fails only at runtime. If you add a new validation result type and forget to update the handler, the compiler won't catch it. You'll only discover the bug when that code path executes in production.

This pattern repeated across many handlers. The [Token endpoint](https://www.abblix.com/en/docs/glossary-overview#token-endpoint) had `TokenError` to `TokenErrorResponse`. The [Revocation endpoint](https://www.abblix.com/en/docs/glossary-overview#revocation-endpoint) had `RevocationError` to `RevocationErrorResponse`. You get the idea. Every handler performed the same mechanical job.

The formatters had the same problem - explicit pattern matching with default exception cases everywhere.

## Why Not Result From Day One

A fair question I get from peer reviewers: why did I ship v1 on inheritance-based discriminated unions, when I knew about the Result pattern back when Oidc.Server started in late 2022 and functional error handling was already well-established?

Two honest reasons.

First, I didn't feel the pain yet. The codebase was small. A few endpoints, a handful of error types. Pattern matching with default-case-throws felt like the standard C# idiom, and most .NET projects I'd worked on used exactly that. I knew Result existed. I didn't have a concrete moment that made me think "this would have prevented today's bug." Without that pressure, the inheritance-based approach was the path of least resistance.

Second, I deliberately limited novelty. Starting an OIDC server from scratch was already a large unknown: six conformance profiles, dozens of edge cases per spec, all of it security-critical. Adding "and the error model is something I've never shipped to production" multiplies risk on the wrong axis. I wanted v1 to land in a reasonable timeframe, and familiar idioms were part of that velocity, not a deficit.

The migration came when both reasons inverted. The codebase had grown to more than a dozen endpoints, and the cost of inheritance-based DU was no longer hypothetical: every new endpoint cost me an hour of mechanical boilerplate. By that point, I'd absorbed enough of the codebase to take on a deeper refactor. Conditions changed; the answer changed.

If I were starting Oidc.Server today, knowing what I know now? I'd probably reach for the Result pattern from the first endpoint. But that's the privilege of hindsight, not a critique of the original choice. v1 shipped on time and passed OpenID Foundation certification. The migration to v2 happened when it was the right move, not earlier and not later.

## Understanding Result Pattern

A quick primer before the code. Result is a functional-programming approach to error handling. Instead of throwing exceptions or returning null, a method returns an explicit type: success with a value, or failure with an error. Nothing else. No nulls and no exceptions. The type system forces you to handle both paths.

The critical part: you can't casually access the success value without handling the failure case. The success value is sealed inside the Result type, and the idiomatic path out is through operations like `Match` and `Bind` that force you to deal with both outcomes (escape hatches like `TryGetSuccess` exist for tests and boundaries). You rarely have to wonder "did I forget to check for errors?"

Think of it as a railway with two tracks:

```mermaid
flowchart TD
    Input([Input])
    Input --> Validate
    Validate -->|"Success ✓"| Process
    Process -->|"Success ✓"| Format
    Format --> Ok([200 OK])

    Validate -->|"Failure ✗"| Err([400 Error])
    Process -->|"Failure ✗"| Err
```

- Success track: If the current operation succeeded, execute the next operation.
- Failure track: If the current operation failed, skip all subsequent operations and propagate the error.

## Abblix Implementation

You might ask why roll your own at all. The .NET ecosystem has solid Result libraries: ErrorOr, FluentResults, LanguageExt, OneOf. The honest answer is that we already had this type, proven in production in Abblix Account, before the migration began. Swapping working code we owned for a third-party dependency would have added external surface to a security library for no functional gain. If you are starting fresh without one, any of those libraries will do; nothing in this article is specific to ours.

Its shape:

```csharp
public abstract record Result<TSuccess, TFailure>
{
    public static implicit operator Result<TSuccess, TFailure>(TSuccess value) => new SuccessResult(value);
    public static implicit operator Result<TSuccess, TFailure>(TFailure failure) => new FailureResult(failure);

    public abstract T Match<T>(Func<TSuccess, T> onSuccess, Func<TFailure, T> onFailure);
    public abstract Result<TNext, TFailure> Bind<TNext>(Func<TSuccess, Result<TNext, TFailure>> func);
    public abstract Task<Result<TNext, TFailure>> BindAsync<TNext>(Func<TSuccess, Task<Result<TNext, TFailure>>> func);

    private sealed record SuccessResult(TSuccess Value) : Result<TSuccess, TFailure>;
    private sealed record FailureResult(TFailure Failure) : Result<TSuccess, TFailure>;
}
```

Implicit conversions. Processors just `return new OidcError(...)` on failure or `return new TokenIssued(...)` on success. The type system wraps them. No `Result.Success()` or `Result.Failure()` ceremony.

Composition with Bind. `Bind` chains operations that return Results. If validation succeeds, processing continues. If it fails, `BindAsync` propagates the error automatically:

```csharp
var validationResult = await _validator.ValidateAsync(request);
return await validationResult             // Result<ValidRequest, OidcError>
    .BindAsync(_processor.ProcessAsync);  // runs only on success
```

Pattern matching with Match. The compiler enforces exhaustiveness. You must handle both cases. No default branches, no runtime exceptions:

```csharp
return response.Match(
    onSuccess: success => FormatSuccess(success),
    onFailure: error => FormatError(error));
```

Additional operations. `MapSuccess`/`MapFailure` transform values without changing the Result shape; `Ensure` adds a predicate that converts success to failure; `TryGetSuccess`/`TryGetFailure` extract safely; `Deconstruct` plugs into C# deconstruction. Together these let you chain validations, transformations, and async operations while errors propagate automatically through the pipeline.

## The Migration Steps

The first step was clear: I needed to establish the foundation. I couldn't touch handlers until I had a solid error type to rely on.

I defined a single `OidcError` record to replace all those duplicate error types:

```csharp
// One error type to rule them all
public record OidcError(string Error, string ErrorDescription);
```

Simple, right? I kept specialized types only where they actually add value:

```csharp
public record AuthorizationRequestValidationError(string Error, string ErrorDescription, Uri? RedirectUri, string ResponseMode)
    : OidcError(Error, ErrorDescription);
```

Why keep this? Because authorization errors need additional properties (`RedirectUri` and `ResponseMode`) that generic `OidcError` doesn't have. This is legitimate specialization - the error carries context-specific information needed for OAuth 2.0 redirect flows.

This is where the security stakes get concrete. An authorization error has to travel back to the client through the registered `redirect_uri`, encoded in the `response_mode` the request asked for. The exception is when the `redirect_uri` itself is what failed validation: redirecting then would be an open redirect, handing an attacker a way to bounce victims off our domain. The error type carries exactly the context that decides "redirect with the error" versus "stop and show it here." When that decision rode on a default case in a switch, a missed branch would not have been a 500 but a potential security hole. In v1 that guarantee rested on tests and review, and it held; exhaustive matching moves it from runtime discipline to a compile-time guarantee, which in an OAuth server is not a nicety.

Here's the final error hierarchy:

```
OidcError (base: Error, ErrorDescription)
├── BackChannelAuthenticationUnauthorized → HTTP 401
├── BackChannelAuthenticationForbidden → HTTP 403
└── AuthorizationRequestValidationError → HTTP 400
    ├── Error, ErrorDescription (inherited)
    ├── RedirectUri (additional)
    └── ResponseMode (additional)
```

I deleted about twenty duplicate error types in this phase. That alone was worth the effort.

Once the foundation was solid, I started migrating endpoints to it. And I discovered that every endpoint followed the exact same transformation pattern.

### Update Interface Signatures

```csharp
// Before - what does this return?
Task<UserInfoResponse> HandleAsync(UserInfoRequest request);

// After - explicitly a success-or-failure operation
Task<Result<UserInfoFoundResponse, OidcError>> HandleAsync(UserInfoRequest request);
```

The new signature is self-documenting. Anyone reading it knows there are exactly two cases and what they are: either `UserInfoFoundResponse` on success or `OidcError` on failure.

### Simplify the Handlers

The payoff:

```csharp
// Before - explicit pattern matching with runtime exception
var validationResult = await _validator.ValidateAsync(userInfoRequest, clientRequest);

return validationResult switch
{
    ValidUserInfoRequest validRequest => await _processor.ProcessAsync(validRequest),

    UserInfoRequestError { Error: var error, ErrorDescription: var description }
        => new UserInfoErrorResponse(error, description),

    _ => throw new UnexpectedTypeException(nameof(validationResult), validationResult.GetType()),
};

// After - just 2 lines that do the same thing
var validationResult = await _validator.ValidateAsync(userInfoRequest, clientRequest);
return await validationResult.BindAsync(_processor.ProcessAsync);
```

That `BindAsync` call? It's railway-oriented programming in action. If validation fails, the error propagates automatically. On success, the processor runs next. No manual error mapping needed.

### Update Processors

Processors became simpler too. Thanks to implicit conversions, they just return success values directly:

```csharp
public async Task<Result<UserInfoFoundResponse, OidcError>> ProcessAsync(...)
{
    var user = await GetUserInfo(...);
    return new UserInfoFoundResponse(user, clientInfo, issuer);
    // Implicitly converts to Result<UserInfoFoundResponse, OidcError>
}
```

No wrapping, the type system handles it.

### Clean Up Formatters

Formatters got the Match treatment:

```csharp
// Before - explicit switch with runtime exception risk
return response switch
{
    UserInfoFoundResponse found => FormatSuccess(found),
    UserInfoErrorResponse error => FormatError(error),
    _ => throw new UnexpectedTypeException()
};

// After - exhaustive, compile-time checked
return response.Match(
    onSuccess: FormatSuccess,
    onFailure: FormatError);
```

Both cases are handled or the code doesn't compile. No runtime surprises.

### Delete the Base Type

The final satisfaction: deleting `UserInfoResponse` entirely. No more discriminated union base types needed.

I repeated this pattern across all the endpoints. By the third one, I could do it in my sleep, and that sameness is what made each migration diff small, atomic, and easy to review.

### Let the Compiler Guide You

After updating interfaces, I just built the project and fixed errors systematically. Each error was a clear signpost showing exactly what needed fixing. I worked from the bottom up: interfaces, then implementations, then tests, one endpoint at a time, keeping the suite green after each step.

## The Challenges

Nothing's perfect. The migration had real costs.

### The Learning Curve

Terms like `monad` and `railway-oriented programming` sound intimidating. Once you get past terminology, Result is simpler than what I had before. The real challenge isn't new complexity, it's unlearning OOP patterns I had convinced myself were normal because I had used them for years. Inheritance hierarchies with base types and switch expressions with default cases throwing exceptions are not simple. They feel simple only because they are familiar.

The mental shifts were more significant than the code changes. Exceptions stopped being control flow and became the mechanism for truly unexpected failures. Validation failures? Expected: use Result. Database down? Unexpected: throw. When a signature returns `Result<User, Error>`, null is unrepresentable (nullable annotations enforce it), so the defensive null checks had nothing left to defend and disappeared.

The biggest shift was replacing inheritance hierarchies with composition through `BindAsync`. Reading Scott Wlaschin's essays on railway-oriented programming was the breakthrough moment; after that I started seeing dual-track patterns everywhere in my codebase.

### Longer Type Signatures

This is a real trade-off:

```csharp
// Before
Task<UserInfoResponse>

// After
Task<Result<UserInfoFoundResponse, OidcError>>
```

The type signature got longer. In deeply nested generics, it can get verbose:

```csharp
Task<Result<IReadOnlyList<Result<ValidClient, OidcError>>, OidcError>>
```

My solution? Type aliases:

```csharp
using ValidationResult = Result<ValidRequest, OidcError>;

Task<ValidationResult> ValidateAsync(...);
```

Much more readable.

### Multiple Error Types

Result holds one error type. What if an operation can fail with database errors or validation errors? A common solution is a shared discriminated union base (`public abstract record OperationError`) with derived types. Another is nested `Result<Result<...>>`, which gets unreadable fast. I chose a third path: Result for expected failures (validation, auth) that map to 4xx status codes, exceptions for unexpected failures (database down, network timeout) that map to 5xx. Expected outcomes flow as values; system failures flow as exceptions.

### Minor Trade-offs

No stack traces for Result errors. Result doesn't capture a call stack. That's fine: expected failures like "missing parameter" don't need one; the error code and description tell you what happened. Stack traces are valuable for unexpected failures, which remain exceptions.

C# doesn't enforce handling. Unlike Rust's `#[must_use]`, C# lets you ignore a returned `Result<T, E>` with no compiler error: the limitation applies to any return value in C#, not specifically Result, and C# has no built-in must-use enforcement, so code review carries this one. The idiomatic operations, `Match` and `Bind`, do force you to acknowledge both paths; the `TryGet*` and `Deconstruct` escape hatches are for tests and boundaries, where the discipline stays on you.

Allocation cost. `Result<TSuccess, TFailure>` is a class-based record, so every pipeline stage allocates a small wrapper object, where struct-based libraries like ErrorOr and OneOf avoid the allocation. That is a deliberate choice - records keep the type simple, avoid large-struct copies, and sidestep default-struct pitfalls - and in an OAuth server the cost disappears into the noise: every request is dominated by I/O and cryptography, and the short-lived wrappers die in gen0. If you are adopting Result on a hot in-memory path, measure first; the struct-based libraries exist for exactly that case.

## What I Gained

I approached the migration incrementally: one endpoint at a time, compiler-guided, with atomic commits I could revert. It touched almost every layer, and a large share of the code in those files simply disappeared, not because I cut corners but because it was boilerplate the Result pattern made obsolete.

Three things changed for good. The twenty-odd near-identical error types collapsed into one `OidcError` with a few specialized variants. Every pipeline handler shrank to two lines. And along the Result-shaped pipeline, the `default` cases that used to throw at runtime became compile-time exhaustiveness: forget a case there now and it does not compile. (The authorize endpoint's multi-shape responses keep a discriminated union with its guarded matching: the residue the C# 15 section returns to.) Type safety moved from runtime hope to a guarantee the compiler checks.

### Automatic Error Propagation

Railway-oriented programming made the pipeline simple:

```
Success Track:  Validate → Process → Format → Return 200
                   ↓           ↓          ↓
Failure Track:  Error ────→ Error ──→ Error → Return 4xx
```

On success, the processor runs; on failure, the error propagates automatically without running the processor. No manual error checking. No if or switch statements. Errors flow automatically.

The first time I saw a validation error skip straight past processing to the error response without a single `if` statement, I knew the migration was going to pay off.

### Unified Error Type Across Pipeline

The same `OidcError` now flows through every stage:

```
Validator → Result<Valid, OidcError>
           ↓
Processor → Result<Success, OidcError>  // Same OidcError!
           ↓
Formatter → ActionResult
```

No more forced type conversions between `ValidationError` and `ProcessingError`. The same `OidcError` flows from validation through processing to formatting. That's what a unified error type buys you.

In languages with native discriminated unions, this unification is built in. In the C# versions we target, the Result pattern provides it.

### Simpler Testing

Before, I had to do type checks in tests:

```csharp
var response = await handler.HandleAsync(request);
Assert.IsInstanceOfType<UserInfoErrorResponse>(response);

var error = (UserInfoErrorResponse)response;
Assert.AreEqual("invalid_token", error.Error);
```

After, `TryGetFailure` extracts the failure directly, no casts and no type checks:

```csharp
var result = await handler.HandleAsync(request);

Assert.IsTrue(result.TryGetFailure(out var error));
Assert.AreEqual("invalid_token", error.Error);
```

## Patterns That Emerged

I now reach for these templates on every new endpoint.

### The Universal Handler Template

Every pipeline handler (validate, then process) now looks like this:

```csharp
public async Task<Result<TSuccess, OidcError>> HandleAsync(TRequest request)
{
    var validationResult = await _validator.ValidateAsync(request);
    return await validationResult.BindAsync(_processor.ProcessAsync);
}
```

Two lines. Every time. The one deliberate outlier is the authorize endpoint, whose multi-shape response is exactly the case "Multiple Success Types" below is about.

When all handlers look the same, you can focus on what makes each endpoint unique (the validation and processing logic), rather than the wiring that carries errors between stages.

### The Formatter Match Pattern

All formatters follow this structure:

```csharp
public Task<ActionResult> FormatResponseAsync(
    TRequest request,
    Result<TSuccess, OidcError> response)
{
    return Task.FromResult(response.Match(
        onSuccess: success => FormatSuccess(request, success),
        onFailure: error => FormatError(error)));
}

private ActionResult FormatSuccess(TRequest request, TSuccess success)
{
    // Success formatting logic
}

private ActionResult FormatError(OidcError error)
{
    // Status selection by error subtype elided; see the hierarchy above
    return new BadRequestObjectResult(
        new ErrorResponse(error.Error, error.ErrorDescription));
}
```

Extract formatting logic into separate methods. Keep Match calls clean.

### Implicit Conversion Pattern

Skip the explicit `Result.Success()` and `Result.Failure()` wrappers. The implicit operators on `Result<TSuccess, TFailure>` let processors just return the domain value or the error directly, and the type system wraps them. The signature announces what the method does; the body doesn't need to repeat it.

### Error Type Inheritance Only for Behavior

Only derive from `OidcError` when the subtype carries real differentiation: a distinct HTTP status code (`BackChannelAuthenticationUnauthorized` -> 401, `BackChannelAuthenticationForbidden` -> 403) or additional properties the caller needs (`AuthorizationRequestValidationError` adds `RedirectUri` and `ResponseMode`). Don't create hierarchies for organizational purposes.

## When NOT to Use Result Pattern

Result isn't a silver bullet.

### Operation Always Succeeds

```csharp
// Bad: Unnecessary
public Result<int, Error> Add(int a, int b) => a + b;

// Good
public int Add(int a, int b) => a + b;
```

If it can't fail, don't use Result.

### Failure Is Truly Exceptional

```csharp
// Bad: Overkill
public Result<UserData, DatabaseError> GetUser(int id);

// Good - database unavailability is exceptional
public UserData GetUser(int id)
{
    // Throws SqlException if database is down
}
```

Database failures are unexpected. Use exceptions for unexpected failures and let them propagate up the call stack.

### Multiple Success Types

If you need to return different success types, use discriminated unions (inheritance):

```csharp
// Good: Discriminated union is appropriate here
public abstract record AuthorizationResponse;
public record AuthorizationSuccess(...) : AuthorizationResponse;
public record AuthorizationRedirect(...) : AuthorizationResponse;
public record AuthorizationError(...) : AuthorizationResponse;
```

Result is for binary success or failure. Multiple success types still need discriminated unions.

### Integrating with Exception-Based APIs

Don't wrap everything in Result just for consistency:

```csharp
// Bad: Unnecessary boilerplate
public Result<UserData, Error> GetUser(int id)
{
    try
    {
        return _database.GetById(id); // throws SqlException
    }
    catch (SqlException ex)
    {
        return new Error(ex.Message);
    }
}

// Good - let exceptions propagate
public UserData GetUser(int id)
{
    return _database.GetById(id);
}
```

Introduce Result only where an expected, domain-level failure needs to travel as a value, not as a blanket wrapper around every exception-based API.

## What C# 15's Union Types Change

If you follow the language, you can probably see the obvious objection coming: C# is getting native unions. Type unions ship in C# 15 with .NET 11, previewed in April 2026 and headed for general availability that November. The `union` keyword finally gives C# what F#, Rust, and TypeScript have had for years. So does that make this whole migration a workaround for a gap that is about to close?

Not really, and the reason clarifies what Result actually is.

Native unions and Result are not different problems so much as two levels of the same one. A union expresses "this value is exactly one of a closed set of types," and the compiler checks that you handle every case. That is exactly the right tool for the places this article tells you not to use Result. An authorization response that is success, redirect, or error is three genuinely different shapes, and a native union will express that more cleanly than the inheritance-based discriminated union I simulate today.

Result adds something the `union` keyword does not provide on its own. It is the binary success-or-failure shape plus the railway composition layered on top: `Bind` chains the next step only on success, `Match` forces both branches, errors short-circuit the pipeline automatically. Could you write those same `Bind` and `Match` methods over a C# 15 union? Of course, and you would have to: the keyword hands you the shape and exhaustive matching, but the composition is library code either way. That composition, `BindAsync(_processor.ProcessAsync)` collapsing a handler to two lines, is the payoff of this migration, and it lives in the library layer, not in the shape underneath it.

So the two are complementary. When C# 15 lands I expect to reach for native unions exactly where I reach for inheritance-based discriminated unions today: the multiple-success-shape cases above. Result stays the success-or-error spine of every endpoint, whatever shape sits under it. The migration was never a bet against the language catching up. It was a bet on railway-oriented composition, which the language is not adding.

Could a future major that requires .NET 11 rebuild Result on a native union and delete the hand-rolled `SuccessResult` and `FailureResult` records? Out of the box a union is not a full replacement: it gives you the shape and exhaustive matching, not the `Bind` and `Match` composition that does the real work. Write those over the union and the two level out; what the native side then adds is that the hand-rolled wrapper records disappear and the one switch inside `Match` becomes compiler-verified. The usual objection, that an untagged `TSuccess | TFailure` cannot tell the two apart when success and failure are the same type, does not bite us: a success payload and an `OidcError` are never the same type, so a native union discriminates our cases directly. If your domain can put the same type on both tracks, the objection does bite (today's implicit conversions collide the same way), so keep the error type distinct.

There is a practical gap too. C# 15 unions are a language and runtime feature, so a library can only put them in its public API by targeting .NET 11 and dropping the frameworks it still supports. We multi-target .NET 8, 9, and 10 today, and .NET 10 is itself an LTS supported into 2028. Result is just a type. It works on all of them now, with no framework or language version to wait for.

That shapes the road ahead. .NET 8 and .NET 9 both retire in November 2026, the same month .NET 11 and C# 15 reach general availability. The likely path is to settle the 2.x line on .NET 10, supported through that LTS into 2028, and build a 3.0 on .NET 11 once those ship in stable form, not on the previews. That 3.0 is most likely where we adopt C# 15 unions as the substrate, with the `Bind` and `Match` composition kept on top: the success-or-error spine unchanged, the plumbing underneath it modernized.

## Recommendations

### Document Result vs. Exception Guidelines

The dual approach needs clear boundaries. Document when each mechanism applies or the team will rediscover the distinction in every code review.

Result works best for domain-level failures - the kind where you want to return a specific error code to the client. Validation failures become 400 Bad Request, authentication failures become 401 Unauthorized, authorization failures become 403 Forbidden. These are expected business outcomes, not exceptional circumstances.

Exceptions remain appropriate for infrastructure failures - situations where the system itself is compromised. Database unavailable, network timeout, missing configuration, programming bugs - these map to 500-level status codes because they indicate something went wrong with the system, not the request.

### Create Helper Extensions

Beyond the core Result type, I created extension methods for common patterns:

#### Ensure - Inline Validation

Convert a plain value into a Result with validation, the plain-value counterpart of the member `Ensure` on Result itself:

```csharp
public static Result<TSuccess, TFailure> Ensure<TSuccess, TFailure>(
    this TSuccess value,
    Func<TSuccess, bool> predicate,
    TFailure failure)
{
    return predicate(value) ? value : failure;
}

public static async Task<Result<TSuccess, TFailure>> EnsureAsync<TSuccess, TFailure>(
    this Task<TSuccess> valueTask,
    Func<TSuccess, bool> predicate,
    TFailure failure)
{
    var value = await valueTask;
    return value.Ensure(predicate, failure);
}
```

Usage:
```csharp
var result = clientId.Ensure(
    id => !string.IsNullOrEmpty(id),
    new OidcError("invalid_client", "Client ID is required"));
```

#### FailIfNull - Null Safety

Converts a nullable into a Result, with a corresponding `FailIfNullAsync` for `Task<T?>`:

```csharp
var result = await _repository.FindUserAsync(userId)
    .FailIfNullAsync(new OidcError("user_not_found", "User does not exist"));
```

#### BindAsync for Task&lt;Result&gt;

Chains operations when you already have a `Task<Result>`, so you don't `await` in the middle of a pipeline:

```csharp
return await _validator.ValidateAsync(request)
    .BindAsync(_processor.ProcessAsync)
    .BindAsync(_enricher.EnrichAsync);
```

### Avoid Nested Results

Don't nest Results unless absolutely necessary. Instead of `Result<Result<Success, ValidationError>, AuthenticationError>`, flatten with a common error base:

```csharp
public abstract record OperationError;
public record ValidationError(...) : OperationError;
public record AuthenticationError(...) : OperationError;

Result<Success, OperationError>
```

(Both are expected, domain-level failures. Infrastructure errors stay exceptions, per the guideline above.)

## Further Reading

- [Railway Oriented Programming](https://fsharpforfunandprofit.com/rop/) by Scott Wlaschin: the definitive guide that introduced the railway metaphor.
- [Functional Error Handling in C#](https://enterprisecraftsmanship.com/posts/functional-c-handling-failures-input-errors/) by Vladimir Khorikov: functional error handling patterns adapted to C#.
- [Result in Rust](https://doc.rust-lang.org/std/result/): first-class Result in a language that enforces handling.

## Summary

Method signatures became self-documenting contracts. The compiler caught, during the migration itself, the kind of mistakes that would otherwise have surfaced only in testing. For a domain where a mishandled error can leak the wrong response down the wrong channel, that shift from runtime hope to compile-time guarantee is the real return.

That's the better way I was looking for.

## Try It Yourself

If you're working with OAuth 2.0, OpenID Connect, or any domain with rich error semantics, try this approach.

Start small: pick one feature with complex error handling, follow the migration steps above, measure the impact.

You don't have to build the Result type yourself, either. The same `Result<TSuccess, TFailure>` this migration runs on is published as [Abblix.Utils](https://www.nuget.org/packages/Abblix.Utils/) on NuGet, the type that proved itself across the Abblix OIDC Server codebase. Pull it in (or reach for ErrorOr or FluentResults; the patterns transfer) and `Bind`, `Match`, `Ensure`, and the rest are already written, so you start on the patterns, not the plumbing.

- [Abblix OIDC Server on GitHub](https://github.com/Abblix/Oidc.Server): Result pattern in action across 200+ files
- [Abblix.Utils on NuGet](https://www.nuget.org/packages/Abblix.Utils/): the open-source Result implementation

The worst thing you can do is continue writing boilerplate error conversions. Try it on one feature. Make an informed decision.
