ResultRly 1.0.0

dotnet add package ResultRly --version 1.0.0
                    
NuGet\Install-Package ResultRly -Version 1.0.0
                    
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="ResultRly" Version="1.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="ResultRly" Version="1.0.0" />
                    
Directory.Packages.props
<PackageReference Include="ResultRly" />
                    
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 ResultRly --version 1.0.0
                    
#r "nuget: ResultRly, 1.0.0"
                    
#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 ResultRly@1.0.0
                    
#: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=ResultRly&version=1.0.0
                    
Install as a Cake Addin
#tool nuget:?package=ResultRly&version=1.0.0
                    
Install as a Cake Tool

ResultRly

A lightweight, MIT-licensed Result pattern library for .NET Standard 2.0.

Return failures as values instead of throwing them, classify them once with a stable code and category, and let your transport layer translate that category into a status code. One package, zero dependencies, no framework coupling.

Targets netstandard2.0 — referenceable from .NET Framework 4.6.1+, .NET Core 2.0+, .NET 5 through .NET 9+, Unity, Xamarin and MAUI. The same assembly is verified running on .NET Framework 4.8 and .NET 9 by samples/ResultRly.Sample, which multi-targets both and produces the same output on each, down to the last line before the banner naming the runtime.

dotnet add package ResultRly
// Domain code returns failures as values — no exceptions, no status codes, no HTTP types.
public Result<Customer> Get(int id) =>
    _customers.TryGetValue(id, out var customer)
        ? customer                                            // implicit conversion to success
        : Error.NotFound($"Customer {id} was not found.", "customer.not_found");

// The edge turns the error's category into a status code and the standard envelope.
Result<Customer> result = service.Get(99);
int status = DefaultHttpStatusMapper.Instance.Map(result.Errors);   // 404, from ErrorType.NotFound
ApiResponse<Customer> body = ApiResponse<Customer>.From(result);    // success/message/data/meta/error
Dependencies none — the package declares an empty dependency group
Public surface fully XML-documented; missing docs fail the build
Nullable reference types enabled, with annotation attributes polyfilled for downlevel targets
Reflection none — safe to consume from a trimmed or Native AOT application
Assembly size ~95 KB
Tests 238

Contents


Why use it

Your domain layer stops knowing about HTTP. A service returns Result<Customer>; the edge turns the error's category into a status code. No HttpContext, no status codes and no IResult in business logic — which also means you can test that logic without a web host.

Failures cost about what a small object costs, not what an exception costs. Returning one allocates just the Error itself — 56 bytes measured — where throwing and catching adds roughly 256 bytes on top of it, plus a stack walk that BenchmarkDotNet put near 2,300 ns against ~3 ns for the return. If "not found" and "email already taken" are ordinary outcomes in your system rather than emergencies, they should not cost what an emergency costs.

Every broken rule is reported at once, not just the first. ValidationBuilder and Sequence collect the whole batch, so a client fixes one form once instead of playing whack-a-mole with your API.

A batch that hides a server fault is not blamed on the caller. ErrorCollection.Primary maps through the highest-severity error, not the first — so three validation errors plus a database outage returns 500, not 400. Hand-rolled result types almost always get this backwards.

One response shape for your entire API. Success, failure and paged responses all carry the same five members, so a client writes one deserialisation path and branches on success. Add the status-code handler and even the framework's own 404, 405 and 415 — which normally arrive as a bare status with no body — come back in the same envelope.

It runs where your code actually runs. One netstandard2.0 assembly reaches .NET Framework 4.6.1+, .NET Core 2.0+, .NET 5–9+, Unity, Xamarin and MAUI. The same DLL is verified running on .NET Framework 4.8 and .NET 9, producing identical output on both. If your domain library is shared with legacy code, most alternatives are simply unavailable to you.

It adds nothing to your dependency graph. The package declares an empty dependency group and the assembly references exactly one thing — netstandard 2.0.0.0. Nothing to conflict with, no version decision forced on anyone downstream, and no reflection, so it is safe under trimming and Native AOT.

The success path allocates zero bytes. Not "few" — measured at 0 B/op across creation, Map, Bind, Ensure, Tap, Match, failure short-circuiting and enumerating a failure, with regression tests that fail the build if that changes.

For where this is not the right choice, see Choosing between this and the alternatives — it names the cases where another library serves you better.


What it gives you

  • Result / Result<T> — readonly structs; success allocates nothing.
  • Error — immutable, value-equal: stable code, human message, ErrorType, target, metadata.
  • ErrorCollection — an immutable list that stores a single error inline and enumerates without allocating. Primary selects the highest-severity error; GroupByTarget(), ForTarget() and ContainsTarget() slice a validation batch by field.
  • CompositionMap, Bind, Ensure, Tap, TapError, MapError, Recover, Match, Switch, Combine, Sequence, Traverse, async variants, and LINQ query syntax.
  • ValidationBuilder — collect every validation problem in one pass, not just the first.
  • HTTP status mappingErrorType → status code, with per-type and per-code overrides. Built on plain int constants, so it carries no web framework with it.
  • PaginationPageRequest (clamping and validating), PageInfo, PagedList<T>.
  • ApiResponse / ApiResponse<T> — one response envelope with the same five members on every response, success or failure. See The response envelope.

