A modern, type-safe Result Pattern implementation for .NET with Railway-Oriented Programming support. Handle success and failure cases elegantly without exceptions.
- β .NET 8 & .NET 10 support (.NET 6 dropped β end of life)
- β
Dependency-free core β the ASP.NET Core integration moved to its own package,
eQuantic.Core.Outcomes.AspNetCore; the core no longer referencesMicrosoft.AspNetCore.App - β System.Text.Json serialization attributes (Newtonsoft.Json dependency removed)
- β FluentValidation 12 in the integration package
- β Automated releases β semantic-release computes versions from commit messages (see docs/releasing.md)
Everything from v2 is still here: immutable records, Railway-Oriented Programming (Map/Bind/Match), typed errors, full async support, result combinators, observability and RFC 7807 integration.
| Package | Purpose |
|---|---|
eQuantic.Core.Outcomes |
The Result Pattern core β dependency-free |
eQuantic.Core.Outcomes.AspNetCore |
Result β HTTP responses with Problem Details |
eQuantic.Core.Outcomes.FluentValidation |
FluentValidation results as typed failures |
dotnet add package eQuantic.Core.Outcomes
dotnet add package eQuantic.Core.Outcomes.AspNetCore # for web APIs
dotnet add package eQuantic.Core.Outcomes.FluentValidation # for validation pipelinesπ Built-in Observability & Distributed Tracing
- β CorrelationId & TraceId - Native OpenTelemetry, Jaeger, Zipkin integration
- β
ExecutionTime - Automatic performance measurement with
Timed()/TimedAsync() - β Metadata - Rich contextual data for APM tools (Datadog, New Relic, Application Insights)
- π₯ UNIQUE: Only Result library with native observability support for microservices!
π Most Complete Result Combinators
- β
Combine()- All-or-nothing aggregation - β
Zip()- Type-safe combination of 2-4 results - β
FirstSuccess()- Fallback/retry patterns - β
SuccessfulValues()- Graceful degradation - β
MergeErrors()- Comprehensive error collection - β
Partition()- Success/failure separation - π₯ UNIQUE: 6 powerful combinators vs. 0-1 in other libraries!
β¨ Seamless FluentValidation Integration
- β
Optional package:
eQuantic.Core.Outcomes.FluentValidation - β
.ToResult()automatic conversion - β
.Validate()/.ValidateAsync()pipeline integration - π₯ UNIQUE: Only library with native FluentValidation support!
π Advanced ASP.NET Core Integration
- β Automatic RFC 7807 Problem Details conversion
- β Smart ErrorType β HTTP Status mapping
- β
ToActionResult(),ToCreatedAtActionResult(),ToNoContentResult() - β REST API best practices built-in
β‘ Complete Async/Await Support
- β
All operations:
MapAsync,BindAsync,MatchAsync,TapAsync,EnsureAsync - β Task unwrapping for cleaner pipelines
- β
CancellationTokensupport throughout - β
Optimized with
ConfigureAwait(false)
π¨ Rich Typed Error System
// 8 semantic error types with factory methods
Error.Validation() // Validation failures
Error.NotFound() // Resource not found
Error.Conflict() // Resource conflicts
Error.Unauthorized() // Authentication failures
Error.Forbidden() // Permission denials
Error.BusinessRule() // Domain rule violations
Error.Technical() // Infrastructure failures
Error.External() // Third-party failures| Feature | eQuantic.Outcomes | FluentResults | ErrorOr | Ardalis.Result |
|---|---|---|---|---|
| Observability/APM | β Built-in | β None | β None | β None |
| Result Combinators | β 6 patterns | β None | β None | |
| FluentValidation | β Native | β Manual | β Manual | β Manual |
| RFC 7807 Auto | β Yes | β No | ||
| Async Support | β Complete | β Limited | ||
| Railway-Oriented | β Full | |||
| Typed Errors | β 8 types | |||
| .NET 8/10 | β Yes | β Yes | β Yes | β Yes |
| XML Docs | β Complete |
Our Advantage: We're the ONLY library combining observability, comprehensive combinators, FluentValidation integration, and full Railway-Oriented Programming in a modern, well-documented package.
using eQuantic.Core.Outcomes;
using eQuantic.Core.Outcomes.Errors;
// Success case
var successResult = Result<int>.Success(42);
Console.WriteLine(successResult.Value); // 42
// Failure case
var error = new Error("USER_001", "User not found", ErrorType.NotFound);
var failureResult = Result<User>.Failure(error);
if (failureResult.IsFailure)
{
Console.WriteLine(failureResult.FirstError.Message);
}// Implicitly convert from value to Result
Result<string> result = "Hello, World!";
// Implicitly convert from error to Result
Result<int> errorResult = new Error("ERR001", "Something went wrong");Chain operations elegantly with automatic short-circuiting on failures:
using eQuantic.Core.Outcomes.Extensions;
var result = GetUser(userId)
.Map(user => user.Email)
.Ensure(email => email.Contains("@"),
Error.Validation("VAL001", "Invalid email format"))
.Bind(email => SendWelcomeEmail(email))
.Tap(email => _logger.LogInformation($"Email sent to {email}"));
return result.ToActionResult();Transform the value inside a successful result:
Result<int> numberResult = Result<int>.Success(5);
Result<string> stringResult = numberResult.Map(n => n.ToString());
// Result: Success("5")Chain operations that return Results:
Result<User> GetUser(int id) { /* ... */ }
Result<Profile> GetProfile(User user) { /* ... */ }
var profileResult = GetUser(userId)
.Bind(user => GetProfile(user));Handle both success and failure cases:
var message = result.Match(
onSuccess: user => $"Welcome, {user.Name}!",
onFailure: errors => $"Error: {errors.First().Message}"
);Add validation inline in your chain:
var result = Result<int>.Success(5)
.Ensure(n => n > 0, Error.Validation("VAL001", "Must be positive"))
.Ensure(n => n < 100, Error.Validation("VAL002", "Must be less than 100"));Execute side effects without breaking the chain:
var result = GetUser(userId)
.Tap(user => _logger.LogInformation($"User {user.Id} retrieved"))
.TapError(errors => _logger.LogError($"Failed: {errors.First().Message}"))
.Map(user => new UserDto(user));All operations have async variants:
using eQuantic.Core.Outcomes.Extensions;
var result = await GetUserAsync(userId)
.MapAsync(user => TransformUserAsync(user))
.BindAsync(user => ValidateUserAsync(user))
.EnsureAsync(user => CheckPermissionsAsync(user),
Error.Forbidden("AUTH001", "Access denied"))
.TapAsync(user => LogActivityAsync(user));Wrap async operations with automatic exception handling:
var result = await _httpClient
.GetStringAsync("https://api.example.com/data")
.ToResultAsync();
if (result.IsSuccess)
{
var data = result.Value;
}Create strongly-typed errors with semantic meaning:
// Validation errors
var error = Error.Validation("VAL001", "Email is required", "Email");
// Not found errors
var error = Error.NotFound("USER_001", "User not found", userId);
// Business rule errors
var error = Error.BusinessRule("BIZ001", "Cannot delete active subscription");
// Conflict errors
var error = Error.Conflict("CONF001", "Email already exists");
// Authorization errors
var error = Error.Unauthorized("AUTH001", "Invalid credentials");
var error = Error.Forbidden("AUTH002", "Insufficient permissions");
// Technical errors
var error = Error.Technical("TECH001", "Database connection failed", exception);
// External service errors
var error = Error.External("EXT001", "Payment gateway timeout", exception);
// From exceptions
var error = Error.FromException(exception, "ERR001", ErrorType.Technical);var validationError = new ValidationError(
code: "VAL001",
message: "Email format is invalid",
propertyName: "Email",
attemptedValue: "invalid-email"
);Optional Package: eQuantic.Core.Outcomes.FluentValidation
Seamlessly integrate FluentValidation with the Result Pattern:
dotnet add package eQuantic.Core.Outcomes.FluentValidationusing eQuantic.Core.Outcomes.FluentValidation;
using FluentValidation;
// Define your validator
public class CreateUserRequestValidator : AbstractValidator<CreateUserRequest>
{
public CreateUserRequestValidator()
{
RuleFor(x => x.Email)
.NotEmpty()
.EmailAddress();
RuleFor(x => x.Age)
.GreaterThanOrEqualTo(18);
}
}
// Use directly with validator
var validator = new CreateUserRequestValidator();
var result = validator.Validate(request);
if (result.IsSuccess)
{
// Process valid request
}
// Or chain with Railway-Oriented Programming
var createResult = Result<CreateUserRequest>.Success(request)
.Validate(validator)
.Bind(req => CreateUserAsync(req))
.Map(user => new UserDto(user));var validationResult = validator.Validate(request);
// Convert to Result<T>
var result = validationResult.ToResult(request);
// Or non-generic Result
var result = validationResult.ToResult();// Direct async validation
var result = await validator.ValidateAsync(request);
// Chain async validation
var finalResult = await Result<CreateUserRequest>.Success(request)
.ValidateAsync(validator)
.BindAsync(req => CreateUserAsync(req));
// Validate Task<Result<T>>
var result = await GetUserAsync(id)
.ValidateAsync(validator);var result = GetUser(userId)
.Validate(userValidator) // Validate if successful
.Bind(user => GetProfile(user.Id))
.Validate(profileValidator) // Chain validations
.Map(profile => new ProfileDto(profile));FluentValidation errors are automatically converted to ValidationError:
var result = validator.Validate(invalidRequest);
result.IsFailure.Should().BeTrue();
result.Errors.Should().AllBeOfType<ValidationError>();
var emailError = result.Errors
.OfType<ValidationError>()
.FirstOrDefault(e => e.PropertyName == "Email");
// ValidationError properties:
// - Code (from ErrorCode)
// - Message (from ErrorMessage)
// - PropertyName
// - AttemptedValueAutomatic conversion to HTTP responses with Problem Details (RFC 7807). Since v3 this lives in its own package, keeping the core dependency-free:
dotnet add package eQuantic.Core.Outcomes.AspNetCoreusing eQuantic.Core.Outcomes.AspNetCore;
[HttpGet("{id}")]
public IActionResult GetUser(int id)
{
var result = _userService.GetById(id);
return result.ToActionResult();
}
// Automatic mapping:
// Success β 200 OK
// NotFound β 404 Not Found with ProblemDetails
// Validation β 400 Bad Request with ValidationProblemDetails
// Unauthorized β 401 Unauthorized
// Forbidden β 403 Forbidden
// Conflict β 409 Conflict
// BusinessRule β 422 Unprocessable Entity
// Others β 500 Internal Server ErrorMinimal APIs get the same treatment through ToHttpResult / ToCreatedHttpResult /
ToNoContentHttpResult (TypedResults-based), and the whole error β HTTP translation is
extensible by deriving from OutcomeHttpMapping and passing your instance to any of the
extensions β see the
package README.
// Custom success status code
return result.ToActionResult(201); // 201 Created
// Created at action
return result.ToCreatedAtActionResult("GetUser", new { id = user.Id });
// No content
return result.ToNoContentResult(); // 204 No ContentCombine and aggregate multiple Results efficiently:
Combine multiple results into one. If all succeed, get all values. If any fails, get all errors:
using eQuantic.Core.Outcomes.Extensions;
// Validate multiple fields in parallel
var emailResult = ValidateEmail(request.Email);
var passwordResult = ValidatePassword(request.Password);
var ageResult = ValidateAge(request.Age);
var validationResult = ResultCombinators.Combine(
emailResult,
passwordResult,
ageResult
);
if (validationResult.IsSuccess)
{
var (email, password, age) = validationResult.Value;
// All validations passed
}
else
{
// Contains all validation errors
return BadRequest(validationResult.Errors);
}Combine results of different types into tuples:
var userResult = GetUser(userId);
var profileResult = GetProfile(userId);
// Combine into tuple
var combined = userResult.Zip(profileResult);
if (combined.IsSuccess)
{
var (user, profile) = combined.Value;
return new UserWithProfile(user, profile);
}
// Supports up to 4 results
var result = result1.Zip(result2, result3, result4);Try multiple sources, return first success:
// Try cache first, then database, then API
var result = ResultCombinators.FirstSuccess(
GetFromCache(key),
GetFromDatabase(key),
GetFromApi(key)
);
// Returns first successful result
// If all fail, returns failure with all errorsGet only successful values, ignore failures:
var processResults = items.Select(item => ProcessItem(item));
var successful = ResultCombinators.SuccessfulValues(processResults);
// Returns Result<IEnumerable<T>> with only successful values
// Useful when some failures are acceptableSplit results into successful values and errors:
var results = items.Select(item => ValidateItem(item));
var (successes, errors) = results.Partition();
Console.WriteLine($"Processed: {successes.Count()}, Failed: {errors.Count()}");Useful for validation scenarios:
var validationResults = new[]
{
ValidateField1(),
ValidateField2(),
ValidateField3()
};
var merged = ResultCombinators.MergeErrors(validationResults);
// Contains all validation errors from all failed resultsTrack and monitor your operations with built-in observability support:
var result = await GetUserAsync(userId)
.WithCorrelationId(httpContext.TraceIdentifier)
.WithTraceId(Activity.Current?.Id)
.WithMetadata("userId", userId)
.WithMetadata("source", "api");
// Access observability data
Console.WriteLine($"CorrelationId: {result.CorrelationId}");
Console.WriteLine($"TraceId: {result.TraceId}");
Console.WriteLine($"Metadata: {result.Metadata["source"]}");Automatically measure operation execution time:
// Synchronous
var result = ObservabilityExtensions.Timed(() =>
{
return PerformExpensiveOperation();
});
Console.WriteLine($"Operation took: {result.ExecutionTime}");
// Asynchronous
var result = await ObservabilityExtensions.TimedAsync(async () =>
{
return await PerformAsyncOperation();
});Perfect for microservices and distributed systems:
public async Task<Result<Order>> ProcessOrderAsync(OrderRequest request, string correlationId)
{
return await ObservabilityExtensions.TimedAsync(async () =>
{
var result = await _orderService.CreateOrderAsync(request);
return result
.WithCorrelationId(correlationId)
.WithTraceId(Activity.Current?.Id)
.WithMetadata("orderId", result.Value?.Id)
.WithMetadata("service", "order-processing")
.WithMetadata("environment", _env.EnvironmentName);
});
}Add any additional context to your results:
var result = Result<User>.Success(user)
.WithMetadata("ipAddress", "192.168.1.1")
.WithMetadata("userAgent", "Mozilla/5.0...")
.WithMetadata("apiVersion", "2.0");
// Add multiple metadata entries at once
var metadata = new Dictionary<string, object>
{
["requestId"] = Guid.NewGuid(),
["timestamp"] = DateTimeOffset.UtcNow,
["region"] = "us-east-1"
};
result = result.WithMetadata(metadata);The v1.x Builder pattern is still available for backward compatibility:
var result = Outcome.FromItemResult<User>()
.WithSuccess()
.WithItem(user)
.Result();However, we recommend migrating to the new API for better type safety and functional programming support.
var resultBuilder = Outcome.FromItemResult<User>();
try
{
var user = await _repository.GetByIdAsync(id);
if (user == null)
{
resultBuilder = resultBuilder
.WithError()
.WithStatus(ResultStatus.NotFound)
.WithMessage("User not found");
return NotFound(resultBuilder.Result());
}
resultBuilder = resultBuilder
.WithSuccess()
.WithItem(user);
return Ok(resultBuilder.Result());
}
catch (Exception ex)
{
resultBuilder = resultBuilder.WithException(ex);
return StatusCode(500, resultBuilder.Result());
}var result = await _repository
.GetByIdAsync(id)
.ToResultAsync()
.Ensure(user => user != null,
Error.NotFound("USER_001", "User not found", id.ToString()));
return result.ToActionResult();var userResult = GetUser(userId);
var profileResult = GetProfile(userId);
var combinedResult = userResult.Bind(user =>
profileResult.Map(profile => new UserWithProfile(user, profile))
);var result = GetUser(userId)
.Ensure(user => user.IsActive,
Error.BusinessRule("BIZ001", "User is not active"))
.Ensure(user => user.EmailVerified,
Error.BusinessRule("BIZ002", "Email not verified"))
.Bind(user => ProcessUser(user));var errors = new List<IError>();
if (string.IsNullOrEmpty(request.Email))
errors.Add(Error.Validation("VAL001", "Email is required", "Email"));
if (request.Age < 18)
errors.Add(Error.Validation("VAL002", "Must be 18 or older", "Age"));
if (errors.Any())
return Result<User>.Failure(errors);
return Result<User>.Success(new User(request));The library includes comprehensive test coverage. Example:
[Fact]
public void Map_OnSuccessResult_ShouldMapValue()
{
// Arrange
var result = Result<int>.Success(5);
// Act
var mappedResult = result.Map(x => x * 2);
// Assert
mappedResult.IsSuccess.Should().BeTrue();
mappedResult.Value.Should().Be(10);
}- Immutable by design - Results use C# records for immutability
- Type-safe - Compile-time guarantees with generic types
- Functional - Railway-Oriented Programming patterns
- Async-first - Full Task<Result> support
- Zero allocations - Optimized for performance
- Nullable reference types - Full C# 8+ support
For more examples and detailed documentation, see:
# Restore dependencies
dotnet restore
# Build
dotnet build
# Run tests
dotnet testThis project uses GitVersion for automatic semantic versioning:
- master: Stable releases (e.g.,
2.0.0) - develop: Preview releases (e.g.,
2.1.0-alpha.5) - feature/*: Feature branches (not published)
- release/*: Beta releases (e.g.,
2.0.0-beta.1)
Use Conventional Commits to control version increments:
# Patch: 2.0.0 β 2.0.1
git commit -m "fix: resolve memory leak"
# Minor: 2.0.0 β 2.1.0
git commit -m "feat: add new combinator"
# Major: 2.0.0 β 3.0.0
git commit -m "feat!: breaking API change"Packages are automatically published to NuGet.org via GitHub Actions:
- Push to master: Publishes stable version
- Push to develop: Publishes preview version
- Create tag (e.g.,
v2.0.1): Publishes specific version
Contributions are welcome! Please feel free to submit a Pull Request.
This project is licensed under the MIT License - see the LICENSE file for details.
Inspired by functional programming patterns from:
- Railway-Oriented Programming (Scott Wlaschin)
- Result types in Rust, F#, and Haskell
- FluentResults, ErrorOr, and other .NET libraries
eQuantic Systems Β© 2019-2025