Skip to content

panixida-dotnet-core/result-pattern

Repository files navigation

PANiXiDA.Core.ResultPattern

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.

Status

CI NuGet NuGet downloads Target Framework License

Overview

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/catch blocks;
  • composing multiple steps becomes harder to read.

PANiXiDA.Core.ResultPattern addresses this by making operation outcomes explicit:

  • Result represents success or failure without a value;
  • Result<T> represents success or failure with a value;
  • Error provides a unified error model with type, message, and metadata;
  • extension methods such as Map, Bind, BindAsync, Ensure, Tap, and Match help compose operation pipelines.

This library is especially useful in:

  • application services;
  • use cases;
  • orchestrator layers;
  • domain factories and validators;
  • API boundaries.

Features

  • Explicit success/failure contract with Result and Result<T>
  • Typed error model through Error and ErrorType
  • 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

Quick Start

Requirements

  • .NET 10 SDK

Installation

The library targets net10.0.

<ItemGroup>
  <PackageReference Include="PANiXiDA.Core.ResultPattern" Version="..." />
</ItemGroup>

Minimal import

using PANiXiDA.Core.ResultPattern;

First example

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

Usage

Creating errors

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

Result without a value

using PANiXiDA.Core.ResultPattern;

Result DeleteUser(bool userExists)
{
    if (!userExists)
    {
        return Result.Failure(Error.NotFound("User not found"));
    }

    return Result.Success();
}

Result<T> with a value

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

Checking IsSuccess / IsFailure and reading FirstError

var result = DeleteUser(userExists: false);

if (result.IsFailure)
{
    Console.WriteLine(result.FirstError.Message);
}

Value, ValueOrDefault, and TryGetValue

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);  // True

Returning multiple errors

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

Combine for joining multiple validations

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 for transforming a result

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 for composing steps that already return Result

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

BindAsync for asynchronous composition

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

Ensure for additional checks after success

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 for side effects

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 for finishing the pipeline

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

Full pipeline example

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

API boundary example

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

Configuration

This library does not require runtime configuration.

There are no required:

  • environment variables;
  • appsettings.json entries;
  • secrets;
  • ports;
  • external services.

The only consumer-side requirement is referencing the package from a compatible .NET project.

Project Structure

.
├── 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

Main repository files

  • src/ — library source code
  • tests/ — automated tests
  • Directory.Build.props — shared MSBuild settings
  • Directory.Build.targets — shared package metadata and package content settings
  • Directory.Packages.props — centralized package versions
  • global.json — SDK and test runner configuration
  • version.json — Nerdbank.GitVersioning configuration
  • .editorconfig — code style rules
  • README.md — package overview and usage documentation

Development

Build

dotnet restore
dotnet build --configuration Release

Format

dotnet format

Test

dotnet test --configuration Release

Full local validation

dotnet restore
dotnet format
dotnet build --configuration Release
dotnet test --configuration Release

Tooling and conventions

This repository uses:

  • .NET 10
  • Nullable enabled
  • Implicit usings enabled
  • Central package management
  • Microsoft Testing Platform
  • xUnit v3
  • FluentAssertions
  • Nerdbank.GitVersioning

API / Contracts / Examples

Core types

  • Error — immutable error model with Message, Type, and Metadata

  • ErrorType — supported error categories:

    • Validation
    • NotFound
    • Conflict
    • Unauthorized
    • Forbidden
    • Failure
    • Unexpected
  • Result — success or failure without a value

  • Result<T> — success or failure with a value

Core operations

  • Result.Success()
  • Result.Success<T>(value)
  • Result.Failure(...)
  • Result.Combine(...)
  • Map(...)
  • Bind(...)
  • BindAsync(...)
  • Ensure(...)
  • Tap(...)
  • Match(...)

Working with errors

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)

Working with values in Result<T>

  • Value — returns the value on success, otherwise throws InvalidOperationException
  • ValueOrDefault — returns the value on success, or default on failure
  • TryGetValue(out value) — safely attempts to get the value

Working with errors in Result

  • Errors — returns the list of errors
  • FirstError — returns the first error, otherwise throws InvalidOperationException
  • IsSuccess / IsFailure — explicit result state checks

Behavioral notes

  • Value throws InvalidOperationException when the result is a failure.
  • FirstError throws InvalidOperationException when the result is successful.
  • Combine aggregates errors from all failed results.
  • Match is intended for finishing a result pipeline at the application boundary.

Roadmap / TODO

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.

Contributing

Contributions are welcome if they keep the package focused and predictable.

General rules

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

Code style

  • follow the repository .editorconfig;
  • do not introduce expression-bodied method declarations;
  • prefer explicit and readable code over overly compact code.

Tests

  • 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; or using FluentAssertions; in test files, because they are provided as global usings in the test project;
  • write DisplayName values in English;
  • structure tests using the Arrange, Act, Assert pattern.

Validation before completion

Before considering work complete, run:

dotnet restore
dotnet format
dotnet build --configuration Release
dotnet test --configuration Release

License

This project is licensed under the Apache License, Version 2.0.

See the LICENSE file for details.

Maintainers / Contacts

Maintained by the PANiXiDA.

Repository:

  • PANiXiDA-Dotnet-Core/result-pattern

For questions or improvements, use:

  • GitHub Issues
  • Pull Requests
  • repository discussions, if enabled

About

A library for implementing the Result pattern in .NET applications with typed errors, result composition support, and convenient error handling without exceptions in business logic.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages