Skip to content

feO2x/Light.PortableResults

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

546 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Light.PortableResults

The Result Pattern for .NET that travels. Every Result<T> serializes reliably over HTTP (with RFC-9457 Problem Details support), CloudEvents, and back — with a validation framework that is at least 5x faster and uses less than 9% of the memory of FluentValidation.

License NuGet Documentation

Most Result Pattern libraries stop at the application boundary. Light.PortableResults does not: a Result<T> can be written as an HTTP response (including RFC-9457 Problem Details support), published as a CloudEvents JSON message, read back from both protocols on the other side, and arrive as a fully-typed Result<T> — without losing errors, metadata, or structure. If you also need validation, the built-in framework lets you write FluentValidation-style rules with a fraction of the allocations. Plus: Roslyn Source Generators write OpenAPI error schemas and examples for you.

Contents

✨ Key Features

  • Clear Result PatternResult / Result<T> is either a success value or one or more structured errors. No exceptions for expected failures.
  • Rich, machine-readable errors — every Error carries a human-readable Message, stable Code, input Target, and Category — ready for API contracts and frontend mapping.
  • Serialization-safe metadata — metadata uses a dedicated JSON-like type system instead of Dictionary<string, object>, so results serialize reliably across any protocol.
  • Full functional operator suiteMap, Bind, Match, Ensure, Tap, Switch, and their Async variants let you build clean, chainable pipelines.
  • Cloud-native round-trip — write results as RFC-9457 HTTP responses or CloudEvents Spec 1.0 JSON payloads, and deserialize them back on any consumer.
  • ASP.NET Core ready — Minimal APIs and MVC packages translate Result and Result<T> directly to IResult / IActionResult with automatic HTTP status mapping.
  • High-performance validation — at least 5x faster than FluentValidation 12.1.1, using less than 9% of its memory footprint. Compose validators, map DTOs to domain objects, and share state — all with full async support.
  • Microsoft.AspNetCore.OpenAPI integration — write validators and generate accurate OpenAPI schemas and examples via source generation.
  • .NET Native AOT — the base, validation, and Minimal APIs packages are compatible with .NET Native AOT.

📦 Installation

Install the packages you need for your scenario.

Core Result Pattern, Metadata, Functional Operators, and serialization support for HTTP and CloudEvents:

dotnet add package Light.PortableResults

Validation context, checks, and synchronous/asynchronous validators:

dotnet add package Light.PortableResults.Validation

ASP.NET Core Minimal APIs integration:

dotnet add package Light.PortableResults.AspNetCore.MinimalApis

ASP.NET Core MVC integration:

dotnet add package Light.PortableResults.AspNetCore.Mvc

OpenAPI integration:

dotnet add package Light.PortableResults.AspNetCore.OpenApi

Built-in validation error contracts for OpenAPI:

dotnet add package Light.PortableResults.Validation.OpenApi

If you only need the Result Pattern itself, Light.PortableResults is the most lightweight dependency.

↔️ When to Use Result vs. Exceptions

Use Result / Result<T> for expected business outcomes:

  • validation failed
  • resource not found
  • user is not authorized
  • domain rule was violated

Use exceptions for truly unexpected failures:

  • database/network outage
  • misconfiguration
  • programming bugs and invariant violations (detected via guard clauses)

This keeps exceptions exceptional and business outcomes explicit.

🤓 Basic Usage

using Light.PortableResults;

static Result<int> ParsePositiveInteger(string input)
{
    if (int.TryParse(input, out var value) && value > 0)
    {
        return Result<int>.Ok(value);
    }

    return Result<int>.Fail(new Error
    {
        Message = "Value must be a positive integer",
        Code = "parse.invalid_positive_int",
        Target = "input",
        Category = ErrorCategory.Validation
    });
}

Examine the result with an if-else...

var input = Console.ReadLine();
Result<int> result = ParsePositiveInteger(input);

if (result.IsValid)
{
    Console.WriteLine($"Success: {result.Value}");
}
else
{
    var error = result.Errors.First;
    Console.WriteLine($"Error {error.Code}: {error.Message}");
}

...or in a functional style:

using Light.PortableResults.FunctionalExtensions;

string message = ParsePositiveInteger(input).Match(
    onSuccess: value => $"Success: {value}",
    onError: errors => $"Error {errors.First.Code}: {errors.First.Message}"
);
Console.WriteLine(message);

Use the non-generic Result for command-style operations that do not return a value:

static Result DeleteUser(Guid id)
{
    if (id == Guid.Empty)
    {
        return Result.Fail(new Error
        {
            Message = "User id must not be empty",
            Code = "user.invalid_id",
            Target = "id",
            Category = ErrorCategory.Validation
        });
    }

    return Result.Ok();
}

Designing useful error payloads

Consistent error shapes make APIs and message consumers easier to evolve. As a rule of thumb:

  • Message: human-readable explanation
  • Code: stable machine-readable identifier (great for frontend/API contracts)
  • Target: which input field, header, or value failed
  • Category: determines transport mapping (for example, HTTP status code)
  • Metadata: additional context (for example, boundary values or comparative amounts)

Error.Exception can be set for local diagnostics, but it is never serialized and is never exposed to calling processes.

🔁 Functional Operators

Category Operators What they are used for
Transform success value Map, Bind Convert successful values or chain operations that already return Result<T>.
Transform errors MapError Normalize or translate errors (for example domain → transport layer).
Add validation rules Ensure, FailIf Keep fluent pipelines while adding business or guard conditions.
Handle outcomes Match, MatchFirst, Else Turn a result into a value or fallback without manually branching every time.
Side effects Tap, TapError, Switch, SwitchFirst Perform logging, metrics, or notifications on success or failure paths.

All operators provide async variants with the Async suffix (for example BindAsync, MatchAsync, TapErrorAsync).

