Table of Contents

AspNet

The DarkPeak.Functional.AspNet package provides extensions that convert Result<T, Error> to ASP.NET Core IResult and ProblemDetails, enabling idiomatic minimal API error handling with zero boilerplate.

Installation

dotnet add package DarkPeak.Functional.AspNet

Basic Usage

Convert any Result<T, Error> to an HTTP response in a single call:

using DarkPeak.Functional;
using DarkPeak.Functional.AspNet;

app.MapGet("/orders/{id}", async (int id, IOrderService service) =>
    (await service.GetOrderAsync(id)).ToIResult());

On success, returns 200 OK with the value as JSON. On failure, returns an error-specific HTTP status code with a ProblemDetails body.

ToIResult

Maps Result<T, Error> to an IResult:

// Sync
app.MapGet("/orders/{id}", (int id, IOrderService service) =>
    service.GetOrder(id).ToIResult());

// Async
app.MapGet("/orders/{id}", async (int id, IOrderService service) =>
    await service.GetOrderAsync(id).ToIResult());

Success produces 200 OK. Failure maps to the appropriate HTTP status code.

ToCreatedResult

Maps success to 201 Created with a Location header:

app.MapPost("/orders", async (CreateOrderRequest request, IOrderService service) =>
    await service.CreateOrderAsync(request)
        .ToCreatedResult(order => $"/orders/{order.Id}"));

The lambda receives the success value and returns the location URI.

ToNoContentResult

Maps success to 204 No Content (useful for updates and deletes):

app.MapDelete("/orders/{id}", async (int id, IOrderService service) =>
    await service.DeleteOrderAsync(id).ToNoContentResult());

Error-to-Status Mapping

Errors are automatically mapped to HTTP status codes based on their type:

Error Type Status Code Title
ValidationError 422 Unprocessable Entity Validation Failed
BadRequestError 400 Bad Request Bad Request
NotFoundError 404 Not Found Not Found
UnauthorizedError 401 Unauthorized Unauthorized
ForbiddenError 403 Forbidden Forbidden
ConflictError 409 Conflict Conflict
ExternalServiceError 502 Bad Gateway Bad Gateway
InternalError 500 Internal Server Error Internal Server Error
Any other Error 500 Internal Server Error Internal Server Error

ValidationError produces a ValidationProblem response with field-level errors preserved. UnauthorizedError produces a bare 401 Unauthorized response with no body (matching ASP.NET conventions). All other errors produce a Problem response with ProblemDetails.

ProblemDetails Conversion

Convert any Error to an RFC 9457 ProblemDetails object directly:

var error = new NotFoundError { Message = "Order 123 not found", Code = "ORDER_NOT_FOUND" };
var problem = error.ToProblemDetails();
// { Status: 404, Title: "Not Found", Detail: "Order 123 not found",
//   Extensions: { "errorCode": "ORDER_NOT_FOUND" } }

ValidationError to HttpValidationProblemDetails

ValidationError gets a specialized conversion that preserves field-level errors:

var error = new ValidationError
{
    Message = "Input validation failed",
    Errors = new Dictionary<string, string[]>
    {
        ["email"] = ["Email is required"],
        ["age"] = ["Must be between 18 and 120"]
    }
};

var problem = error.ToValidationProblemDetails();
// Status: 422, Title: "Validation Failed"
// Errors: { "email": ["Email is required"], "age": ["Must be between 18 and 120"] }

Error Code and Metadata

Error Code and Metadata are included in ProblemDetails.Extensions:

var error = new NotFoundError
{
    Message = "Order not found",
    Code = "ORDER_NOT_FOUND",
    Metadata = new Dictionary<string, object> { ["orderId"] = 123 }
};

var problem = error.ToProblemDetails();
// Extensions: { "errorCode": "ORDER_NOT_FOUND", "orderId": 123 }

Minimal API Example

A complete user registration API using Validation, Result, and the AspNet extensions:

using DarkPeak.Functional;
using DarkPeak.Functional.AspNet;
using DarkPeak.Functional.Extensions;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IUserRepository, InMemoryUserRepository>();

var app = builder.Build();