Not included, on purpose: transport integration. There is no dependency on ASP.NET Core, no IResult, no ActionResult, no middleware — those would pull a web framework into a package that workers, desktop apps and .NET Framework code also reference. The envelope is a plain object graph with no serialisation attributes; turning it into a response is about forty lines you own, and Wiring it into ASP.NET Core gives you all of them.


Quick start

Your domain layer never mentions transport:

public Result<Customer> Get(int id) =>
    _customers.TryGetValue(id, out var customer)
        ? customer                              // implicit conversion to a success
        : Error.NotFoundByKey("Customer", id);  // implicit conversion to a failure

public Result<Customer> Create(CreateCustomerRequest request)
{
    var validation = new ValidationBuilder()
        .NotEmpty(request.Name, nameof(request.Name))
        .NotEmpty(request.Email, nameof(request.Email))
        .Must(request.Email?.IndexOf('@') >= 0,
              () => Error.InvalidField(nameof(request.Email), "Email must contain an @."));

    if (validation.HasErrors)
    {
        return validation.ToResult<Customer>();     // every problem, not just the first
    }

    if (EmailTaken(request.Email!))
    {
        return Error.Conflict("That email is already registered.", "customer.email_taken");
    }

    return Save(request);
}

Operators short-circuit — once a result fails, later steps are skipped and the original errors pass through untouched:

public Result<Customer> UpdateEmail(int id, string? email) =>
    Get(id)
        .Ensure(_ => !string.IsNullOrWhiteSpace(email), Error.Required("email"))
        .Ensure(_ => email!.IndexOf('@') >= 0, Error.InvalidField("email", "Email must contain an @."))
        .Ensure(c => !EmailTakenByAnother(c.Id, email!),
                Error.Conflict("Taken.", "customer.email_taken"))
        .Map(c => c with { Email = email! })
        .Tap(Save);

For sequences, pick your failure semantics deliberately:

  • results.Sequence() — collect every error (what validation wants)
  • results.Traverse() — stop at the first failure (what a pipeline usually wants)

Seeing all of it run

samples/ResultRly.Sample is a tour of every area, one file per area, printing what each operator actually does:

# -f is required: the project multi-targets, so dotnet run needs to be told which one.
dotnet run --project samples/ResultRly.Sample -f net9.0                      # everything
dotnet run --project samples/ResultRly.Sample -f net9.0 -- async             # one area
dotnet run --project samples/ResultRly.Sample -f net9.0 -- validation http   # several

Areas: basics, errors, composition, async, validation, aggregation, exceptions, http, pagination, service.

It multi-targets net48 and net9.0 on purpose. Both produce identical output from the same netstandard2.0 assembly — the only line that differs is the closing banner, which names the runtime it is on. That is the check that the library really does reach both ends of its supported range, and it is wired into CI.

Poking at it over HTTP

samples/ResultRly.Sample.API is the same tour as a minimal API, with Swagger UI at the root:

dotnet run --project samples/ResultRly.Sample.API
# then open http://localhost:5216

Twenty-one operations under six tags. Start with GET /status-mapping/table for the whole ErrorType → status mapping, then:

Tag Shows
Customers CRUD: 404 from NotFound, 201 with Location, mutations that confirm in message, an async chain that recovers
Status mapping Every category's status, and why a validation batch hiding a server fault returns 500, not 400
Validation A batch of failures in the envelope, the same failures as RFC 9457, and a cross-field rule with no target
Aggregation Sequence vs Traverse vs Combine on identical input
Exceptions An unhandled exception, a thrown ResultException, a thrown batch, Result.Try
Pagination Clamp vs TryCreate on hostile input — try pageSize=1000000

This is what makes Wiring it into ASP.NET Core below executable rather than illustrative: the two files it walks through — ResultHttpExtensions.cs and ResultExceptionMiddleware.cs — are that project's entire integration, and the only thing in it that ASP.NET Core has to know about. Not one handler names a status code, inspects an error type, or assembles an error body.

GET /exceptions/unhandled is worth a look with the console visible. It throws a message containing a connection string; the client gets "An unexpected error occurred while processing your request." and the real exception appears only in the log.

Async chains

The async surface is a complete matrix rather than a selection, because a half-filled one is worse than none: you build a chain, one link is missing, and you break out into a local just to carry on. Two rules let you predict what exists.

1. The receiver may be a result or a task of one. A task receiver is awaited for you, so links compose with no intervening await:

var result = await _repository.GetAsync(id)                       // Task<Result<Customer>>
    .EnsureAsync(c => c.IsActive, Error.Conflict("Not active."))
    .BindAsync(c => _pricing.QuoteAsync(c))                       // async step
    .TapErrorAsync(errors => _logger.LogWarning("Quote failed: {Errors}", errors))
    .MapAsync(quote => quote.Total)
    .RecoverAsync(_ => _cache.LastKnownTotalAsync(id));           // async fallback

2. The callback may be sync or async, and overload resolution picks by the lambda you pass:

.TapAsync(v => _metrics.Record(v))                 // sync — Action<T>
.TapAsync(async v => await _bus.PublishAsync(v))   // async — genuinely awaited, not dropped