using Light.PortableResults;
using Light.PortableResults.FunctionalExtensions;

Result<string> message = GetUser(userId)
    .Ensure(user => user.IsActive, new Error
    {
        Message = "User is not active",
        Code = "user.inactive",
        Category = ErrorCategory.Forbidden
    })
    .Map(user => user.Email)
    .Match(
        onSuccess: email => $"User email: {email}",
        onError: errors => $"Failed: {errors.First.Message}"
    );

ℹ️ Metadata

Metadata is not a Dictionary<string, object>. Instead it uses a dedicated JSON-like type system so every result serializes and deserializes correctly across any protocol — HTTP, CloudEvents, or otherwise.

Metadata can be attached to Result<T> / Result instances as well as to individual Error instances.

using Light.PortableResults;
using Light.PortableResults.Metadata;

// MetadataObject uses implicit conversions from bool, long, double, string, decimal,
// nested objects, and arrays.
var metadata = MetadataObject.Create(
    ("requestId", "550e8400-e29b-41d4-a716-446655440000"),
    ("timestamp", DateTimeOffset.UtcNow.ToUnixTimeSeconds()),
    ("cacheHit", false),
    ("attemptCount", 3)
);

Result<Order> result = Result<Order>.Ok(
    new Order { Id = Guid.NewGuid(), Total = 99.99m },
    metadata
);

// Attach metadata to an error for additional context
var error = new Error
{
    Message = "Order exceeds account limit",
    Code = "order.limit_exceeded",
    Target = "total",
    Category = ErrorCategory.Validation,
    Metadata = MetadataObject.Create(
        ("accountLimit", 500.00m),
        ("requestedAmount", 599.99m),
        ("currency", "USD")
    )
};

// Read metadata from a result
if (result.Metadata?.TryGetString("requestId", out var requestId) == true)
{
    Console.WriteLine($"Request: {requestId}");
}

🛡️ Validation Quick Start

Instead of constructing Error instances manually, reference Light.PortableResults.Validation and write a typed validator:

using Light.PortableResults.Validation;

public sealed record MovieRatingDto
{
    public required Guid Id { get; init; }
    public required Guid MovieId { get; init; }
    public required string UserName { get; set; } = string.Empty;
    public required string Comment { get; set; } = string.Empty;
    public required int Rating { get; init; }
}

public sealed class MovieRatingValidator : Validator<MovieRatingDto>
{
    public MovieRatingValidator(IValidationContextFactory validationContextFactory)
        : base(validationContextFactory) { }

    protected override ValidatedValue<MovieRatingDto> PerformValidation(
        ValidationContext context,
        ValidationCheckpoint checkpoint,
        MovieRatingDto dto
    )
    {
        context.Check(dto.Id).IsNotEmpty();
        context.Check(dto.MovieId).IsNotEmpty();

        // Check() normalizes strings by default (null → "", non-null → trimmed).
        // Assign the return value back to persist the normalized string.
        dto.Comment = context.Check(dto.Comment).HasLengthIn(10, 1000);
        dto.UserName = context.Check(dto.UserName).IsNotNullOrWhiteSpace();

        context.Check(dto.Rating).IsInRange(1, 5);

        return checkpoint.ToValidatedValue(dto);
    }
}

Call the validator from a service using CheckForErrors to avoid the if (!result.IsValid) ceremony:

public sealed class AddMovieRatingService
{
    private readonly MovieRatingValidator _validator;

    public AddMovieRatingService(MovieRatingValidator validator) => _validator = validator;

    public async Task<Result<MovieRating>> AddMovieRatingAsync(
        MovieRatingDto dto,
        CancellationToken cancellationToken = default
    )
    {
        if (_validator.CheckForErrors(dto, out var errorResult))
        {
            return Result<MovieRating>.Fail(errorResult.Errors);
        }

        var movieRating = new MovieRating(...);
        return Result<MovieRating>.Ok(movieRating);
    }
}

Register validators as singletons when they have no scoped dependencies — they are stateless by design:

services
    .AddValidationForPortableResults()
    .AddSingleton<MovieRatingValidator>();

See Validation In Depth for composing validators, async validation, domain object mapping, sharing state between validators, custom assertions, and configuration options.

⚡ Validation Performance

Light.PortableResults Validation is significantly faster and leaner than FluentValidation. All benchmarks ran on:

BenchmarkDotNet v0.15.8, macOS Tahoe 26.4 (25E246) [Darwin 25.4.0]
Apple M3 Max, 1 CPU, 16 logical and 16 physical cores
.NET SDK 10.0.103
  [Host]     : .NET 10.0.5 (10.0.5, 10.0.526.15411), Arm64 RyuJIT armv8.0-a
  DefaultJob : .NET 10.0.5 (10.0.5, 10.0.526.15411), Arm64 RyuJIT armv8.0-a

Flat DTO — valid (no errors)

Method Mean Ratio Allocated Alloc Ratio
FluentValidationScopedOrTransient 1,324.57 ns 1.00 6984 B 1.00
FluentValidationSingleton 105.84 ns 0.08 632 B 0.09
LightPortableResults 50.49 ns 0.04 104 B 0.01

Flat DTO — invalid (all three properties fail)

Method Mean Ratio Allocated Alloc Ratio
FluentValidationScopedOrTransient 3,145.2 ns 1.00 14672 B 1.00
FluentValidationSingleton 1,793.6 ns 0.57 8320 B 0.57
LightPortableResults 289.6 ns 0.09 688 B 0.05

Complex DTO — valid (one nested object, two nested collections, no errors)

Method Mean Ratio Allocated Alloc Ratio
FluentValidationScopedOrTransient 8,318.7 ns 1.00 33.94 KB 1.00
FluentValidationSingleton 1,685.9 ns 0.20 5.77 KB 0.17
LightPortableResults 742.2 ns 0.09 1.27 KB 0.04

