Skip to content

Latest commit

History

709 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

Light.PortableResults

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

LicenseNuGetDocumentation

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

Contents

✨ Key Features

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

📦 Installation

Install the packages you need for your scenario.

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

dotnet add package Light.PortableResults

Validation context, checks, and synchronous/asynchronous validators:

dotnet add package Light.PortableResults.Validation

ASP.NET Core Minimal APIs integration:

dotnet add package Light.PortableResults.AspNetCore.MinimalApis

ASP.NET Core MVC integration:

dotnet add package Light.PortableResults.AspNetCore.Mvc

OpenAPI integration:

dotnet add package Light.PortableResults.AspNetCore.OpenApi

Built-in validation error contracts for OpenAPI:

dotnet add package Light.PortableResults.Validation.OpenApi

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

↔️ When to Use Result vs. Exceptions

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

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

Use exceptions for truly unexpected failures:

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

This keeps exceptions exceptional and business outcomes explicit.

🤓 Basic Usage

usingLight.PortableResults;staticResult<int>ParsePositiveInteger(stringinput){if(int.TryParse(input,outvarvalue)&&value>0){returnResult<int>.Ok(value);}returnResult<int>.Fail(newError{Message="Value must be a positive integer",Code="parse.invalid_positive_int",Target="input",Category=ErrorCategory.Validation});}

Examine the result with an if-else...

varinput=Console.ReadLine();Result<int>result=ParsePositiveInteger(input);if(result.IsValid){Console.WriteLine($"Success: {result.Value}");}else{varerror=result.Errors.First;Console.WriteLine($"Error {error.Code}: {error.Message}");}

...or in a functional style:

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

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

staticResultDeleteUser(Guidid){if(id==Guid.Empty){returnResult.Fail(newError{Message="User id must not be empty",Code="user.invalid_id",Target="id",Category=ErrorCategory.Validation});}returnResult.Ok();}

Designing useful error payloads

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

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

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

🔁 Functional Operators

CategoryOperatorsWhat they are used for
Transform success valueMap, BindConvert successful values or chain operations that already return Result<T>.
Transform errorsMapErrorNormalize or translate errors (for example domain → transport layer).
Add validation rulesEnsure, FailIfKeep fluent pipelines while adding business or guard conditions.
Handle outcomesMatch, MatchFirst, ElseTurn a result into a value or fallback without manually branching every time.
Side effectsTap, TapError, Switch, SwitchFirstPerform logging, metrics, or notifications on success or failure paths.

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

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

ℹ️ Metadata

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

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

usingLight.PortableResults;usingLight.PortableResults.Metadata;// MetadataObject uses implicit conversions from bool, long, double, string, decimal,// nested objects, and arrays.varmetadata=MetadataObject.Create(("requestId","550e8400-e29b-41d4-a716-446655440000"),("timestamp",DateTimeOffset.UtcNow.ToUnixTimeSeconds()),("cacheHit",false),("attemptCount",3));Result<Order>result=Result<Order>.Ok(newOrder{Id=Guid.NewGuid(),Total=99.99m},metadata);// Attach metadata to an error for additional contextvarerror=newError{Message="Order exceeds account limit",Code="order.limit_exceeded",Target="total",Category=ErrorCategory.Validation,Metadata=MetadataObject.Create(("accountLimit",500.00m),("requestedAmount",599.99m),("currency","USD"))};// Read metadata from a resultif(result.Metadata?.TryGetString("requestId",outvarrequestId)==true){Console.WriteLine($"Request: {requestId}");}

🛡️ Validation Quick Start

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