Everything returning a task is suffixed Async, including where only the receiver is asynchronous. Covered operators: Map, Bind, Ensure, Tap, TapError, MapError, Recover, Match, Switch, Try, plus the terminals GetValueOrDefaultAsync, ToResultAsync, ThrowIfFailureAsync and ValueOrThrowAsync.

One deliberate hole: MatchAsync with async branches exists only on the value receivers, not on Task<Result<T>>. Because TOut is inferred, an async lambda satisfies both Func<T, TOut> and Func<T, Task<TOut>>, so the call would fail to compile as ambiguous rather than doing something surprising. Await the task first in that one case.


Choosing between this and the alternatives

ErrorOr, FluentResults, Ardalis.Result, CSharpFunctionalExtensions and LanguageExt are all good, and several are more widely used than this. The differences are about scope and constraints rather than quality, so pick on what you actually need:

Where ResultRly is the better fit

  • You need reach below .NET 6. It targets netstandard2.0, so the same assembly works in .NET Framework, Unity, Xamarin and MAUI. If your domain library is shared with legacy code, that is often the deciding constraint.
  • You want zero dependencies. The package declares an empty dependency group, so it can sit at the bottom of your graph without forcing a version decision on anyone.
  • You care about the allocation profile. The success path allocates nothing and Result is a 24-byte struct — measured with BenchmarkDotNet during development (figures below).
  • You want validation batches to be first classValidationBuilder and Sequence collect every error, and ErrorCollection.Primary makes a batch map to a sensible status code.

Where you should use something else

If you want… Prefer
IResult / ActionResult conversion in the box, not a README snippet Ardalis.Result
A full functional toolkit — Option, Either, applicatives, monad transformers LanguageExt
Errors modelled as a class hierarchy you extend and pattern-match on FluentResults — ResultRly's Error is deliberately sealed
The largest community and the most examples to copy, on modern .NET only ErrorOr
A general discriminated-union type rather than a result specifically OneOf, or C# unions when they land

The sharpest trade-off is that Error is sealed. That buys value equality, a predictable serialised shape and a struct that never varies in size — and it costs you the ability to subclass errors. If your design wants class NotFoundError : Error, this library will fight you and FluentResults will not.

The second is that transport integration is out of scope on purpose, so the package stays free of a framework reference and usable from a worker or a desktop app. The response envelope itself ships in the box — it is a plain object graph, which costs nothing in dependencies — but converting it into an IResult or an ActionResult does not. That means about forty lines of glue in your API project, documented in full below. If you would rather install a package and be done, Ardalis.Result is the honest recommendation.

Library capabilities move, so verify anything here against the current versions before deciding.


Creating errors

Every category is reachable from a message alone, so you never have to invent a code just to report a problem. Supply your own stable code when clients need to switch on it:

Error.Invalid("Either email or phone is required.");      // request-level rule, no target
Error.NotFound("No active subscription.");
Error.Conflict("The order is already shipped.");
Error.Precondition("The version does not match.");
Error.Forbidden();                                        // sensible default message
Error.Unexpected();

Error.Conflict("That email is already registered.", "customer.email_taken");
Error.NotFoundByKey("Customer", id);                      // → code "customer.not_found"

The default code is the name of the categoryNotFound, Conflict, Validation — which ErrorCodes.ForType(type) returns. That keeps the default code space exactly as large as the enum, with nothing invented in between, and lets you ask whether an error carries a real code or just the default:

if (error.Code != ErrorCodes.ForType(error.Type))
{
    // the application chose this code deliberately; it is part of your contract with clients
}

Anything more specific than the category is yours to supply, via the code parameter or WithCode.

For problems that belong to a specific input, name the member so a client can attach the message to the right field:

Error.InvalidField(nameof(request.Email), "Email must contain an @.");  // name says target-first
Error.Required(nameof(request.Name));                               // → "Name is required."
Error.OutOfRange(nameof(request.Quantity));

One argument order, everywhere

The required argument comes first and optional refinements follow it. Applied without exception, that gives exactly two shapes:

Shape Members Why this order
X(message, code = null) every category — Failure, Invalid, NotFound, Conflict, Precondition, Unauthorized, Forbidden, TooManyRequests, Timeout, Unavailable, Unexpected the message is always required; a caller-supplied code is the refinement
X(target, message = null) the field helpers — InvalidField, Required, OutOfRange here the target is the required input and the message is derived from it

So a bare string pair means (message, code) on any category factory, and (target, message) on anything whose name ends in Field or names a field rule. Nothing is ambiguous by position, and Create(message, type, code, target) follows the same rule.

If you would rather name the code at the call site than pass it positionally, WithCode joins the existing With* family:

Error.Conflict("Already shipped.").WithCode("order.already_shipped");

Slicing a validation batch by field

A failure carrying several validation errors usually has to be reshaped per input before a client can show it against the right box. GroupByTarget() does that:

Dictionary<string, string[]> byField = result.Errors.GroupByTarget();
// { "Email": ["Email is required.", "Email must contain an @."], "Age": ["Must be 18 or older."] }

Errors with no target — request-level rules from Error.Invalid(message) — land under the empty-string key, which is the convention both ASP.NET Core model state and RFC 9457 use. Rename it with GroupByTarget(untargetedKey: "_request") if you prefer.