Complex DTO — invalid (nine errors overall)

Method Mean Ratio Allocated Alloc Ratio
FluentValidationScopedOrTransient 13.985 μs 1.00 53.45 KB 1.00
FluentValidationSingleton 6.755 μs 0.48 25.47 KB 0.48
LightPortableResults 1.507 μs 0.11 1.99 KB 0.04

See the benchmarks/Benchmarks project for the full benchmark source.

🚀 HTTP Quick Start

Given the classes from the Validation Quick Start above, you can easily integrate Light.PortableResults with ASP.NET Core in a few lines.

Minimal APIs

using Light.PortableResults;
using Light.PortableResults.AspNetCore.MinimalApis;
using Light.PortableResults.Http.Writing;

var builder = WebApplication.CreateBuilder(args);
builder.Services
    .AddPortableResultsForMinimalApis()
    .AddValidationForPortableResults()
    .Configure<PortableResultsHttpWriteOptions>(
        // Rich format is recommended — it serializes errors Code/Target/Category/Metadata in one object.
        x => x.ValidationProblemSerializationFormat = ValidationProblemSerializationFormat.Rich
    )
    .AddSingleton<MovieRatingValidator>()
    .AddScoped<AddMovieRatingService>();

var app = builder.Build();

app.MapPut("/api/movieRatings", async (MovieRatingDto dto, AddMovieRatingService service) =>
{
    var result = await service.AddMovieRatingAsync(dto);
    return result.ToMinimalApiResult();
});

app.Run();

To auto-generate accurate OpenAPI schemas and examples from your validators, add Light.PortableResults.Validation.OpenApi, annotate your validator with [GeneratePortableValidationOpenApi], and replace .ProducesPortableValidationProblem(...) with .ProducesPortableValidationProblemFor<TValidator>(...) on the endpoint. See OpenAPI Support.

MVC

using Light.PortableResults;
using Light.PortableResults.AspNetCore.Mvc;

builder.Services.AddControllers();
builder.Services
    .AddPortableResultsForMvc()
    .AddValidationForPortableResults()
    .AddSingleton<MovieRatingValidator>()
    .AddScoped<AddMovieRatingService>();

var app = builder.Build();
app.MapControllers();
app.Run();

[ApiController]
[Route("api/movieRatings")]
public sealed class AddMovieRatingsController : ControllerBase
{
    private readonly AddMovieRatingService _service;

    public AddMovieRatingsController(AddMovieRatingService service) => _service = service;

    [HttpPut]
    public async Task<LightActionResult<MovieRating>> AddMovieRating(MovieRatingDto dto)
    {
        var result = await _service.AddMovieRatingAsync(dto);
        return result.ToMvcActionResult();
    }
}

To auto-generate accurate OpenAPI schemas and examples from your validators, add Light.PortableResults.Validation.OpenApi, annotate your validator with [GeneratePortableValidationOpenApi], and replace [ProducesPortableValidationProblem] with [ProducesPortableValidationProblemFor<TValidator>] on the action. See OpenAPI Support.

HTTP Responses on the Wire

Successful update (200 OK):

HTTP/1.1 200 OK
Content-Type: application/json

{
  "comment": "The Answer Is Out There, Neo. It's Looking for You.",
  "movieId": "5c200e1d-4a16-4572-b884-e3a3957771fc",
  "userName": "Trinity",
  "rating": 5,
  "id": "b507182e-f9ff-48d7-8a78-bcdc15cb4d0a"
}

Validation failure (400 Bad Request):

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "Bad Request",
  "status": 400,
  "detail": "One or more validation errors occurred.",
  "errors": [
    {
      "message": "comment must be between 10 and 1000 characters long",
      "code": "LengthInRange",
      "target": "comment",
      "category": "Validation",
      "metadata": {
        "minLength": 10,
        "maxLength": 1000
      }
    },
    {
      "message": "userName must not be empty or whitespace",
      "code": "NotNullOrWhiteSpace",
      "target": "userName",
      "category": "Validation"
    },
    {
      "message": "rating must be between 1 and 5",
      "code": "InRange",
      "target": "rating",
      "category": "Validation",
      "metadata": {
        "lowerBoundary": 1,
        "upperBoundary": 5
      }
    }
  ]
}

Deserializing Result<T> from HttpResponseMessage

using System.Net.Http.Json;
using Light.PortableResults;
using Light.PortableResults.Http.Reading;

using var httpClient = new HttpClient { BaseAddress = new Uri("https://localhost:5000") };

using var response = await httpClient.PutAsJsonAsync("/api/movieRatings", requestDto);

Result<MovieRating> result = await response.ReadResultAsync<MovieRating>();

if (result.IsValid)
{
    Console.WriteLine($"Added movie rating with id {result.Value.Id}");
}
else
{
    foreach (var error in result.Errors)
    {
        Console.WriteLine($"{error.Target}: {error.Message}");
    }
}

☁️ CloudEvents Quick Start

Light.PortableResults can serialize a Result<T> as a CloudEvents Spec 1.0 JSON payload and deserialize it on any consumer. The key API calls are result.ToCloudEvent(...) and ReadResult<T>().

Publish to RabbitMQ

using Light.PortableResults;
using Light.PortableResults.CloudEvents;
using Light.PortableResults.CloudEvents.Writing;
using RabbitMQ.Client;

var result = Result<UserDto>.Ok(new UserDto
{
    Id = Guid.Parse("6b8a4dca-779d-4f36-8274-487fe3e86b5a"),
    Email = "ada@example.com"
});

byte[] cloudEvent = result.ToCloudEvent(
    successType: "users.updated",
    failureType: "users.update.failed",
    source: "urn:light-portable-results:sample:user-service",
    subject: "users/6b8a4dca-779d-4f36-8274-487fe3e86b5a"
);

