Integrate Result Pattern with ASP.NET Core

code
csharp
design-pattern
openapi
Author

XU HUI

Published

October 9, 2025

Last year, I read this blog, and then I implemented a simple one.

last year version

using System.Diagnostics.CodeAnalysis;

namespace OALY2000.Results;

public class Result<T>
{
    public T? Value { get; private init; }
    public Exception? Error { get; private init; }

    private Result(T value)
    {
        IsSuccess = true;
        Value = value;
        Error = null;
    }

    private Result(Exception error)
    {
        IsSuccess = false;
        Value = default;
        Error = error;
    }

    [MemberNotNullWhen(true, nameof(Value))]
    [MemberNotNullWhen(false, nameof(Error))]
    public bool IsSuccess { get; private init; }

    public static Result<T> Success(T value) => new(value);
    public static Result<T> Fail(Exception error) => new(error);

    public static implicit operator Result<T>(T value) => Success(value);
    public static implicit operator Result<T>(Exception error) => Fail(error);
}


public static class ResultExtensions
{
    public static Result<TReturn> Switch<T, TReturn>(this Result<T> source, Func<T, Result<TReturn>> onSuccess, Func<Exception, Result<TReturn>> onFailure)
    {
        if (source.IsSuccess) return onSuccess(source.Value);
        else return onFailure(source.Error);
    }

    public static Result<TResult> Select<TFrom, TResult>(this Result<TFrom> source, Func<TFrom, TResult> selector)
    {
        return source.Switch(r => selector(r), Result<TResult>.Fail);
    }

    public static Result<TResult> SelectMany<TSource, TMiddle, TResult>(
        this Result<TSource> source,
        Func<TSource, Result<TMiddle>> collectionSelector,
        Func<TSource, TMiddle, TResult> resultSelector)
    {
        if (source.IsSuccess) return collectionSelector(source.Value).Select(v => resultSelector(source.Value!, v));
        else return source.Error;
    }

    public static async Task<Result<TResult>> Select<TFrom, TResult>(this Task<Result<TFrom>> source, Func<TFrom, TResult> selector)
        => (await source).Select(selector);

    public static async Task<Result<TResult>> SelectMany<TSource, TMiddle, TResult>(
        this Task<Result<TSource>> source,
        Func<TSource, Task<Result<TMiddle>>> collectionSelector,
        Func<TSource, TMiddle, TResult> resultSelector)
    {
        var result = await source;

        if (result.IsSuccess) return (await collectionSelector(result.Value)).Select(v => resultSelector(result.Value!, v));
        else return result.Error;
    }
}

integrate with ASP.NET Core

In practice, I found that it makes some boilerplate code:

app.MapGet("/", () => 
{
    Result<string> result = GetSomething();

    if (result.IsSuccess)
    {
        return result.Value;
    }
    else 
    {
        throw result.Error;
    }
});

app.MapGet("/alternative", IResult () => 
{
    Result<string> result = GetSomething();

    if (result.IsSuccess)
    {
        return TypedResults.Ok(result.Value)
    }
    else 
    {
        return TypedResults.Problem(result.Error.Message)
    }
});

The first one need a IExceptionHandler or middleware to handle the exception. The second one break openapi document. And both of them are not so elegant.

After Reading source code of TypedResults.Ok, I found the way to remove boilerplate code: IResult to define serialization and IEndpointMetadataProvider to define openapi metadata.

public class Result<T> : IResult, IEndpointMetadataProvider
{
    // ...

    public Task ExecuteAsync(HttpContext httpContext)
    {
        if (IsSuccess)
        {
            return TypedResults.Ok(Value).ExecuteAsync(httpContext);
        }

        if (httpContext.RequestServices.GetService<IFailedResultHandler>() is { } handler)
        {
            return handler.ExecuteAsync(httpContext, Error);
        }

        return TypedResults.Problem(title: Error.Message).ExecuteAsync(httpContext);
    }

    static void IEndpointMetadataProvider.PopulateMetadata(MethodInfo method, EndpointBuilder builder)
    {
        ArgumentNullException.ThrowIfNull(method);
        ArgumentNullException.ThrowIfNull(builder);

        builder.Metadata.Add(new ProducesResponseTypeMetadata(200, typeof(T), ["application/json"]));
    }
}

public interface IFailedResultHandler
{
    Task ExecuteAsync(HttpContext context, Exception error);
}

Now, we can write api like this:

app.MapGet("/", () => GetSomething());