This is exactly what the envelope's error.fields member is, so if you use ApiResponse you get it without calling this yourself.

It returns a concrete Dictionary<string, string[]> deliberately, so it drops straight into the APIs it exists to feed:

return Results.ValidationProblem(result.Errors.GroupByTarget());

Results.ValidationProblem on .NET 8 and the HttpValidationProblemDetails constructor both take IDictionary<string, string[]>, which an IReadOnlyDictionary does not satisfy — returning the read-only interface would make that line compile on .NET 9 and fail on .NET 8. The dictionary is a fresh projection on every call, not internal state, so you can add to it freely.

Only messages travel that way. When a client needs codes too, ForTarget() hands back the full errors for one member, and ContainsTarget() answers whether a given field failed at all:

if (result.Errors.ContainsTarget(nameof(request.Email)))
{
    foreach (var error in result.Errors.ForTarget(nameof(request.Email)))
    {
        _logger.LogDebug("{Code}: {Message}", error.Code, error.Message);
    }
}

Error.Target is compared ordinally throughout, so Email and email are different members and the casing you pass to Error.InvalidField is the casing the client receives.

Result and Result<T> mirror all of it — same names, same argument order — so a method body rarely needs to mention Error at all:

return Result<Customer>.NotFound("No active subscription.");
return Result.Invalid("Either email or phone is required.");
return Result.Precondition("The version does not match.", "order.version_mismatch");
return Result<Customer>.InvalidField(nameof(request.Email), "Email must contain an @.");

Status code mapping

Mapping is driven by ErrorType, so domain code never names a status code.

ErrorType Status ErrorType Status
Failure 400 Forbidden 403
Validation 400 (configurable to 422) TooManyRequests 429
NotFound 404 Timeout 504
Conflict 409 Unavailable 503
Precondition 412 Unexpected 500
Unauthorized 401
IHttpStatusMapper mapper = new DefaultHttpStatusMapper(
    validationStatusCode: HttpStatus.UnprocessableEntity,
    typeOverrides: new Dictionary<ErrorType, int> { [ErrorType.Forbidden] = HttpStatus.NotFound },
    codeOverrides: new Dictionary<string, int> { ["payment.card_declined"] = 402 });

int status = mapper.Map(result.Errors);

When a failure carries several errors, the status comes from the highest-severity error, not the first. A validation batch that also contains an Unexpected error answers 500, not 400 — otherwise a server fault would be reported to the client as bad input. The sample demonstrates this.


Pagination

// Untrusted query string: coerce rather than reject, but always cap the page size.
var request = PageRequest.Clamp(page, pageSize, defaultPageSize: 20, maxPageSize: 100);

// Or report bad paging as validation errors instead:
Result<PageRequest> validated = PageRequest.TryCreate(page, pageSize, maxPageSize: 100);

return customers.ToPagedList(request).ToResult();   // → Result<PagedList<Customer>>

PagedList<T> is an IReadOnlyList<T> plus a PageInfo carrying Page, PageSize, TotalCount and the computed TotalPages, HasPrevious, HasNext. PageInfo carries no serialisation attributes — that is what keeps the package dependency-free — but it still serialises correctly by convention under JsonSerializerDefaults.Web, which is what ASP.NET Core uses:

{ "page": 2, "pageSize": 10, "totalCount": 25, "totalPages": 3, "hasPrevious": true, "hasNext": true }

Two consequences of having no attributes: member order is whatever the serialiser chooses rather than pinned, and everything public is serialised — which is why IsOutOfRange() is a method, so a server-side convenience check cannot leak into your wire contract. If you need the shape guaranteed regardless of serialiser configuration, project it onto a DTO in your API project.

The maximum page size is not optional: an uncapped pageSize is a denial-of-service vector against your database.

Paging a database query

ToPagedList has an IQueryable<T> overload that pushes paging to the provider — a COUNT plus OFFSET … FETCH NEXT, so only the page crosses the wire, rather than reading the table to count it. It is synchronous, though, which against a database blocks a thread-pool thread for the whole round trip. Note the corollary: if your source is in memory the IEnumerable<T> overload is equivalent, so a remote provider is the only case where the IQueryable<T> overload earns its keep — and that is precisely the case that should be asynchronous.

So write the async version once, in the layer that already references EF Core. It is eight lines, and ResultRly supplies everything except the two awaits:

public static async Task<PagedList<T>> ToPagedListAsync<T>(
    this IQueryable<T> source, PageRequest request, CancellationToken ct = default)
{
    var total = await source.LongCountAsync(ct);

    // Skip the second round trip when the requested page starts past the end of the data.
    if (request.Skip >= total)
    {
        return PagedList<T>.Empty(request);
    }

    var items = await source.Skip(request.Skip).Take(request.Take).ToListAsync(ct);
    return PagedList<T>.Create(items, request, total);
}
public async Task<Result<PagedList<CustomerDto>>> ListAsync(int? page, int? size, CancellationToken ct)
{
    var request = PageRequest.Clamp(page, size, defaultPageSize: 20, maxPageSize: 100);

    var page = await _db.Customers
        .Where(c => c.IsActive)
        .OrderBy(c => c.Name).ThenBy(c => c.Id)   // see below — a total ordering is required
        .Select(c => new CustomerDto(c.Id, c.Name, c.Email))
        .ToPagedListAsync(request, ct);

    return page.ToResult();
}