var properties = new BasicProperties();
properties.ContentType = CloudEventsConstants.CloudEventsJsonContentType;

await channel.BasicPublishAsync(
    exchange: "",
    routingKey: "users.updated",
    mandatory: false,
    basicProperties: properties,
    body: cloudEvent
);

Consume from RabbitMQ

using Light.PortableResults;
using Light.PortableResults.CloudEvents.Reading;
using RabbitMQ.Client.Events;

consumer.ReceivedAsync += async (_, eventArgs) =>
{
    Result<UserDto> result = eventArgs.Body.ReadResult<UserDto>();

    if (result.IsValid)
    {
        Console.WriteLine($"Updated user: {result.Value.Email}");
    }
    else
    {
        foreach (var error in result.Errors)
        {
            Console.WriteLine($"{error.Target}: {error.Message}");
        }
    }

    await channel.BasicAckAsync(eventArgs.DeliveryTag, multiple: false);
};

🔬 Validation In Depth

Composing Validators

Use child validators when your DTO contains nested objects or collections that each have their own validation rules. Validators compose by sharing a single ValidationContext — errors from all levels accumulate in one pass.

public sealed record PurchaseOrderDto
{
    public required Guid OrderId { get; set; }
    public required string CustomerEmail { get; set; } = string.Empty;
    public required ShippingAddressDto ShippingAddress { get; set; }
    public required List<string> Tags { get; set; }
    public required List<OrderItemDto> Items { get; set; }
}

public sealed class PurchaseOrderValidator : Validator<PurchaseOrderDto>
{
    private readonly ShippingAddressValidator _addressValidator;
    private readonly OrderItemValidator _itemValidator;

    public PurchaseOrderValidator(
        IValidationContextFactory validationContextFactory,
        ShippingAddressValidator addressValidator,
        OrderItemValidator itemValidator
    ) : base(validationContextFactory)
    {
        _addressValidator = addressValidator;
        _itemValidator = itemValidator;
    }

    protected override ValidatedValue<PurchaseOrderDto> PerformValidation(
        ValidationContext context,
        ValidationCheckpoint checkpoint,
        PurchaseOrderDto dto
    )
    {
        context.Check(dto.OrderId).IsNotEmpty();
        dto.CustomerEmail = context.Check(dto.CustomerEmail).IsEmail();

        // If dto.ShippingAddress is null the child validator emits a null error automatically.
        context.Check(dto.ShippingAddress).ValidateChild(_addressValidator);

        // If dto.Tags is null the framework emits a NotNull error automatically.
        context.Check(dto.Tags).ValidateItems(
            static (Check<string> tag) => tag.HasLengthIn(2, 30)
        );

        context.Check(dto.Items).ValidateItems(_itemValidator);

        return checkpoint.ToValidatedValue(dto);
    }
}

public sealed class ShippingAddressValidator : Validator<ShippingAddressDto>
{
    public ShippingAddressValidator(IValidationContextFactory validationContextFactory)
        : base(validationContextFactory) { }

    protected override ValidatedValue<ShippingAddressDto> PerformValidation(
        ValidationContext context,
        ValidationCheckpoint checkpoint,
        ShippingAddressDto dto
    )
    {
        dto.RecipientName = context.Check(dto.RecipientName).IsNotNullOrWhiteSpace();
        dto.Street = context.Check(dto.Street).IsNotNullOrWhiteSpace();
        dto.PostalCode = context.Check(dto.PostalCode).HasLengthIn(4, 12);
        dto.CountryCode = context.Check(dto.CountryCode).HasLengthIn(2, 2);
        return checkpoint.ToValidatedValue(dto);
    }
}

public sealed class OrderItemValidator : Validator<OrderItemDto>
{
    public OrderItemValidator(IValidationContextFactory validationContextFactory)
        : base(validationContextFactory) { }

    protected override ValidatedValue<OrderItemDto> PerformValidation(
        ValidationContext context,
        ValidationCheckpoint checkpoint,
        OrderItemDto dto
    )
    {
        dto.Sku = context.Check(dto.Sku).IsNotNullOrWhiteSpace();
        context.Check(dto.Quantity).IsGreaterThanOrEqualTo(1);
        context.Check(dto.UnitPrice).IsGreaterThan(0m);
        return checkpoint.ToValidatedValue(dto);
    }
}

What is ValidatedValue<T>?

ValidatedValue<T> is the handshake type between a validator and its callers within a single validation pipeline run. Rather than surfacing errors immediately as Result<T>, it carries the signal back: either a successfully validated value via ValidatedValue<T>.Success(value), or ValidatedValue<T>.NoValue when errors were added. checkpoint.ToValidatedValue(dto) chooses the right outcome based on whether any errors were added since the checkpoint was created. You never need to construct ValidatedValue<T> directly unless you are writing a transforming validator — see Mapping to Domain Objects.

Register all validators as singletons when they have no scoped dependencies:

services
    .AddValidationForPortableResults()
    .AddSingleton<ShippingAddressValidator>()
    .AddSingleton<OrderItemValidator>()
    .AddSingleton<PurchaseOrderValidator>();

Automatic Null Checking

The validation framework handles null values automatically so you rarely need an explicit IsNotNull() guard. The active AutomaticNullErrorProvider (configurable via ValidationContextOptions) decides what error to produce; the default emits a NotNull validation error.

  • Validators — when the source value passed to Validate / ValidateAsync is null, the validator adds the automatic null error and returns a failed Result without calling PerformValidation. The isAutomaticNullCheckingEnabled constructor parameter (default true) controls this per validator class.
  • Child validation (ValidateChild, ValidateChildAsync) — when the nested value is null, the child validator's null check fires for that target and the parent continues collecting other errors.
  • Collection item validation (ValidateItems, ValidateItemsAsync) — when a null collection is passed, the null error is added for the collection target and item validators are skipped. Individual item validators also handle null items automatically.