usingLight.PortableResults.Validation;publicsealedrecordMovieRatingDto{publicrequiredGuidId{get;init;}publicrequiredGuidMovieId{get;init;}publicrequiredstringUserName{get;set;}=string.Empty;publicrequiredstringComment{get;set;}=string.Empty;publicrequiredintRating{get;init;}}publicsealedclassMovieRatingValidator:Validator<MovieRatingDto>{publicMovieRatingValidator(IValidationContextFactoryvalidationContextFactory):base(validationContextFactory){}protectedoverrideValidatedValue<MovieRatingDto>PerformValidation(ValidationContextcontext,ValidationCheckpointcheckpoint,MovieRatingDtodto){context.Check(dto.Id).IsNotEmpty();context.Check(dto.MovieId).IsNotEmpty();// Check() normalizes strings by default (null → "", non-null → trimmed).// Assign the return value back to persist the normalized string.dto.Comment=context.Check(dto.Comment).HasLengthIn(10,1000);dto.UserName=context.Check(dto.UserName).IsNotNullOrWhiteSpace();context.Check(dto.Rating).IsInRange(1,5);returncheckpoint.ToValidatedValue(dto);}}

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

publicsealedclassAddMovieRatingService{privatereadonlyMovieRatingValidator_validator;publicAddMovieRatingService(MovieRatingValidatorvalidator)=>_validator=validator;publicasyncTask<Result<MovieRating>>AddMovieRatingAsync(MovieRatingDtodto,CancellationTokencancellationToken=default){if(_validator.CheckForErrors(dto,outvarerrorResult)){returnResult<MovieRating>.Fail(errorResult.Errors);}varmovieRating=newMovieRating(...);returnResult<MovieRating>.Ok(movieRating);}}

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

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

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

⚡ Validation Performance

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

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

Flat DTO — valid (no errors)

MethodMeanRatioAllocatedAlloc Ratio
FluentValidationScopedOrTransient1,324.57 ns1.006984 B1.00
FluentValidationSingleton105.84 ns0.08632 B0.09
LightPortableResults50.49 ns0.04104 B0.01

Flat DTO — invalid (all three properties fail)

MethodMeanRatioAllocatedAlloc Ratio
FluentValidationScopedOrTransient3,145.2 ns1.0014672 B1.00
FluentValidationSingleton1,793.6 ns0.578320 B0.57
LightPortableResults289.6 ns0.09688 B0.05

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

MethodMeanRatioAllocatedAlloc Ratio
FluentValidationScopedOrTransient8,318.7 ns1.0033.94 KB1.00
FluentValidationSingleton1,685.9 ns0.205.77 KB0.17
LightPortableResults742.2 ns0.091.27 KB0.04

Complex DTO — invalid (nine errors overall)

MethodMeanRatioAllocatedAlloc Ratio
FluentValidationScopedOrTransient13.985 μs1.0053.45 KB1.00
FluentValidationSingleton6.755 μs0.4825.47 KB0.48
LightPortableResults1.507 μs0.111.99 KB0.04

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

🚀 HTTP Quick Start

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

Minimal APIs

usingLight.PortableResults;usingLight.PortableResults.AspNetCore.MinimalApis;usingLight.PortableResults.Http.Writing;varbuilder=WebApplication.CreateBuilder(args);builder.Services.AddPortableResultsForMinimalApis().AddValidationForPortableResults().Configure<PortableResultsHttpWriteOptions>(// Rich format is recommended — it serializes errors Code/Target/Category/Metadata in one object.
x =>x.ValidationProblemSerializationFormat=ValidationProblemSerializationFormat.Rich).AddSingleton<MovieRatingValidator>().AddScoped<AddMovieRatingService>();varapp=builder.Build();app.MapPut("/api/movieRatings",async(MovieRatingDtodto,AddMovieRatingServiceservice)=>{varresult=awaitservice.AddMovieRatingAsync(dto);returnresult.ToMinimalApiResult();});app.Run();

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

MVC

usingLight.PortableResults;usingLight.PortableResults.AspNetCore.Mvc;builder.Services.AddControllers();builder.Services.AddPortableResultsForMvc().AddValidationForPortableResults().AddSingleton<MovieRatingValidator>().AddScoped<AddMovieRatingService>();varapp=builder.Build();app.MapControllers();app.Run();[ApiController][Route("api/movieRatings")]publicsealedclassAddMovieRatingsController:ControllerBase{privatereadonlyAddMovieRatingService_service;publicAddMovieRatingsController(AddMovieRatingServiceservice)=>_service=service;[HttpPut]publicasyncTask<LightActionResult<MovieRating>>AddMovieRating(MovieRatingDtodto){varresult=await_service.AddMovieRatingAsync(dto);returnresult.ToMvcActionResult();}}

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

HTTP Responses on the Wire

Successful update (200 OK):

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

Validation failure (400 Bad Request):

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

Deserializing Result<T> from HttpResponseMessage

usingSystem.Net.Http.Json;usingLight.PortableResults;usingLight.PortableResults.Http.Reading;usingvarhttpClient=newHttpClient{BaseAddress=newUri("https://localhost:5000")};usingvarresponse=awaithttpClient.PutAsJsonAsync("/api/movieRatings",requestDto);Result<MovieRating>result=awaitresponse.ReadResultAsync<MovieRating>();if(result.IsValid){Console.WriteLine($"Added movie rating with id {result.Value.Id}");}else{foreach(varerrorinresult.Errors){Console.WriteLine($"{error.Target}: {error.Message}");}}

☁️ CloudEvents Quick Start

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

Publish to RabbitMQ

usingLight.PortableResults;usingLight.PortableResults.CloudEvents;usingLight.PortableResults.CloudEvents.Writing;usingRabbitMQ.Client;varresult=Result<UserDto>.Ok(newUserDto{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");varproperties=newBasicProperties();properties.ContentType=CloudEventsConstants.CloudEventsJsonContentType;awaitchannel.BasicPublishAsync(exchange:"",routingKey:"users.updated",mandatory:false,basicProperties:properties,body:cloudEvent);

Extension Attribute Encoding

CloudEvents extension attributes use the JSON Event Format context-attribute mapping, not each MetadataKind's natural JSON shape:

Metadata valueExtension-attribute JSON
NullOmitted (unset)
BooleanJSON boolean
Int64 from -2147483648 through 2147483647JSON number
Every other non-null primitive, including larger Int64, Double, Single, and DecimalJSON string containing canonical invariant text
Array or ObjectRejected

CloudEvents String values are validated without normalization: controls, Unicode noncharacters, and unpaired surrogates are rejected. This mapping affects only extension attributes; metadata inside data and problem+json keeps its normal JSON representation.

The Int64 rule is value-dependent. For example, one extension name can be emitted as 2147483647 in one event and as "2147483648" in another. This is a deliberate deviation from CloudEvents' stable-type recommendation so all in-range CloudEvents integers retain their natural JSON representation while the full long domain remains lossless. If a key requires a stable string shape, convert it to MetadataKind.String with a CloudEventsAttributeConverter:

publicsealedclassStableSequenceConverter:CloudEventsAttributeConverter{publicStableSequenceConverter():base(["sequence"]){}publicoverrideKeyValuePair<string,MetadataValue>PrepareCloudEventsAttribute(stringmetadataKey,MetadataValuevalue)=>new(metadataKey,MetadataValue.FromString(value.ToCanonicalString(),value.Annotation));}

On read-back, a JSON boolean becomes MetadataKind.Boolean, an integer-number becomes MetadataKind.Int64, and every string-mapped value initially becomes MetadataKind.String. Canonical out-of-range integer text remains accessible through MetadataValue.TryGetInt64. Register a CloudEventsAttributeParser when a particular attribute must be restored to another original kind. Inbound null means unset and is not added to extension metadata.

Consume from RabbitMQ

usingLight.PortableResults;usingLight.PortableResults.CloudEvents.Reading;usingRabbitMQ.Client.Events;consumer.ReceivedAsync+=async(_,eventArgs)=>{Result<UserDto>result=eventArgs.Body.ReadResult<UserDto>();if(result.IsValid){Console.WriteLine($"Updated user: {result.Value.Email}");}else{foreach(varerrorinresult.Errors){Console.WriteLine($"{error.Target}: {error.Message}");}}awaitchannel.BasicAckAsync(eventArgs.DeliveryTag,multiple:false);};

🔬 Validation In Depth

Composing Validators

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

publicsealedrecordPurchaseOrderDto{publicrequiredGuidOrderId{get;set;}publicrequiredDateTimePlacedAt{get;set;}publicrequiredstringCustomerEmail{get;set;}=string.Empty;publicrequiredShippingAddressDtoShippingAddress{get;set;}publicrequiredList<string>Tags{get;set;}publicrequiredList<OrderItemDto>Items{get;set;}}publicsealedclassPurchaseOrderValidator:Validator<PurchaseOrderDto>{privatereadonlyShippingAddressValidator_addressValidator;privatereadonlyOrderItemValidator_itemValidator;publicPurchaseOrderValidator(IValidationContextFactoryvalidationContextFactory,ShippingAddressValidatoraddressValidator,OrderItemValidatoritemValidator):base(validationContextFactory){_addressValidator=addressValidator;_itemValidator=itemValidator;}protectedoverrideValidatedValue<PurchaseOrderDto>PerformValidation(ValidationContextcontext,ValidationCheckpointcheckpoint,PurchaseOrderDtodto){// The client mints the order ID, so require a UUIDv7 — its leading timestamp keeps// client-generated keys roughly sortable and index-friendly. Guid.Empty and v4 GUIDs fail.context.Check(dto.OrderId).IsUuidV7();// Timestamps must be unambiguous, so require UTC. With the default System.Text.Json// converter this means the payload has to carry a trailing "Z".context.Check(dto.PlacedAt).IsUtc();dto.CustomerEmail=context.Check(dto.CustomerEmail).IsEmail();// If dto.ShippingAddress is null the child validator emits a null error automatically.context.Check(dto.ShippingAddress).ValidateChild(_addressValidator);// If dto.Tags is null the framework emits a NotNull error automatically.context.Check(dto.Tags).ValidateItems(static(Check<string>tag)=>tag.HasLengthIn(2,30));context.Check(dto.Items).ValidateItems(_itemValidator);returncheckpoint.ToValidatedValue(dto);}}publicsealedclassShippingAddressValidator:Validator<ShippingAddressDto>{publicShippingAddressValidator(IValidationContextFactoryvalidationContextFactory):base(validationContextFactory){}protectedoverrideValidatedValue<ShippingAddressDto>PerformValidation(ValidationContextcontext,ValidationCheckpointcheckpoint,ShippingAddressDtodto){dto.RecipientName=context.Check(dto.RecipientName).IsNotNullOrWhiteSpace();dto.Street=context.Check(dto.Street).IsNotNullOrWhiteSpace();dto.PostalCode=context.Check(dto.PostalCode).HasLengthIn(4,12);dto.CountryCode=context.Check(dto.CountryCode).HasLengthIn(2,2);returncheckpoint.ToValidatedValue(dto);}}publicsealedclassOrderItemValidator:Validator<OrderItemDto>{publicOrderItemValidator(IValidationContextFactoryvalidationContextFactory):base(validationContextFactory){}protectedoverrideValidatedValue<OrderItemDto>PerformValidation(ValidationContextcontext,ValidationCheckpointcheckpoint,OrderItemDtodto){dto.Sku=context.Check(dto.Sku).IsNotNullOrWhiteSpace();context.Check(dto.Quantity).IsGreaterThanOrEqualTo(1);context.Check(dto.UnitPrice).IsGreaterThan(0m);returncheckpoint.ToValidatedValue(dto);}}

IsUuidV7 fails with the UuidV7 error code unless the GUID's RFC 9562 version field is 7and its variant bits are the RFC variant. The same invariant is available standalone as guid.IsUuidV7() (GuidExtensions) when a repository or message handler needs to guard it outside a check chain.

IsUtc, IsLocal, and IsUnspecified assert the DateTime.Kind of the checked value and fail with the Utc, Local, and Unspecified error codes. They partition DateTimeKind: every DateTime is accepted by exactly one of them. The contract is only the kind of the value the check sees, independent of its origin — DateTime.UtcNow, DateTime.SpecifyKind, a custom converter, and non-JSON transports can all produce Utc just as a JSON payload with a trailing Z does.

That matters for JSON requests, because the default System.Text.Json converter maps the ISO 8601 forms to kinds like this:

Wire valueDateTime.KindAccepted by
2026-08-02T10:00:00ZUtcIsUtc
2026-08-02T10:00:00+00:00LocalIsLocal
2026-08-02T10:00:00+02:00LocalIsLocal
2026-08-02T10:00:00UnspecifiedIsUnspecified

So IsUtc effectively requires a trailing Z on the wire: an explicit numeric offset is converted to server-local time and deserializes as Local, and that includes +00:00 even though it denotes the same instant as Z. Document the Z requirement for your clients — a +00:00 payload is rejected. Conversely, the two Local values above only agree on the instant; their wall-clock value depends on the server's time zone, which is rarely what a portable API wants.

DateTime values reach these assertions after the per-check normalizer or ValidationContextOptions.ValueNormalizer has run — the default TrimStringNormalizer passes non-strings through unchanged. Do not install a normalizer that coerces Unspecified to Utc: it destroys exactly the signal being validated, and IsUtc would then pass for every request. Convert to UTC in your mapping code, after validation. Note also that default(DateTime) is Unspecified, so IsUnspecified accepts a missing value; pair it with a range check when that matters.

What is ValidatedValue<T>?

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

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

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

Automatic Null Checking

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

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

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

// Default configuration — no explicit guard neededcontext.Check(dto.ShippingAddress).ValidateChild(_addressValidator);context.Check(dto.Tags).ValidateItems(static(Check<string>tag)=>tag.HasLengthIn(2,30));// Explicit guard — short-circuits any further checks on this valuecontext.Check(dto.Tags).IsNotNull().ValidateItems(static(Check<string>tag)=>tag.HasLengthIn(2,30));

Mapping to Domain Objects

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

// Mutable DTO received from the APIpublicsealedrecordCreateMovieDto{publicrequiredstringTitle{get;set;}=string.Empty;publicrequiredintReleaseYear{get;set;}publicrequiredstringDirectorName{get;set;}=string.Empty;}// Immutable domain entity — no public setterspublicsealedrecordMovie{publicrequiredGuidId{get;init;}publicrequiredstringTitle{get;init;}publicrequiredintReleaseYear{get;init;}publicrequiredstringDirectorName{get;init;}}publicsealedclassCreateMovieValidator:Validator<CreateMovieDto,Movie>{publicCreateMovieValidator(IValidationContextFactoryvalidationContextFactory):base(validationContextFactory){}// PerformValidation returns ValidatedValue<Movie>.// The domain object is only constructed when all checks pass.protectedoverrideValidatedValue<Movie>PerformValidation(ValidationContextcontext,ValidationCheckpointcheckpoint,CreateMovieDtodto){vartitle=context.Check(dto.Title).IsNotNullOrWhiteSpace();context.Check(dto.ReleaseYear).IsInRange(1888,DateTime.UtcNow.Year);vardirectorName=context.Check(dto.DirectorName).IsNotNullOrWhiteSpace();if(checkpoint.HasNewErrors){returnValidatedValue<Movie>.NoValue;}returnValidatedValue<Movie>.Success(newMovie{Id=Guid.CreateVersion7(),Title=title,ReleaseYear=dto.ReleaseYear,DirectorName=directorName});}}

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

publicasyncTask<Result<Movie>>CreateMovieAsync(CreateMovieDtodto){Result<Movie>result=_validator.Validate(dto);if(!result.IsValid){returnresult;}await_movieRepository.AddAsync(result.Value);returnresult;}

Async Validators

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

publicsealedclassAddMovieRatingValidator:AsyncValidator<MovieRatingDto>{privatereadonlyIMovieRepository_movieRepository;publicAddMovieRatingValidator(IValidationContextFactoryvalidationContextFactory,IMovieRepositorymovieRepository):base(validationContextFactory){_movieRepository=movieRepository;}protectedoverrideasyncValueTask<ValidatedValue<MovieRatingDto>>PerformValidationAsync(ValidationContextcontext,ValidationCheckpointcheckpoint,MovieRatingDtodto,CancellationTokencancellationToken){// Synchronous checks first — cheap and allocation-freecontext.Check(dto.Id).IsNotEmpty();context.Check(dto.MovieId).IsNotEmpty();dto.UserName=context.Check(dto.UserName).IsNotNullOrWhiteSpace();dto.Comment=context.Check(dto.Comment).HasLengthIn(10,1000);context.Check(dto.Rating).IsInRange(1,5);// Only hit the database if the synchronous checks passedif(!checkpoint.HasNewErrors){varmovieExists=await_movieRepository.ExistsAsync(dto.MovieId,cancellationToken);if(!movieExists){context.Check(dto.MovieId).AddError(newError{Message="The specified movie does not exist",Code="movie.notFound",Target="movieId",Category=ErrorCategory.NotFound});}}returncheckpoint.ToValidatedValue(dto);}}

Call ValidateAsync from the service layer:

publicasyncTask<Result<MovieRating>>AddMovieRatingAsync(MovieRatingDtodto,CancellationTokencancellationToken=default){Result<MovieRatingDto>validationResult=await_validator.ValidateAsync(dto,cancellationToken);if(!validationResult.IsValid){returnResult<MovieRating>.Fail(validationResult.Errors);}varmovieRating=newMovieRating(...);returnResult<MovieRating>.Ok(movieRating);}

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

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

Using ValidationContext Directly

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

publicasyncTask<Result<IReadOnlyList<Movie>>>SearchMoviesAsync(string?query,intpage,intpageSize,CancellationTokencancellationToken=default){varcontext=_contextFactory.CreateValidationContext();varnormalizedQuery=context.Check(query).IsNotNullOrWhiteSpace();context.Check(page).IsGreaterThanOrEqualTo(1);context.Check(pageSize).IsInRange(1,100);if(context.HasErrors){returnResult<IReadOnlyList<Movie>>.Fail(context.ToFailureResult().Errors);}returnResult<IReadOnlyList<Movie>>.Ok(await_movieRepository.SearchAsync(normalizedQuery,page,pageSize,cancellationToken));}

IValidationContextFactory is registered automatically by AddValidationForPortableResults().

Sharing State Between Validators

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

// Define the key once — store it as a static field near the validators that use itpublicstaticclassMovieConstants{publicstaticreadonlyValidationContextKey<Movie>MovieKey=new("movie");}// Parent loads the movie and stores it in the contextprotectedoverrideasyncValueTask<ValidatedValue<MovieRatingDto>>PerformValidationAsync(ValidationContextcontext,ValidationCheckpointcheckpoint,MovieRatingDtodto,CancellationTokencancellationToken){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){varmovie=await_movieClient.GetAsync(dto.MovieId,cancellationToken);if(movieisnull){context.Check(dto.MovieId).AddError(newError{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);}}returncheckpoint.ToValidatedValue(dto);}// Child retrieves the pre-loaded entity without touching the databaseprotectedoverrideValidatedValue<MovieRatingDto>PerformValidation(ValidationContextcontext,ValidationCheckpointcheckpoint,MovieRatingDtodto){varmovie=context.GetRequiredItem(MovieConstants.MovieKey);if(movie.MaxRatingsPerUser>0&&movie.CurrentRatingCount>=movie.MaxRatingsPerUser){context.Check(dto.MovieId).AddError(newError{Message="Rating quota for this movie has been reached",Code="movie.quotaExceeded",Target="movieId",Category=ErrorCategory.Conflict});}returncheckpoint.ToValidatedValue(dto);}

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

Custom Assertions

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

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

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

usingLight.PortableResults.Validation;usingLight.PortableResults.Validation.Definitions;usingLight.PortableResults.Validation.Messaging;publicsealedclassMustBeValidMovieYearDefinition:ValidationErrorDefinition{publicstaticreadonlyMustBeValidMovieYearDefinitionInstance=new();privateMustBeValidMovieYearDefinition():base(code:"MustBeValidMovieYear"){}publicoverrideboolIsMessageStable=>true;publicoverrideboolTryGetStableMessageProvider(ReadOnlyValidationContextcontext,outobject?provider){provider=this;returntrue;}publicoverrideValidationErrorMessageProvideMessage<T>(inValidationErrorMessageContext<T>context)=>new($"{context.DisplayName} must be a valid movie release year (1888 or later, not in the future)");}publicstaticclassMovieValidationExtensions{publicstaticCheck<int>MustBeValidMovieYear(thisCheck<int>check,boolshortCircuitOnError=false){if(check.IsShortCircuited)returncheck;varyear=check.Value;if(year>=1888&&year<=DateTime.UtcNow.Year)returncheck;check=check.AddError(MustBeValidMovieYearDefinition.Instance);returnshortCircuitOnError?check.ShortCircuit():check;}}

Use it exactly like any built-in assertion:

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

Configuring Validation Behavior

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

PropertyDefaultWhat it controls
ValueNormalizerTrimStringNormalizer.InstanceHow values are normalized before checks see them. The default trims strings and converts null to "". Replace with NoOpValueNormalizer.Instance to disable.
TargetNormalizerValidationTargets.DefaultNormalizerHow caller-expression targets (e.g. dto.ShippingAddress) are converted to error target strings.
CultureInfoCultureInfo.InvariantCultureCulture used to format number parameters in error messages.
AutomaticNullErrorProviderDefaultAutomaticNullErrorProvider.InstanceProduces the error when a validator receives a null source value.
ErrorTemplatesValidationErrorTemplates.DefaultThe full set of built-in message templates. Replace individual templates to customize wording globally.
ErrorDefinitionCacheValidationErrorDefinitionCache.DefaultShared cache for reusable definition instances. The default is a process-wide singleton.

Register a customized factory before calling AddValidationForPortableResults():

usingSystem.Globalization;usingLight.PortableResults.Validation;builder.Services.AddSingleton<IValidationContextFactory>(
_ =>DefaultValidationContextFactory.Create(newValidationContextOptions{CultureInfo=CultureInfo.GetCultureInfo("de-DE")}));builder.Services.AddValidationForPortableResults();

Without a DI host:

varfactory=DefaultValidationContextFactory.Create(newValidationContextOptions{CultureInfo=CultureInfo.GetCultureInfo("de-DE")});varvalidator=newCreateMovieValidator(factory);

Validate Microsoft.Extensions.Configuration Options

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

publicsealedclassEmailSenderOptions{publicstringHost{get;set;}=string.Empty;publicintPort{get;set;}publicstringApiKey{get;set;}=string.Empty;}publicsealedclassEmailSenderOptionsValidator:Validator<EmailSenderOptions>{publicEmailSenderOptionsValidator(IValidationContextFactoryvalidationContextFactory):base(validationContextFactory){}protectedoverrideValidatedValue<EmailSenderOptions>PerformValidation(ValidationContextcontext,ValidationCheckpointcheckpoint,EmailSenderOptionsoptions){context.Check(options.Host).IsNotNullOrWhiteSpace();context.Check(options.Port).IsInRange(1,65535);context.Check(options.ApiKey).IsNotNullOrWhiteSpace();returncheckpoint.ToValidatedValue(options);}}services.AddOptions<EmailSenderOptions>().BindConfiguration("EmailSender").ValidateWithPortableResults<EmailSenderOptions,EmailSenderOptionsValidator>().ValidateOnStart();

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

🌐 OpenAPI Support

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

Registration

usingLight.PortableResults.AspNetCore.MinimalApis;usingLight.PortableResults.AspNetCore.OpenApi;usingLight.PortableResults.Validation.OpenApi;builder.Services.AddOpenApi().AddPortableResultsForMinimalApis().AddPortableResultsOpenApi(contracts =>contracts.RegisterBuiltInValidationErrors());

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

Documenting Endpoints

Minimal APIs expose three helpers:

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

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

app.MapPut("/api/movieRatings",async(MovieRatingDtodto,AddMovieRatingServiceservice)=>{varresult=awaitservice.AddMovieRatingAsync(dto);returnresult.ToMinimalApiResult();}).ProducesPortableSuccessResponse<MovieRating>().ProducesPortableValidationProblem(configure: x =>x.UseFormat(ValidationProblemSerializationFormat.Rich).WithErrorCodes(ValidationErrorCodes.NotEmpty,ValidationErrorCodes.LengthInRange).WithInRangeError<int>()).ProducesPortableProblem();

Narrowing Error Schemas

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

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

Source Generation

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

[GeneratePortableValidationOpenApi]publicsealedpartialclassAddMovieRatingValidator:Validator<MovieRatingDto>{protectedoverrideValidatedValue<MovieRatingDto>PerformValidation(ValidationContextcontext,ValidationCheckpointcheckpoint,MovieRatingDtodto){context.Check(dto.Id).IsNotEmpty();dto.Comment=context.Check(dto.Comment).HasLengthIn(10,1000);context.Check(dto.Rating).IsInRange(1,5);returncheckpoint.ToValidatedValue(dto);}}app.MapPut("/api/movieRatings",AddMovieRating).ProducesPortableValidationProblemFor<AddMovieRatingValidator>(configure: x =>x.UseFormat(ValidationProblemSerializationFormat.Rich));

The generator analyzes top-level context.Check(...).Rule(...) chains and produces response schemas and examples from compile-time constants (for example, HasLengthIn(10, 1000) or IsInRange(1, 5)). It also reconstructs deterministic DateTime, DateTimeOffset, TimeSpan, DateOnly, TimeOnly, Guid, and Uri boundaries written with supported constructors, factories, well-known static values, or static readonly fields declared in the validator's file. Reconstructed message values and example metadata use the same canonical text.

Runtime-computed or unsupported boundary expressions still generate valid schemas, but the affected example omits its message and metadata and reports LPRSG0015 at the argument. DateTimeKind.Local, culture-sensitive parsing, arithmetic or chained calls, and invalid constructor or factory arguments follow this degraded path. A static readonly field declared in another file or assembly is deliberately not followed and reports LPRSG0016; write the value inline or move its declaration into the validator's file when the complete example is required.

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

[GeneratePortableValidationOpenApi][PortableValidationOpenApiErrorHint("MovieAlreadyRated")]publicsealedpartialclassAddMovieRatingValidator:Validator<MovieRatingDto>{ ...}

Reusable Error Code Contracts

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

builder.Services.AddPortableResultsOpenApi(contracts =>{contracts.ForCode<VersionMismatchMetadata>("VersionMismatch");contracts.ForCode<InsufficientFundsMetadata>("InsufficientFunds");});app.MapPut("/api/movieRatings",handler).ProducesPortableValidationProblem(configure: x =>x.WithErrorCodes("VersionMismatch"));

🛩️ Native AOT and Trimming

Light.PortableResults and Light.PortableResults.Validation ship net10.0 assets that are built with IsAotCompatible, and CI publishes a Native AOT sample so that trim and AOT regressions fail the build.

What you have to register

You only register your own result value types with a JsonSerializerContext. Every envelope and payload type that Light.PortableResults owns is resolved by the library itself, so you never declare closed library wrappers such as CloudEventsEnvelopeForWriting<MovieRating> or HttpReadAutoSuccessResultPayload<MovieRating>. Combining source-generated resolvers cannot synthesize those closed generics from separate contracts anyway.

usingSystem.Text.Json;usingSystem.Text.Json.Serialization;[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)][JsonSerializable(typeof(MovieRating))]publicsealedpartialclassMovieRatingJsonContext:JsonSerializerContext;

Compose the options by setting the resolver and then adding the Light.PortableResults converters:

usingLight.PortableResults.CloudEvents.Reading;usingLight.PortableResults.CloudEvents.Writing;usingLight.PortableResults.Http.Reading;varserializerOptions=newJsonSerializerOptions(JsonSerializerDefaults.Web){TypeInfoResolver=MovieRatingJsonContext.Default};serializerOptions.AddDefaultPortableResultsCloudEventsWriteJsonConverters();varwriteOptions=newPortableResultsCloudEventsWriteOptions{SerializerOptions=serializerOptions,Source="urn:movies:rating-service"};byte[]cloudEvent=result.ToCloudEvent(successType:"movie.rated",options:writeOptions);

The same composition applies to AddDefaultPortableResultsCloudEventsReadJsonConverters and AddDefaultPortableResultsHttpReadJsonConverters. Non-generic Result values require no consumer registration at all: every type involved in writing and reading them belongs to Light.PortableResults.

How a contract is resolved

For each library-owned type, PortableResultsJsonContracts resolves in this order:

  1. When the options carry no resolver and reflection-based serialization is enabled, the reflection resolver is materialized so that the shared default options keep working exactly as before.
  2. A contract supplied by the configured TypeInfoResolver wins. Declaring a library type in your own context therefore still overrides the library.
  3. Otherwise the converter that the options select for the type is used, following the normal JsonSerializerOptions precedence — the first entry in Converters that can convert the type. A converter you insert before the Light.PortableResults defaults keeps its precedence; the library converter is only reached when nothing else matches. Contracts created this way are cached per JsonSerializerOptions instance and can be created after the options became read-only. Creating one also makes the options read-only, so register every converter before the first read or write.

If a value type is missing from your context, the affected entry point throws an InvalidOperationException that names the unresolved type and tells you to register it, instead of failing deep inside System.Text.Json.

HTTP writing with ASP.NET Core

The HTTP write path is the exception: LightResult/LightActionResult serialize HttpResultForWriting and HttpResultForWriting<T> through the configured resolver, so those wrappers still have to be declared in your context — see the samples/NativeAotMovieRating project. Light.PortableResults.AspNetCore.Mvc is not Native AOT compatible.

⚙️ Configuration Reference

HTTP write options (PortableResultsHttpWriteOptions)

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

HTTP read options (PortableResultsHttpReadOptions)

OptionDefaultDescription
HeaderParsingServiceParseNoHttpHeadersService.InstanceControls how HTTP headers are converted into metadata.
MergeStrategyAddOrReplaceStrategy when merging metadata with the same key from headers and body.
PreferSuccessPayloadAutoHow to interpret successful payloads (Auto, BareValue, WrappedValue).
TreatProblemDetailsAsFailuretrueIf true, application/problem+json is treated as failure even for 2xx status codes.
SerializerOptionsModule.DefaultSerializerOptionsSystem.Text.JSON serializer options used for deserialization.
varreadOptions=newPortableResultsHttpReadOptions{HeaderParsingService=newDefaultHttpHeaderParsingService(newAllHeadersSelectionStrategy()),PreferSuccessPayload=PreferSuccessPayload.Auto,TreatProblemDetailsAsFailure=true};Result<UserDto>result=awaitresponse.ReadResultAsync<UserDto>(readOptions);

CloudEvents write options (PortableResultsCloudEventsWriteOptions)

OptionDefaultDescription
SourcenullDefault CloudEvents source URI if not set per call.
MetadataSerializationModeAlwaysControls whether metadata is serialized into CloudEvents data.
SerializerOptionsModule.DefaultSerializerOptionsSystem.Text.JSON serializer options.
ConversionServiceDefaultCloudEventsAttributeConversionService.InstanceConverts metadata entries into CloudEvents extension attributes.
SuccessTypenullDefault CloudEvents type for successful results.
FailureTypenullDefault CloudEvents type for failed results.
SubjectnullDefault CloudEvents subject.
DataSchemanullDefault CloudEvents dataschema URI.
TimenullDefault time value (UTC now used when omitted).
IdResolvernullOptional function used to generate CloudEvents id values.
ArrayPoolArrayPool<byte>.SharedBuffer pool used for serialization.
PooledArrayInitialCapacityRentedArrayBufferWriter.DefaultInitialCapacity (2048 B)Initial buffer size for pooled serialization.
builder.Services.Configure<PortableResultsCloudEventsWriteOptions>(options =>{options.Source="urn:light-portable-results:sample:user-service";options.SuccessType="users.updated";options.FailureType="users.update.failed";options.MetadataSerializationMode=MetadataSerializationMode.Always;});

CloudEvents read options (PortableResultsCloudEventsReadOptions)

OptionDefaultDescription
SerializerOptionsModule.DefaultSerializerOptionsSystem.Text.JSON serializer options.
PreferSuccessPayloadAutoHow to interpret successful payloads (Auto, BareValue, WrappedValue).
IsFailureTypenullOptional fallback classifier to decide failure based on CloudEvents type.
ParsingServicenullOptional parser for mapping extension attributes to metadata.
MergeStrategyAddOrReplaceStrategy when merging extension attributes and payload metadata.
varcloudReadOptions=newPortableResultsCloudEventsReadOptions{IsFailureType= eventType =>eventType.EndsWith(".failed",StringComparison.Ordinal),PreferSuccessPayload=PreferSuccessPayload.Auto};Result<UserDto>result=messageBody.ReadResult<UserDto>(cloudReadOptions);

Supported Error Categories

ErrorCategoryHTTP Status
Unclassified500
Validation400
Unauthorized401
PaymentRequired402
Forbidden403
NotFound404
MethodNotAllowed405
NotAcceptable406
Timeout408
Conflict409
Gone410
LengthRequired411
PreconditionFailed412
ContentTooLarge413
UriTooLong414
UnsupportedMediaType415
RequestedRangeNotSatisfiable416
ExpectationFailed417
MisdirectedRequest421
UnprocessableContent422
Locked423
FailedDependency424
UpgradeRequired426
PreconditionRequired428
TooManyRequests429
RequestHeaderFieldsTooLarge431
UnavailableForLegalReasons451
InternalError500
NotImplemented501
BadGateway502
ServiceUnavailable503
GatewayTimeout504
InsufficientStorage507

About

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

Resources

Stars

14 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages