ResultRly 1.0.0
dotnet add package ResultRly --version 1.0.0
NuGet\Install-Package ResultRly -Version 1.0.0
<PackageReference Include="ResultRly" Version="1.0.0" />
<PackageVersion Include="ResultRly" Version="1.0.0" />
<PackageReference Include="ResultRly" />
paket add ResultRly --version 1.0.0
#r "nuget: ResultRly, 1.0.0"
#:package ResultRly@1.0.0
#addin nuget:?package=ResultRly&version=1.0.0
#tool nuget:?package=ResultRly&version=1.0.0
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
- What it gives you
- Quick start
- Choosing between this and the alternatives
- Creating errors
- Status code mapping
- Pagination
- The response envelope
- Wiring it into ASP.NET Core
- Design decisions worth knowing
- Licence
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.Primaryselects the highest-severity error;GroupByTarget(),ForTarget()andContainsTarget()slice a validation batch by field.- Composition —
Map,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 mapping —
ErrorType→ status code, with per-type and per-code overrides. Built on plainintconstants, so it carries no web framework with it. - Pagination —
PageRequest(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
Resultis a 24-byte struct — measured with BenchmarkDotNet during development (figures below). - You want validation batches to be first class —
ValidationBuilderandSequencecollect every error, andErrorCollection.Primarymakes 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 category — NotFound, 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:
- Order by a total ordering, or pages will lie.
Skip/Takeover 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, asThenBy(c => c.Id)does above. - It is two round trips. If the client only needs "is there more", drop the count and fetch
PageSize + 1rows instead — cheaper, at the cost of not reportingtotalCount. - Deep offsets degrade.
OFFSET 500000makes 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 withWHERE key > lastSeenKey.PageInfodoes 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 Contenthas no body at all, by definition of the status — soToNoContent()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.JsonandWriteAsJsonAsyncabove use reflection-based serialisation. Under Native AOT, pass aJsonTypeInfo<T>from your ownJsonSerializerContextinstead — ResultRly itself contains no reflection, so that is the only change needed.- To make MVC binding failures look identical to domain failures, project
ModelStateDictionaryinto anErrorCollection(oneError.InvalidFieldper model error) insideApiBehaviorOptions.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:
recordandinitneed a five-lineIsExternalInitof your own — seesamples/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 | Versions 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. |
-
.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 |