A lightweight C# library for the Result pattern – error handling without exceptions.
Instead of:
// ❌ Exceptions as control flow – hard to read, easy to forget
try {
var user = GetUser(id); // throws NotFoundException
return Ok(user);
} catch (NotFoundException ex) {
return NotFound(ex.Message);
} catch (Exception ex) {
return StatusCode(500, ex.Message);
}Use this:
// ✅ Explicit, type-safe, no try/catch
return GetUser(id).Match<IActionResult>(
onSuccess: user => Ok(user),
onFailure: err => err.Code switch
{
"NOT_FOUND" => NotFound(err.Message),
_ => StatusCode(500, err.Message)
}
);Resulta packages target both net8.0 and net10.0.
dotnet add package Resultadotnet add package Resulta.AspNetCore
dotnet add package Resulta.FluentValidationusing Resulta;
Result<int> Divide(int a, int b)
{
if (b == 0)
return Result.Fail<int>("Division by zero!");
return Result.Ok(a / b);
}
var result = Divide(10, 2);
if (result.IsSuccess)
Console.WriteLine(result.Value); // 5
else
Console.WriteLine(result.Error); // error messagevar result = Result.OkIf(user.IsActive, user, Error.Unauthorized("Account is inactive"));
var conflict = Result.FailIf(exists, resource, Error.Conflict("Already exists"));var dto = LoadUser(1)
.Bind(ConvertToDto)
.Ensure(d => d.Email.Contains('@'), "Not a valid email")
.Map(d => d with { Name = d.Name.Trim() });string response = result.Match(
onSuccess: value => $"Result: {value}",
onFailure: err => $"Error: {err.Message}"
);For reference types, Result<T>.Ok(value) still represents success when value is null. If null is invalid in your domain, validate explicitly and return Result<T>.Fail(...) instead. Value types are unaffected (Result<int>.Ok always carries a value).
var result = ResultExtensions.Try(
() => int.Parse(input),
ex => new Error("Invalid number").WithCode("PARSE_ERROR")
);var result = await ResultExtensions.CombineAsync(
LoadUserAsync(id),
LoadOrderAsync(id),
LoadAddressAsync(id)
);var err = new Error("Not found")
.WithCode("NOT_FOUND")
.WithMetadata("id", 42);
// Predefined factories
var err = Error.NotFound("Product");
var err = Error.Validation("email", "Invalid email address");
var err = Error.Unauthorized();
var err = Error.Forbidden();
var err = Error.Unexpected(exception);
var err = Error.Conflict("Name already taken");
var err = Error.Unprocessable("Cannot process the submitted state");
var err = Error.TooManyRequests();
// Error chain
var err = Error.NotFound("User")
.WithCause(new Error("Database connection failed"));var result = Validator<RegisterDto>.For(dto)
.Must(d => d.Name.Length >= 2, Error.Validation("name", "At least 2 characters"))
.Must(d => d.Email.Contains('@'), Error.Validation("email", "Must be a valid email"))
.Must(d => d.Age >= 18, Error.Validation("age", "Must be at least 18"))
.Validate();
result.Match(
onSuccess: dto => Console.WriteLine($"Registered: {dto.Name}"),
onFailure: errors => errors.ToList().ForEach(e => Console.WriteLine($" x {e.Message}"))
);var token = Pipeline<string>
.Start(input)
.Validate(s => s.Length > 0, "Must not be empty")
.Then(s => FindUser(s))
.Tap(user => logger.LogInformation("Login: {Name}", user.Name))
.Then(user => CreateToken(user))
.Finally(
onSuccess: t => $"Token: {t}",
onFailure: err => $"Error: {err.Message}"
);var result = await AsyncPipeline<Order>
.Start(() => LoadOrderAsync(id))
.Validate(order => order.Items.Count > 0, "Order must contain at least one item")
.ThenAsync(order => ReserveStockAsync(order))
.Tap(order => logger.LogInformation("Stock reserved: {Id}", order.Id))
.ThenAsync(order => ProcessPaymentAsync(order))
.TapAsync(async order => await SendConfirmationAsync(order))
.Finally(
onSuccess: _ => "Order placed successfully!",
onFailure: e => $"Error: {e.Message}"
);dotnet add package Resulta.AspNetCorebuilder.Services.AddResulta();
app.UseResulta();
// MVC
[HttpGet("{id}")]
public IActionResult Get(int id)
=> _service.GetUser(id).ToActionResult(this);
// Minimal API
app.MapGet("/api/users/{id}", (int id, UserService svc)
=> svc.GetUser(id).ToMinimalApiResult());
// Minimal API with TypedResults + OpenAPI annotations
app.MapGet("/api/users/{id}", (int id, UserService svc)
=> svc.GetUser(id).ToTypedResult())
.ProducesResultaErrors();Error.Code |
HTTP Status |
|---|---|
NOT_FOUND |
404 Not Found |
VALIDATION_ERROR |
400 Bad Request |
UNAUTHORIZED |
401 Unauthorized |
FORBIDDEN |
403 Forbidden |
CONFLICT |
409 Conflict |
UNPROCESSABLE |
422 Unprocessable Entity |
TOO_MANY_REQUESTS |
429 Too Many Requests |
| (anything else) | 500 Internal Server Error |
All failure responses use the RFC 7807 application/problem+json format. Validation errors return HttpValidationProblemDetails with an errors dictionary keyed by field name. Other codes return ProblemDetails. The original Error.Code is preserved on every response as the code extension property:
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.4",
"title": "Not Found",
"status": 404,
"detail": "'User' was not found.",
"instance": "/api/users/42",
"code": "NOT_FOUND"
}For type-safe Minimal API endpoints with full OpenAPI metadata, use ToTypedResult() together with ProducesResultaErrors() — the endpoint then advertises every possible response shape (200/204, 404, 400 validation, 409, and ProblemHttpResult for other error responses).
builder.Services.AddResulta(options =>
{
options.MapError("RATE_LIMITED", StatusCodes.Status429TooManyRequests,
"Rate Limited", "https://example.com/problems/rate-limited");
options.ConfigureProblemDetails = (problem, error, http) =>
{
problem.Extensions["traceId"] = http?.TraceIdentifier;
};
});The core package also ships System.Text.Json converters if you want to serialize Result<T> over the wire (for inter-service messaging, queues, or persistence) instead of mapping through HTTP:
var options = new JsonSerializerOptions().AddResultaConverters();
var json = JsonSerializer.Serialize(Result<int>.Fail(Error.Validation("email", "Invalid")), options);
// → {"isSuccess":false,"error":{"message":"Validation failed for 'email': Invalid","code":"VALIDATION_ERROR","field":"email"}}The converters never leak exception stack traces and truncate causedBy chains at three levels.
dotnet add package Resulta.FluentValidationpublic Result<User> Register(RegisterDto dto) =>
_validator.ValidateToResult(dto).Bind(CreateUser);
public async Task<Result<User>> RegisterAsync(RegisterDto dto) =>
await _validator.ValidateToResultAsync(dto).Bind(CreateUserAsync);Resulta/
├── Resulta/
│ ├── src/
│ │ ├── Result.cs
│ │ ├── ResultT.cs
│ │ ├── Error.cs
│ │ └── ResultExtensions.cs
│ └── extensions/
│ ├── ValidationResult.cs
│ └── Pipeline.cs
├── Resulta.AspNetCore/
│ └── AspNetCoreIntegration.cs
├── Resulta.FluentValidation/
│ └── FluentValidationBridge.cs
├── samples/
│ └── Resulta.Samples/ # optional console demos (not packed)
├── Resulta.Tests/
├── .github/
│ └── workflows/
│ ├── ci.yml
│ └── release.yml
├── CHANGELOG.md
├── VERSIONING.md
└── README.md
Resulta follows Semantic Versioning:
- MAJOR for breaking changes
- MINOR for backwards-compatible features
- PATCH for fixes and small improvements
GitHub Releases are created for every vX.Y.Z tag. NuGet packages are published only for MINOR or MAJOR tags where the patch component is 0 (for example v3.1.0 or v4.0.0). Patch tags such as v3.0.1 are GitHub-only.
For release history, see CHANGELOG.md. For version bump rules and release guidance, see VERSIONING.md.
Contributions, issues and feature requests are welcome! Feel free to open an issue or submit a pull request on GitHub.
MIT – see LICENSE for details.