Three things that bite people, in order of how often:

  1. Order by a total ordering, or pages will lie. Skip/Take over an unordered SQL query has no defined result — page 2 may repeat or omit rows from page 1. Sorting on a non-unique column is not enough either; add a unique tie-break such as the primary key, as ThenBy(c => c.Id) does above.
  2. It is two round trips. If the client only needs "is there more", drop the count and fetch PageSize + 1 rows instead — cheaper, at the cost of not reporting totalCount.
  3. Deep offsets degrade. OFFSET 500000 makes the engine scan and discard half a million rows. Past a few thousand pages, switch to keyset paging: order by a unique key and filter with WHERE key > lastSeenKey. PageInfo does not model that; you would return the last key as a cursor instead.

Projecting with Select before paging, as above, also matters: it keeps the SELECT list to the columns the DTO needs instead of materialising whole entities you are about to discard.

If you narrow a query to IEnumerable<T>, ToPagedList still dispatches on the runtime type and pages through the provider. Overload resolution follows the static type, so that call would otherwise bind to the in-memory overload and read the entire table to return twenty rows — a silent cliff, so it is guarded and covered by a test.


The response envelope

Every response your API returns has the same five members, whichever way the operation went:

ApiResponse<T>
├── success        bool          the one member a client must always read
├── message        string?       confirmation, or the primary error's message
├── data           T?            the payload
├── meta           ApiMeta?      paging counts and anything else out of band
└── error          ApiError?     non-null exactly when success is false
    ├── code       string        the code to branch on
    ├── type       string        the ErrorType name the status was derived from
    └── fields     Dictionary<string, string[]>   every message, keyed by input

Nothing is omitted when empty. That is the point: a client reads response.error.code or response.meta.pagination after one null check, instead of probing for whether a key exists. A success:

{ "success": true, "message": null, "data": { "id": 1, "name": "Customer 1" }, "meta": null, "error": null }

A failure — same five members, same order:

{
  "success": false,
  "message": "Customer 99 was not found.",
  "data": null,
  "meta": null,
  "error": { "code": "customer.not_found", "type": "NotFound", "fields": { "": ["Customer 99 was not found."] } }
}

A validation batch. fields is GroupByTarget(), so every message sits under the input it belongs to and a form can render them in place:

{
  "success": false,
  "message": "Name is required.",
  "data": null,
  "meta": null,
  "error": {
    "code": "Validation",
    "type": "Validation",
    "fields": { "Name": ["Name is required."], "Email": ["Email must contain an @."] }
  }
}

A page. data is a flat array and the counts live in meta.pagination, which is what lets paged and unpaged endpoints share one shape:

{
  "success": true,
  "message": null,
  "data": [{ "id": 1 }, { "id": 2 }],
  "meta": {
    "pagination": { "page": 1, "pageSize": 2, "totalCount": 7, "totalPages": 4, "hasPrevious": false, "hasNext": true },
    "extra": null
  },
  "error": null
}

Building one:

ApiResponse.Ok("Deleted.")                    // success, no payload
ApiResponse<CustomerDto>.Ok(customer)         // success with a payload
ApiResponse.ForPage(pagedList)                // success, paging in meta
ApiResponse.Fail(result.Errors)               // failure; message defaults to the primary error's
ApiResponse.Fail(errors, "Please try again.") // failure with your own wording
ApiResponse<CustomerDto>.From(result)         // whichever way the result went

The statics sit on each type rather than being overloads of one name, mirroring Result/Result<T>. That is not stylistic: a single Ok with both an optional message and a generic payload makes ApiResponse.Ok("Deleted.") ambiguous, and C#'s preference for the non-generic candidate would silently resolve it as a message for someone who meant it as data.

Mutations confirm; reads do not

POST, PUT and DELETE should all say what they did. Pass the text through the conversion call and it lands in message:

service.Create(request).ToCreated(c => $"/customers/{c.Id}", "Customer created successfully.");
service.UpdateEmail(id, request).ToHttpResult("Customer email updated successfully.");
service.Delete(id).ToHttpResult("Customer deleted successfully.");
{
  "success": true,
  "message": "Customer deleted successfully.",
  "data": { "id": 2 },
  "meta": null,
  "error": null
}

GET responses leave message null — the data is the answer, and a "Customer retrieved successfully." on every read is noise. On a failure you need pass nothing either way: message already defaults to the primary error's message, so a mutation that fails explains itself.

A deletion cannot be 204 No Content if you want this. That status forbids a body, so it can carry neither a message nor an id — a 204 and a uniform envelope are mutually exclusive, and this is the one place the two rules in this README collide. The sample resolves it by having Delete return the id it removed:

public Result<ResourceId> Delete(int id) =>
    _customers.TryRemove(id, out _) ? new ResourceId(id) : Error.NotFoundByKey("Customer", id);

ResourceId is a record wrapping the int rather than the bare int, for the reason in the scalar note above: ApiResponse<int> writes "data": 0 on failure, which a client cannot distinguish from a real zero. ToNoContent() is still in the glue if you would rather have 204 and no confirmation.

