Vulthil.Results provides a railway-oriented Result / Result<T> type that models success and failure explicitly, eliminating the need to throw exceptions for expected error conditions.
| Type | Description |
|---|---|
Result |
Represents success or failure without a value |
Result<T> |
Represents success with a value of type T, or failure |
Error |
Structured error with Code, Description, and ErrorType |
ValidationError |
An Error subtype that carries multiple inner errors |
// Success
Result ok = Result.Success();
Result<int> typed = Result.Success(42);
// Failure
Error error = Error.NotFound("User.NotFound", "User was not found");
Result<User> failed = Result.Failure<User>(error);
// Validation failure
var validation = new ValidationError([
Error.Validation("Email.Required", "Email is required"),
Error.Validation("Name.TooLong", "Name exceeds 100 characters")
]);
Result<User> invalid = Result.ValidationFailure<User>(validation);ErrorType determines how the error maps to HTTP status codes in the API layer:
| ErrorType | Factory Method | HTTP Status | Meaning |
|---|---|---|---|
Failure |
Error.Failure(...) |
500 Internal Server Error | Unclassified/unexpected failure |
NotFound |
Error.NotFound(...) |
404 Not Found | The requested resource doesn't exist |
Problem |
Error.Problem(...) |
400 Bad Request | Client-addressable business-rule violation; full error detail is returned |
Conflict |
Error.Conflict(...) |
409 Conflict | The request conflicts with current state |
Validation |
Error.Validation(...) / ValidationError |
400 Bad Request | Input validation failure; ValidationError.Errors carries the per-field details |
Error.Validation mints the inner errors of a ValidationError (this is what ValidationPipelineBehavior uses for
FluentValidation failures). Error.Problem is for a single, standalone business-rule error — it is a different
classification from Validation, not a supertype of it.
Keep domain errors as static fields so they are reusable and discoverable:
public static class UserErrors
{
public static readonly Error NotFound =
Error.NotFound("User.NotFound", "User was not found");
public static readonly Error EmailTaken =
Error.Conflict("User.EmailTaken", "Email is already in use");
}The library ships extension methods that let you compose operations without manual if/else branching.
Result<Order> result = await GetUser(userId)
.BindAsync(user => CreateOrder(user));If GetUser fails, the error propagates and CreateOrder is never called.
Result<string> email = getUser
.Map(user => user.Email);Result<User> result = await CreateUser(command)
.TapAsync(user => logger.LogInformation("Created {Id}", user.Id));string message = result.Match(
onSuccess: user => $"Welcome, {user.Name}",
onFailure: error => $"Error: {error.Description}");All extensions have synchronous and asynchronous overloads so they compose naturally with Task<Result<T>>.
Combine (a sequence of Result) and Zip (exactly two Result<T>) both follow the same aggregation rule:
- All succeed → success.
- Exactly one fails → that failure's original error propagates unwrapped. Combining a single
NotFoundfailure still surfaces asNotFound, not as a validation failure. - More than one fails → the failures are aggregated into a single
ValidationErrorwhoseErrorslist holds every failed error in order.
Result combined = ResultExtensions.Combine(CheckName(), CheckEmail(), CheckAge());
// combined.Error is the original NotFound/Conflict/etc. error if only one check failed,
// or a ValidationError aggregating all of them if more than one failed.
Result<(User, Order)> zipped = GetUser(userId).Zip(GetOrder(orderId));
// same rule: a single failure propagates as-is; two failures aggregate into a ValidationError.A few behaviors are easy to trip over. They are intentional for now; genuinely breaking fixes (marked below) are deferred to a future major version.
- Implicit
TValue? → Result<TValue>conversion swallowsnullas a failure. Assigning anullvalue to aResult<TValue>compiles and silently produces a failed result carryingError.NullValue— the conversion picks the error for you, and there is no compiler warning at the call site. Prefer constructing the result explicitly (Result.Failure<TValue>(someDomainError)) when the caller should choose the error. (Making this conversion explicit is a tracked future breaking change.) ResultandResult<T>have no value equality. Both are plain classes with noEquals/GetHashCodeoverride, so two results are only equal by reference — two separately-constructed successes (or two identically-failed results) are neverEquals-equal. CompareIsSuccess/Error/Valueinstead of the result itself. (Adding value equality is a tracked future breaking change.)- A custom error that "looks like"
Error.Noneis a valid failure error. TheResultconstructor rejects a failure whose error is literally theError.Nonesingleton, but a custom error with the same empty code and description (e.g.Error.Failure(string.Empty, string.Empty)) is a distinct object and is accepted — it is not treated as "no error" just because its field values match.
Vulthil.SharedKernel.Api provides helpers that turn a Result into the correct HTTP response with typed results for OpenAPI documentation:
// Minimal API endpoint – returns Results<Ok<UserDto>, ValidationProblem, NotFound, Conflict, ProblemHttpResult>
app.MapGet("/users/{id:guid}", async (Guid id, ISender sender) =>
{
var result = await sender.SendAsync(new GetUserQuery(id));
return result.ToIResult();
});
// Controller-based endpoint with typed results
public async Task<Results<Ok<UserDto>, ValidationProblem, NotFound, Conflict, ProblemHttpResult>> Get(Guid id)
{
var result = await _sender.SendAsync(new GetUserQuery(id));
return result.ToIResult();
}
// Controller-based endpoint with IActionResult
public async Task<IActionResult> Get(Guid id)
{
var result = await _sender.SendAsync(new GetUserQuery(id));
return result.ToActionResult(this);
}Success maps to 200 Ok<T> (Result<T>), 204 NoContent (non-generic Result), or 201 CreatedAtRoute<T>
via ToCreatedAtRouteHttpResult. Every failure returns an RFC 7807 problem body with the status from the
table above: detail carries the error's Description, and the error's Code
appears as a key in the problem extensions (mapped to the description). Validation failures return a
validation problem whose per-field errors dictionary is built from the ValidationError's inner errors. The
minimal-API and MVC paths produce the same status and detail for the same error.
One nuance of the typed union: at runtime NotFound and Conflict errors surface as a ProblemHttpResult
carrying 404/409 with that problem body — the NotFound/Conflict members of the union exist so OpenAPI
documents those status codes, not as the results actually returned.