LowCodeHub.Outcomes 0.0.1

dotnet add package LowCodeHub.Outcomes --version 0.0.1
                    
NuGet\Install-Package LowCodeHub.Outcomes -Version 0.0.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="LowCodeHub.Outcomes" Version="0.0.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="LowCodeHub.Outcomes" Version="0.0.1" />
                    
Directory.Packages.props
<PackageReference Include="LowCodeHub.Outcomes" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add LowCodeHub.Outcomes --version 0.0.1
                    
#r "nuget: LowCodeHub.Outcomes, 0.0.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package LowCodeHub.Outcomes@0.0.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=LowCodeHub.Outcomes&version=0.0.1
                    
Install as a Cake Addin
#tool nuget:?package=LowCodeHub.Outcomes&version=0.0.1
                    
Install as a Cake Tool

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 (default JsonSerializerOptions.Web); for Success any 2xx wins and the body is ignored. An empty body for any other T is Http.EmptyBody (500); an unparsable body is Http.InvalidBody (500).
  • Non-2xx with a problem-shaped JSON body (application/problem+json, or JSON with title/status/detail/errors/type) is returned as HttpValidationProblemDetails unchanged, with Status taken from the response if omitted.
  • Any other non-2xx becomes Http.{status} with the reason phrase as Detail; the raw body is never copied.
  • Pass (response, ct) => Task<ProblemDetails> as errorReader for 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's detail and 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:

  1. looks up the code in the localizer (LocalizerProvider(factory) if set, else the registered IStringLocalizer, else nothing is localized);
  2. sets detail to the localized text formatted with Extensions["args"] (current culture). If the placeholders do not match the arguments (including a template with placeholders but no args), or there is no resource, detail is left as-is;
  3. removes args from 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 Compatible and additional computed target framework versions.
.NET net11.0 is compatible. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • 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