Details worth knowing

message is not inside error. There is one place to look for text to show a user, whichever way the call went. error carries only machine-readable members.

error has no target. A failure can name several inputs, so one target member is the wrong shape; fields already keys every message by its input, and the primary error's target is the first key. Rules spanning two fields — and server faults, which belong to no input — go under "", the convention both model state and RFC 9457 use.

code, type and message describe the primary error, not the first. Primary is the highest-severity one, so a validation batch that also hides a server fault reports as a 500 rather than blaming the caller for bad input. fields still lists everything:

{
  "success": false,
  "message": "The database is unreachable.",
  "data": null,
  "meta": null,
  "error": {
    "code": "Unexpected",
    "type": "Unexpected",
    "fields": { "Email": ["Email is required."], "": ["The database is unreachable."] }
  }
}

fields is never null on a failure, even for a single non-validation error. It duplicates message in that case, and buys the guarantee that no error in a batch is ever dropped — since code and type only ever describe one of them.

type is a string, not the enum. Typed as ErrorType it would serialise as 2 on any host without a JsonStringEnumConverter registered — a silent contract change that no test in your API project would catch. As a string it does not depend on serialiser configuration at all.

Nothing is conditional, which is why the package still has no dependencies. No [JsonIgnore], no [JsonPropertyOrder], no attributes of any kind — an envelope that omitted empty members would need them, and they live in System.Text.Json. It also steps around a trap: suppressing nulls on data cannot be expressed for a value type, and WhenWritingNull on such a member makes every ApiResponse<int> throw at serialisation time.

The same caveat as PageInfo applies, for the same reason — member order is the serialiser's choice, not pinned by an attribute. In practice both System.Text.Json and Newtonsoft.Json follow declaration order, and the test suite asserts the exact sequence above so a change would not pass silently; but it is a convention rather than a guarantee. This costs nothing in practice, since JSON object members are unordered by definition and no client should depend on their sequence. What is guaranteed is that the members are always present, which is the part a client actually reads.

The consequence to know: a scalar payload is written even at its default, so ApiResponse<int> on failure says "data": 0 rather than null, and "no data" is indistinguishable from "zero". Return a DTO rather than a bare scalar and it never arises.

Names arrive as camelCase under ASP.NET Core, whose JSON defaults apply that policy. Elsewhere, pass JsonSerializerDefaults.Web.

meta.extra is the one extension point. Deriving from ApiMeta would not work — a serialiser writing a member typed ApiMeta emits only the members ApiMeta declares.

The three responses that are not the envelope

Worth stating, because "every response" is a claim that ought to be checkable:

  • 204 No Content has no body at all, by definition of the status — so ToNoContent() opts out of the envelope entirely. No endpoint in the sample uses it, precisely because mutations confirm; it is there for callers who prefer 204 to a confirmation.
  • application/problem+json, if you deliberately choose RFC 9457 for an endpoint — the sample has one such endpoint purely to show the alternative.
  • The OpenAPI document itself, which is not an API response.

Everything else is the envelope, including the responses your code never runs for: unknown route, wrong method, unsupported media type, unparseable body. Those are the easy ones to miss, since the framework emits them as a bare status code with no body — see the responses your code never sees below.


Wiring it into ASP.NET Core

Three concerns, two files, because the envelope above already ships in the package. Both files are in samples/ResultRly.Sample.API, and that project contains nothing else web-shaped.

1. Conversion extensions

using Microsoft.AspNetCore.Http;
using ResultRly;
using ResultRly.Http;
using ResultRly.Pagination;

public static class ResultHttpExtensions
{
    private static readonly IHttpStatusMapper Mapper = DefaultHttpStatusMapper.Instance;

    public static IResult ToHttpResult(this Result result, string? message = null, int successStatus = HttpStatus.Ok) =>
        result.IsSuccess
            ? Results.Json(ApiResponse.Ok(message), statusCode: successStatus)
            : Failure(result.Errors);

    public static IResult ToHttpResult<T>(this Result<T> result, string? message = null, int successStatus = HttpStatus.Ok) =>
        result.IsSuccess
            ? Results.Json(ApiResponse<T>.Ok(result.Value, message), statusCode: successStatus)
            : Failure<T>(result.Errors);

    public static IResult ToCreated<T>(this Result<T> result, Func<T, string> location, string? message = null) =>
        result.IsSuccess
            ? Results.Created(location(result.Value), ApiResponse<T>.Ok(result.Value, message))
            : Failure<T>(result.Errors);

    // The one place the envelope is legitimately absent on success: 204 means no body at all, so
    // this cannot carry a confirmation message. Prefer ToHttpResult(message) for mutations.
    public static IResult ToNoContent(this Result result) =>
        result.IsSuccess ? Results.NoContent() : Failure(result.Errors);

    public static IResult ToPagedHttpResult<T>(this Result<PagedList<T>> result, string? message = null) =>
        result.IsSuccess
            ? Results.Json(ApiResponse.ForPage(result.Value, message))
            : Failure<IReadOnlyList<T>>(result.Errors);

    // Awaits a pending result, so an async chain ends without an extra local.
    public static async Task<IResult> ToHttpResultAsync<T>(
        this Task<Result<T>> resultTask, string? message = null, int successStatus = HttpStatus.Ok) =>
        (await resultTask.ConfigureAwait(false)).ToHttpResult(message, successStatus);

