LowCodeHub.Outcomes
0.0.1
dotnet add package LowCodeHub.Outcomes --version 0.0.1
NuGet\Install-Package LowCodeHub.Outcomes -Version 0.0.1
<PackageReference Include="LowCodeHub.Outcomes" Version="0.0.1" />
<PackageVersion Include="LowCodeHub.Outcomes" Version="0.0.1" />
<PackageReference Include="LowCodeHub.Outcomes" />
paket add LowCodeHub.Outcomes --version 0.0.1
#r "nuget: LowCodeHub.Outcomes, 0.0.1"
#:package LowCodeHub.Outcomes@0.0.1
#addin nuget:?package=LowCodeHub.Outcomes&version=0.0.1
#tool nuget:?package=LowCodeHub.Outcomes&version=0.0.1
LowCodeHub.Outcomes
A result type for ASP.NET Core apps, built on C# 15 unions. Outcome<T> is either a value of T or a ProblemDetails.
Requires net11.0 (.NET 11 SDK) and <LangVersion>latest</LangVersion>.
Quickstart
using LowCodeHub.Outcomes;
Outcome<User> Get(int id) =>
_users.Find(id) is { } user
? user // implicit conversion from T
: Problems.NotFound("User.NotFound", "User not found"); // implicit conversion from ProblemDetails
// Exhaustive: no discard arm needed.
var message = Get(1) switch
{
User u => $"Hello {u.Name}",
ProblemDetails p => $"Failed: {p.GetErrorCode()}",
};
Operations with no return value use Outcome<Success> and return Success.Value.
Problems builders
All builders return a ProblemDetails, set the HTTP status and write the code to Extensions["errorCode"].
Optional args (localization format arguments) are written to Extensions["args"] only when non-empty.
| Builder | Status |
|---|---|
Problems.BadRequest(code, detail, args?) |
400 |
Problems.Validation(code, errors, detail?) |
400 (HttpValidationProblemDetails) |
Problems.Unauthorized(code, detail, args?) |
401 |
Problems.Forbidden(code, detail, args?) |
403 |
Problems.NotFound(code, detail, args?) |
404 |
Problems.Conflict(code, detail, args?) |
409 |
Problems.Gone(code, detail, args?) |
410 |
Problems.Failure(code, detail, args?) |
422 (business-rule failure) |
Problems.TooManyRequests(code, detail, retryAfter?, args?) |
429 (retryAfter in Extensions["retryAfter"]; to pass args without a retryAfter, use the named form args: [...] or pass null positionally) |
Problems.Unexpected(code, detail, args?) |
500 |
Problems.Create(status, code, detail, args?) |
any |
problem.GetErrorCode() and problem.HasErrorCode("X") read the code back.
Error code convention
Use stable, dotted codes: <Area>.<Reason>, for example User.NotFound, Otp.Expired. Keep them as constants next to the
feature that owns them. Codes are the contract with clients and localization resources. detail is a fully formatted human
fallback (for example Problems.NotFound("Order.NotFound", $"No such order {id}", id)); args feed only the localized
resource template.
Map, Bind, Match
Outcome<int> total = GetOrder(id).Map(o => o.Total);
Outcome<Invoice> invoice = GetOrder(id).Bind(o => Invoice(o));
string text = GetOrder(id).Match(o => "ok", p => p.Detail ?? "failed");
Each has Task<Outcome<T>> and async-lambda overloads, so chains can be awaited. Nothing else is provided on purpose.
Restrictions
Values of T must be non-null. A null value is indistinguishable from default(Outcome<T>): a two-arm user switch over
such an outcome throws SwitchExpressionException, while Map, Bind, Match and ToHttpResult report it as the
Outcome.Uninitialized problem (500).
Outcome<ProblemDetails> (or any ProblemDetails subtype as T) is meaningless because both cases would be the same
type. Converting a ProblemDetails to Outcome<ProblemDetails> is already a compile error (CS0457, ambiguous
conversion). As a backstop the union declares a static constructor that throws (surfacing as TypeInitializationException)
the first time such a type is used, for example via default plus Value.
Expected vs unexpected failures
Expected outcomes (not found, conflict, rule violations) are returned as Outcome<T>. Transport failures, timeouts,
cancellation and argument or configuration errors still throw.
Reading outgoing HTTP responses
ReadOutcomeAsync<T>() turns an HttpResponseMessage (or a Task<HttpResponseMessage>) into an Outcome<T>:
Outcome<User> user = await http.GetAsync($"users/{id}", ct).ReadOutcomeAsync<User>(ct: ct);
Outcome<Success> done = await http.DeleteAsync($"users/{id}", ct).ReadOutcomeAsync<Success>(ct: ct);
- 2xx deserializes
T(defaultJsonSerializerOptions.Web); forSuccessany 2xx wins and the body is ignored. An empty body for any otherTisHttp.EmptyBody(500); an unparsable body isHttp.InvalidBody(500). - Non-2xx with a problem-shaped JSON body (
application/problem+json, or JSON withtitle/status/detail/errors/type) is returned asHttpValidationProblemDetailsunchanged, withStatustaken from the response if omitted. - Any other non-2xx becomes
Http.{status}with the reason phrase asDetail; the raw body is never copied. - Pass
(response, ct) => Task<ProblemDetails>aserrorReaderfor APIs whose errors are not problem+json. - Transport exceptions and cancellation still throw.
- Forwarding a remote problem to your own clients with
ToHttpResult()exposes the remote'sdetailand extensions to them; map it to your own problem when the remote is not trusted.
Rendering outcomes as HTTP results
ToHttpResult() turns an outcome into an IResult for Minimal APIs (always via TypedResults):
app.MapGet("/users/{id}", async (int id, IUserService users) =>
await users.GetAsync(id).ToHttpResult()); // 200 OK + JSON, or problem
app.MapDelete("/users/{id}", (int id, IUserService users) =>
users.DeleteAsync(id).ToHttpResult()); // Outcome<Success>: 204, or problem
app.MapPost("/users", (CreateUser cmd, IUserService users) =>
users.CreateAsync(cmd).ToHttpResult(u => TypedResults.Created($"/users/{u.Id}", u)));
return Problems.Forbidden("User.Forbidden", "Not allowed").ToHttpResult(); // bare problem
| Input | Result |
|---|---|
Outcome<T> holding a value |
TypedResults.Ok(value) (200) |
Outcome<Success> holding Success |
TypedResults.NoContent() (204, empty body) |
ToHttpResult(onSuccess) holding a value |
whatever onSuccess returns |
any outcome holding a problem, or a ProblemDetails |
TypedResults.Problem(problem) |
default(Outcome<T>) |
500 problem with code Outcome.Uninitialized |
A problem with a null Status is rendered as 500 (400 for HttpValidationProblemDetails). Every overload also exists for
Task<Outcome<T>>. Problems are written through IProblemDetailsService when the app calls AddProblemDetails(), so
CustomizeProblemDetails, traceId and the default type/title apply.
Rendering never mutates your ProblemDetails: a copy is written (an HttpValidationProblemDetails with its Errors when the
input is one, otherwise a plain ProblemDetails; properties of other subclasses are not rendered, put such data in
Extensions). Static or cached problem instances are therefore safe to return from any number of requests.
Localizing problems
builder.Services.AddProblemDetails();
builder.Services.AddProblemDetailsLocalization(); // uses the registered IStringLocalizer
// or pick the localizer explicitly (e.g. resx):
builder.Services.AddProblemDetailsLocalization(o => o.LocalizerProvider = f => f.Create(typeof(ErrorMessages)));
For each problem that carries Extensions["errorCode"], the hook:
- looks up the code in the localizer (
LocalizerProvider(factory)if set, else the registeredIStringLocalizer, else nothing is localized); - sets
detailto the localized text formatted withExtensions["args"](current culture). If the placeholders do not match the arguments (including a template with placeholders but noargs), or there is no resource,detailis left as-is; - removes
argsfrom the response (it is an internal localization input).
args can still reach clients when IProblemDetailsService does not write the response (for example Accept: text/plain)
or when the hook is not registered, so never put secrets or PII in args.
Problems without an errorCode are untouched. The hook composes with Configure<ProblemDetailsOptions> and AddProblemDetails(...) customizers in any order
(yours runs after localization). If you set CustomizeProblemDetails in your own PostConfigure, wrap it (call the existing
delegate) instead of assigning over it. Calling AddProblemDetailsLocalization() more than once is safe.
Without AddProblemDetailsLocalization(), nothing is localized and args is written to the response as-is.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net11.0 is compatible. |
-
net11.0
- No dependencies.
NuGet packages (5)
Showing the top 5 NuGet packages that depend on LowCodeHub.Outcomes:
| Package | Downloads |
|---|---|
|
LowCodeHub.MinimalEndpoints
ASP.NET Core Minimal API helpers built on the native .NET 11 stack (sync + async AddValidation with localized messages, ProblemDetails, request localization): modular endpoints, CQRS-lite operations returning Outcome<T>, JSON-file IStringLocalizer, timezone awareness, correlation id, in-memory/Redis domain event bus, and contract-only Model Context Protocol (MCP) integration that exposes opt-in API contracts to AI agents over Streamable HTTP behind a single API key. |
|
|
LowCodeHub.Webhooks
A dynamic webhook invocation library for ASP.NET Core with database-driven subscriptions, outbox-based reliable delivery, resilience policies, and multi-tenant support. |
|
|
LowCodeHub.Jobs
Durable, distributed background jobs for ASP.NET Core: typed handlers, retries with backoff, delayed/recurring/continuation scheduling, cooperative cancellation, durable SSE progress streaming, and an embedded admin dashboard. SQL Server, PostgreSQL or in-memory storage. |
|
|
LowCodeHub.OTP
Provider-neutral OTP flow library for ASP.NET Core with Send, Verify, and Resend operations, secure hashed storage, resend limits, expiry, replay prevention, and in-memory, Redis, SQL Server, and PostgreSQL stores. |
|
|
LowCodeHub.Keycloak.Client
Typed Keycloak client SDK for ASP.NET Core with admin token management, resilient HTTP clients, and user, group, role, session, client, authentication, impersonation, and federated identity APIs. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.0.1 | 108 | 10/22/2026 |