From 2ff6e7a17c325222f694d7e5504fc64bf0d256b7 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sat, 23 May 2026 15:16:38 +0200 Subject: [PATCH 1/9] chore: update version to 0.6.0 Signed-off-by: Kenny Pflug --- Directory.Build.props | 2 +- README.md | 3 ++- .../NativeAotMovieRating/packages.lock.json | 20 +++++++++---------- ...tableResults.AspNetCore.MinimalApis.csproj | 2 +- ...ight.PortableResults.AspNetCore.Mvc.csproj | 2 +- ....PortableResults.AspNetCore.OpenApi.csproj | 2 +- ...t.PortableResults.AspNetCore.Shared.csproj | 2 +- ....PortableResults.Validation.OpenApi.csproj | 2 +- .../Light.PortableResults.Validation.csproj | 2 +- .../Light.PortableResults.csproj | 2 +- 10 files changed, 20 insertions(+), 19 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index c00bc41..23897ff 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -10,6 +10,6 @@ Kenny Pflug Kenny Pflug Copyright (c) 2026 Kenny Pflug - 0.5.0 + 0.6.0 diff --git a/README.md b/README.md index 29cf5a9..2177514 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ *A high-performant, enterprise-grade .NET library implementing the Result Pattern where each result is serializable and deserializable. Comes with integrations for ASP.NET Core Minimal APIs and MVC, `HttpResponseMessage`, and CloudEvents JSON format, as well as a validation framework.* [![License](https://img.shields.io/badge/License-MIT-green.svg?style=for-the-badge)](https://github.com/feO2x/Light.PortableResults/blob/main/LICENSE) -[![NuGet](https://img.shields.io/badge/NuGet-0.5.0-blue.svg?style=for-the-badge)](https://www.nuget.org/packages?q=Light.PortableResults) +[![NuGet](https://img.shields.io/badge/NuGet-0.6.0-blue.svg?style=for-the-badge)](https://www.nuget.org/packages?q=Light.PortableResults) [![Documentation](https://img.shields.io/badge/Docs-Changelog-yellowgreen.svg?style=for-the-badge)](https://github.com/feO2x/Light.PortableResults/releases) ## ✨ Key Features @@ -18,6 +18,7 @@ - πŸ› οΈ **Microsoft.Extensions.Configuration**: validate options with your custom `Validator` implementations. - ⚑ **Allocation-minimal by design** β€” pooled buffers, struct-friendly internals, smart caching, and fast paths keep GC pressure near zero even at high throughput. - 🧊 **.NET Native AOT** β€” The base, validation, and Minimal APIs packages are designed to work seamlessly with .NET Native AOT, ensuring minimal runtime overhead and efficient memory usage. +- 🌐 **Microsoft.AspNetCore.OpenAPI Integration** - WritevValidators and create automatic OpenAPI schemas and examples via Source Generation. ## πŸ“¦ Installation diff --git a/samples/NativeAotMovieRating/packages.lock.json b/samples/NativeAotMovieRating/packages.lock.json index 28b5403..e6c8110 100644 --- a/samples/NativeAotMovieRating/packages.lock.json +++ b/samples/NativeAotMovieRating/packages.lock.json @@ -147,33 +147,33 @@ "light.portableresults.aspnetcore.minimalapis": { "type": "Project", "dependencies": { - "Light.PortableResults.AspNetCore.Shared": "[0.5.0, )" + "Light.PortableResults.AspNetCore.Shared": "[0.6.0, )" } }, "light.portableresults.aspnetcore.openapi": { "type": "Project", "dependencies": { - "Light.PortableResults.AspNetCore.Shared": "[0.5.0, )", + "Light.PortableResults.AspNetCore.Shared": "[0.6.0, )", "Microsoft.AspNetCore.OpenApi": "[10.0.5, )" } }, "light.portableresults.aspnetcore.shared": { "type": "Project", "dependencies": { - "Light.PortableResults": "[0.5.0, )" + "Light.PortableResults": "[0.6.0, )" } }, "light.portableresults.validation": { "type": "Project", "dependencies": { - "Light.PortableResults": "[0.5.0, )" + "Light.PortableResults": "[0.6.0, )" } }, "light.portableresults.validation.openapi": { "type": "Project", "dependencies": { - "Light.PortableResults.AspNetCore.OpenApi": "[0.5.0, )", - "Light.PortableResults.Validation": "[0.5.0, )" + "Light.PortableResults.AspNetCore.OpenApi": "[0.6.0, )", + "Light.PortableResults.Validation": "[0.6.0, )" } }, "Microsoft.Bcl.HashCode": { @@ -189,20 +189,20 @@ "contentHash": "V6crLJ8a29raWeNwxYGfH9RTKA3H0nR0D9LAGzN3KtEsbiiaWkUjDor6OT5Oz7pxCK+NaY2hu2FLoYEOa8oCkA==" } }, - "net10.0/linux-x64": { + "net10.0/osx-arm64": { "Microsoft.DotNet.ILCompiler": { "type": "Direct", "requested": "[10.0.7, )", "resolved": "10.0.7", "contentHash": "2H7j1NltkQx04sPWBkUtFrZNBtro7vwsxRtdThP0oDj6Sn3ouGHCQlxATZ4Me2aJE67+KiXMX2V1IHDjt1uIpw==", "dependencies": { - "runtime.linux-x64.Microsoft.DotNet.ILCompiler": "10.0.7" + "runtime.osx-arm64.Microsoft.DotNet.ILCompiler": "10.0.7" } }, - "runtime.linux-x64.Microsoft.DotNet.ILCompiler": { + "runtime.osx-arm64.Microsoft.DotNet.ILCompiler": { "type": "Transitive", "resolved": "10.0.7", - "contentHash": "bz+Di9NJXvaWTvoma5Pf9JrgFj6MGkbPo9dlWRo+jOHXDEme511jeWVEBWoPdoDe6BjDWRngGi9P9EUBCCgzgw==" + "contentHash": "ycFCaZwEvd0nNqcW53l0KWM+fz74owXpWj5C/z0GjznwAtHwmGTeh3vGTGFrXD9LEagX8G3cHRtzGDrTabIrwQ==" } } } diff --git a/src/Light.PortableResults.AspNetCore.MinimalApis/Light.PortableResults.AspNetCore.MinimalApis.csproj b/src/Light.PortableResults.AspNetCore.MinimalApis/Light.PortableResults.AspNetCore.MinimalApis.csproj index ea5e553..f04b50f 100644 --- a/src/Light.PortableResults.AspNetCore.MinimalApis/Light.PortableResults.AspNetCore.MinimalApis.csproj +++ b/src/Light.PortableResults.AspNetCore.MinimalApis/Light.PortableResults.AspNetCore.MinimalApis.csproj @@ -4,7 +4,7 @@ true Integration package for turning result instances into Minimal API's IResult instances. Compatible with Native AOT. Compatible with RFC 9457 (and RFC 7807) Problem Details responses. - Light.PortableResults.AspNetCore.MinimalApis 0.5.0 + Light.PortableResults.AspNetCore.MinimalApis 0.6.0 --------------------------------- - LightResult and LightResult<T> and corresponding extension methods to turn result instances into HTTP success responses or RFC 9457 (and RFC 7807) compatible Problem Details responses. diff --git a/src/Light.PortableResults.AspNetCore.Mvc/Light.PortableResults.AspNetCore.Mvc.csproj b/src/Light.PortableResults.AspNetCore.Mvc/Light.PortableResults.AspNetCore.Mvc.csproj index ac9be15..25f8203 100644 --- a/src/Light.PortableResults.AspNetCore.Mvc/Light.PortableResults.AspNetCore.Mvc.csproj +++ b/src/Light.PortableResults.AspNetCore.Mvc/Light.PortableResults.AspNetCore.Mvc.csproj @@ -3,7 +3,7 @@ Integration package for turning result instances into MVC's IActionResult instances. Compatible with RFC 9457 (and RFC 7807) Problem Details responses. - Light.PortableResults.AspNetCore.MVC 0.5.0 + Light.PortableResults.AspNetCore.MVC 0.6.0 --------------------------------- - LightActionResult and LightActionResult<T> and corresponding extension methods to turn result instances into HTTP success responses or RFC 9457 (and RFC 7807) compatible Problem Details responses. diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/Light.PortableResults.AspNetCore.OpenApi.csproj b/src/Light.PortableResults.AspNetCore.OpenApi/Light.PortableResults.AspNetCore.OpenApi.csproj index 2cfaf8c..37ceea2 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/Light.PortableResults.AspNetCore.OpenApi.csproj +++ b/src/Light.PortableResults.AspNetCore.OpenApi/Light.PortableResults.AspNetCore.OpenApi.csproj @@ -4,7 +4,7 @@ true Opt-in OpenAPI integration package for Light.PortableResults ASP.NET Core applications. Provides a library-authored schema catalog, endpoint metadata attributes, Minimal API helpers, and a document transformer. - Light.PortableResults.AspNetCore.OpenApi 0.5.0 + Light.PortableResults.AspNetCore.OpenApi 0.6.0 ------------------------------------- - Opt-in OpenAPI integration via IServiceCollection.AddPortableResultsOpenApi. diff --git a/src/Light.PortableResults.AspNetCore.Shared/Light.PortableResults.AspNetCore.Shared.csproj b/src/Light.PortableResults.AspNetCore.Shared/Light.PortableResults.AspNetCore.Shared.csproj index c9df45d..9ef99da 100644 --- a/src/Light.PortableResults.AspNetCore.Shared/Light.PortableResults.AspNetCore.Shared.csproj +++ b/src/Light.PortableResults.AspNetCore.Shared/Light.PortableResults.AspNetCore.Shared.csproj @@ -4,7 +4,7 @@ true The Light.PortableResults.AspNetCore.Shared package contains shared functionality for writing ASP.NET Core HTTP responses. Compatible with Native AOT. Check out the integration packages Light.PortableResults.AspNetCore.MinimalApis or Light.PortableResults.AspNetCore.Mvc. - Light.PortableResults.AspNetCore.Shared 0.5.0 + Light.PortableResults.AspNetCore.Shared 0.6.0 --------------------------------- - Result enrichment with ASP.NET Core's HttpContext. diff --git a/src/Light.PortableResults.Validation.OpenApi/Light.PortableResults.Validation.OpenApi.csproj b/src/Light.PortableResults.Validation.OpenApi/Light.PortableResults.Validation.OpenApi.csproj index 6fd2c8d..8af97d9 100644 --- a/src/Light.PortableResults.Validation.OpenApi/Light.PortableResults.Validation.OpenApi.csproj +++ b/src/Light.PortableResults.Validation.OpenApi/Light.PortableResults.Validation.OpenApi.csproj @@ -4,7 +4,7 @@ true OpenAPI bridge package for Light.PortableResults.Validation. Provides built-in validation error metadata contracts and typed helpers for endpoint-specific validation error narrowing. - Light.PortableResults.Validation.OpenApi 0.5.0 + Light.PortableResults.Validation.OpenApi 0.6.0 ---------------------------------------- - Built-in OpenAPI metadata contracts for Light.PortableResults.Validation error codes. diff --git a/src/Light.PortableResults.Validation/Light.PortableResults.Validation.csproj b/src/Light.PortableResults.Validation/Light.PortableResults.Validation.csproj index 4e1abae..4b2d709 100644 --- a/src/Light.PortableResults.Validation/Light.PortableResults.Validation.csproj +++ b/src/Light.PortableResults.Validation/Light.PortableResults.Validation.csproj @@ -4,7 +4,7 @@ false Framework-agnostic validation foundations for Light.PortableResults, including validation contexts, low-allocation checks, validator base classes, and validated value pipelines. - Light.PortableResults.Validation 0.5.0 + Light.PortableResults.Validation 0.6.0 --------------------------------- - Validation foundations with validation contexts, checks, validated value pipelines, and sync/async validator base classes. diff --git a/src/Light.PortableResults/Light.PortableResults.csproj b/src/Light.PortableResults/Light.PortableResults.csproj index 8f34fee..7ff7937 100644 --- a/src/Light.PortableResults/Light.PortableResults.csproj +++ b/src/Light.PortableResults/Light.PortableResults.csproj @@ -4,7 +4,7 @@ false The Light.PortableResults package implements the core functionality: Results, Errors, Metadata, Functional Extensions, and serialization support for various formats like HTTP and CloudEvents. Compatible with Native AOT. Check out the integration packages Light.PortableResults.AspNetCore.MinimalApis or Light.PortableResults.AspNetCore.Mvc. - Light.PortableResults 0.5.0 + Light.PortableResults 0.6.0 --------------------------------- - Contains core functionality: Results, Errors, Metadata, Functional Extensions, and JSON serialization support for HTTP and CloudEvents. From 65c68130f1b03fa44326078b5a634435afc0a0f2 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sat, 23 May 2026 16:26:40 +0200 Subject: [PATCH 2/9] docs: update readme with new structure Signed-off-by: Kenny Pflug --- README.md | 1311 ++++++++++++++++++----------------------------------- 1 file changed, 431 insertions(+), 880 deletions(-) diff --git a/README.md b/README.md index 2177514..f839b95 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,24 @@ # Light.PortableResults -*A high-performant, enterprise-grade .NET library implementing the Result Pattern where each result is serializable and deserializable. Comes with integrations for ASP.NET Core Minimal APIs and MVC, `HttpResponseMessage`, and CloudEvents JSON format, as well as a validation framework.* +*The Result Pattern for .NET that travels. Every `Result` serializes reliably over HTTP (RFC-9457), CloudEvents, and back β€” with a validation framework that is at least 5x faster and uses less than 9% of the memory of FluentValidation.* [![License](https://img.shields.io/badge/License-MIT-green.svg?style=for-the-badge)](https://github.com/feO2x/Light.PortableResults/blob/main/LICENSE) [![NuGet](https://img.shields.io/badge/NuGet-0.6.0-blue.svg?style=for-the-badge)](https://www.nuget.org/packages?q=Light.PortableResults) [![Documentation](https://img.shields.io/badge/Docs-Changelog-yellowgreen.svg?style=for-the-badge)](https://github.com/feO2x/Light.PortableResults/releases) +Most Result Pattern libraries stop at the application boundary. Light.PortableResults does not: a `Result` can be written as an RFC-9457 Problem Details response, published as a CloudEvents JSON message, read back on the other side, and arrive as a fully-typed `Result` β€” 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. + ## ✨ Key Features -- 🧱 **Clear Result Pattern** β€” `Result` / `Result` 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`, so results serialize reliably across any protocol. -- πŸ” **Full functional operator suite** β€” `Map`, `Bind`, `Match`, `Ensure`, `Tap`, `Switch`, and their `Async` variants let you build clean, chainable pipelines. -- ☁️ **Cloud-Native** β€” Light.PortableResults contains System.Text.Json serialization support for HTTP responses, including RFC-9457 Problem Details compatibility, and CloudEvents Spec 1.0 JSON payloads for asynchronous messaging. Full round-trip included. -- 🧩 **ASP.NET Core ready** β€” Minimal APIs and MVC packages translate `Result` and `Result` directly to `IResult` / `IActionResult` with automatic HTTP status mapping and RFC-9457 Problem Details support. -- πŸ›‘οΈ **Validation framework** β€” Light.PortableResults.Validation allows you to easily validate DTOs and any values. Use transforming validators to write efficient Anti-Corruption Layers. At least 5x faster than FluentValidation 12.1.1 while having less than 9% of FluentValidation's memory footprint. -- πŸ› οΈ **Microsoft.Extensions.Configuration**: validate options with your custom `Validator` implementations. -- ⚑ **Allocation-minimal by design** β€” pooled buffers, struct-friendly internals, smart caching, and fast paths keep GC pressure near zero even at high throughput. -- 🧊 **.NET Native AOT** β€” The base, validation, and Minimal APIs packages are designed to work seamlessly with .NET Native AOT, ensuring minimal runtime overhead and efficient memory usage. -- 🌐 **Microsoft.AspNetCore.OpenAPI Integration** - WritevValidators and create automatic OpenAPI schemas and examples via Source Generation. +- **Clear Result Pattern** β€” `Result` / `Result` 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`, so results serialize reliably across any protocol. +- **Full functional operator suite** β€” `Map`, `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` directly to `IResult` / `IActionResult` with automatic HTTP status mapping. +- **High-performance validation** β€” at least 5x faster than FluentValidation 12.1.1 and 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 @@ -36,13 +36,13 @@ Validation context, checks, and synchronous/asynchronous validators: dotnet add package Light.PortableResults.Validation ``` -ASP.NET Core Minimal APIs integration with support for Dependency Injection and `IResult`: +ASP.NET Core Minimal APIs integration: ```bash dotnet add package Light.PortableResults.AspNetCore.MinimalApis ``` -ASP.NET Core MVC integration with support for Dependency Injection and `IActionResult`: +ASP.NET Core MVC integration: ```bash dotnet add package Light.PortableResults.AspNetCore.Mvc @@ -62,36 +62,41 @@ dotnet add package Light.PortableResults.Validation.OpenApi If you only need the Result Pattern itself, `Light.PortableResults` is the most lightweight dependency. -## πŸ€“ Basic Usage +## When to Use Result vs. Exceptions -If you are new to the Result Pattern, think of it like this: +Use `Result` / `Result` for **expected business outcomes**: -- A method can either succeed or fail. -- Instead of throwing exceptions for expected failures (validation, not found, conflicts), the method returns a value that explicitly describes the outcome. -- Callers must handle both paths on purpose, which makes Control Flow easier to read and test. +- 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. -This is covered by the following types in Light.PortableResults: +## πŸ€“ Basic Usage -- `Result` means: either a success value of type `T`, or one or more errors. -- `Result` (non-generic) means: success/failure without a return value (corresponds to `void`). -- Each `Error` can carry machine-readable details such as `Code`, `Target`, `Category`, and `Metadata`. +If you are new to the Result Pattern, think of it like this: -You can then build business logic around these types: +- A method can either succeed or fail. +- Instead of throwing exceptions for expected failures, the method returns a value that explicitly describes the outcome. +- Callers must handle both paths on purpose, which makes control flow easier to read and test. ```csharp using Light.PortableResults; -// Use Result or Result as response types in your methods static Result ParsePositiveInteger(string input) { if (int.TryParse(input, out var value) && value > 0) { - // If everything is fine, then use Result.Ok() to return a success value return Result.Ok(value); } - // If an error occurred, use Result.Fail() to indicate an issue. - // Here we create a single error, but you can also return multiple errors. return Result.Fail(new Error { Message = "Value must be a positive integer", @@ -102,11 +107,11 @@ static Result ParsePositiveInteger(string input) } ``` -You can then examine results in two ways: with implicit if-else Control Flow... +Examine the result with an if-else... ```csharp var input = Console.ReadLine(); -Result result = ParsePositiveInteger(); +Result result = ParsePositiveInteger(input); if (result.IsValid) { @@ -124,7 +129,6 @@ else ```csharp using Light.PortableResults.FunctionalExtensions; -var input = Console.ReadLine(); string message = ParsePositiveInteger(input).Match( onSuccess: value => $"Success: {value}", onError: errors => $"Error {errors.First.Code}: {errors.First.Message}" @@ -132,23 +136,80 @@ string message = ParsePositiveInteger(input).Match( Console.WriteLine(message); ``` -See [Functional Operators](#functional-operators) for more details on the available operators. +Use the non-generic `Result` for command-style operations that do not return a value: + +```csharp +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(); +} +``` -> The core idea is that you avoid throwing exceptions as part of the contract between a method and its caller. Instead, you return a `Result` or `Result` instance that explicitly indicates success or failure. +### 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`. | +| 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`). + +```csharp +using Light.PortableResults; +using Light.PortableResults.FunctionalExtensions; + +Result 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 -In Light.PortableResults, metadata is not just a `Dictionary` as with many other Result Pattern implementations. Instead, it uses a type system pretty similar to JSON which allows each result instance to be serialized and deserialized. +Metadata is not a `Dictionary`. 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/Result` instances as well as to `Error` instances. +Metadata can be attached to `Result` / `Result` instances as well as to individual `Error` instances. ```csharp using Light.PortableResults; using Light.PortableResults.Metadata; -// Create metadata using primitive types (bool, long, double, string, decimal) -// or nested objects and arrays. MetadataObject uses implicit conversions -// from these types for easy construction. +// 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()), @@ -156,13 +217,12 @@ var metadata = MetadataObject.Create( ("attemptCount", 3) ); -// Attach metadata to a successful result Result result = Result.Ok( new Order { Id = Guid.NewGuid(), Total = 99.99m }, metadata ); -// Or attach metadata to an error for additional context +// Attach metadata to an error for additional context var error = new Error { Message = "Order exceeds account limit", @@ -176,18 +236,20 @@ var error = new Error ) }; -// Access metadata from a result or error +// Read metadata from a result if (result.Metadata?.TryGetString("requestId", out var requestId) == true) { Console.WriteLine($"Request: {requestId}"); } ``` -## πŸ›«οΈ Validation Quick Start +## πŸ›‘οΈ Validation Quick Start -Instead of creating `Error` instances manually, you can reference the `Light.PortableResults.Validation` package and use its rich assertions and support for validators, similar to FluentValidation. Here is an example: +Instead of constructing `Error` instances manually, reference `Light.PortableResults.Validation` and write a typed validator: ```csharp +using Light.PortableResults.Validation; + public sealed record MovieRatingDto { public required Guid Id { get; init; } @@ -199,43 +261,33 @@ public sealed record MovieRatingDto public sealed class MovieRatingValidator : Validator { - // Inject any service you need for validation into the constructor. - // The IValidationContextFactory is used to obtain a ValidationContext instance - // and must always be injected. public MovieRatingValidator(IValidationContextFactory validationContextFactory) : base(validationContextFactory) { } protected override ValidatedValue PerformValidation( - ValidationContext context, // Collects errors during validation - ValidationCheckpoint checkpoint, // Used to determine if errors occurred in this method - MovieRatingDto dto // The value to validate + ValidationContext context, + ValidationCheckpoint checkpoint, + MovieRatingDto dto ) { - // Use the ValidationContext.Check method to create Check instances. - // These offer various extension methods which attach errors to the context - // if validation fails. The Check call will also smartly obtain a value for - // Error.Target depending on your argument (CallerArgumentExpression). context.Check(dto.Id).IsNotEmpty(); context.Check(dto.MovieId).IsNotEmpty(); - // Instead of only examining values, ValidationContext.Check normalizes values. - // By default, strings are processed in the following way: - // - Null -> Empty string (avoids NullReferenceException) - // - Not-Null -> Trimmed string - // You can write these normalized string values back to ensure safe processing - // after validation finished. See ValidationContextOptions.ValueNormalizer. + // 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); - // Use the checkpoint to determine if validation errors were attached to - // to the ValidationContext during this method call. The checkpoint will - // automatically return a corresponding ValidatedValue instance for you. return checkpoint.ToValidatedValue(dto); } } +``` + +Call the validator from a service using `CheckForErrors` to avoid the `if (!result.IsValid)` ceremony: +```csharp public sealed class AddMovieRatingService { private readonly MovieRatingValidator _validator; @@ -252,52 +304,96 @@ public sealed class AddMovieRatingService return Result.Fail(errorResult.Errors); } - // Do something useful with the validated DTO. In the end - // a MovieRating domain object is created and returned. var movieRating = new MovieRating(...); return Result.Ok(movieRating); } } ``` -Light.PortableResults is significantly faster and leaner than FluentValidation β€” see the [benchmark results](#composing-validators) in Validation In Depth. +Register validators as singletons when they have no scoped dependencies β€” they are stateless by design: + +```csharp +services + .AddValidationForPortableResults() + .AddSingleton(); +``` See [Validation In Depth](#-validation-in-depth) for composing validators, async validation, domain object mapping, sharing state between validators, custom assertions, and configuration options. +## ⚑ 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 in the previous Validation Quick Start section, you can easily integrate Light.PortableResults into ASP.NET Core. +Given the classes from the Validation Quick Start above, integrate with ASP.NET Core in a few lines. ### Minimal APIs ```csharp using Light.PortableResults; using Light.PortableResults.AspNetCore.MinimalApis; +using Light.PortableResults.Http.Writing; var builder = WebApplication.CreateBuilder(args); -builder - .Services - .AddPortableResultsForMinimalApis() - .AddValidationForPortableResults() - .Configure( - // We highly recommend using the Rich serialization format for HTTP responses. - // If you do not adjust this value, the default value of - // ValidationProblemSerializationFormat.AspNetCoreCompatible is used which - // writes the Problem Details errors in the same way as ASP.NET Core does. - x => x.ValidationProblemSerializationFormat = ValidationProblemSerializationFormat.Rich +builder.Services + .AddPortableResultsForMinimalApis() + .AddValidationForPortableResults() + .Configure( + // Rich format is recommended β€” it serializes errors with full Code/Target/Category/Metadata. + x => x.ValidationProblemSerializationFormat = ValidationProblemSerializationFormat.Rich ) - .AddSingleton() // Register validators as singletons by default - .AddScoped(); + .AddSingleton() + .AddScoped(); var app = builder.Build(); app.MapPut("/api/movieRatings", async (MovieRatingDto dto, AddMovieRatingService service) => { - var result = await service.AddMovieRatingAsync(dto); - // Any Result/Result instance can be easily converted to - // Minimal API's IResult. Under the covers, we use an - // optimized LightResult/LightResult type. - return result.ToMinimalApiResult(); + var result = await service.AddMovieRatingAsync(dto); + return result.ToMinimalApiResult(); }); app.Run(); @@ -310,8 +406,7 @@ using Light.PortableResults; using Light.PortableResults.AspNetCore.Mvc; builder.Services.AddControllers(); -builder - .Services +builder.Services .AddPortableResultsForMvc() .AddValidationForPortableResults() .AddSingleton() @@ -322,13 +417,12 @@ app.MapControllers(); app.Run(); [ApiController] -[Route("moveRatings")] +[Route("api/movieRatings")] public sealed class AddMovieRatingsController : ControllerBase { - public AddMovieRatingsController(AddMovieRatingsService service) - { - _service = service; - } + private readonly AddMovieRatingService _service; + + public AddMovieRatingsController(AddMovieRatingService service) => _service = service; [HttpPut] public async Task> AddMovieRating(AddMovieRatingDto dto) @@ -339,9 +433,7 @@ public sealed class AddMovieRatingsController : ControllerBase } ``` -### HTTP Response On the Wire - -For both examples above (Minimal APIs and MVC), the HTTP response shape is the same. +### HTTP Responses on the Wire Successful update (`200 OK`): @@ -400,39 +492,22 @@ Content-Type: application/problem+json } ``` -### Deserializing Result back from HttpResponseMessage +### Deserializing `Result` from `HttpResponseMessage` ```csharp -using System; -using System.Net.Http; 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 httpClient = new HttpClient { BaseAddress = new Uri("https://localhost:5000") }; -var requestDto = new MovieRatingDto -{ - Id = Guid.CreateVersion7(), - MovieId = matrixMovie.Id, - UserName = "Trinity", - Comment = "The Answer Is Out There, Neo. It's Looking for You.", - Rating = 5 -}; +using var response = await httpClient.PutAsJsonAsync("/api/movieRatings", requestDto); -using var response = await httpClient.PutAsJsonAsync( - "/api/movieRatings", - requestDto -); - -Result result = await response.ReadResultAsync(); +Result result = await response.ReadResultAsync(); if (result.IsValid) { - Console.WriteLine($"Added movie rating"); + Console.WriteLine($"Added movie rating with id {result.Value.Id}"); } else { @@ -445,45 +520,38 @@ else ## ☁️ CloudEvents Quick Start -The following example uses `RabbitMQ.Client` to publish and consume a CloudEvents JSON message carrying `Result`. +Light.PortableResults can serialize a `Result` as a CloudEvents Spec 1.0 JSON payload and deserialize it on any consumer. The key API calls are `result.ToCloudEvent(...)` and `ReadResult()`. ### Publish to RabbitMQ ```csharp -using System; using Light.PortableResults; using Light.PortableResults.CloudEvents; using Light.PortableResults.CloudEvents.Writing; using RabbitMQ.Client; -var factory = new ConnectionFactory { HostName = "localhost" }; -await using var connection = await factory.CreateConnectionAsync(); -await using var channel = await connection.CreateChannelAsync(); - -await channel.QueueDeclareAsync(queue: "users.updated", durable: true, exclusive: false, autoDelete: false); - var result = Result.Ok(new UserDto { - Id = Guid.Parse("6b8a4dca-779d-4f36-8274-487fe3e86b5a"), - Email = "ada@example.com" + 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" + 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 + exchange: "", + routingKey: "users.updated", + mandatory: false, + basicProperties: properties, + body: cloudEvent ); ``` @@ -492,130 +560,33 @@ await channel.BasicPublishAsync( ```csharp using Light.PortableResults; using Light.PortableResults.CloudEvents.Reading; -using RabbitMQ.Client; using RabbitMQ.Client.Events; -var factory = new ConnectionFactory { HostName = "localhost" }; -await using var connection = await factory.CreateConnectionAsync(); -await using var channel = await connection.CreateChannelAsync(); - -await channel.QueueDeclareAsync(queue: "users.updated", durable: true, exclusive: false, autoDelete: false); - -var consumer = new AsyncEventingBasicConsumer(channel); consumer.ReceivedAsync += async (_, eventArgs) => { - Result result = eventArgs.Body.ReadResult(); - - 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); -}; - -await channel.BasicConsumeAsync(queue: "users.updated", autoAck: false, consumer: consumer); -``` - -## When to Use Result vs. Exceptions - -Use `Result` / `Result` for expected business outcomes: - -- validation failed -- resource not found -- user is not authorized -- domain rule was violated + Result result = eventArgs.Body.ReadResult(); -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. - -## Use non-generic `Result` for command-style operations - -```csharp -using Light.PortableResults; - -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(); -} -``` - -## Functional Operators - -Supported functional operators: - -| Category | Operators | What they are used for | -|-------------------------|--------------------------------------------|--------------------------------------------------------------------------------| -| Transform success value | `Map`, `Bind` | Convert successful values or chain operations that already return `Result`. | -| 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/fallback without manually branching every time. | -| Side effects | `Tap`, `TapError`, `Switch`, `SwitchFirst` | Perform logging/metrics/notifications on success or failure paths. | - -All operators also provide async variants with the `Async` suffix (for example `BindAsync`, `MatchAsync`, `TapErrorAsync`). - -Example pipeline: - -```csharp -using Light.PortableResults; -using Light.PortableResults.FunctionalExtensions; + if (result.IsValid) + { + Console.WriteLine($"Updated user: {result.Value.Email}"); + } + else + { + foreach (var error in result.Errors) + { + Console.WriteLine($"{error.Target}: {error.Message}"); + } + } -Result 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}" - ); + await channel.BasicAckAsync(eventArgs.DeliveryTag, multiple: false); +}; ``` -## Keep error payloads useful for clients - -As a rule of thumb: - -- `Message`: human-readable explanation -- `Code`: stable machine-readable identifier (great for frontend/API contracts) -- `Target`: which input field/header/value failed -- `Category`: determines transport mapping (for example, HTTP status) -- `Metadata`: additional information (for example, header values or comparative values) - -Using a consistent error shape early will make your APIs and message consumers easier to evolve. - -There is an `Error.Exception` property which you can also set, but it is never serialized and thus never exposed to calling processes. - ## πŸ”¬ 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, and all of them are reported at once. +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. ```csharp public sealed record PurchaseOrderDto @@ -627,21 +598,6 @@ public sealed record PurchaseOrderDto public required List Items { get; set; } } -public sealed record ShippingAddressDto -{ - public required string RecipientName { get; set; } = string.Empty; - public required string Street { get; set; } = string.Empty; - public required string PostalCode { get; set; } = string.Empty; - public required string CountryCode { get; set; } = string.Empty; -} - -public sealed record OrderItemDto -{ - public required string Sku { get; set; } = string.Empty; - public required int Quantity { get; set; } - public required decimal UnitPrice { get; set; } -} - public sealed class PurchaseOrderValidator : Validator { private readonly ShippingAddressValidator _addressValidator; @@ -666,23 +622,14 @@ public sealed class PurchaseOrderValidator : Validator context.Check(dto.OrderId).IsNotEmpty(); dto.CustomerEmail = context.Check(dto.CustomerEmail).IsEmail(); - // ValidateChild delegates to the child validator, including its automatic null check. - // If dto.ShippingAddress is null, the child validator produces a validation error - // automatically β€” no explicit IsNotNull() call needed here. + // If dto.ShippingAddress is null the child validator emits a null error automatically. context.Check(dto.ShippingAddress).ValidateChild(_addressValidator); - // ValidateItems automatically handles a null collection by emitting the standard - // NotNull error via the active AutomaticNullErrorProvider β€” no explicit IsNotNull() - // is needed under default configuration. Guard explicitly only when the provider is - // configured to skip automatic null errors (e.g. NoOpAutomaticNullErrorProvider). - // The default string normalization already trims whitespace for individual string items, - // so no IsNotNullOrWhiteSpace() is needed inside the lambda. + // If dto.Tags is null the framework emits a NotNull error automatically. context.Check(dto.Tags).ValidateItems( static (Check tag) => tag.HasLengthIn(2, 30) ); - // ValidateItems with a Validator delegates null-item handling to the item - // validator's automatic null check, just like ValidateChild does for single objects. context.Check(dto.Items).ValidateItems(_itemValidator); return checkpoint.ToValidatedValue(dto); @@ -727,11 +674,11 @@ public sealed class OrderItemValidator : Validator } ``` -> **What is a `ValidatedValue`?** +> **What is `ValidatedValue`?** > -> `ValidatedValue` is the internal handshake type between a validator and its callers within a single validation pipeline run. Especially in composition scenarios, a validator's `PerformValidation` method gets passed a shared `ValidationContext` and errors accumulate in that context. Rather than being surfaced immediately as a `Result`, `ValidatedValue` carries the signal back: either a successfully validated (and potentially normalized) value via `ValidatedValue.Success(value)`, or `ValidatedValue.NoValue` when errors were added. `checkpoint.ToValidatedValue(dto)` chooses the right outcome for you based on whether any errors were added since the checkpoint was created. You can use this method in `Validator` where the return type is not different from the input type. You never need to construct `ValidatedValue` directly except in transforming validators β€” see [Mapping to Domain Objects](#mapping-to-domain-objects). +> `ValidatedValue` is the handshake type between a validator and its callers within a single validation pipeline run. Rather than surfacing errors immediately as `Result`, it carries the signal back: either a successfully validated value via `ValidatedValue.Success(value)`, or `ValidatedValue.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` directly unless you are writing a transforming validator β€” see [Mapping to Domain Objects](#mapping-to-domain-objects). -If you don't inject scoped dependencies into them, register all validators as singletons β€” they should be designed stateless and safe to share: +Register all validators as singletons when they have no scoped dependencies: ```csharp services @@ -743,160 +690,26 @@ services ### 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; under the default configuration it emits a `NotNull` validation error. +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 (`Validator`, `Validator`, `AsyncValidator`, `AsyncValidator`)** β€” 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(Async)`. The `isAutomaticNullCheckingEnabled` constructor parameter (default `true`) controls this per validator class. +- **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. -- **Child validation (`ValidateChild`, `ValidateChildAsync`)** β€” when a nested collection value is `null`, the child validator's own automatic null check fires, adds the error for the child target, and returns `ValidatedValue.NoValue`. The parent continues collecting other errors in the same pass. `Check` instances that are short-circuited are ignored (no automatic null error will be added to the context, `PerformValidation(Async)` will not be called). - -- **Collection item validation (`ValidateItems`, `ValidateItemsAsync`)** β€” when a `null` collection is passed on a non-short-circuited check, the same automatic null-error pipeline runs for the collection target and the method returns `ValidatedValue<...>.NoValue` without invoking any item validators or delegates. Regarding the items in the collection: item validators as well as delegates have automatic null checking of items, too. - -Guard explicitly with `IsNotNull()` when `NoOpAutomaticNullErrorProvider` was set on `ValidationContextOptions.AutomaticNullErrorProvider` (which means that automatic null errors are disabled). +Guard explicitly with `IsNotNull()` only when `NoOpAutomaticNullErrorProvider` is configured (automatic null errors disabled), or when you need to short-circuit further checks: ```csharp -// Default configuration β€” automatic null handling, no explicit guard needed +// Default configuration β€” no explicit guard needed context.Check(dto.ShippingAddress).ValidateChild(_addressValidator); context.Check(dto.Tags).ValidateItems(static (Check tag) => tag.HasLengthIn(2, 30)); -// Explicit guard β€” required when the active provider does not emit automatic null errors, -// or when you want to short-circuit further checks on null +// Explicit guard β€” short-circuits any further checks on this value context.Check(dto.Tags).IsNotNull().ValidateItems(static (Check tag) => tag.HasLengthIn(2, 30)); ``` -Short-circuited checks are always a no-op: validation methods return `ValidatedValue<...>.NoValue` immediately without adding any error or invoking validators or delegates. - -### Validation Benchmarks - -Below are benchmark results comparing Light.PortableResults to FluentValidation using flat and composite DTOs (see the `benchmarks/Benchmarks` project for details). The `PurchaseOrderDto` example above corresponds to the Complex DTO scenario. - -All benchmarks were run on the handware and software versions: - -``` -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 Benchmark Setup - -```csharp -public sealed record MovieRatingDto -{ - public required Guid Id { get; set; } - public required string Comment { get; set; } = string.Empty; - public required int Rating { get; set; } -} - -// FluentValidation Validator -public sealed class FluentValidationMovieRatingDtoValidator : AbstractValidator -{ - public FluentValidationMovieRatingDtoValidator() - { - RuleFor(x => x.Id).NotEmpty(); - RuleFor(x => x.Comment).NotEmpty().Length(10, 1000); - RuleFor(x => x.Rating).InclusiveBetween(1, 5); - } -} - -// Light.PortableResults Validator -public sealed class LightPortableResultsMovieRatingDtoValidator : Validator -{ - public LightPortableResultsMovieRatingDtoValidator(IValidationContextFactory validationContextFactory) - : base(validationContextFactory) { } - - protected override ValidatedValue PerformValidation( - ValidationContext context, - ValidationCheckpoint checkpoint, - MovieRatingDto dto - ) - { - context.Check(dto.Id).IsNotEmpty(); - dto.Comment = context.Check(dto.Comment).IsNotNullOrWhiteSpace().HasLengthIn(10, 1000); - context.Check(dto.Rating).IsInRange(1, 5); - return checkpoint.ToValidatedValue(dto); - } -} -``` - -The details can be found [here](https://github.com/feO2x/Light.PortableResults/blob/main/benchmarks/Benchmarks/FlatDtoValidationBenchmarks.cs). - -#### Valid Flat DTO Benchmarks - -No errors on all three properties. - -| Method | Mean | Error | StdDev | Ratio | Gen0 | Gen1 | Allocated | Alloc Ratio | -|---------------------------------- |------------:|---------:|---------:|------:|-------:|-------:|----------:|------------:| -| FluentValidationScopedOrTransient | 1,324.57 ns | 8.570 ns | 7.156 ns | 1.00 | 0.8316 | 0.0076 | 6984 B | 1.00 | -| FluentValidationSingleton | 105.84 ns | 0.246 ns | 0.205 ns | 0.08 | 0.0755 | 0.0001 | 632 B | 0.09 | -| LightPortableResults | 50.49 ns | 0.091 ns | 0.076 ns | 0.04 | 0.0124 | - | 104 B | 0.01 | - -#### Invalid Flat DTO Benchmarks - -All three properties are invalid. - -| Method | Mean | Error | StdDev | Ratio | Gen0 | Gen1 | Allocated | Alloc Ratio | -|---------------------------------- |-----------:|---------:|--------:|------:|-------:|-------:|----------:|------------:| -| FluentValidationScopedOrTransient | 3,145.2 ns | 10.38 ns | 9.71 ns | 1.00 | 1.7509 | 0.0267 | 14672 B | 1.00 | -| FluentValidationSingleton | 1,793.6 ns | 3.93 ns | 3.48 ns | 0.57 | 0.9937 | 0.0095 | 8320 B | 0.57 | -| LightPortableResults | 289.6 ns | 0.57 ns | 0.51 ns | 0.09 | 0.0820 | - | 688 B | 0.05 | - -#### Complex DTO Benchmark Setup - -This is the DTO with one nested object, one nested collection with primitive items (strings), and one nested collection with complex items. - -```csharp -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 Tags { get; set; } - public required List Items { get; set; } -} - -public sealed record ShippingAddressDto -{ - public required string RecipientName { get; set; } = string.Empty; - public required string Street { get; set; } = string.Empty; - public required string PostalCode { get; set; } = string.Empty; - public required string CountryCode { get; set; } = string.Empty; -} - -public sealed record OrderItemDto -{ - public required string Sku { get; set; } = string.Empty; - public required int Quantity { get; set; } - public required decimal UnitPrice { get; set; } -} -``` - -The details can be found [here](https://github.com/feO2x/Light.PortableResults/blob/main/benchmarks/Benchmarks/ComplexDtoValidationBenchmarks.cs). - -#### Valid Complex DTO Benchmarks - -No errors in the DTO object graph. - -| Method | Mean | Error | StdDev | Ratio | Gen0 | Gen1 | Allocated | Alloc Ratio | -|---------------------------------- |-----------:|---------:|---------:|------:|-------:|-------:|----------:|------------:| -| FluentValidationScopedOrTransient | 8,318.7 ns | 78.34 ns | 69.45 ns | 1.00 | 4.1504 | 0.1221 | 33.94 KB | 1.00 | -| FluentValidationSingleton | 1,685.9 ns | 5.01 ns | 4.69 ns | 0.20 | 0.7057 | 0.0019 | 5.77 KB | 0.17 | -| LightPortableResults | 742.2 ns | 7.40 ns | 6.93 ns | 0.09 | 0.1554 | - | 1.27 KB | 0.04 | - -#### Invalid Complex DTO Benchmarks - -Nine errors overall in the object graph. - -| Method | Mean | Error | StdDev | Ratio | Gen0 | Gen1 | Allocated | Alloc Ratio | -|-----------------------------------|----------:|----------:|----------:|------:|-------:|-------:|----------:|------------:| -| FluentValidationScopedOrTransient | 13.985 ΞΌs | 0.0705 ΞΌs | 0.0625 ΞΌs | 1.00 | 6.5308 | 0.3052 | 53.45 KB | 1.00 | -| FluentValidationSingleton | 6.755 ΞΌs | 0.0410 ΞΌs | 0.0343 ΞΌs | 0.48 | 3.1128 | 0.0763 | 25.47 KB | 0.48 | -| LightPortableResults | 1.507 ΞΌs | 0.0019 ΞΌs | 0.0018 ΞΌs | 0.11 | 0.2422 | - | 1.99 KB | 0.04 | - ### Mapping to Domain Objects -Use `Validator` when validation must also produce a *different* output type β€” typically a mutable DTO in, an immutable domain object out. This pattern implements an Anti-Corruption Layer: the domain model is never exposed to raw, unvalidated input. +Use `Validator` 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. ```csharp // Mutable DTO received from the API @@ -921,7 +734,7 @@ public sealed class CreateMovieValidator : Validator public CreateMovieValidator(IValidationContextFactory validationContextFactory) : base(validationContextFactory) { } - // PerformValidation returns ValidatedValue, not ValidatedValue. + // PerformValidation returns ValidatedValue. // The domain object is only constructed when all checks pass. protected override ValidatedValue PerformValidation( ValidationContext context, @@ -949,7 +762,7 @@ public sealed class CreateMovieValidator : Validator } ``` -The caller receives a `Result` β€” the `CreateMovieDto` type never escapes the validator boundary: +The caller receives `Result` β€” `CreateMovieDto` never escapes the validator boundary: ```csharp public async Task> CreateMovieAsync(CreateMovieDto dto) @@ -965,8 +778,6 @@ public async Task> CreateMovieAsync(CreateMovieDto dto) } ``` -> `Validator` (single type parameter) can also normalize field values β€” as shown in the Quick Start where strings are trimmed and written back. The difference with `Validator` is purely at the type level: when the validated output must be a structurally different type, use the two-parameter form. - ### Async Validators Use `AsyncValidator` (or `AsyncValidator`) when any validation step requires an async operation such as a database look-up or an external API call. @@ -991,7 +802,7 @@ public sealed class AddMovieRatingValidator : AsyncValidator CancellationToken cancellationToken ) { - // Synchronous checks run first β€” cheap and allocation-free + // Synchronous checks first β€” cheap and allocation-free context.Check(dto.Id).IsNotEmpty(); context.Check(dto.MovieId).IsNotEmpty(); dto.UserName = context.Check(dto.UserName).IsNotNullOrWhiteSpace(); @@ -1044,49 +855,34 @@ Register async validators that depend on scoped services as scoped themselves: builder.Services .AddValidationForPortableResults() .AddScoped() - .AddScoped(); // scoped: depends on a scoped repository + .AddScoped(); // scoped because it depends on a scoped repository ``` -### Using ValidationContext Directly +### Using `ValidationContext` Directly -You do not need to write a validator class for every case. Inject `IValidationContextFactory` and use `ValidationContext` directly anywhere inline validation is more practical than a dedicated class. +You do not need a validator class for every case. Inject `IValidationContextFactory` and use `ValidationContext` directly for inline validation: ```csharp -public sealed class MovieSearchService +public async Task>> SearchMoviesAsync( + string? query, + int page, + int pageSize, + CancellationToken cancellationToken = default +) { - private readonly IValidationContextFactory _contextFactory; - private readonly IMovieRepository _movieRepository; + var context = _contextFactory.CreateValidationContext(); + var normalizedQuery = context.Check(query).IsNotNullOrWhiteSpace(); + context.Check(page).IsGreaterThanOrEqualTo(1); + context.Check(pageSize).IsInRange(1, 100); - public MovieSearchService( - IValidationContextFactory contextFactory, - IMovieRepository movieRepository - ) + if (context.HasErrors) { - _contextFactory = contextFactory; - _movieRepository = movieRepository; + return Result>.Fail(context.ToFailureResult().Errors); } - public async Task>> 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>.Fail(context.ToFailureResult().Errors); - } - - return Result>.Ok( - await _movieRepository.SearchAsync(normalizedQuery, page, pageSize, cancellationToken) - ); - } + return Result>.Ok( + await _movieRepository.SearchAsync(normalizedQuery, page, pageSize, cancellationToken) + ); } ``` @@ -1094,7 +890,7 @@ public sealed class MovieSearchService ### Sharing State Between Validators -When a child validator needs data that was loaded or computed 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. +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. ```csharp // Define the key once β€” store it as a static field near the validators that use it @@ -1103,97 +899,72 @@ public static class MovieConstants public static readonly ValidationContextKey MovieKey = new("movie"); } -// Parent async validator loads the movie and shares it via the context -public sealed class AddMovieRatingValidator : AsyncValidator +// Parent loads the movie and stores it in the context +protected override async ValueTask> PerformValidationAsync( + ValidationContext context, + ValidationCheckpoint checkpoint, + MovieRatingDto dto, + CancellationToken cancellationToken +) { - private readonly IMovieClient _movieClient; - private readonly MovieQuotaValidator _quotaValidator; - - public AddMovieRatingValidator( - IValidationContextFactory validationContextFactory, - IMovieClient movieClient, - MovieQuotaValidator quotaValidator - ) : base(validationContextFactory) - { - _movieClient = movieClient; - _quotaValidator = quotaValidator; - } + 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); - protected override async ValueTask> PerformValidationAsync( - ValidationContext context, - ValidationCheckpoint checkpoint, - MovieRatingDto dto, - CancellationToken cancellationToken - ) + if (!checkpoint.HasNewErrors) { - 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) { - 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.Check(dto.MovieId).AddError(new Error { - // Store the loaded entity so the child validator can access it - context.SetItem(MovieConstants.MovieKey, movie); - context.Check(dto).ValidateChild(_quotaValidator); - } + 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); } + + return checkpoint.ToValidatedValue(dto); } -// Child validator retrieves the pre-loaded entity without hitting the database again -public sealed class MovieQuotaValidator : Validator +// Child retrieves the pre-loaded entity without touching the database +protected override ValidatedValue PerformValidation( + ValidationContext context, + ValidationCheckpoint checkpoint, + MovieRatingDto dto +) { - public MovieQuotaValidator(IValidationContextFactory validationContextFactory) - : base(validationContextFactory) { } + var movie = context.GetRequiredItem(MovieConstants.MovieKey); - protected override ValidatedValue PerformValidation( - ValidationContext context, - ValidationCheckpoint checkpoint, - MovieRatingDto dto - ) + if (movie.MaxRatingsPerUser > 0 && movie.CurrentRatingCount >= movie.MaxRatingsPerUser) { - var movie = context.GetRequiredItem(MovieConstants.MovieKey); - - if (movie.MaxRatingsPerUser > 0 && movie.CurrentRatingCount >= movie.MaxRatingsPerUser) + context.Check(dto.MovieId).AddError(new Error { - 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); + Message = "Rating quota for this movie has been reached", + Code = "movie.quotaExceeded", + Target = "movieId", + Category = ErrorCategory.Conflict + }); } + + return checkpoint.ToValidatedValue(dto); } ``` -`ValidationContextKey` is a typed key that ensures you cannot accidentally retrieve the wrong type from the context. Use `TryGetItem` instead of `GetRequiredItem` when the item may not have been set. +`ValidationContextKey` 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 -For domain-specific rules that do not map to any built-in assertion, you have two options. - -**Ad-hoc predicate** β€” use `Must` for a one-off check with a custom message: +**Ad-hoc predicate** β€” use `Must` for a one-off check: ```csharp context.Check(dto.ReleaseYear).Must( @@ -1201,22 +972,19 @@ context.Check(dto.ReleaseYear).Must( ); ``` -**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 achieves the same performance as built-in assertions. +**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. ```csharp using Light.PortableResults.Validation; using Light.PortableResults.Validation.Definitions; using Light.PortableResults.Validation.Messaging; -// 1. Define the rule β€” a static singleton so the cache key is stable public sealed class MustBeValidMovieYearDefinition : ValidationErrorDefinition { public static readonly MustBeValidMovieYearDefinition Instance = new(); private MustBeValidMovieYearDefinition() : base(code: "MustBeValidMovieYear") { } - // IsMessageStable = true tells the framework that the message only depends on - // DisplayName and can be cached across calls β€” no dynamic parameters. public override bool IsMessageStable => true; public override bool TryGetStableMessageProvider( @@ -1229,10 +997,9 @@ public sealed class MustBeValidMovieYearDefinition : ValidationErrorDefinition } public override ValidationErrorMessage ProvideMessage(in ValidationErrorMessageContext context) => - new ($"{context.DisplayName} must be a valid movie release year (1888 or later, not in the future)"); + new($"{context.DisplayName} must be a valid movie release year (1888 or later, not in the future)"); } -// 2. Expose it as a fluent extension method on Check public static class MovieValidationExtensions { public static Check MustBeValidMovieYear(this Check check, bool shortCircuitOnError = false) @@ -1258,18 +1025,18 @@ context.Check(dto.ReleaseYear).MustBeValidMovieYear(); ### Configuring Validation Behavior -`ValidationContextOptions` controls how a `ValidationContext` behaves. All properties are `init`-only, so the record is immutable once created. +`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. Change for locale-aware output. | -| `AutomaticNullErrorProvider` | `DefaultAutomaticNullErrorProvider.Instance` | Produces the error when a validator receives a null source value. Replace to customize the null error shape. | -| `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. | +| 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. | -To use custom options in a dependency-injection host, register a customized `IValidationContextFactory` before calling `AddValidationForPortableResults()`: +Register a customized factory before calling `AddValidationForPortableResults()`: ```csharp using System.Globalization; @@ -1284,7 +1051,7 @@ builder.Services.AddSingleton( builder.Services.AddValidationForPortableResults(); ``` -Without a DI host, create the factory directly: +Without a DI host: ```csharp var factory = DefaultValidationContextFactory.Create(new ValidationContextOptions @@ -1296,7 +1063,7 @@ var validator = new CreateMovieValidator(factory); ### Validate `Microsoft.Extensions.Configuration` Options -You can write your custom `Validator` implementations to validate options bound from configuration through `IValidateOptions`. Use the `ValidateWithPortableResults()` extension method to integrate with the standard options validation pipeline. +Use `ValidateWithPortableResults()` to integrate your `Validator` implementations with the standard options validation pipeline: ```csharp public sealed class EmailSenderOptions @@ -1324,20 +1091,18 @@ public sealed class EmailSenderOptionsValidator : Validator } } -IServiceCollection services = new ServiceCollection(); - services .AddOptions() .BindConfiguration("EmailSender") .ValidateWithPortableResults() - .ValidateOnStart(); // Not required, but usually what you want + .ValidateOnStart(); ``` -`ValidateWithPortableResults` integrates with the standard options validation pipeline, supports named options, and forwards the current options name to the `ValidationContext`. Use `ValidationContext.TryGetItem(ConfigurationConstants.OptionsNameKey, out var optionsName);` to access the options name in your validator. +`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 -OpenAPI support lives in the dedicated `Light.PortableResults.AspNetCore.OpenApi` package. It is opt-in and does not change runtime serialization. `LightResult` / `LightActionResult` still serialize through the JSON writers in `Light.PortableResults`; the OpenAPI package only contributes endpoint metadata plus a document transformer. If you use `Light.PortableResults.Validation`, add `Light.PortableResults.Validation.OpenApi` to opt into the library-owned built-in validation error contracts. +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` / `LightActionResult`. ### Registration @@ -1352,94 +1117,45 @@ builder.Services .AddPortableResultsOpenApi(contracts => contracts.RegisterBuiltInValidationErrors()); ``` -Use `AddPortableResultsForMvc()` instead of `AddPortableResultsForMinimalApis()` for MVC applications. OpenAPI support is intentionally separate so applications that never generate OpenAPI documents do not take on the extra dependency. - -### Public surface - -Minimal APIs expose three helpers in `Light.PortableResults.AspNetCore.OpenApi`: - -- `ProducesPortableSuccessResponse(...)` -- `ProducesPortableProblem(...)` -- `ProducesPortableValidationProblem(...)` - -MVC exposes three matching attributes: - -- `[ProducesPortableSuccessResponse]` -- `[ProducesPortableProblem]` -- `[ProducesPortableValidationProblem]` +Use `AddPortableResultsForMvc()` for MVC applications. `RegisterBuiltInValidationErrors()` registers schemas for all built-in validation error codes from `Light.PortableResults.Validation`. -`ProducesPortableSuccessResponse` documents both runtime success shapes: +### Documenting Endpoints -- Under `MetadataSerializationMode.ErrorsOnly`, the documented body is the bare `TValue`. -- Under `MetadataSerializationMode.Always`, the documented body is `{ value, metadata }`. +Minimal APIs expose three helpers: -Use `UseMetadataSerializationMode(...)` on Minimal APIs or the `MetadataSerializationMode = ...` named argument on the MVC attribute when the endpoint’s documented shape differs from the DI default. +- `ProducesPortableSuccessResponse(...)` β€” 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. -`ProducesPortableValidationProblem(...)` automatically selects the rich or ASP.NET Core-compatible validation envelope from `PortableResultsHttpWriteOptions.ValidationProblemSerializationFormat`. Use `UseFormat(...)` on Minimal APIs or `Format = ...` on the MVC attribute for a per-endpoint override. - -PortableResults OpenAPI metadata is authoritative for a given `(status code, content type)` response slot. If another OpenAPI contributor already documented the same slot, the document transformer replaces that media-type schema instead of merging it. Avoid combining `ProducesPortable...` helpers or attributes with ASP.NET Core response-schema helpers for the same response slot unless you want PortableResults to win. - -### Documenting metadata - -Top-level metadata and per-error-code metadata are caller-owned contracts. The OpenAPI package documents them explicitly; the runtime still writes `MetadataObject`. - -`WithErrorCodes(...)`, endpoint-scoped `WithErrorMetadata(code)`, and the typed validation helpers such as `WithInRangeError()` narrow error items exhaustively by default once you document at least one code. The generated item schema becomes a discriminated `oneOf` over the documented variants with `code` required, so you are asserting that every emitted error item has a non-null `code` and that the code is in the documented set. - -If an endpoint can still emit additional codes outside the documented set, opt out explicitly with `AllowUnknownErrorCodes()` on the Minimal API builders or `AllowUnknownErrorCodes = true` on the MVC attributes. In that mode the generated schema falls back to the non-exhaustive `anyOf` shape with the canonical `PortableError` / `PortableValidationErrorDetail` branch preserved for unknown codes. - -`AllowUnknownErrorCodes()` does not relax the `code` requirement on narrowed item schemas. If an endpoint can emit code-less errors, do not narrow that endpoint's error items in the first place; use the canonical envelope schema instead. - -When top-level metadata or documented error items are narrowed, the generated response envelope is a flattened concrete object schema that copies the canonical problem-details properties and overrides only `errors` / `errorDetails` / `metadata`. This improves Swagger UI and code-generator output without changing the runtime wire format. - -For built-in validation errors, reference `Light.PortableResults.Validation.OpenApi` and pass the catalog registration to `AddPortableResultsOpenApi(...)` once: +MVC exposes matching attributes: `[ProducesPortableSuccessResponse]`, `[ProducesPortableProblem]`, and `[ProducesPortableValidationProblem]`. ```csharp -using Light.PortableResults.AspNetCore.OpenApi; -using Light.PortableResults.Validation.OpenApi; - -builder.Services.AddPortableResultsOpenApi( - contracts => contracts.RegisterBuiltInValidationErrors() -); -``` - -Use `ValidationErrorCodes` when opting endpoints into built-in codes. Codes such as `NotEmpty`, `LengthInRange`, and `Count` reuse global schemas from the built-in catalog: - -```csharp -using Light.PortableResults.Validation; -using Light.PortableResults.Validation.OpenApi; - -app.MapPut("/api/movieRatings", AddMovieRating) +app.MapPut("/api/movieRatings", async (MovieRatingDto dto, AddMovieRatingService service) => + { + var result = await service.AddMovieRatingAsync(dto); + return result.ToMinimalApiResult(); + }) + .ProducesPortableSuccessResponse() .ProducesPortableValidationProblem( configure: x => x.UseFormat(ValidationProblemSerializationFormat.Rich) .WithErrorCodes(ValidationErrorCodes.NotEmpty, ValidationErrorCodes.LengthInRange) .WithInRangeError() - ); + ) + .ProducesPortableProblem(); ``` -Use `AllowUnknownErrorCodes()` when the endpoint may emit additional documented-shape errors outside the documented code set, for example when built-in validation codes are documented but a downstream lookup may still add a custom code: +### Narrowing Error Schemas -```csharp -app.MapGet("/api/movies", GetMovies) - .ProducesPortableValidationProblem( - configure: x => - x.UseFormat(ValidationProblemSerializationFormat.Rich) - .WithErrorCodes(ValidationErrorCodes.NotEmpty) - .WithInRangeError() - .AllowUnknownErrorCodes() - ); -``` +`WithErrorCodes(...)`, `WithErrorMetadata(code)`, and typed helpers like `WithInRangeError()` narrow error items exhaustively once you document at least one code. The generated schema becomes a `oneOf` discriminated by `code`. -Comparison and range codes are polymorphic at the global code level, so the validation bridge also ships typed endpoint helpers: `WithEqualToError()`, `WithNotEqualToError()`, `WithGreaterThanError()`, `WithGreaterThanOrEqualToError()`, `WithLessThanError()`, `WithLessThanOrEqualToError()`, `WithInRangeError()`, `WithNotInRangeError()`, and `WithExclusiveRangeError()`. These helpers use the existing inline metadata path, so an endpoint can document concrete metadata such as integer range boundaries for `IsInRange(1, 5)` while still reusing global schemas for shape-fixed codes. +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. -### Validation OpenAPI source generation +### Source Generation -`Light.PortableResults.Validation.OpenApi` also includes an opt-in source generator for validation responses. Mark a synchronous validator with `[GeneratePortableValidationOpenApi]`, make it `partial`, and apply the generated contract from Minimal API endpoints or MVC actions: +Mark a synchronous `Validator` with `[GeneratePortableValidationOpenApi]` and make it `partial` to let the source generator produce an OpenAPI contract automatically from the validator's check calls: ```csharp -using Light.PortableResults.Validation; -using Light.PortableResults.Validation.OpenApi; - [GeneratePortableValidationOpenApi] public sealed partial class AddMovieRatingValidator : Validator { @@ -1450,7 +1166,7 @@ public sealed partial class AddMovieRatingValidator : Validator ) { context.Check(dto.Id).IsNotEmpty(); - context.Check(dto.Comment).HasLengthIn(10, 1000); + dto.Comment = context.Check(dto.Comment).HasLengthIn(10, 1000); context.Check(dto.Rating).IsInRange(1, 5); return checkpoint.ToValidatedValue(dto); } @@ -1460,283 +1176,118 @@ app.MapPut("/api/movieRatings", AddMovieRating) .ProducesPortableValidationProblemFor( configure: x => x.UseFormat(ValidationProblemSerializationFormat.Rich) ); - -[ApiController] -[Route("api/movieRatings")] -public sealed class MovieRatingsController : ControllerBase -{ - [HttpPut] - [ProducesPortableValidationProblemFor( - Format = ValidationProblemSerializationFormat.Rich - )] - public IActionResult Put(MovieRatingDto dto) - { - // ... - } -} - -public sealed class MovieRatingsEndpointOpenApiContract : IPortableValidationOpenApiContract -{ - public static void ConfigurePortableValidationOpenApi(PortableValidationProblemOpenApiBuilder builder) - { - builder - .UseFormat(ValidationProblemSerializationFormat.Rich) - .WithErrorCodes(ValidationErrorCodes.NotNull) - .WithErrorMetadata("MovieAlreadyRated") - .WithErrorExample( - "MovieAlreadyRated", - "movieId", - "movie has already been rated" - ) - .AllowUnknownErrorCodes(); - } -} - -[ApiController] -[Route("api/movieRatings")] -public sealed class MovieRatingsCustomizedController : ControllerBase -{ - [HttpPut] - [ProducesPortableValidationProblemFor< - AddMovieRatingValidator, - MovieRatingsEndpointOpenApiContract - >( - StatusCodes.Status422UnprocessableEntity - )] - public IActionResult Put(MovieRatingDto dto) - { - // ... - } -} ``` -The generated contract calls the same builder APIs you would write by hand. For Minimal APIs, the endpoint's `configure` callback runs afterward, so you can still set the validation format, add manual metadata contracts, or call `AllowUnknownErrorCodes()` for errors that are outside the validator's documentable rules. For MVC, the existing one-generic-parameter attribute remains the simple path when you only need generated metadata plus scalar named-property overrides such as `Format`, `TopLevelMetadataType`, or `AllowUnknownErrorCodes`. When an MVC action needs additive endpoint-local customization, use `ProducesPortableValidationProblemFor` and put the extra builder calls into a small `IPortableValidationOpenApiContract` implementation. The validator contract runs first and the endpoint contract runs second, so generated `ErrorCodes`, inline metadata contracts, typed helper contracts, and examples are preserved while endpoint-local additions are appended. - -The generator analyzes top-level `context.Check(...).Rule(...)` chains in synchronous `Validator` and `Validator` implementations. It supports built-in annotated rules, assignments that consume a checked value, explicit error hints via `[PortableValidationOpenApiErrorHint]`, and user-defined check methods annotated with `[ValidationRule]` plus optional `[ValidationErrorContract]` metadata definitions. Checks inside `if`, `switch`, loops, lambdas, local functions, `try`, or `using` blocks are skipped with a warning; lift the check to a top-level statement or add explicit hints when those errors must appear in the OpenAPI schema. - -When metadata arguments are compile-time constants, such as `HasLengthIn(10, 1000)` or `IsInRange(1, 5)`, the generated contract also contributes a response-level OpenAPI example. Scalar and Swagger UI show these concrete values in the validation problem example body, including representative default messages such as `"comment must be between 10 and 1000 characters long"` and `"rating must be between 1 and 5"`. These messages are documentation examples based on the framework defaults; runtime responses can differ when applications configure validation templates, display names, target normalization, culture, or error overrides. Non-constant metadata arguments still get documented schemas and can still appear as code/target examples, but the generated message and concrete metadata values are omitted for that call site so the OpenAPI transformer uses the generic fallback message. Delegate-based `Must(...)`, `Custom(...)`, `ErrorOverrides`, async validators, source-null errors emitted before `PerformValidation`, child validators, and complex target inference are intentionally outside the first-generation analysis. +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 explicit hints when the validator emits known validation error contracts that the generator cannot infer. For an opaque `Custom(...)` path that only needs a code in the schema, add a code-only hint at the validator or `PerformValidation` method level: +Use `[PortableValidationOpenApiErrorHint]` to annotate codes the generator cannot infer (for example, from `Must(...)`, `Custom(...)`, or child validators): ```csharp [GeneratePortableValidationOpenApi] [PortableValidationOpenApiErrorHint("MovieAlreadyRated")] -public sealed partial class AddMovieRatingValidator : Validator -{ - // ... -} +public sealed partial class AddMovieRatingValidator : Validator { ... } ``` -If the opaque path has endpoint-specific metadata, either point to a metadata type or declare the metadata schema inline: +### Reusable Error Code Contracts -```csharp -[PortableValidationOpenApiErrorHint("MovieAlreadyRated", typeof(MovieAlreadyRatedMetadata))] - -[PortableValidationOpenApiErrorHint("RatingTooLow")] -[PortableValidationOpenApiErrorMetadataProperty("RatingTooLow", "lowerBoundary", typeof(int))] -[PortableValidationOpenApiErrorMetadataProperty("RatingTooLow", "upperBoundary", typeof(int))] -``` - -Hints compose with inferred rules. Matching schema shapes are deduplicated, while conflicting metadata shapes for the same code are reported by the generator because the emitted OpenAPI contract would otherwise be ambiguous. Hinting a code never makes the generated response non-exhaustive and never calls `AllowUnknownErrorCodes()` by itself. - -You can also provide response-example entries for opaque paths. An example-only hint documents the code as code-only, so the common case does not need a separate `[PortableValidationOpenApiErrorHint]`. Use `Message` when the opaque path needs exact example text; otherwise the OpenAPI transformer writes `"Validation failed."`. Metadata values are compile-time constants declared with companion attributes: +Register per-error-code metadata contracts once in DI, then opt specific endpoints into them: ```csharp -[PortableValidationOpenApiExampleHint( - "RatingTooLow", - Target = "rating", - Message = "rating must be at least 1" -)] -[PortableValidationOpenApiExampleMetadata("RatingTooLow", "lowerBoundary", 1)] -[PortableValidationOpenApiExampleMetadata("RatingTooLow", "upperBoundary", 5)] -``` - -Use `AllowUnknownErrorCodes()` only when the endpoint may emit additional codes that are not enumerable at build time. Explicit hints and `AllowUnknownErrorCodes()` compose: hints document the known contract, and the unknown-code opt-in keeps the generated schema non-exhaustive for the rest. Endpoint-level customization that is not validator-local, such as changing the validation problem format, documenting multiple example targets for the same code, or adding contracts decided outside the validator, still belongs in the endpoint `configure` callback. - -Register reusable per-error-code metadata contracts once in DI by passing them to `AddPortableResultsOpenApi(...)`: - -```csharp -using Light.PortableResults.AspNetCore.OpenApi; - builder.Services.AddPortableResultsOpenApi(contracts => { contracts.ForCode("VersionMismatch"); contracts.ForCode("InsufficientFunds"); }); -``` - -User-defined codes continue to use the type-based overloads above, or endpoint-scoped `WithErrorMetadata(code)` when a contract only applies to one operation. - -Then opt the relevant codes into each endpoint: -```csharp -using Light.PortableResults; -using Light.PortableResults.AspNetCore.MinimalApis; -using Light.PortableResults.AspNetCore.OpenApi; -using Light.PortableResults.Http.Writing; - -app.MapPut("/api/movieRatings", async (MovieRatingDto dto, AddMovieRatingService service) => - { - var result = await service.AddMovieRatingAsync(dto); - return result.ToMinimalApiResult(); - }) - .ProducesPortableSuccessResponse( - configure: x => - x.WithMetadata() - .UseMetadataSerializationMode(MetadataSerializationMode.Always) - ) +app.MapPut("/api/movieRatings", handler) .ProducesPortableValidationProblem( - configure: x => - x.UseFormat(ValidationProblemSerializationFormat.Rich) - .WithErrorCodes("VersionMismatch") - ) - .ProducesPortableProblem( - statusCode: StatusCodes.Status404NotFound, - configure: x => - x.WithMetadata() - .WithErrorMetadata("MovieNotFound") - ) - .ProducesPortableProblem(); -``` - -The MVC equivalent uses named attribute arguments: - -```csharp -using Light.PortableResults; -using Light.PortableResults.AspNetCore.Mvc; -using Light.PortableResults.AspNetCore.OpenApi; -using Light.PortableResults.Http.Writing; -using Microsoft.AspNetCore.Mvc; - -[ApiController] -[Route("api/movieRatings")] -public sealed class AddMovieRatingsController(AddMovieRatingService service) : ControllerBase -{ - [HttpPut] - [ProducesPortableSuccessResponse( - TopLevelMetadataType = typeof(MovieRatingResponseMetadata), - MetadataSerializationMode = MetadataSerializationMode.Always - )] - [ProducesPortableValidationProblem( - Format = ValidationProblemSerializationFormat.Rich, - ErrorCodes = new[] { "VersionMismatch" } - )] - [ProducesPortableProblem( - statusCode: StatusCodes.Status404NotFound, - TopLevelMetadataType = typeof(MovieProblemMetadata), - InlineErrorMetadataCodes = new[] { "MovieNotFound" }, - InlineErrorMetadataContracts = new[] { ErrorMetadataContract.FromType(typeof(MovieNotFoundMetadata)) } - )] - [ProducesPortableProblem] - public async Task> AddMovieRating(AddMovieRatingDto dto) - { - var result = await service.AddMovieRatingAsync(dto); - return result.ToMvcActionResult(); - } -} + configure: x => x.WithErrorCodes("VersionMismatch") + ); ``` -## βš™οΈ Configuration for HTTP and CloudEvents +## βš™οΈ Configuration Reference ### HTTP write options (`PortableResultsHttpWriteOptions`) | Option | Default | Description | |----------------------------------------|------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `ValidationProblemSerializationFormat` | `AspNetCoreCompatible` | Controls how validation errors are serialized for HTTP 400/422 responses. Defaults to `AspNetCoreCompatible` for backwards-compatibility, we encourage you to use `Rich`. | +| `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 for failures. If `false`, Light.PortableResults checks if all errors have the same category and chooses `Unclassified` when they differ. | - -### HTTP read options (`PortableResultsHttpReadOptions`) - -| Option | Default | Description | -| --- | --- | --- | -| `HeaderParsingService` | `ParseNoHttpHeadersService.Instance` | Controls how HTTP headers are converted into metadata (default: skip all headers). | -| `MergeStrategy` | `AddOrReplace` | Strategy used 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. | - -### CloudEvents write options (`PortableResultsCloudEventsWriteOptions`) - -| Option | Default | Description | -| --- | --- | --- | -| `Source` | `null` | Default CloudEvents `source` URI reference if not set per call. | -| `MetadataSerializationMode` | `Always` | Controls whether metadata is serialized into CloudEvents `data`. | -| `SerializerOptions` | `Module.DefaultSerializerOptions` | System.Text.JSON serializer options used for deserialization. | -| `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 CloudEvents `time` value (`UTC now` is used when omitted). | -| `IdResolver` | `null` | Optional function used to generate CloudEvents `id` values. | -| `ArrayPool` | `ArrayPool.Shared` | Buffer pool used for CloudEvents serialization. | -| `PooledArrayInitialCapacity` | `RentedArrayBufferWriter.DefaultInitialCapacity` | Initial buffer size used for pooled serialization, which is 2048 bytes. | - -### CloudEvents read options (`PortableResultsCloudEventsReadOptions`) - -| Option | Default | Description | -| --- | --- | --- | -| `SerializerOptions` | `Module.DefaultSerializerOptions` | System.Text.JSON serializer options used for deserialization. | -| `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 used when merging envelope extension attributes and payload metadata. | - -### Configure HTTP behavior +| `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. | ```csharp -using Light.PortableResults.Http.Writing; -using Light.PortableResults.SharedJsonSerialization; - builder.Services.Configure(options => { - options.ValidationProblemSerializationFormat = ValidationProblemSerializationFormat.Rich; - options.MetadataSerializationMode = MetadataSerializationMode.Always; - options.FirstErrorCategoryIsLeadingCategory = false; + options.ValidationProblemSerializationFormat = ValidationProblemSerializationFormat.Rich; + options.MetadataSerializationMode = MetadataSerializationMode.Always; + options.FirstErrorCategoryIsLeadingCategory = false; }); ``` -```csharp -using Light.PortableResults.Http.Reading; -using Light.PortableResults.Http.Reading.Headers; -using Light.PortableResults.Http.Reading.Json; +### 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. | +```csharp var readOptions = new PortableResultsHttpReadOptions { - HeaderParsingService = new DefaultHttpHeaderParsingService(new AllHeadersSelectionStrategy()), - PreferSuccessPayload = PreferSuccessPayload.Auto, - TreatProblemDetailsAsFailure = true + HeaderParsingService = new DefaultHttpHeaderParsingService(new AllHeadersSelectionStrategy()), + PreferSuccessPayload = PreferSuccessPayload.Auto, + TreatProblemDetailsAsFailure = true }; Result result = await response.ReadResultAsync(readOptions); ``` -### Configure CloudEvents behavior +### CloudEvents write options (`PortableResultsCloudEventsWriteOptions`) -```csharp -using Light.PortableResults.CloudEvents.Writing; -using Light.PortableResults.SharedJsonSerialization; +| 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.Shared` | Buffer pool used for serialization. | +| `PooledArrayInitialCapacity` | `RentedArrayBufferWriter.DefaultInitialCapacity` (2048 B) | Initial buffer size for pooled serialization. | +```csharp builder.Services.Configure(options => { - options.Source = "urn:light-portable-results:sample:user-service"; - options.SuccessType = "users.updated"; - options.FailureType = "users.update.failed"; - options.MetadataSerializationMode = MetadataSerializationMode.Always; + options.Source = "urn:light-portable-results:sample:user-service"; + options.SuccessType = "users.updated"; + options.FailureType = "users.update.failed"; + options.MetadataSerializationMode = MetadataSerializationMode.Always; }); ``` -```csharp -using System; -using Light.PortableResults.CloudEvents.Reading; -using Light.PortableResults.Http.Reading.Json; +### 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. | +```csharp var cloudReadOptions = new PortableResultsCloudEventsReadOptions { - IsFailureType = eventType => eventType.EndsWith(".failed", StringComparison.Ordinal), - PreferSuccessPayload = PreferSuccessPayload.Auto + IsFailureType = eventType => eventType.EndsWith(".failed", StringComparison.Ordinal), + PreferSuccessPayload = PreferSuccessPayload.Auto }; Result result = messageBody.ReadResult(cloudReadOptions); @@ -1744,38 +1295,38 @@ Result result = messageBody.ReadResult(cloudReadOptions); ### Supported Error Categories -| `ErrorCategory` | HTTP Status Code | -| --- | --- | -| `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` | +| `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 | From 7870b2aa33ad2ef474b4b68141292963eb084d0a Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sat, 23 May 2026 16:41:48 +0200 Subject: [PATCH 3/9] docs: add table of contents to README.md Signed-off-by: Kenny Pflug --- README.md | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index f839b95..9837fb5 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,22 @@ Most Result Pattern libraries stop at the application boundary. Light.PortableResults does not: a `Result` can be written as an RFC-9457 Problem Details response, published as a CloudEvents JSON message, read back on the other side, and arrive as a fully-typed `Result` β€” 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. +## Contents + +- [Key Features](#-key-features) +- [Installation](#-installation) +- [When to Use Result vs. Exceptions](#-when-to-use-result-vs-exceptions) +- [Basic Usage](#-basic-usage) +- [Functional Operators](#-functional-operators) +- [Metadata](#-metadata) +- [Validation Quick Start](#-validation-quick-start) +- [Performance](#-performance) +- [HTTP Quick Start](#-http-quick-start) +- [CloudEvents Quick Start](#-cloudevents-quick-start) +- [Validation In Depth](#-validation-in-depth) +- [OpenAPI Support](#-openapi-support) +- [Configuration Reference](#-configuration-reference) + ## ✨ Key Features - **Clear Result Pattern** β€” `Result` / `Result` is either a success value or one or more structured errors. No exceptions for expected failures. @@ -62,7 +78,7 @@ 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 +## ↔️ When to Use Result vs. Exceptions Use `Result` / `Result` for **expected business outcomes**: @@ -81,12 +97,6 @@ This keeps exceptions exceptional and business outcomes explicit. ## πŸ€“ Basic Usage -If you are new to the Result Pattern, think of it like this: - -- A method can either succeed or fail. -- Instead of throwing exceptions for expected failures, the method returns a value that explicitly describes the outcome. -- Callers must handle both paths on purpose, which makes control flow easier to read and test. - ```csharp using Light.PortableResults; @@ -425,7 +435,7 @@ public sealed class AddMovieRatingsController : ControllerBase public AddMovieRatingsController(AddMovieRatingService service) => _service = service; [HttpPut] - public async Task> AddMovieRating(AddMovieRatingDto dto) + public async Task> AddMovieRating(MovieRatingDto dto) { var result = await _service.AddMovieRatingAsync(dto); return result.ToMvcActionResult(); From d7d3a13dd7f9595629c773780cee15da3186ae63 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sat, 23 May 2026 18:25:35 +0200 Subject: [PATCH 4/9] chore: fix metada link in README ToC Signed-off-by: Kenny Pflug --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9837fb5..bf924ec 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Most Result Pattern libraries stop at the application boundary. Light.PortableRe - [When to Use Result vs. Exceptions](#-when-to-use-result-vs-exceptions) - [Basic Usage](#-basic-usage) - [Functional Operators](#-functional-operators) -- [Metadata](#-metadata) +- [Metadata](#metadata) - [Validation Quick Start](#-validation-quick-start) - [Performance](#-performance) - [HTTP Quick Start](#-http-quick-start) @@ -208,6 +208,8 @@ Result message = GetUser(userId) ); ``` + + ## ℹ️ Metadata Metadata is not a `Dictionary`. Instead it uses a dedicated JSON-like type system so every result serializes and deserializes correctly across any protocol β€” HTTP, CloudEvents, or otherwise. From fdfe8baece681b410325026c9569cf3867d49522 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sat, 23 May 2026 18:30:40 +0200 Subject: [PATCH 5/9] chore: fix further links in README.md --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index bf924ec..de2b819 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,8 @@ if (result.Metadata?.TryGetString("requestId", out var requestId) == true) } ``` + + ## πŸ›‘οΈ Validation Quick Start Instead of constructing `Error` instances manually, reference `Light.PortableResults.Validation` and write a typed validator: @@ -530,6 +532,8 @@ else } ``` + + ## ☁️ CloudEvents Quick Start Light.PortableResults can serialize a `Result` as a CloudEvents Spec 1.0 JSON payload and deserialize it on any consumer. The key API calls are `result.ToCloudEvent(...)` and `ReadResult()`. @@ -1217,6 +1221,8 @@ app.MapPut("/api/movieRatings", handler) ); ``` + + ## βš™οΈ Configuration Reference ### HTTP write options (`PortableResultsHttpWriteOptions`) From d49c9ab4a9bb55c2e159ec10c99e2619c93a68fd Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sat, 23 May 2026 18:35:45 +0200 Subject: [PATCH 6/9] chore: unify approach to linking in README ToC Signed-off-by: Kenny Pflug --- README.md | 44 +++++++++++++++++++++++++++++++------------- 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index de2b819..243dd46 100644 --- a/README.md +++ b/README.md @@ -10,19 +10,21 @@ Most Result Pattern libraries stop at the application boundary. Light.PortableRe ## Contents -- [Key Features](#-key-features) -- [Installation](#-installation) -- [When to Use Result vs. Exceptions](#-when-to-use-result-vs-exceptions) -- [Basic Usage](#-basic-usage) -- [Functional Operators](#-functional-operators) +- [Key Features](#key-features) +- [Installation](#installation) +- [When to Use Result vs. Exceptions](#when-to-use-result-vs-exceptions) +- [Basic Usage](#basic-usage) +- [Functional Operators](#functional-operators) - [Metadata](#metadata) -- [Validation Quick Start](#-validation-quick-start) -- [Performance](#-performance) -- [HTTP Quick Start](#-http-quick-start) -- [CloudEvents Quick Start](#-cloudevents-quick-start) -- [Validation In Depth](#-validation-in-depth) -- [OpenAPI Support](#-openapi-support) -- [Configuration Reference](#-configuration-reference) +- [Validation Quick Start](#validation-quick-start) +- [Performance](#performance) +- [HTTP Quick Start](#http-quick-start) +- [CloudEvents Quick Start](#cloudevents-quick-start) +- [Validation In Depth](#validation-in-depth) +- [OpenAPI Support](#openapi-support) +- [Configuration Reference](#configuration-reference) + + ## ✨ Key Features @@ -36,6 +38,8 @@ Most Result Pattern libraries stop at the application boundary. Light.PortableRe - **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. @@ -78,6 +82,8 @@ 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` for **expected business outcomes**: @@ -95,6 +101,8 @@ Use **exceptions** for truly unexpected failures: This keeps exceptions exceptional and business outcomes explicit. + + ## πŸ€“ Basic Usage ```csharp @@ -178,6 +186,8 @@ Consistent error shapes make APIs and message consumers easier to evolve. As a r `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 | @@ -332,7 +342,9 @@ services .AddSingleton(); ``` -See [Validation In Depth](#-validation-in-depth) for composing validators, async validation, domain object mapping, sharing state between validators, custom assertions, and configuration options. +See [Validation In Depth](#validation-in-depth) for composing validators, async validation, domain object mapping, sharing state between validators, custom assertions, and configuration options. + + ## ⚑ Performance @@ -380,6 +392,8 @@ Apple M3 Max, 1 CPU, 16 logical and 16 physical cores See the `benchmarks/Benchmarks` project for the full benchmark source. + + ## πŸš€ HTTP Quick Start Given the classes from the Validation Quick Start above, integrate with ASP.NET Core in a few lines. @@ -598,6 +612,8 @@ consumer.ReceivedAsync += async (_, eventArgs) => }; ``` + + ## πŸ”¬ Validation In Depth ### Composing Validators @@ -1116,6 +1132,8 @@ services `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` / `LightActionResult`. From 4866cf93727d617ca0beebdf98ae1d9f1315cdc3 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sat, 23 May 2026 18:42:44 +0200 Subject: [PATCH 7/9] docs: slightly rephrase first paragraph in HTTP Quick Start Signed-off-by: Kenny Pflug --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 243dd46..76016cf 100644 --- a/README.md +++ b/README.md @@ -396,7 +396,7 @@ See the `benchmarks/Benchmarks` project for the full benchmark source. ## πŸš€ HTTP Quick Start -Given the classes from the Validation Quick Start above, integrate with ASP.NET Core in a few lines. +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 @@ -410,7 +410,7 @@ builder.Services .AddPortableResultsForMinimalApis() .AddValidationForPortableResults() .Configure( - // Rich format is recommended β€” it serializes errors with full Code/Target/Category/Metadata. + // Rich format is recommended β€” it serializes errors Code/Target/Category/Metadata in one object. x => x.ValidationProblemSerializationFormat = ValidationProblemSerializationFormat.Rich ) .AddSingleton() From b87e5ec363b80a148a0534cc32908ffa2933e6cb Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sat, 23 May 2026 18:54:26 +0200 Subject: [PATCH 8/9] docs: add quote about OpenAPI source generation in HTTP Quick Start Signed-off-by: Kenny Pflug --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 76016cf..03c9f80 100644 --- a/README.md +++ b/README.md @@ -427,6 +427,8 @@ app.MapPut("/api/movieRatings", async (MovieRatingDto dto, AddMovieRatingService app.Run(); ``` +> To auto-generate accurate OpenAPI schemas and examples from your validators, add `Light.PortableResults.AspNetCore.OpenApi`, annotate your validator with `[GeneratePortableValidationOpenApi]`, and replace `.ProducesPortableValidationProblem(...)` with `.ProducesPortableValidationProblemFor(...)` on the endpoint. See [OpenAPI Support](#openapi-support). + ### MVC ```csharp @@ -461,6 +463,8 @@ public sealed class AddMovieRatingsController : ControllerBase } ``` +> To auto-generate accurate OpenAPI schemas and examples from your validators, add `Light.PortableResults.AspNetCore.OpenApi`, annotate your validator with `[GeneratePortableValidationOpenApi]`, and replace `[ProducesPortableValidationProblem]` with `[ProducesPortableValidationProblemFor]` on the action. See [OpenAPI Support](#openapi-support). + ### HTTP Responses on the Wire Successful update (`200 OK`): From 8f4e30a096e7a1d82fbc11e8eff43bb999bca54d Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sat, 23 May 2026 19:15:28 +0200 Subject: [PATCH 9/9] docs: final polishing for v0.6.0 README Signed-off-by: Kenny Pflug --- README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 03c9f80..e5b5dbb 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # Light.PortableResults -*The Result Pattern for .NET that travels. Every `Result` serializes reliably over HTTP (RFC-9457), CloudEvents, and back β€” with a validation framework that is at least 5x faster and uses less than 9% of the memory of FluentValidation.* +*The Result Pattern for .NET that travels. Every `Result` 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](https://img.shields.io/badge/License-MIT-green.svg?style=for-the-badge)](https://github.com/feO2x/Light.PortableResults/blob/main/LICENSE) [![NuGet](https://img.shields.io/badge/NuGet-0.6.0-blue.svg?style=for-the-badge)](https://www.nuget.org/packages?q=Light.PortableResults) [![Documentation](https://img.shields.io/badge/Docs-Changelog-yellowgreen.svg?style=for-the-badge)](https://github.com/feO2x/Light.PortableResults/releases) -Most Result Pattern libraries stop at the application boundary. Light.PortableResults does not: a `Result` can be written as an RFC-9457 Problem Details response, published as a CloudEvents JSON message, read back on the other side, and arrive as a fully-typed `Result` β€” 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. +Most Result Pattern libraries stop at the application boundary. Light.PortableResults does not: a `Result` 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` β€” 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 @@ -17,7 +17,7 @@ Most Result Pattern libraries stop at the application boundary. Light.PortableRe - [Functional Operators](#functional-operators) - [Metadata](#metadata) - [Validation Quick Start](#validation-quick-start) -- [Performance](#performance) +- [Validation Performance](#validation-performance) - [HTTP Quick Start](#http-quick-start) - [CloudEvents Quick Start](#cloudevents-quick-start) - [Validation In Depth](#validation-in-depth) @@ -34,7 +34,7 @@ Most Result Pattern libraries stop at the application boundary. Light.PortableRe - **Full functional operator suite** β€” `Map`, `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` directly to `IResult` / `IActionResult` with automatic HTTP status mapping. -- **High-performance validation** β€” at least 5x faster than FluentValidation 12.1.1 and less than 9% of its memory footprint. Compose validators, map DTOs to domain objects, and share state β€” all with full async support. +- **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. @@ -344,9 +344,9 @@ services See [Validation In Depth](#validation-in-depth) for composing validators, async validation, domain object mapping, sharing state between validators, custom assertions, and configuration options. - + -## ⚑ Performance +## ⚑ Validation Performance Light.PortableResults Validation is significantly faster and leaner than FluentValidation. All benchmarks ran on: @@ -427,7 +427,7 @@ app.MapPut("/api/movieRatings", async (MovieRatingDto dto, AddMovieRatingService app.Run(); ``` -> To auto-generate accurate OpenAPI schemas and examples from your validators, add `Light.PortableResults.AspNetCore.OpenApi`, annotate your validator with `[GeneratePortableValidationOpenApi]`, and replace `.ProducesPortableValidationProblem(...)` with `.ProducesPortableValidationProblemFor(...)` on the endpoint. See [OpenAPI Support](#openapi-support). +> 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(...)` on the endpoint. See [OpenAPI Support](#openapi-support). ### MVC @@ -463,7 +463,7 @@ public sealed class AddMovieRatingsController : ControllerBase } ``` -> To auto-generate accurate OpenAPI schemas and examples from your validators, add `Light.PortableResults.AspNetCore.OpenApi`, annotate your validator with `[GeneratePortableValidationOpenApi]`, and replace `[ProducesPortableValidationProblem]` with `[ProducesPortableValidationProblemFor]` on the action. See [OpenAPI Support](#openapi-support). +> 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]` on the action. See [OpenAPI Support](#openapi-support). ### HTTP Responses on the Wire