Guard explicitly with IsNotNull() only when NoOpAutomaticNullErrorProvider is configured (automatic null errors disabled), or when you need to short-circuit further checks:

// Default configuration — no explicit guard needed
context.Check(dto.ShippingAddress).ValidateChild(_addressValidator);
context.Check(dto.Tags).ValidateItems(static (Check<string> tag) => tag.HasLengthIn(2, 30));

// Explicit guard — short-circuits any further checks on this value
context.Check(dto.Tags).IsNotNull().ValidateItems(static (Check<string> tag) => tag.HasLengthIn(2, 30));

Mapping to Domain Objects

Use Validator<TSource, TValidated> when validation must produce a different output type — typically a mutable DTO in, an immutable domain object out. This pattern implements an Anti-Corruption Layer.

// Mutable DTO received from the API
public sealed record CreateMovieDto
{
    public required string Title { get; set; } = string.Empty;
    public required int ReleaseYear { get; set; }
    public required string DirectorName { get; set; } = string.Empty;
}

// Immutable domain entity — no public setters
public sealed record Movie
{
    public required Guid Id { get; init; }
    public required string Title { get; init; }
    public required int ReleaseYear { get; init; }
    public required string DirectorName { get; init; }
}

public sealed class CreateMovieValidator : Validator<CreateMovieDto, Movie>
{
    public CreateMovieValidator(IValidationContextFactory validationContextFactory)
        : base(validationContextFactory) { }

    // PerformValidation returns ValidatedValue<Movie>.
    // The domain object is only constructed when all checks pass.
    protected override ValidatedValue<Movie> PerformValidation(
        ValidationContext context,
        ValidationCheckpoint checkpoint,
        CreateMovieDto dto
    )
    {
        var title = context.Check(dto.Title).IsNotNullOrWhiteSpace();
        context.Check(dto.ReleaseYear).IsInRange(1888, DateTime.UtcNow.Year);
        var directorName = context.Check(dto.DirectorName).IsNotNullOrWhiteSpace();

        if (checkpoint.HasNewErrors)
        {
            return ValidatedValue<Movie>.NoValue;
        }

        return ValidatedValue<Movie>.Success(new Movie
        {
            Id = Guid.CreateVersion7(),
            Title = title,
            ReleaseYear = dto.ReleaseYear,
            DirectorName = directorName
        });
    }
}

The caller receives Result<Movie>CreateMovieDto never escapes the validator boundary:

public async Task<Result<Movie>> CreateMovieAsync(CreateMovieDto dto)
{
    Result<Movie> result = _validator.Validate(dto);
    if (!result.IsValid)
    {
        return result;
    }

    await _movieRepository.AddAsync(result.Value);
    return result;
}

Async Validators

Use AsyncValidator<T> (or AsyncValidator<TSource, TValidated>) when any validation step requires an async operation such as a database look-up or an external API call.

public sealed class AddMovieRatingValidator : AsyncValidator<MovieRatingDto>
{
    private readonly IMovieRepository _movieRepository;

    public AddMovieRatingValidator(
        IValidationContextFactory validationContextFactory,
        IMovieRepository movieRepository
    ) : base(validationContextFactory)
    {
        _movieRepository = movieRepository;
    }

    protected override async ValueTask<ValidatedValue<MovieRatingDto>> PerformValidationAsync(
        ValidationContext context,
        ValidationCheckpoint checkpoint,
        MovieRatingDto dto,
        CancellationToken cancellationToken
    )
    {
        // Synchronous checks first — cheap and allocation-free
        context.Check(dto.Id).IsNotEmpty();
        context.Check(dto.MovieId).IsNotEmpty();
        dto.UserName = context.Check(dto.UserName).IsNotNullOrWhiteSpace();
        dto.Comment = context.Check(dto.Comment).HasLengthIn(10, 1000);
        context.Check(dto.Rating).IsInRange(1, 5);

        // Only hit the database if the synchronous checks passed
        if (!checkpoint.HasNewErrors)
        {
            var movieExists = await _movieRepository.ExistsAsync(dto.MovieId, cancellationToken);
            if (!movieExists)
            {
                context.Check(dto.MovieId).AddError(new Error
                {
                    Message = "The specified movie does not exist",
                    Code = "movie.notFound",
                    Target = "movieId",
                    Category = ErrorCategory.NotFound
                });
            }
        }

        return checkpoint.ToValidatedValue(dto);
    }
}

Call ValidateAsync from the service layer:

public async Task<Result<MovieRating>> AddMovieRatingAsync(
    MovieRatingDto dto,
    CancellationToken cancellationToken = default
)
{
    Result<MovieRatingDto> validationResult = await _validator.ValidateAsync(dto, cancellationToken);
    if (!validationResult.IsValid)
    {
        return Result<MovieRating>.Fail(validationResult.Errors);
    }

    var movieRating = new MovieRating(...);
    return Result<MovieRating>.Ok(movieRating);
}

Register async validators that depend on scoped services as scoped themselves:

builder.Services
    .AddValidationForPortableResults()
    .AddScoped<IMovieRepository, MovieRepository>()
    .AddScoped<AddMovieRatingValidator>(); // scoped because it depends on a scoped repository

Using ValidationContext Directly

You do not need a validator class for every case. Inject IValidationContextFactory and use ValidationContext directly for inline validation:

public async Task<Result<IReadOnlyList<Movie>>> SearchMoviesAsync(
    string? query,
    int page,
    int pageSize,
    CancellationToken cancellationToken = default
)
{
    var context = _contextFactory.CreateValidationContext();
    var normalizedQuery = context.Check(query).IsNotNullOrWhiteSpace();
    context.Check(page).IsGreaterThanOrEqualTo(1);
    context.Check(pageSize).IsInRange(1, 100);

    if (context.HasErrors)
    {
        return Result<IReadOnlyList<Movie>>.Fail(context.ToFailureResult().Errors);
    }

    return Result<IReadOnlyList<Movie>>.Ok(
        await _movieRepository.SearchAsync(normalizedQuery, page, pageSize, cancellationToken)
    );
}