    private static IResult Failure(ErrorCollection errors) =>
        Results.Json(ApiResponse.Fail(errors), statusCode: Mapper.Map(in errors));

    // Typed on the failure path too, so a client deserialising ApiResponse<T> gets the same shape
    // back whichever way the call went — data is simply null.
    private static IResult Failure<T>(ErrorCollection errors) =>
        Results.Json(ApiResponse<T>.Fail(errors), statusCode: Mapper.Map(in errors));
}

That is the whole integration. No configuration is needed alongside it: no JsonStringEnumConverter, because error.type is already a name, and no null-handling options, because nothing is suppressed.

app.MapGet("/customers/{id:int}", (int id, CustomerService s) => s.Get(id).ToHttpResult());
app.MapPost("/customers", (CreateCustomerRequest r, CustomerService s) =>
    s.Create(r).ToCreated(c => $"/customers/{c.Id}"));
app.MapDelete("/customers/{id:int}", (int id, CustomerService s) => s.Delete(id).ToNoContent());

For controllers, the same idea against ObjectResult:

public static IActionResult ToActionResult(this Result result, int successStatus = HttpStatus.Ok) =>
    result.IsSuccess
        ? new ObjectResult(ApiResponse.Ok()) { StatusCode = successStatus }
        : new ObjectResult(ApiResponse.Fail(result.Errors))
          { StatusCode = DefaultHttpStatusMapper.Instance.Map(result.Errors) };

2. The responses your code never sees

An unknown route, the wrong method, an unsupported media type or an unparseable body never reach an endpoint, so nothing above converts them. ASP.NET Core answers them with a bare status code and no body at all — which is exactly the case where a client trusting your contract gets a parse error instead of a diagnosis. UseStatusCodePages closes it, in the same file as the extensions above:

public static IApplicationBuilder UseEnvelopedStatusCodes(this IApplicationBuilder app) =>
    app.UseStatusCodePages(async context =>
    {
        var response = context.HttpContext.Response;
        var status = response.StatusCode;

        var error = Error.Create(MessageFor(status), TypeFor(status), code: $"http.{status}");

        // WriteAsJsonAsync leaves the status alone, which is what we want: the framework
        // already chose it, and it is right.
        await response.WriteAsJsonAsync(ApiResponse.Fail(error), context.HttpContext.RequestAborted);
    });

// 405 and 415 have no ErrorType and never will — the enum describes domain outcomes, not the
// HTTP surface — so report the nearest honest type and let the code carry the exact status.
private static ErrorType TypeFor(int status) => status switch
{
    HttpStatus.NotFound => ErrorType.NotFound,
    HttpStatus.Unauthorized => ErrorType.Unauthorized,
    HttpStatus.Forbidden => ErrorType.Forbidden,
    HttpStatus.TooManyRequests => ErrorType.TooManyRequests,
    _ => status >= 500 ? ErrorType.Unexpected : ErrorType.Failure,
};

Register it just after the exception middleware:

app.UseMiddleware<ResultExceptionMiddleware>();
app.UseEnvelopedStatusCodes();

A request to a route that does not exist then answers:

{
  "success": false,
  "message": "The requested resource was not found.",
  "data": null,
  "meta": null,
  "error": { "code": "http.404", "type": "NotFound", "fields": { "": ["The requested resource was not found."] } }
}

Note the status is taken as given rather than recomputed from the error — 405 stays 405.

3. Exception middleware

ResultException exists for the boundaries that cannot return a result. Unwrapping it here keeps its original status code, so nothing is lost by throwing rather than returning:

app.Use(async (context, next) =>
{
    try
    {
        await next(context);
    }
    // Only handle it if a response can still be written; once headers are on the wire the only
    // honest outcome is to let it propagate.
    catch (Exception ex) when (!context.Response.HasStarted)
    {
        // A client disconnect is not a server fault. Logging these as errors is the most common
        // source of false alarms in production dashboards.
        if (ex is OperationCanceledException && context.RequestAborted.IsCancellationRequested)
        {
            context.Response.StatusCode = HttpStatus.ClientClosedRequest;   // 499
            return;
        }

        ErrorCollection errors;

        if (ex is ResultException resultException)
        {
            errors = resultException.Errors;        // keeps a whole validation batch intact
        }
        else
        {
            context.RequestServices.GetRequiredService<ILoggerFactory>()
                .CreateLogger("UnhandledException")
                .LogError(ex, "Unhandled exception handling {Method} {Path}",
                    context.Request.Method, context.Request.Path);

            // Generic message: the real detail is logged, never sent.
            errors = Error.Unexpected("An unexpected error occurred while processing your request.");
        }

        context.Response.Clear();
        context.Response.StatusCode = DefaultHttpStatusMapper.Instance.Map(in errors);
        await context.Response.WriteAsJsonAsync(ApiResponse.Fail(errors));
    }
});

Map your own exceptions the same way — a database concurrency exception into Error.Conflict(…), your validation library's exception into a batch of Error.InvalidField(…).

