PANiXiDA.Core.ResultPattern is a small .NET library for explicit success and failure handling in business logic without using exceptions as the primary control-flow contract.
It is designed for .NET developers who want predictable result-based workflows, typed errors, and composable synchronous and asynchronous operation pipelines.
When a method in business logic can end not only with success but also with an expected failure, exceptions often become an awkward contract:
- the method signature does not show that the method can fail;
- business errors get mixed with technical exceptions;
- the code starts to grow with
try/catchblocks; - composing multiple steps becomes harder to read.
PANiXiDA.Core.ResultPattern addresses this by making operation outcomes explicit:
Resultrepresents success or failure without a value;Result<T>represents success or failure with a value;Errorprovides a unified error model with type, message, and metadata;- extension methods such as
Map,Bind,BindAsync,Ensure,Tap, andMatchhelp compose operation pipelines.
This library is especially useful in:
- application services;
- use cases;
- orchestrator layers;
- domain factories and validators;
- API boundaries.
- Explicit success/failure contract with
ResultandResult<T> - Typed error model through
ErrorandErrorType - Support for both single and multiple errors
- Synchronous and asynchronous pipeline composition
- Validation-style workflow support with error aggregation
- Lightweight public API
- XML-documented public API surface
- Suitable for application, domain, and API boundary layers
- .NET 10 SDK
The library targets net10.0.
<ItemGroup>
<PackageReference Include="PANiXiDA.Core.ResultPattern" Version="..." />
</ItemGroup>using PANiXiDA.Core.ResultPattern;using PANiXiDA.Core.ResultPattern;
Result<string> GetUserName(bool exists)
{
if (!exists)
{
return Result.Failure<string>(Error.NotFound("User not found"));
}
return Result.Success("John");
}
var result = GetUserName(exists: true);
if (result.IsSuccess)
{
Console.WriteLine(result.Value);
}using PANiXiDA.Core.ResultPattern;
var validationError = Error.Validation("Email is required");
var notFoundError = Error.NotFound("User not found");
var conflictError = Error.Conflict("Email is already in use");
var forbiddenError = Error.Forbidden("Insufficient permissions");
var fieldError = Error.Validation("Invalid email format")
.WithField("email")
.WithMetadata("attemptedValue", "not-an-email");using PANiXiDA.Core.ResultPattern;
Result DeleteUser(bool userExists)
{
if (!userExists)
{
return Result.Failure(Error.NotFound("User not found"));
}
return Result.Success();
}using PANiXiDA.Core.ResultPattern;
public sealed record UserDto(Guid Id, string Email);
Result<UserDto> GetUser(Guid id, UserDto? user)
{
if (user is null)
{
return Result.Failure<UserDto>(Error.NotFound("User not found"));
}
return Result.Success(user);
}var result = DeleteUser(userExists: false);
if (result.IsFailure)
{
Console.WriteLine(result.FirstError.Message);
}var userResult = GetUser(Guid.NewGuid(), new UserDto(Guid.NewGuid(), "user@example.com"));
var value = userResult.Value;
var sameValue = userResult.ValueOrDefault;
if (userResult.TryGetValue(out var user))
{
Console.WriteLine(user.Email);
}var failedResult = Result.Failure<UserDto>(Error.NotFound("User not found"));
var defaultValue = failedResult.ValueOrDefault;
var hasValue = failedResult.TryGetValue(out var missingUser);
Console.WriteLine(defaultValue is null); // True
Console.WriteLine(hasValue); // False
Console.WriteLine(missingUser is null); // TrueResult ValidateRegistration(string email, string password)
{
var errors = new List<Error>();
if (string.IsNullOrWhiteSpace(email))
{
errors.Add(Error.Validation("Email is required").WithField("email"));
}
if (string.IsNullOrWhiteSpace(password))
{
errors.Add(Error.Validation("Password is required").WithField("password"));
}
if (errors.Count > 0)
{
return Result.Failure(errors);
}
return Result.Success();
}var emailValidation = ValidateEmail(email);
var passwordValidation = ValidatePassword(password);
var agreementValidation = ValidateAgreement(agreementAccepted);
var validationResult = Result.Combine(
emailValidation,
passwordValidation,
agreementValidation);
if (validationResult.IsFailure)
{
return validationResult;
}Map is useful when the source operation is already successful and you only need to transform the value.
Result validationResult = ValidateRegistration(email, password);
Result<Guid> requestIdResult = validationResult.Map(() => Guid.NewGuid());public sealed record User(Guid Id, string Email);
public sealed record UserResponse(Guid Id, string Email);
Result<User> userResult = Result.Success(new User(Guid.NewGuid(), "user@example.com"));
Result<UserResponse> responseResult = userResult.Map(user =>
{
return new UserResponse(user.Id, user.Email);
});Bind is useful when the next step can also fail.
Result validationResult = ValidateRegistration(email, password);
Result<Guid> createUserResult = validationResult.Bind(() =>
{
return CreateUser(email, password);
});Result<User> userResult = GetUserById(userId);
Result activationResult = userResult.Bind(ActivateUser);Result<User> userResult = GetUserById(userId);
Result<UserResponse> responseResult = userResult.Bind(user =>
{
return LoadProfile(user.Id).Map(profile =>
{
return new UserResponse(user.Id, user.Email);
});
});Result validationResult = ValidateRegistration(email, password);
Result<Guid> createUserResult = await validationResult.BindAsync(() =>
{
return CreateUserAsync(email, password);
});Result<User> userResult = await GetUserByIdAsync(userId);
Result<UserResponse> responseResult = await userResult.BindAsync(async user =>
{
var profileResult = await LoadProfileAsync(user.Id);
return profileResult.Map(profile =>
{
return new UserResponse(user.Id, user.Email);
});
});Result<User> userResult = GetUserById(userId);
Result<User> activeUserResult = userResult
.Ensure(
user => user.IsActive,
Error.Forbidden("User is blocked"))
.Ensure(
user => user.EmailConfirmed,
Error.Validation("Email is not confirmed").WithField("email"));Tap does not change the result and is useful for logging, auditing, metrics, and other side effects.
Result<User> createResult = CreateUser(email, password);
Result<User> sameResult = createResult.Tap(user =>
{
Console.WriteLine($"User created: {user.Id}");
});Match is convenient at the application boundary, when you need to choose the final behavior for success and failure.
Result<UserResponse> result = GetUserById(userId)
.Map(user =>
{
return new UserResponse(user.Id, user.Email);
});
var response = result.Match(
onSuccess: user =>
{
return $"200 OK: {user.Email}";
},
onFailure: errors =>
{
return $"400/404: {string.Join("; ", errors.Select(error => error.Message))}";
});Result deleteResult = DeleteUser(userExists: false);
var message = deleteResult.Match(
onSuccess: () =>
{
return "User deleted";
},
onFailure: errors =>
{
return $"Deletion failed: {errors[0].Message}";
});using PANiXiDA.Core.ResultPattern;
public sealed record RegisterUserCommand(string Email, string Password);
public sealed record User(Guid Id, string Email, bool IsActive, bool EmailConfirmed);
public sealed record UserResponse(Guid Id, string Email);
public async Task<Result<UserResponse>> RegisterAsync(RegisterUserCommand command)
{
var validationResult = ValidateRegistration(command.Email, command.Password);
var uniqueEmailResult = validationResult.Bind(() =>
{
return EnsureEmailIsUnique(command.Email);
});
if (uniqueEmailResult.IsFailure)
{
return Result.Failure<UserResponse>(uniqueEmailResult.Errors);
}
var createResult = await uniqueEmailResult.BindAsync(() =>
{
return CreateUserAsync(command);
});
var guardedResult = createResult
.Ensure(user => user.IsActive, Error.Failure("User was created in an inconsistent state"))
.Ensure(user => user.EmailConfirmed, Error.Validation("Email is not confirmed").WithField("email"))
.Tap(user =>
{
Console.WriteLine($"Created user {user.Id}");
});
return guardedResult.Map(user =>
{
return new UserResponse(user.Id, user.Email);
});
}public async Task<IResult> Register(RegisterUserCommand command)
{
var result = await RegisterAsync(command);
return result.Match<IResult>(
onSuccess: user =>
{
return Results.Ok(user);
},
onFailure: errors =>
{
var firstError = errors[0];
return firstError.Type switch
{
ErrorType.Validation => Results.BadRequest(errors),
ErrorType.NotFound => Results.NotFound(errors),
ErrorType.Conflict => Results.Conflict(errors),
ErrorType.Unauthorized => Results.Unauthorized(),
ErrorType.Forbidden => Results.StatusCode(StatusCodes.Status403Forbidden),
_ => Results.StatusCode(StatusCodes.Status500InternalServerError)
};
});
}This library does not require runtime configuration.
There are no required:
- environment variables;
appsettings.jsonentries;- secrets;
- ports;
- external services.
The only consumer-side requirement is referencing the package from a compatible .NET project.
.
├── src/
│ └── PANiXiDA.Core.ResultPattern/
│ └── PANiXiDA.Core.ResultPattern.csproj
├── tests/
│ └── PANiXiDA.Core.ResultPattern.UnitTests/
│ └── PANiXiDA.Core.ResultPattern.UnitTests.csproj
├── .editorconfig
├── .gitattributes
├── .gitignore
├── Directory.Build.props
├── Directory.Build.targets
├── Directory.Packages.props
├── global.json
├── version.json
├── LICENSE
└── README.md
src/— library source codetests/— automated testsDirectory.Build.props— shared MSBuild settingsDirectory.Build.targets— shared package metadata and package content settingsDirectory.Packages.props— centralized package versionsglobal.json— SDK and test runner configurationversion.json— Nerdbank.GitVersioning configuration.editorconfig— code style rulesREADME.md— package overview and usage documentation
dotnet restore
dotnet build --configuration Releasedotnet formatdotnet test --configuration Releasedotnet restore
dotnet format
dotnet build --configuration Release
dotnet test --configuration ReleaseThis repository uses:
- .NET 10
- Nullable enabled
- Implicit usings enabled
- Central package management
- Microsoft Testing Platform
- xUnit v3
- FluentAssertions
- Nerdbank.GitVersioning
-
Error— immutable error model withMessage,Type, andMetadata -
ErrorType— supported error categories:ValidationNotFoundConflictUnauthorizedForbiddenFailureUnexpected
-
Result— success or failure without a value -
Result<T>— success or failure with a value
Result.Success()Result.Success<T>(value)Result.Failure(...)Result.Combine(...)Map(...)Bind(...)BindAsync(...)Ensure(...)Tap(...)Match(...)
Factory methods:
Error.Validation(message)Error.NotFound(message)Error.Conflict(message)Error.Unauthorized(message)Error.Forbidden(message)Error.Failure(message)Error.Unexpected(message)
Additional helpers:
WithMetadata(key, value)WithField(field)
Value— returns the value on success, otherwise throwsInvalidOperationExceptionValueOrDefault— returns the value on success, ordefaulton failureTryGetValue(out value)— safely attempts to get the value
Errors— returns the list of errorsFirstError— returns the first error, otherwise throwsInvalidOperationExceptionIsSuccess/IsFailure— explicit result state checks
ValuethrowsInvalidOperationExceptionwhen the result is a failure.FirstErrorthrowsInvalidOperationExceptionwhen the result is successful.Combineaggregates errors from all failed results.Matchis intended for finishing a result pipeline at the application boundary.
Potential future improvements:
- add more advanced composition helpers if a clear use case appears;
- extend documentation with more domain-oriented examples;
- add dedicated examples for ASP.NET Core minimal APIs;
- keep the package as a reusable standard for future PANiXiDA NuGet libraries.
Contributions are welcome if they keep the package focused and predictable.
- keep the public API small and intentional;
- avoid unnecessary dependencies;
- preserve existing naming;
- do not introduce breaking API changes without a strong reason;
- public APIs must have XML documentation in English.
- follow the repository
.editorconfig; - do not introduce expression-bodied method declarations;
- prefer explicit and readable code over overly compact code.
- add or update tests for every meaningful behavior change;
- cover happy path, guard clauses, and failure scenarios;
- verify public API behavior, not implementation details, unless required;
- add a regression test first when fixing a bug;
- do not add
using Xunit;orusing FluentAssertions;in test files, because they are provided as global usings in the test project; - write
DisplayNamevalues in English; - structure tests using the Arrange, Act, Assert pattern.
Before considering work complete, run:
dotnet restore
dotnet format
dotnet build --configuration Release
dotnet test --configuration ReleaseThis project is licensed under the Apache License, Version 2.0.
See the LICENSE file for details.
Maintained by the PANiXiDA.
Repository:
PANiXiDA-Dotnet-Core/result-pattern
For questions or improvements, use:
- GitHub Issues
- Pull Requests
- repository discussions, if enabled