IValidationContextFactory is registered automatically by AddValidationForPortableResults().

Sharing State Between Validators

When a child validator needs data loaded by the parent, use ValidationContext.SetItem and GetRequiredItem with a typed key. This avoids loading the same data twice and keeps child validators free from infrastructure dependencies.

// Define the key once — store it as a static field near the validators that use it
public static class MovieConstants
{
    public static readonly ValidationContextKey<Movie> MovieKey = new("movie");
}

// Parent loads the movie and stores it in the context
protected override async ValueTask<ValidatedValue<MovieRatingDto>> PerformValidationAsync(
    ValidationContext context,
    ValidationCheckpoint checkpoint,
    MovieRatingDto dto,
    CancellationToken cancellationToken
)
{
    context.Check(dto.Id).IsNotEmpty();
    context.Check(dto.MovieId).IsNotEmpty(shortCircuitOnError: true);
    dto.UserName = context.Check(dto.UserName).IsNotNullOrWhiteSpace();
    dto.Comment = context.Check(dto.Comment).HasLengthIn(10, 1000);
    context.Check(dto.Rating).IsInRange(1, 5);

    if (!checkpoint.HasNewErrors)
    {
        var movie = await _movieClient.GetAsync(dto.MovieId, cancellationToken);
        if (movie is null)
        {
            context.Check(dto.MovieId).AddError(new Error
            {
                Message = "The specified movie does not exist",
                Code = "movie.notFound",
                Target = "movieId",
                Category = ErrorCategory.NotFound
            });
        }
        else
        {
            context.SetItem(MovieConstants.MovieKey, movie);
            context.Check(dto).ValidateChild(_quotaValidator);
        }
    }

    return checkpoint.ToValidatedValue(dto);
}

// Child retrieves the pre-loaded entity without touching the database
protected override ValidatedValue<MovieRatingDto> PerformValidation(
    ValidationContext context,
    ValidationCheckpoint checkpoint,
    MovieRatingDto dto
)
{
    var movie = context.GetRequiredItem(MovieConstants.MovieKey);

    if (movie.MaxRatingsPerUser > 0 && movie.CurrentRatingCount >= movie.MaxRatingsPerUser)
    {
        context.Check(dto.MovieId).AddError(new Error
        {
            Message = "Rating quota for this movie has been reached",
            Code = "movie.quotaExceeded",
            Target = "movieId",
            Category = ErrorCategory.Conflict
        });
    }

    return checkpoint.ToValidatedValue(dto);
}

ValidationContextKey<T> is typed so you cannot accidentally retrieve the wrong type. Use TryGetItem instead of GetRequiredItem when the item may not have been set.

Custom Assertions

Ad-hoc predicate — use Must for a one-off check:

context.Check(dto.ReleaseYear).Must(
    year => year >= 1888 && year <= DateTime.UtcNow.Year
);

Reusable definition — for rules used across multiple validators, create a ValidationErrorDefinition subclass and expose it as a fluent extension method. This participates in the library's message caching and has the same performance as built-in assertions.

using Light.PortableResults.Validation;
using Light.PortableResults.Validation.Definitions;
using Light.PortableResults.Validation.Messaging;

public sealed class MustBeValidMovieYearDefinition : ValidationErrorDefinition
{
    public static readonly MustBeValidMovieYearDefinition Instance = new();

    private MustBeValidMovieYearDefinition() : base(code: "MustBeValidMovieYear") { }

    public override bool IsMessageStable => true;

    public override bool TryGetStableMessageProvider(
        ReadOnlyValidationContext context,
        out object? provider
    )
    {
        provider = this;
        return true;
    }

    public override ValidationErrorMessage ProvideMessage<T>(in ValidationErrorMessageContext<T> context) =>
        new($"{context.DisplayName} must be a valid movie release year (1888 or later, not in the future)");
}

public static class MovieValidationExtensions
{
    public static Check<int> MustBeValidMovieYear(this Check<int> check, bool shortCircuitOnError = false)
    {
        if (check.IsShortCircuited)
            return check;

        var year = check.Value;
        if (year >= 1888 && year <= DateTime.UtcNow.Year)
            return check;

        check = check.AddError(MustBeValidMovieYearDefinition.Instance);
        return shortCircuitOnError ? check.ShortCircuit() : check;
    }
}

Use it exactly like any built-in assertion:

context.Check(dto.ReleaseYear).MustBeValidMovieYear();

Configuring Validation Behavior

ValidationContextOptions controls how a ValidationContext behaves. All properties are init-only.

Property Default What it controls
ValueNormalizer TrimStringNormalizer.Instance How values are normalized before checks see them. The default trims strings and converts null to "". Replace with NoOpValueNormalizer.Instance to disable.
TargetNormalizer ValidationTargets.DefaultNormalizer How caller-expression targets (e.g. dto.ShippingAddress) are converted to error target strings.
CultureInfo CultureInfo.InvariantCulture Culture used to format number parameters in error messages.
AutomaticNullErrorProvider DefaultAutomaticNullErrorProvider.Instance Produces the error when a validator receives a null source value.
ErrorTemplates ValidationErrorTemplates.Default The full set of built-in message templates. Replace individual templates to customize wording globally.
ErrorDefinitionCache ValidationErrorDefinitionCache.Default Shared cache for reusable definition instances. The default is a process-wide singleton.

Register a customized factory before calling AddValidationForPortableResults():

using System.Globalization;
using Light.PortableResults.Validation;