Two things worth resisting: don't map ArgumentException to 400 (it almost always signals a server-side bug, and 400 blames the caller for your defect while hiding it from your error budget), and don't let the generic handler swallow OperationCanceledException.

Notes

  • Results.Json and WriteAsJsonAsync above use reflection-based serialisation. Under Native AOT, pass a JsonTypeInfo<T> from your own JsonSerializerContext instead — ResultRly itself contains no reflection, so that is the only change needed.
  • To make MVC binding failures look identical to domain failures, project ModelStateDictionary into an ErrorCollection (one Error.InvalidField per model error) inside ApiBehaviorOptions.InvalidModelStateResponseFactory.

Design decisions worth knowing

default(Result) is a failure, not a success. An uninitialised struct reports a single Unexpected error with code Uninitialized. A default-valued field must never read as "the operation worked".

Result and Result<T> are readonly structs. Success allocates nothing — measured at 0 B/op for create, map and copy — and a failure allocates only the Error objects. ErrorCollection stores a single error inline (the overwhelmingly common case) and only allocates an array for a batch. Its foreach enumerator is allocation-free. On x64: ErrorCollection is 16 bytes, Result 24, Result<int> 24.

Aggregation is single-pass. Combine, Sequence and Traverse gather errors into one exact-size array rather than folding with ErrorCollection.Concat, which would reallocate and recopy per failure — quadratic precisely where it hurts, on a large validation batch. Prefer them over hand-rolled Concat loops for the same reason. Measured over 200 failures: 865 ns / 1.6 KB single-pass versus 14,150 ns / 165 KB for the fold — and the gap widens with the failure count, which is the signature of the difference being complexity rather than a constant factor.

Returning a failure allocates only the Error — 56 B measured; throwing and catching one adds about 256 B on top, and BenchmarkDotNet timed the throw near 2,300 ns against ~3 ns for the return. The gap is the library's whole premise, and it is why ResultException is documented as a boundary tool rather than a control-flow mechanism.

Errors have value equality, which makes them trivial to assert on in tests.

Zero dependencies is a deliberate constraint, not a coincidence. A library this low in the dependency graph forces a version decision on every consumer, and that is the usual source of diamond conflicts. So nothing here references a package — not even System.Text.Json. The types are plain CLR types that serialise by convention, which leaves the choice of serialiser entirely with the application. ApiResponse is the sharpest case: a response envelope is the one type you would expect to need serialisation attributes, and it has none.

Every envelope member is written, even when null. ApiResponse emits all five of success, message, data, meta and error on every response, rather than omitting the empty ones. It costs about thirty bytes and buys two things: a client reads response.error.code after one null check instead of probing for key presence, and the envelope needs no [JsonIgnore] — which is what lets it ship in a package with no dependencies. See The response envelope for the consequences, including the one wrinkle for scalar payloads.

ErrorCollection.Primary is not First. Primary returns the highest-severity error, which is what status mapping uses. First returns declaration order. Confusing the two is how a server fault gets reported as a client error.

Validation messages contain no quote characters. System.Text.Json's default encoder escapes them to ', which would litter every validation response.

Try/TryAsync never swallow OperationCanceledException. Cancellation is not a domain failure; converting it would silently ignore the caller's token.

Polyfilled attributes are internal. IsExternalInit, NotNullWhen and friends are declared internally rather than published. A public IsExternalInit is the single most common source of duplicate-type collisions, because so many projects declare their own.

If you consume this from .NET Framework

ResultRly itself needs nothing extra — it brings no packages, so there is no dependency graph to reconcile and no binding redirects to generate on its account.

Two things you may hit in your own code, both demonstrated by the sample, which multi-targets net48:

  • record and init need a five-line IsExternalInit of your own — see samples/ResultRly.Sample/Polyfills.cs. ResultRly declares one internally and does not publish it, because a public copy is the most common source of duplicate-type collisions.
  • If you serialise with System.Text.Json, reference it yourself and set <AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects> — .NET Framework has no in-box copy, and it brings a graph of facade assemblies with it. The sample does exactly this, which is also how it proves the library is not smuggling the dependency in.

Licence

MIT. The full text is in the LICENSE file alongside this README, and the package declares the MIT license expression so NuGet resolves it without one.

Product Compatible and additional computed target framework versions.
.NET net5.0 was computed.  net5.0-windows was computed.  net6.0 was computed.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 was computed.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
.NET Core netcoreapp2.0 was computed.  netcoreapp2.1 was computed.  netcoreapp2.2 was computed.  netcoreapp3.0 was computed.  netcoreapp3.1 was computed. 
.NET Standard netstandard2.0 is compatible.  netstandard2.1 was computed. 
.NET Framework net461 was computed.  net462 was computed.  net463 was computed.  net47 was computed.  net471 was computed.  net472 was computed.  net48 was computed.  net481 was computed. 
MonoAndroid monoandroid was computed. 
MonoMac monomac was computed. 
MonoTouch monotouch was computed. 
Tizen tizen40 was computed.  tizen60 was computed. 
Xamarin.iOS xamarinios was computed. 
Xamarin.Mac xamarinmac was computed. 
Xamarin.TVOS xamarintvos was computed. 
Xamarin.WatchOS xamarinwatchos was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • .NETStandard 2.0

    • No dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.0 112 8/11/2026