Skip to content

Kentarohakase/Resulta

Repository files navigation

Resulta 🎯

A lightweight C# library for the Result pattern – error handling without exceptions.

NuGet NuGet Downloads CI License: MIT .NET


Why Resulta?

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)
    }
);

Installation

Core package

dotnet add package Resulta

Optional integrations

dotnet add package Resulta.AspNetCore
dotnet add package Resulta.FluentValidation

Quick Start

Ok & Fail

using 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 message

OkIf & FailIf

var result = Result.OkIf(user.IsActive, user, Error.Unauthorized("Account is inactive"));

var conflict = Result.FailIf(exists, resource, Error.Conflict("Already exists"));

Map & Bind – Chaining

var dto = LoadUser(1)
    .Bind(ConvertToDto)
    .Ensure(d => d.Email.Contains('@'), "Not a valid email")
    .Map(d => d with { Name = d.Name.Trim() });

Match – handle both cases

string response = result.Match(
    onSuccess: value => $"Result: {value}",
    onFailure: err   => $"Error: {err.Message}"
);

<<<<<<< HEAD

Success and null (reference types)

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).

Try and TryAsync

=======

Try – catch exceptions

2e990f0d7e8fdf7de2f918c595ecf2317a6942ba

var result = ResultExtensions.Try(
    () => int.Parse(input),
    ex  => new Error("Invalid number").WithCode("PARSE_ERROR")
);

CombineAsync – parallel async operations

var result = await ResultExtensions.CombineAsync(
    LoadUserAsync(id),
    LoadOrderAsync(id),
    LoadAddressAsync(id)
);

Error Class

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.Unexpected(exception);
var err = Error.Conflict("Name already taken");

// Error chain
var err = Error.NotFound("User")
    .WithCause(new Error("Database connection failed"));

ValidationResult – collect multiple errors

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}"))
);

Railway Pipelines

Synchronous

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}"
    );

Asynchronous

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}"
    );

ASP.NET Core Integration

dotnet add package Resulta.AspNetCore
builder.Services.AddResulta();
app.UseResulta();

[HttpGet("{id}")]
public IActionResult Get(int id)
    => _service.GetUser(id).ToActionResult(this);

app.MapGet("/api/users/{id}", (int id, UserService svc)
    => svc.GetUser(id).ToMinimalApiResult());
Error.Code HTTP Status
NOT_FOUND 404 Not Found
VALIDATION_ERROR 400 Bad Request
UNAUTHORIZED 401 Unauthorized
CONFLICT 409 Conflict
(anything else) 500 Internal Server Error

MVC (ToActionResult) and Minimal APIs (ToMinimalApiResult) use the same rules: validation errors may include a field property when present in Error.Metadata, and unknown codes return a JSON body with code INTERNAL_ERROR.


FluentValidation Bridge

dotnet add package Resulta.FluentValidation
public Result<User> Register(RegisterDto dto) =>
    _validator.ValidateToResult(dto).Bind(CreateUser);

public async Task<Result<User>> RegisterAsync(RegisterDto dto) =>
    await _validator.ValidateToResultAsync(dto).Bind(CreateUserAsync);

<<<<<<< HEAD

Project Structure

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

Releases and Versioning

Resulta follows Semantic Versioning:

  • MAJOR for breaking changes
  • MINOR for backwards-compatible features
  • PATCH for fixes and small improvements

NuGet packages are published on every MINOR or MAJOR version bump.

For release history, see CHANGELOG.md. For version bump rules and release guidance, see VERSIONING.md.


=======

2e990f0d7e8fdf7de2f918c595ecf2317a6942ba

Contributing

Contributions, issues and feature requests are welcome! Feel free to open an issue or submit a pull request on GitHub.


License

MIT – see LICENSE for details.