ViniBas.ResultPattern.AspNet
3.0.0
dotnet add package ViniBas.ResultPattern.AspNet --version 3.0.0
NuGet\Install-Package ViniBas.ResultPattern.AspNet -Version 3.0.0
<PackageReference Include="ViniBas.ResultPattern.AspNet" Version="3.0.0" />
<PackageVersion Include="ViniBas.ResultPattern.AspNet" Version="3.0.0" />
<PackageReference Include="ViniBas.ResultPattern.AspNet" />
paket add ViniBas.ResultPattern.AspNet --version 3.0.0
#r "nuget: ViniBas.ResultPattern.AspNet, 3.0.0"
#:package ViniBas.ResultPattern.AspNet@3.0.0
#addin nuget:?package=ViniBas.ResultPattern.AspNet&version=3.0.0
#tool nuget:?package=ViniBas.ResultPattern.AspNet&version=3.0.0
ViniBas.ResultPattern
Copyright (c) Vinícius Bastos da Silva 2025-2026
Licensed under the GNU Lesser General Public License v3 (LGPL v3).
See the LICENSE.txt file for details.
What is the Result Pattern?
The Result Pattern is a functional approach to error handling where methods return a Result object instead of throwing exceptions. This object explicitly indicates success or failure, carrying either the expected value or error details. It leads to cleaner, more predictable code — no hidden control flows, no swallowed exceptions, no guessing what a method can return.
About this library
ViniBas.ResultPattern brings the Result Pattern to .NET with two packages:
- ViniBas.ResultPattern — Core library with
Result,Result<T>,Error, andResultResponsetypes. No external dependencies, so it can be used in any layer (domain, application, infrastructure) and in any type of project — ASP.NET, Console, WPF, etc. - ViniBas.ResultPattern.AspNet — ASP.NET integration with
Matchextensions that automatically map results to HTTP responses (IActionResult,IResult, or typedResults<>), filters, ProblemDetails support, and more.
The library is free and open-source (LGPL v3). The goal is to be simple, clean, and effective: reduce verbosity, eliminate manual status code mapping with the Match API, and handle edge cases like ProblemDetails formatting and error type registration out of the box.
Installation
dotnet add package ViniBas.ResultPattern --version 3.0.0
dotnet add package ViniBas.ResultPattern.AspNet --version 3.0.0
Important: Starting from version 3.0.0, both packages follow the same versioning. When installing or updating, always use the same version for both packages, since
ViniBas.ResultPattern.AspNetdepends onViniBas.ResultPattern.
ViniBas.ResultPattern (Core)
Result and Result<TData>
Result and Result<TData> are the types you use as return values in your service methods. They indicate success or failure, and in the generic case, carry a Data property with the return value.
You can return an Error, a List<Error>, or the value directly — no need for verbose construction:
public Result<UserModel> GetUser(string name)
{
var user = _users.FirstOrDefault(u => u.Name == name);
if (user == null)
return Error.NotFound("UserNotFound", "User not found");
return user; // Implicit conversion to Result<UserModel>
}
For methods that don't return a value, use Result (non-generic):
public Result SaveNewUser(UserModel user)
{
if (string.IsNullOrWhiteSpace(user.Name))
return Error.Validation("Err1", "Name cannot be empty");
_users.Add(user);
return Result.Success();
}
You can also accumulate multiple errors of the same type:
public Result SaveNewUser(UserModel user)
{
var validationErrors = new List<Error>();
if (string.IsNullOrWhiteSpace(user.Name))
validationErrors.Add(Error.Validation("Err1", "Name cannot be empty"));
if (user.Age < 18)
validationErrors.Add(Error.Validation("Err2", "Age must be at least 18"));
if (validationErrors.Any())
return validationErrors; // Implicit conversion — errors are merged
_users.Add(user);
return Result.Success();
}
Checking the result:
var result = GetUser("Alice");
if (result.IsSuccess)
Console.WriteLine(result.Data.Name);
if (result.IsFailure)
foreach (var description in result.Error.ListDescriptions())
Console.WriteLine(description);
Error
The Error type encapsulates a list of ErrorDetails (code + description) and a Type string. Built-in error types and their factory methods:
| Factory Method | Type | Default HTTP Status |
|---|---|---|
Error.Failure(code, description) |
Failure |
500 |
Error.Validation(code, description) |
Validation |
400 |
Error.NotFound(code, description) |
NotFound |
404 |
Error.Conflict(code, description) |
Conflict |
409 |
Error.Unauthorized(code, description) |
Unauthorized |
401 |
Error.Forbidden(code, description) |
Forbidden |
403 |
You can create custom error types by registering them and, optionally, mapping them to an HTTP status code:
Error.ErrorTypes.AddTypes("NotAcceptable");
GlobalConfiguration.ErrorTypeMaps.TryAdd("NotAcceptable", (StatusCodes.Status406NotAcceptable, "Not Acceptable"));
Then create instances using the constructor:
var error = new Error("ErrCode", "Description", "NotAcceptable");
ResultResponse
The ResultResponse family (ResultResponseSuccess, ResultResponseSuccess<TData>, ResultResponseError) are DTOs designed for HTTP transport. They are generated automatically from result.ToResponse() and contain a clean, serializable representation of the result — without implementation details.
- Success:
IsSuccess = true, optionally withData. - Error:
IsSuccess = false, withErrors(list ofErrorDetails) andType.
ViniBas.ResultPattern.AspNet
Match Extensions
The core feature of the ASP.NET package is the Match method, available as extension methods on Result, Result<TData>, and ResultResponse. It maps the result to the appropriate HTTP response by accepting two optional callbacks: onSuccess and onFailure. Both can be omitted — when they are, the library uses built-in fallback behavior (see Fallback Behavior).
MVC (IActionResult)
using ViniBas.ResultPattern.AspNet.Mvc;
[ApiController]
[Route("[controller]")]
public class UsersController : ControllerBase
{
// Provide both onSuccess and onFailure
[HttpGet("health/{alive}")]
public IActionResult Health(bool alive)
=> _userService.Health(alive).Match(Ok, BadRequest);
// Omit onFailure — the library returns the appropriate status automatically
[HttpGet("{name}")]
public IActionResult Get(string name)
=> _userService.GetUserByName(name).Match(r => Ok(r.Data));
// Omit both — defaults to Ok on success, automatic error handling on failure
[HttpPost]
public IActionResult Create(UserModel user)
=> _userService.SaveNewUser(user).Match();
}
Minimal API — Generic (IResult)
using ViniBas.ResultPattern.AspNet.MinimalApi;
app.MapGet("health/{alive}",
(bool alive) => _userService.Health(alive).Match(Results.Ok, Results.BadRequest));
app.MapGet("{name}",
(string name) => _userService.GetUserByName(name).Match(r => Results.Ok(r.Data)));
app.MapPost("",
(UserModel user) => _userService.SaveNewUser(user).Match());
When using the generic Match in Minimal API, the return type is IResult. This means ASP.NET does not infer the response metadata automatically — you need to add it manually with .Produces() and .ProducesProblem() for OpenAPI documentation:
app.MapGet("{name}",
(string name) => _userService.GetUserByName(name).Match(r => Results.Ok(r.Data)))
.Produces<UserModel>(200)
.ProducesProblem(404);
Minimal API — Typed
For endpoints where you want ASP.NET to automatically infer response metadata for OpenAPI, use the typed match extensions. The framework knows all possible response types at build time, so documentation is generated automatically.
There are two variants:
Match<TResult> — Returns a single typed result. Useful when you know only one response type will be returned (e.g., Ok<UserModel>):
app.MapGet("{name}", (string name) =>
_userService.GetUserByName(name)
.Match<Ok<UserModel>, UserModel>(r => TypedResults.Ok(r.Data)));
MatchResults — Returns a Results<T1, T2, ...> union type, for endpoints with multiple possible response types:
using ViniBas.ResultPattern.AspNet.MinimalApi;
app.MapGet("health/{alive}", (bool alive) =>
_userService.Health(alive)
.MatchResults<Ok<ResultResponseSuccess>, BadRequest<ResultResponseError>>
(r => TypedResults.Ok(r), r => TypedResults.BadRequest(r)));
app.MapGet("{name}", (string name) =>
_userService.GetUserByName(name)
.MatchResults<Ok<UserModel>, NotFound<ProblemDetails>, UserModel>
(r => TypedResults.Ok(r.Data)));
// Omit both callbacks — the library maps the result to the appropriate typed response
app.MapPost("", (UserModel user) =>
_userService.SaveNewUser(user)
.MatchResults<Ok<ResultResponseSuccess>, BadRequest<ResultResponseError>, Conflict<ResultResponseError>>());
Trade-off: With typed results, you lose compile-time validation of return types (since the library internally casts IResult to the declared Results<> type). In exchange, you get runtime validation: if a result doesn't match any of the declared types, a TypedResultCastException is thrown with a descriptive message showing the expected vs. actual types.
To ensure this doesn't cause errors in production, register the TypedResultCastExceptionHandler. It's an IExceptionHandler that catches TypedResultCastException, logs the issue, and returns the original IResult instead of letting it propagate as a 500 error:
// In Program.cs — register the handler and the exception handling middleware
builder.Services.AddExceptionHandler<TypedResultCastExceptionHandler>();
var app = builder.Build();
if (app.Environment.IsProduction())
app.UseExceptionHandler();
All Match and MatchResults extensions also have async variants (MatchAsync / MatchResultsAsync).
Fallback Behavior
When onSuccess or onFailure callbacks are omitted (or passed as null), the library uses built-in fallback logic:
Success fallback (onSuccess omitted):
- If
UnwrapSuccessDataisfalse(default): returns200 OKwith the fullResultResponseSuccesswrapper. - If
UnwrapSuccessDataistrue: returns200 OKwithDatadirectly forResult<TData>, or an empty200 OK/204 No ContentforResult.
Failure fallback (onFailure omitted):
- If
UseProblemDetailsistrue(default): returns an RFC 7807ProblemDetailsresponse with the HTTP status code mapped from the error type. - If
UseProblemDetailsisfalse: returns the rawResultResponseError(or just theErrorslist whenUnwrapSuccessDataistrue) with the mapped status code.
For typed match extensions (Match<TResult> and MatchResults), the fallback uses GlobalConfiguration.TypedResultMaps to determine which typed result wrapper to produce for each HTTP status code. The default mappings cover common status codes (Ok, Created, NoContent, BadRequest, NotFound, Conflict, UnprocessableEntity, Unauthorized, Forbid, and Failure on .NET 9+). You can add or replace entries to customize the typed result for any status code:
GlobalConfiguration.TypedResultMaps[StatusCodes.Status418ImATeapot] = TypedResultBuilders.Json(418);
If you need full control, you can replace the entire fallback logic per return type using FallbackOverrides:
GlobalConfiguration.FallbackOverrides.Mvc = (resultResponse, context) =>
{
// Custom fallback logic for MVC (IActionResult) endpoints
// context.UseProblemDetails and context.UnwrapSuccessData reflect the active configuration
};
GlobalConfiguration.FallbackOverrides.MinimalApi = (resultResponse, context) =>
{
// Custom fallback logic for Minimal API (IResult) endpoints
};
Global Configuration
Configure the library behavior globally in Program.cs:
using ViniBas.ResultPattern.AspNet.Configurations;
// Return ProblemDetails on failure (default: true)
GlobalConfiguration.UseProblemDetails = true;
// When true, success responses return Data directly instead of the ResultResponseSuccess wrapper (default: false)
GlobalConfiguration.UnwrapSuccessData = false;
You can also provide a custom ProblemDetails factory:
GlobalConfiguration.ProblemDetailsOverride = error => new ProblemDetails
{
Title = "Custom title",
Status = 500,
Detail = string.Join(", ", error.Errors.Select(e => e.Description))
};
Scoped Configuration
Override global settings for a specific scope using ScopedConfiguration:
using ViniBas.ResultPattern.AspNet.Configurations;
[HttpPost]
public IActionResult Create(UserModel user)
{
using (ScopedConfiguration.Override(useProblemDetails: false, unwrapSuccessData: true))
{
return _userService.SaveNewUser(user).Match();
}
}
The override applies only within the using block and is automatically restored afterward. It works per async context, so concurrent requests are not affected.
ProblemDetails
When UseProblemDetails is true (default), failure responses follow the RFC 7807 standard using ASP.NET's native ProblemDetails. The response includes:
title: Mapped fromGlobalConfiguration.ErrorTypeMaps(e.g., "Not Found")status: HTTP status code mapped from the error typedetail: Error descriptionsextensions.isSuccess:falseextensions.errors: List ofErrorDetails(code + description)extensions.descriptions: List of error description strings
Filters
Two filters are included to automatically convert Result, Error, or ResultResponse objects returned directly from endpoints into proper HTTP responses:
MVC:
builder.Services.AddControllers(opt => opt.Filters.Add<ResponseMappingFilter>());
Minimal API:
app.MapDelete("{name}", (string name) => _userService.Delete(name))
.WithResponseMappingFilter();
// Or without the extension method:
app.MapDelete("{name}", handler)
.AddEndpointFilter<ResponseMappingEndpointFilter>();
With the filter registered, you can return Result directly from your endpoint without calling Match:
[HttpDelete("{userName}")]
public Result Delete(string userName)
=> _userService.HardDeleteUser(userName);
ModelState Extensions (MVC)
Convert ModelStateDictionary to Error or directly to an IActionResult with ProblemDetails:
if (!ModelState.IsValid)
return ModelState.ToProblemDetailsActionResult();
// Or get the Error object for further handling
Error error = ModelState.ModelStateToError();
Demo
A demonstration ASP.NET project with examples for MVC, Minimal API (generic), and Minimal API (typed) can be found in the samples folder of the repository.
Target Frameworks
Both packages support: net8.0, net9.0, net10.0.
License
LGPL v3 — see LICENSE.txt for details.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. 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 is compatible. 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 is compatible. 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. |
-
net10.0
- ViniBas.ResultPattern (>= 3.0.0)
-
net8.0
- ViniBas.ResultPattern (>= 3.0.0)
-
net9.0
- ViniBas.ResultPattern (>= 3.0.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
v3.0.0: ScopedConfiguration; UnwrapSuccessData; ProblemDetailsOverride; FallbackOverrides; TypedResultBuilders and TypedResultMaps; typed Match extensions (Match<TResult> and MatchResults); TypedResultCastExceptionHandler; MatchAsync for all variants; filters renamed to ResponseMappingFilter and ResponseMappingEndpointFilter. Both packages now follow the same versioning.