Light.PortableResults
One Result model. Many transports. RFC-compatible error handling for .NET microservices.
Install / Use
/learn @feO2x/Light.PortableResultsREADME
Light.PortableResults
A lightweight .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.
✨ Key Features
- 🧱 Zero-boilerplate result model —
Result/Result<T>is either a success value or one or more structured errors. No exceptions for expected failures. - 📝 Rich, machine-readable errors — every
Errorcarries a human-readableMessage, stableCode, inputTarget, andCategory— 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 suite —
Map,Bind,Match,Ensure,Tap,Switch, and theirAsyncvariants let you build clean, chainable pipelines. - 🌐 HTTP-native — serialize results as HTTP response, including automatic support for RFC-9457 Problem Details, and
deserialize
HttpResponseMessageback into typedResult/Result<T>. Full round-trip support included. - 🧩 ASP.NET Core ready — Minimal APIs and MVC packages translate
ResultandResult<T>directly toIResult/IActionResultwith automatic HTTP status mapping and RFC-9457 Problem Details support. - ☁️ CloudEvents JSON support — publish and consume results as CloudEvents Spec 1.0 payloads for reliable async messaging. Full round-trip support included.
- ⚡ Allocation-minimal by design — pooled buffers, struct-friendly internals, and fast paths keep GC pressure near zero even at high throughput.
📦 Installation
Install only 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 contexts, checks, synchronous/asynchronous validators, and flat hierarchical validation errors:
dotnet add package Light.PortableResults.Validation
- ASP.NET Core Minimal APIs integration with support for Dependency Injection and
IResult:
dotnet add package Light.PortableResults.AspNetCore.MinimalApis
- ASP.NET Core MVC integration with support for Dependency Injection and
IActionResult:
dotnet add package Light.PortableResults.AspNetCore.Mvc
If you only need the Result Pattern itself, install Light.PortableResults only.
Validation Quick Start
Light.PortableResults.Validation keeps validation targets flat across nested objects and collections, so child
validation produces paths such as
address.zipCode and
lines[0].sku. Nullable collections must be guarded
explicitly before item validation, typically with
IsNotNull().
using System.Collections.Generic;
using Light.PortableResults.Validation;
public sealed class CreateOrderValidator : Validator<CreateOrderRequest, CreateOrderCommand>
{
private readonly AddressValidator _addressValidator;
private readonly OrderLineValidator _lineValidator;
public CreateOrderValidator(IValidationContextFactory validationContextFactory)
: base(validationContextFactory)
{
_addressValidator = new AddressValidator(validationContextFactory);
_lineValidator = new OrderLineValidator(validationContextFactory);
}
protected override ValidatedValue<CreateOrderCommand> PerformValidation(
ValidationContext context,
CreateOrderRequest value
)
{
var address = context.Check(value.Address).IsNotNull().ValidateChild(_addressValidator);
var tags = context.Check(value.Tags).IsNotNull().ValidateItems(static tag =>
{
if (string.IsNullOrWhiteSpace(tag.Value))
{
tag.AddError("tag must not be empty", "NotEmpty");
}
});
var lines = context.Check(value.Lines).IsNotNull().ValidateItems(_lineValidator);
if (context.HasErrors)
{
return ValidatedValue<CreateOrderCommand>.NoValue;
}
return ValidatedValue.Success(new CreateOrderCommand(address.Value, tags.Value, lines.Value));
}
}
Delegate-based item validation works well for primitive collections, while validator-based item transformation is
intentionally limited to
T[],
List<T>, and
ImmutableArray<T>. For custom collection shapes, validate the
collection through a dedicated child validator instead of expecting the built-in item helpers to preserve that shape.
Async child and collection validation follows the same model and uses
ValueTask plus
CancellationToken throughout:
using System.Threading;
using System.Threading.Tasks;
using Light.PortableResults.Validation;
public sealed class ImportValidator : AsyncValidator<ImportRequest, ImportCommand>
{
private readonly AsyncCustomerValidator _customerValidator;
public ImportValidator(IValidationContextFactory validationContextFactory)
: base(validationContextFactory) =>
_customerValidator = new AsyncCustomerValidator(validationContextFactory);
protected override async ValueTask<ValidatedValue<ImportCommand>> PerformValidationAsync(
ValidationContext context,
ImportRequest value,
CancellationToken cancellationToken
)
{
var customer = await context.Check(value.Customer)
.IsNotNull()
.ValidateChildAsync(_customerValidator, cancellationToken);
var amounts = await context.Check(value.Amounts)
.IsNotNull()
.ValidateItemsAsync(
async (amount, ct) =>
{
await Task.Yield();
ct.ThrowIfCancellationRequested();
if (amount.Value < 0)
{
amount.AddError("amount must be zero or greater", "NonNegative");
}
},
cancellationToken
);
if (context.HasErrors)
{
return ValidatedValue<ImportCommand>.NoValue;
}
return ValidatedValue.Success(new ImportCommand(customer.Value, amounts.Value));
}
}
For advanced manual target control, use
ValidationTarget
instead of overloading the string-based target: parameter on
Check(...).
The common overload still interprets that string like a caller expression, even when you pass it explicitly.
Use
ValidationTarget.Relative(...)
for paths below the current validation scope and
ValidationTarget.Absolute(...)
for already-rooted paths:
using Light.PortableResults.Validation;
var context = validationContextFactory.CreateValidationContext();
var customerContext = context.ForMember("customer", isNormalized: true);
var relativeCheck = customerContext.Check(
request.FirstName,
ValidationTarget.Relative("firstName", isNormalized: true),
displayName: "First name"
);
var absoluteCheck = customerContext.Check(
request.OwnerName,
ValidationTarget.Absolute("account.ownerName", isNormalized: true),
displayName: "Owner name"
);
🚀 HTTP Quick Start
Minimal APIs
using System;
using System.Collections.Generic;
using Light.PortableResults;
using Light.PortableResults.AspNetCore.MinimalApis;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddPortableResultsForMinimalApis();
var app = builder.Build();
app.MapPut("/users/{id:guid}", (Guid id, UpdateUserDto dto) =>
{
var result = UpdateUser(id, dto);
return result.ToMinimalApiResult(); // LightResult<T> implements IResult
});
app.Run();
static Result<UserDto> UpdateUser(Guid id, UpdateUserDto dto)
{
List<Error> errors = [];
if (id == Guid.Empty)
{
errors.Add(new Error
{
Message = "User id must not be empty",
Code = "user.invalid_id",
Target = "id",
Category = ErrorCategory.Validation
});
}
if (string.IsNullOrWhiteSpace(dto.Email))
{
errors.Add(new Error
{
Message = "Email is required",
Code = "user.email_required",
Target = "email",
Category = ErrorCategory.Validation
});
}
if (errors.Count > 0)
{
return Result<UserDto>.Fail(errors.ToArray());
}
var response = new UserDto
{
Id = id,
Email = dto.Email
};
return Result<UserDto>.Ok(response);
}
public sealed record UpdateUserDto
{
public string? Email { get; init; }
}
public sealed record UserDto
{
public Guid Id { get; set; }
public string Email { get; init; } = string.Empty;
}
MVC
using System;
using System.Collections.Generic;
using Light.PortableResults;
using Light.PortableResults.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("users")]
public sealed class UsersController : ControllerBase
{
[HttpPut("{id:guid}")]
public LightActionResult<UserDto> UpdateUser(Guid id, [FromBody] UpdateUserDto dto)
{
var result = ValidateAndUpdate(id, dto);
return result.ToMvcActionResult();
}
private static Result<UserDto> ValidateAndUpdate(Guid id, UpdateUserDto dto)
{
List<Error> errors = [];
if (id == Guid.Empty)
{
errors.Add(new Error
{
Message = "User id must not be empty",
Code = "user.invalid_id",
Target = "id",
Category = ErrorCategory.Validation
});
}
if (string.IsNullOrWhiteSpace(dto.Email))
{
errors.Add(new Error
{
Message = "Email is required",
Code = "user.email_required",
Target = "email",
Category = ErrorCategory.Validation
});
}
if (errors.Count > 0)
{
return Result<UserDto>.Fail(errors.ToArray());
}
return Result<UserDto>.Ok(new UserDto
{
Id = id,
Email = dto.Email!
});
}
}
public sealed record UpdateUserDto
{
public string? Email { get; set; }
}
public sealed record UserDto
{
public Guid Id { get; init; }
public string Email { get; init; } = string.Empty;
}
MVC setup in Program.cs:
builder.