app.MapPost("/users", (CreateUserRequest request, IUserRepository repo) =>
    ValidateName(request.Name)
        .ZipWith(
            ValidateEmail(request.Email),
            ValidateAge(request.Age),
            (name, email, age) => new User(name, email, age, Guid.NewGuid()))
        .ToResult()
        .Bind(user => repo.Save(user))
        .ToCreatedResult(user => $"/users/{user.Id}"));

app.Run();

Domain Types

public record CreateUserRequest(string Name, string Email, int Age);
public record User(string Name, string Email, int Age, Guid Id);

Validators

static Validation<string, ValidationError> ValidateName(string name) =>
    string.IsNullOrWhiteSpace(name)
        ? Validation.Invalid<string, ValidationError>(
            new ValidationError { Message = "Name is required", Code = "name" })
        : name.Length > 100
            ? Validation.Invalid<string, ValidationError>(
                new ValidationError { Message = "Name must be 100 characters or fewer", Code = "name" })
            : Validation.Valid<string, ValidationError>(name.Trim());

static Validation<string, ValidationError> ValidateEmail(string email) =>
    string.IsNullOrWhiteSpace(email)
        ? Validation.Invalid<string, ValidationError>(
            new ValidationError { Message = "Email is required", Code = "email" })
        : !email.Contains('@')
            ? Validation.Invalid<string, ValidationError>(
                new ValidationError { Message = "Email must be a valid address", Code = "email" })
            : Validation.Valid<string, ValidationError>(email.Trim().ToLower());

static Validation<int, ValidationError> ValidateAge(int age) =>
    age is < 18 or > 120
        ? Validation.Invalid<int, ValidationError>(
            new ValidationError { Message = "Age must be between 18 and 120", Code = "age" })
        : Validation.Valid<int, ValidationError>(age);

Repository

public interface IUserRepository
{
    Result<User, Error> Save(User user);
}

public class InMemoryUserRepository : IUserRepository
{
    private readonly Dictionary<Guid, User> _users = new();

    public Result<User, Error> Save(User user)
    {
        if (_users.Values.Any(u => u.Email == user.Email))
            return Result.Failure<User, Error>(
                new ConflictError { Message = $"User with email {user.Email} already exists" });

        _users[user.Id] = user;
        return Result.Success<User, Error>(user);
    }
}

HTTP Responses

Success — 201 Created:

POST /users
Content-Type: application/json

{ "name": "Alice", "email": "alice@example.com", "age": 30 }
HTTP/1.1 201 Created
Location: /users/3fa85f64-5717-4562-b3fc-2c963f66afa6

{
  "name": "Alice",
  "email": "alice@example.com",
  "age": 30,
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}

Validation Failure — 422 Unprocessable Entity:

POST /users
Content-Type: application/json

{ "name": "", "email": "bad", "age": 200 }
HTTP/1.1 422 Unprocessable Entity

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "One or more validation errors occurred.",
  "errors": {
    "name": ["Name is required"],
    "email": ["Email must be a valid address"],
    "age": ["Age must be between 18 and 120"]
  }
}

Conflict — 409:

HTTP/1.1 409 Conflict

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.10",
  "title": "Conflict",
  "detail": "User with email alice@example.com already exists",
  "status": 409
}

Compare this to the manual approach — instead of pattern matching on error types and calling Results.Conflict(), Results.Problem(), etc., the AspNet extensions handle the mapping automatically via .ToCreatedResult().

Composition with Http Extensions

Use the Http library to call external APIs and convert the result directly to an HTTP response:

app.MapGet("/proxy/orders/{id}", async (int id, HttpClient httpClient) =>
    await httpClient
        .GetResultAsync<Order>($"https://api.example.com/orders/{id}")
        .ToIResult());

Add retry and circuit breaker for resilient API proxying:

var breaker = CircuitBreaker
    .WithFailureThreshold(5)
    .WithResetTimeout(TimeSpan.FromSeconds(30));

app.MapGet("/proxy/orders/{id}", async (int id, HttpClient httpClient) =>
    await Retry
        .WithMaxAttempts(3)
        .WithBackoff(Backoff.Exponential(TimeSpan.FromMilliseconds(200)))
        .ExecuteAsync(() =>
            breaker.ExecuteAsync(
                () => httpClient.GetResultAsync<Order>(
                    $"https://api.example.com/orders/{id}")))
        .ToIResult());