builder.Services.AddSingleton<IValidationContextFactory>(
    _ => DefaultValidationContextFactory.Create(new ValidationContextOptions
    {
        CultureInfo = CultureInfo.GetCultureInfo("de-DE")
    })
);
builder.Services.AddValidationForPortableResults();

Without a DI host:

var factory = DefaultValidationContextFactory.Create(new ValidationContextOptions
{
    CultureInfo = CultureInfo.GetCultureInfo("de-DE")
});
var validator = new CreateMovieValidator(factory);

Validate Microsoft.Extensions.Configuration Options

Use ValidateWithPortableResults<TOptions, TValidator>() to integrate your Validator<T> implementations with the standard options validation pipeline:

public sealed class EmailSenderOptions
{
    public string Host { get; set; } = string.Empty;
    public int Port { get; set; }
    public string ApiKey { get; set; } = string.Empty;
}

public sealed class EmailSenderOptionsValidator : Validator<EmailSenderOptions>
{
    public EmailSenderOptionsValidator(IValidationContextFactory validationContextFactory)
        : base(validationContextFactory) { }

    protected override ValidatedValue<EmailSenderOptions> PerformValidation(
        ValidationContext context,
        ValidationCheckpoint checkpoint,
        EmailSenderOptions options
    )
    {
        context.Check(options.Host).IsNotNullOrWhiteSpace();
        context.Check(options.Port).IsInRange(1, 65535);
        context.Check(options.ApiKey).IsNotNullOrWhiteSpace();
        return checkpoint.ToValidatedValue(options);
    }
}

services
    .AddOptions<EmailSenderOptions>()
    .BindConfiguration("EmailSender")
    .ValidateWithPortableResults<EmailSenderOptions, EmailSenderOptionsValidator>()
    .ValidateOnStart();

ValidateWithPortableResults supports named options and forwards the current options name to the ValidationContext. Use ValidationContext.TryGetItem(ConfigurationConstants.OptionsNameKey, out var optionsName) to access it in your validator.

🌐 OpenAPI Support

OpenAPI support lives in the dedicated Light.PortableResults.AspNetCore.OpenApi package and is opt-in — it does not change runtime serialization. The package contributes endpoint metadata and a document transformer that understands LightResult<T> / LightActionResult<T>.

Registration

using Light.PortableResults.AspNetCore.MinimalApis;
using Light.PortableResults.AspNetCore.OpenApi;
using Light.PortableResults.Validation.OpenApi;

builder.Services
       .AddOpenApi()
       .AddPortableResultsForMinimalApis()
       .AddPortableResultsOpenApi(contracts => contracts.RegisterBuiltInValidationErrors());

Use AddPortableResultsForMvc() for MVC applications. RegisterBuiltInValidationErrors() registers schemas for all built-in validation error codes from Light.PortableResults.Validation.

Documenting Endpoints

Minimal APIs expose three helpers:

  • ProducesPortableSuccessResponse<TValue>(...) — documents the success response (bare TValue or { value, metadata } depending on MetadataSerializationMode).
  • ProducesPortableProblem(...) — documents a non-validation failure response.
  • ProducesPortableValidationProblem(...) — documents a validation failure (400/422), selecting the rich or ASP.NET Core-compatible envelope shape automatically.

MVC exposes matching attributes: [ProducesPortableSuccessResponse<TValue>], [ProducesPortableProblem], and [ProducesPortableValidationProblem].

app.MapPut("/api/movieRatings", async (MovieRatingDto dto, AddMovieRatingService service) =>
    {
        var result = await service.AddMovieRatingAsync(dto);
        return result.ToMinimalApiResult();
    })
   .ProducesPortableSuccessResponse<MovieRating>()
   .ProducesPortableValidationProblem(
        configure: x =>
            x.UseFormat(ValidationProblemSerializationFormat.Rich)
             .WithErrorCodes(ValidationErrorCodes.NotEmpty, ValidationErrorCodes.LengthInRange)
             .WithInRangeError<int>()
    )
   .ProducesPortableProblem();

Narrowing Error Schemas

WithErrorCodes(...), WithErrorMetadata<TMetadata>(code), and typed helpers like WithInRangeError<T>() narrow error items exhaustively once you document at least one code. The generated schema becomes a oneOf discriminated by code.

If an endpoint can emit additional codes that cannot be enumerated at build time, opt out with AllowUnknownErrorCodes() (Minimal APIs) or AllowUnknownErrorCodes = true (MVC attributes). The schema then falls back to a non-exhaustive anyOf shape while still documenting the known variants.

Source Generation

Mark a synchronous Validator<T> with [GeneratePortableValidationOpenApi] and make it partial to let the source generator produce an OpenAPI contract automatically from the validator's check calls:

[GeneratePortableValidationOpenApi]
public sealed partial class AddMovieRatingValidator : Validator<MovieRatingDto>
{
    protected override ValidatedValue<MovieRatingDto> PerformValidation(
        ValidationContext context,
        ValidationCheckpoint checkpoint,
        MovieRatingDto dto
    )
    {
        context.Check(dto.Id).IsNotEmpty();
        dto.Comment = context.Check(dto.Comment).HasLengthIn(10, 1000);
        context.Check(dto.Rating).IsInRange(1, 5);
        return checkpoint.ToValidatedValue(dto);
    }
}

app.MapPut("/api/movieRatings", AddMovieRating)
   .ProducesPortableValidationProblemFor<AddMovieRatingValidator>(
        configure: x => x.UseFormat(ValidationProblemSerializationFormat.Rich)
    );

The generator analyzes top-level context.Check(...).Rule(...) chains and produces response schemas and examples when metadata arguments are compile-time constants (e.g. HasLengthIn(10, 1000) or IsInRange(1, 5)).

Use [PortableValidationOpenApiErrorHint] to annotate codes the generator cannot infer (for example, from Must(...), Custom(...), or child validators):

[GeneratePortableValidationOpenApi]
[PortableValidationOpenApiErrorHint("MovieAlreadyRated")]
public sealed partial class AddMovieRatingValidator : Validator<MovieRatingDto> { ... }

Reusable Error Code Contracts

Register per-error-code metadata contracts once in DI, then opt specific endpoints into them:

builder.Services.AddPortableResultsOpenApi(contracts =>
{
    contracts.ForCode<VersionMismatchMetadata>("VersionMismatch");
    contracts.ForCode<InsufficientFundsMetadata>("InsufficientFunds");
});

app.MapPut("/api/movieRatings", handler)
   .ProducesPortableValidationProblem(
        configure: x => x.WithErrorCodes("VersionMismatch")
    );

⚙️ Configuration Reference

HTTP write options (PortableResultsHttpWriteOptions)

Option Default Description
ValidationProblemSerializationFormat AspNetCoreCompatible Controls how validation errors are serialized for HTTP 400/422 responses. We encourage using Rich.
MetadataSerializationMode ErrorsOnly Controls whether metadata is serialized in response bodies (ErrorsOnly or Always).
CreateProblemDetailsInfo null Optional custom factory for generating Problem Details fields (type, title, detail, etc.).
FirstErrorCategoryIsLeadingCategory true If true, the first error category decides the HTTP status code. If false, all errors must share the same category; otherwise Unclassified (500) is used.
builder.Services.Configure<PortableResultsHttpWriteOptions>(options =>
{
    options.ValidationProblemSerializationFormat = ValidationProblemSerializationFormat.Rich;
    options.MetadataSerializationMode = MetadataSerializationMode.Always;
    options.FirstErrorCategoryIsLeadingCategory = false;
});

HTTP read options (PortableResultsHttpReadOptions)

Option Default Description
HeaderParsingService ParseNoHttpHeadersService.Instance Controls how HTTP headers are converted into metadata.
MergeStrategy AddOrReplace Strategy when merging metadata with the same key from headers and body.
PreferSuccessPayload Auto How to interpret successful payloads (Auto, BareValue, WrappedValue).
TreatProblemDetailsAsFailure true If true, application/problem+json is treated as failure even for 2xx status codes.
SerializerOptions Module.DefaultSerializerOptions System.Text.JSON serializer options used for deserialization.
var readOptions = new PortableResultsHttpReadOptions
{
    HeaderParsingService = new DefaultHttpHeaderParsingService(new AllHeadersSelectionStrategy()),
    PreferSuccessPayload = PreferSuccessPayload.Auto,
    TreatProblemDetailsAsFailure = true
};

Result<UserDto> result = await response.ReadResultAsync<UserDto>(readOptions);

CloudEvents write options (PortableResultsCloudEventsWriteOptions)

Option Default Description
Source null Default CloudEvents source URI if not set per call.
MetadataSerializationMode Always Controls whether metadata is serialized into CloudEvents data.
SerializerOptions Module.DefaultSerializerOptions System.Text.JSON serializer options.
ConversionService DefaultCloudEventsAttributeConversionService.Instance Converts metadata entries into CloudEvents extension attributes.
SuccessType null Default CloudEvents type for successful results.
FailureType null Default CloudEvents type for failed results.
Subject null Default CloudEvents subject.
DataSchema null Default CloudEvents dataschema URI.
Time null Default time value (UTC now used when omitted).
IdResolver null Optional function used to generate CloudEvents id values.
ArrayPool ArrayPool<byte>.Shared Buffer pool used for serialization.
PooledArrayInitialCapacity RentedArrayBufferWriter.DefaultInitialCapacity (2048 B) Initial buffer size for pooled serialization.
builder.Services.Configure<PortableResultsCloudEventsWriteOptions>(options =>
{
    options.Source = "urn:light-portable-results:sample:user-service";
    options.SuccessType = "users.updated";
    options.FailureType = "users.update.failed";
    options.MetadataSerializationMode = MetadataSerializationMode.Always;
});

CloudEvents read options (PortableResultsCloudEventsReadOptions)

Option Default Description
SerializerOptions Module.DefaultSerializerOptions System.Text.JSON serializer options.
PreferSuccessPayload Auto How to interpret successful payloads (Auto, BareValue, WrappedValue).
IsFailureType null Optional fallback classifier to decide failure based on CloudEvents type.
ParsingService null Optional parser for mapping extension attributes to metadata.
MergeStrategy AddOrReplace Strategy when merging extension attributes and payload metadata.
var cloudReadOptions = new PortableResultsCloudEventsReadOptions
{
    IsFailureType = eventType => eventType.EndsWith(".failed", StringComparison.Ordinal),
    PreferSuccessPayload = PreferSuccessPayload.Auto
};

Result<UserDto> result = messageBody.ReadResult<UserDto>(cloudReadOptions);

Supported Error Categories

ErrorCategory HTTP Status
Unclassified 500
Validation 400
Unauthorized 401
PaymentRequired 402
Forbidden 403
NotFound 404
MethodNotAllowed 405
NotAcceptable 406
Timeout 408
Conflict 409
Gone 410
LengthRequired 411
PreconditionFailed 412
ContentTooLarge 413
UriTooLong 414
UnsupportedMediaType 415
RequestedRangeNotSatisfiable 416
ExpectationFailed 417
MisdirectedRequest 421
UnprocessableContent 422
Locked 423
FailedDependency 424
UpgradeRequired 426
PreconditionRequired 428
TooManyRequests 429
RequestHeaderFieldsTooLarge 431
UnavailableForLegalReasons 451
InternalError 500
NotImplemented 501
BadGateway 502
ServiceUnavailable 503
GatewayTimeout 504
InsufficientStorage 507

About

One Result model. Many transports. RFC-compatible error handling for .NET microservices.

Resources

License

Stars

Watchers

Forks

Contributors