AxisResult 1.1.1

dotnet add package AxisResult --version 1.1.1
                    
NuGet\Install-Package AxisResult -Version 1.1.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="AxisResult" Version="1.1.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="AxisResult" Version="1.1.1" />
                    
Directory.Packages.props
<PackageReference Include="AxisResult" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add AxisResult --version 1.1.1
                    
#r "nuget: AxisResult, 1.1.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package AxisResult@1.1.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=AxisResult&version=1.1.1
                    
Install as a Cake Addin
#tool nuget:?package=AxisResult&version=1.1.1
                    
Install as a Cake Tool

AxisResult

Railway-Oriented Programming for C# that actually works in production.

A zero-dependency Result monad built for real-world .NET applications. No exceptions for control flow. No null checks scattered everywhere. No try/catch in your business logic. Just clean, composable pipelines that make your intent crystal clear.

public Task<AxisResult<AddCellphoneResponse>> HandleAsync(AddCellphoneCommand cmd)
    => personFactory.GetByIdAsync(cmd.PersonId)
        .ThenAsync(person => cellphoneMediator.AddAsync(new() { CountryId = cmd.CountryId, Number = cmd.Number }))
        .ThenAsync(response => response.AddCellphoneAsync(cmd.CellphoneId))
        .ThenAsync(_ => unitOfWork.SaveChangesAsync())
        .MapAsync(_ => new AddCellphoneResponse { CellphoneId = cmd.CellphoneId });

Every operation either succeeds and flows forward, or fails and short-circuits. No nesting. No branching. No ambiguity.


The Problem

This is what "enterprise C#" looks like in most codebases:

public async Task<ApiResponse> Handle(CreateOrderCommand cmd)
{
    try
    {
        var customer = await _customerRepo.GetByIdAsync(cmd.CustomerId);
        if (customer == null)
            throw new NotFoundException("Customer not found");

        if (!customer.IsActive)
            throw new BusinessRuleException("Customer is not active");

        var existingOrder = await _orderRepo.GetByReferenceAsync(cmd.Reference);
        if (existingOrder != null)
            throw new ConflictException("Order already exists");

        var product = await _productRepo.GetByIdAsync(cmd.ProductId);
        if (product == null)
            throw new NotFoundException("Product not found");

        if (product.Stock < cmd.Quantity)
            throw new BusinessRuleException("Insufficient stock");

        try
        {
            var order = new Order(customer.Id, product.Id, cmd.Quantity);
            await _orderRepo.CreateAsync(order);
            await _unitOfWork.SaveChangesAsync();

            try
            {
                await _eventBus.PublishAsync(new OrderCreatedEvent(order.Id));
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex, "Failed to publish event");
                // Swallow? Rethrow? Who decides?
            }

            return new ApiResponse { OrderId = order.Id };
        }
        catch (DbUpdateException ex)
        {
            throw new ConflictException("Duplicate order", ex);
        }
    }
    catch (NotFoundException ex) { return NotFound(ex.Message); }
    catch (BusinessRuleException ex) { return BadRequest(ex.Message); }
    catch (ConflictException ex) { return Conflict(ex.Message); }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Unexpected error");
        return InternalServerError();
    }
}

40 lines. 5 catch blocks. 3 null checks. 2 nested try/catch. And the actual business intent is buried under defensive ceremony.

Exceptions are goto statements in disguise. They break your call stack, they're invisible in method signatures, they force you to guess what might fail, and they make every caller responsible for catching things that aren't exceptional at all. "Customer not found" isn't an exception — it's a perfectly normal outcome.

Now look at the same logic with AxisResult:

public Task<AxisResult<CreateOrderResponse>> HandleAsync(CreateOrderCommand cmd)
    => customerFactory.GetByIdAsync(cmd.CustomerId)
        .ThenAsync(customer => orderFactory.CreateAsync(new()
        {
            CustomerId = customer.CustomerId,
            ProductId = cmd.ProductId,
            Quantity = cmd.Quantity
        }))
        .ThenAsync(_ => unitOfWork.SaveChangesAsync())
        .TapAsync(order => logger.LogInformation("Order {OrderId} created", order.OrderId))
        .MapAsync(order => new CreateOrderResponse { OrderId = order.OrderId });

5 lines. Zero try/catch. Zero null checks. Zero exceptions. Every possible failure is encoded in the return type. The pipeline reads like a sentence describing what the operation does.

That's Railway-Oriented Programming.


What is Railway-Oriented Programming?

Imagine your code as a railway track with two rails:

Success ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━▶  Result
              ┃              ┃              ┃              ┃
           Validate       GetUser      CreateOrder      Save
              ┃              ┃              ┃              ┃
Failure ━━━━━━╋━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━╋━▶  Errors

On the success rail, data flows from one operation to the next. Each operation transforms or validates the data, then passes it forward.

On the failure rail, errors propagate automatically. The moment any operation fails, all subsequent operations are skipped — no if (result.IsFailure) return result; boilerplate.

The magic is in the switches (the points). Operations like ThenAsync, MapAsync, and EnsureAsync are railway switches: they only execute when on the success rail. If you're already on the failure rail, they let you pass through untouched.

This isn't a new idea — it comes from functional programming (Haskell's Either, F#'s Result, Rust's Result<T, E>). But AxisResult is the first C# library that implements it completely, with full async support, zero dependencies, and APIs designed for how C# developers actually write code.


Why AxisResult?

There are other Result libraries for C#. Here's why AxisResult is different.

vs. FluentResults

FluentResults is popular but limited. It lacks monadic composition — you can't chain async operations without manual unwrapping. There's no ValueTask support, no tuple composition, no recovery patterns, and no typed error categories. It's a container, not a railway.

vs. ErrorOr

ErrorOr offers basic chaining but misses the depth needed for production systems. No ValueTask variants, no Zip for combining independent operations, no Recover/RecoverWhen for conditional fallbacks, no RequireNotFound for idempotent creation patterns.

vs. LanguageExt

LanguageExt is a 7.5MB functional programming framework. If you only need Result types, you're pulling in immutable collections, State monads, Reader monads, and an entirely non-idiomatic API. AxisResult gives you the composition power without the weight or the learning curve.

vs. CSharpFunctionalExtensions

Solid library, but no ValueTask support, no Zip, no typed error categories, no recovery patterns, no parallel aggregation. It's good for basic Result patterns but falls short in complex domain scenarios.

vs. Ardalis.Result

Designed for ASP.NET controllers, not for domain logic. Basic Map/Bind support, no composition depth, no async variants, no recovery. Great for HTTP response mapping, limited everywhere else.

The Comparison

Feature AxisResult FluentResults ErrorOr LanguageExt CSharpFunctExt
Monadic composition (Then/Map) Yes Partial Yes Yes Yes
Task + ValueTask async Yes No No No No
Tuple composition (Zip) Up to 4 No No Yes No
Conditional recovery (Recover/RecoverWhen) Yes No No No No
Typed error categories 12 types No Partial No No
Transient error detection Yes No No No No
RequireNotFound pattern Yes No No No No
LINQ query syntax Yes No Partial Yes Yes
Parallel aggregation (Combine/All) Yes No No Yes No
Error accumulation (OrElse) Yes Partial No No No
Zero external dependencies Yes Partial Partial No Yes
Lightweight (~240KB) Yes Yes Yes No (7.5MB) Yes
CancellationToken-aware overloads Yes No No Partial No
Parallel zip (independent ops) Yes No No Partial No

Getting Started

Installation

dotnet add package AxisResult

Creating Results

// Success
AxisResult success = AxisResult.Ok();
AxisResult<int> typed = AxisResult.Ok(42);

// Failure
AxisResult failure = AxisError.NotFound("USER_NOT_FOUND");     // implicit conversion
AxisResult<int> typed = AxisError.BusinessRule("INSUFFICIENT_STOCK");

// From values (implicit)
AxisResult<string> name = "John";   // auto-wraps in Ok

// From exceptions (safe boundary)
AxisResult result = AxisResult.Try(() => riskyOperation());
AxisResult<int> parsed = AxisResult.Try(() => int.Parse(input));

Checking Results

if (result.IsSuccess)
    Console.WriteLine($"Value: {result.Value}");

if (result.IsFailure)
    Console.WriteLine($"Errors: {result.JoinErrorCodes()}");

// Pattern matching
var message = result.Match(
    onSuccess: value => $"Got {value}",
    onFailure: errors => $"Failed: {errors[0].Code}"
);

Chaining Operations

// Each step only runs if the previous one succeeded
var result = await GetUserAsync(userId)
    .ThenAsync(user => ValidateOrder(user, productId))
    .ThenAsync(order => CalculateTotal(order))
    .MapAsync(total => new OrderResponse { Total = total });

Real-World Examples

These are from a production codebase using AxisResult with Hexagonal Architecture + DDD + CQRS.

Command Handler: Regenerate API Secret

A handler that loads an entity, updates its secret, persists, and invalidates the cache — all in one pipeline:

internal class GenerateNewSecretHandler(
    IUnitOfWorkProvider uowProvider,
    IExternalApiAggregateApplicationFactory factory,
    ICachedSecretResolver cacheResolver
) : IAxisCommandHandler<GenerateNewSecretCommand, GenerateNewSecretResponse>
{
    public Task<AxisResult<GenerateNewSecretResponse>> HandleAsync(GenerateNewSecretCommand cmd)
    {
        var plainSecret = ExternalApiSecret.Generate();
        var hashedSecret = ExternalApiSecret.Hash(plainSecret);

        return factory.GetByIdAsync(cmd.ExternalApiId)           // Load entity (NotFound if missing)
            .ThenAsync(app => app.UpdateSecretAsync(hashedSecret)) // Domain operation (preserves entity)
            .ThenAsync(_ => uowProvider.UnitOfWork.SaveChangesAsync()) // Persist
            .ThenAsync(_ => cacheResolver.RemoveAsync(cmd.ExternalApiId)) // Invalidate cache
            .MapAsync(_ => new GenerateNewSecretResponse           // Transform to response
            {
                ExternalApiId = cmd.ExternalApiId,
                Secret = plainSecret
            });
    }
}

Notice: ThenAsync preserves the typed value. After UpdateSecretAsync returns AxisResult (no value), the pipeline still carries the original IExternalApiAggregateApplication. If any step fails, everything after it is skipped — including SaveChangesAsync.

Query Handler: Get Entity by ID

Queries go directly from handler to port — no domain layer needed:

internal class GetExternalApiByIdHandler(
    IExternalApisReaderPort readerPort
) : IAxisQueryHandler<GetExternalApiByIdQuery, GetExternalApiByIdResponse>
{
    public Task<AxisResult<GetExternalApiByIdResponse>> HandleAsync(GetExternalApiByIdQuery query)
        => readerPort.GetByIdAsync(query.ExternalApiId)
            .MapAsync(entity => new GetExternalApiByIdResponse
            {
                ExternalApiId = entity.ExternalApiId,
                Name = entity.ApiName
            });
}

If the entity doesn't exist, the reader port returns AxisError.NotFound("EXTERNAL_API_NOT_FOUND"). The MapAsync is never called. The error propagates cleanly to the caller.

Authentication Handler: Verify Credentials

internal class AuthenticateHandler(
    ICachedSecretResolver cachedResolver
) : IAxisCommandHandler<AuthenticateCommand>
{
    public Task<AxisResult> HandleAsync(AuthenticateCommand cmd)
        => cachedResolver.GetExternalApiAsync(cmd.ExternalApiId)
            .ThenAsync(app => app.VerifySecret(cmd.Secret));
}

Two lines. Load from cache, verify secret. If the API key doesn't exist: NotFound. If the secret is wrong: Unauthorized. The handler doesn't need to know or care about the details.

Factory: Create with Duplicate Detection

The RequireNotFound pattern elegantly handles "create only if it doesn't exist":

public Task<AxisResult<IPersonAggregateApplication>> CreateAsync(NewArgs args)
    => readerPort.GetByNationalIdAsync(args.NationalId)
        .RequireNotFoundAsync(AxisError.BusinessRule("DOCUMENT_ALREADY_EXISTS"))
        .WithValueAsync(new PersonEntity(true, PersonId.New, args.DisplayName, args.PictureProxyUrl, args.OriginId))
        .MapAsync(NewInstance)
        .ActionAsync(app => app.IsValidAsync())
        .ThenAsync(writePort.CreateAsync);

Here's what happens:

  1. Try to find an existing person by national ID
  2. RequireNotFoundAsync: If found, fail with DOCUMENT_ALREADY_EXISTS. If not found, continue (success!)
  3. WithValueAsync: Create the new entity
  4. MapAsync: Wrap in the application layer
  5. ActionAsync: Run domain validation (phone format, document rules) — preserves the value on success
  6. ThenAsync: Persist to database

Factory: Load with Domain Validation

When an entity has invariants that need validation on load:

public Task<AxisResult<ICellphoneAggregateApplication>> GetByIdAsync(CellphoneId id)
    => readerPort.GetByIdAsync(id)
        .MapAsync(NewInstance)
        .ActionAsync(app => app.IsValidAsync());  // Validate after hydration

ActionAsync runs domain validation and preserves the original value on success. If validation fails, the errors propagate.

Aggregate Application: Domain + Persistence

The application layer coordinates domain rules with infrastructure:

internal class PersonAggregateApplication(
    IPersonEntityProperties properties,
    IPersonCellphonesWriter cellphonesWriter
) : PersonEntity(properties), IPersonAggregateApplication
{
    public Task<AxisResult> AddCellphoneAsync(CellphoneId cellphoneId)
        => AddCellphone()   // Domain rule: checks if person is active
            .TapAsync(() => cellphonesWriter.AddCellphoneAsync(PersonId, cellphoneId));
}

AddCellphone() is a domain rule returning AxisResult. If the person isn't active, it returns AxisError.BusinessRule("PERSON_NOT_ACTIVE") and TapAsync never executes.

Repository: Database Access Without Exceptions

protected async Task<AxisResult<T>> GetAsync<T>(
    string sql,
    Action<NpgsqlParameterCollection> addParams,
    Func<NpgsqlDataReader, T> map,
    string notFoundCode)
{
    try
    {
        await using var command = await uow.NewCommandAsync(sql);
        addParams(command.Parameters);
        await using var reader = await command.ExecuteReaderAsync(CancellationToken);
        if (!await reader.ReadAsync(CancellationToken))
            return AxisError.NotFound(notFoundCode);
        return AxisResult.Ok(map(reader));
    }
    catch (Exception ex)
    {
        logger.LogError(ex, "POSTGRES_GET_ERROR");
        return AxisError.InternalServerError("POSTGRES_GET_ERROR");
    }
}

The only try/catch in the entire architecture lives at the infrastructure boundary. Database exceptions are converted to AxisResult once, at the edge. Everything above — handlers, factories, domain rules — is exception-free.

Boundary adapter pattern. For HttpClient, database drivers, message brokers and any other infrastructure with a leaky exception surface, the recommended pattern is to write a thin adapter whose job is exclusively converting the external surface into AxisResult<Response>. Every exception the underlying client can throw is mapped to a typed AxisError (timeouts → Timeout, 5xx → ServiceUnavailable, 401 → Unauthorized, etc.). The rest of the application consumes only AxisResult<Response> and never sees a raw HttpResponseMessage. Dedicated helper libraries for the HTTP and repository adapters are on the roadmap.

Note on Try / TryAsync. AxisResult.Try does not catch "programmer error" exceptions — NullReferenceException, ArgumentNullException, OperationCanceledException, OutOfMemoryException, StackOverflowException and ThreadAbortException are rethrown. These represent bugs or genuinely unrecoverable situations and should not be silently turned into a result value. If you want a specific exception type captured, pass an errorHandler override or catch it manually in your adapter.

Validator: Automatic Pipeline Validation

Validators run automatically before the handler (via pipeline behavior):

internal class AuthenticateValidator : AxisValidatorBase<AuthenticateCommand>
{
    public AuthenticateValidator()
    {
        RequiredTryParse(x => x.ExternalApiId, "EXTERNAL_API_ID_NOT_VALID",
            x => ExternalApiId.TryParse(x, out _));
        NotNullOrEmpty(x => x.Secret, "SECRET_NULL_OR_EMPTY");
    }
}

If validation fails, the handler is never called. Errors are returned as AxisResult with AxisErrorType.ValidationRule.


Combining Independent Operations

Zip: Build Tuples from Multiple Results

When you need values from multiple independent operations:

var result = await GetUserAsync(userId)
    .ZipAsync(user => GetAccountAsync(user.AccountId))     // (User, Account)
    .ZipAsync((user, account) => GetPlanAsync(account.PlanId))  // (User, Account, Plan)
    .MapAsync((user, account, plan) => new DashboardResponse
    {
        UserName = user.Name,
        AccountBalance = account.Balance,
        PlanName = plan.Name
    });

Each ZipAsync adds a value to the tuple. If any step fails, the whole chain short-circuits. The final MapAsync destructures the tuple cleanly — no .Value1, .Value2 needed.

Combine: Validate Multiple Things in Parallel

var result = AxisResult.Combine(
    ValidateName(cmd.Name),
    ValidateEmail(cmd.Email),
    ValidateAge(cmd.Age)
);
// Collects ALL errors, not just the first one

All: Aggregate Typed Results

var result = await AxisResult.AllAsync(
    userIds.Select(id => GetUserAsync(id))
);
// AxisResult<IReadOnlyList<User>> — all users, or all errors

Recovery and Fallbacks

Recover: Provide a Default

var settings = await GetUserSettingsAsync(userId)
    .RecoverNotFoundAsync(() => DefaultSettings.Create());

RecoverWhen: Conditional Recovery

var data = await FetchFromPrimaryAsync()
    .RecoverWhenAsync(AxisErrorType.ServiceUnavailable, () => FetchFromFallbackAsync());

OrElse: Try an Alternative

var user = await FindByEmailAsync(email)
    .OrElseAsync(_ => FindByUsernameAsync(email));  // Try username if email fails

OrElse with Error Accumulation

var user = await FindByEmailAsync(email)
    .OrElseAsync(_ => FindByPhoneAsync(phone), combineErrors: true);
// If both fail, you get errors from BOTH attempts

Error Categories

Every AxisError has a typed category:

AxisError.NotFound("USER_NOT_FOUND")             // → 404
AxisError.ValidationRule("EMAIL_INVALID")         // → 400
AxisError.BusinessRule("INSUFFICIENT_BALANCE")    // → 422
AxisError.Conflict("ORDER_ALREADY_EXISTS")        // → 409
AxisError.Unauthorized("INVALID_TOKEN")           // → 401
AxisError.Forbidden("ADMIN_ONLY")                 // → 403
AxisError.InternalServerError("DB_FAILURE")       // → 500
AxisError.ServiceUnavailable("API_DOWN")          // → 503
AxisError.Timeout("GATEWAY_TIMEOUT")              // → 504
AxisError.TooManyRequests("RATE_LIMITED")          // → 429
AxisError.GatewayTimeout("UPSTREAM_TIMEOUT")       // → 504
AxisError.Mapping("INVALID_DATA_FORMAT")           // → 500

Why No Message Field on AxisError?

AxisError carries only a stable Code and a Type. This is deliberate:

  • The code is the primary key. It must be immutable and stable across releases so that logs, metrics, alerting rules and retry policies can pivot on it without parsing strings.
  • Human messages are a different concern. Localization, tone, wording, and the decision of whether to expose an internal detail to an end-user — none of that belongs inside a pipeline value.

The recommended pattern is a code → message resolver at the presentation boundary:

public interface IAxisErrorMessageResolver
{
    string Resolve(AxisError error, CultureInfo culture);
}

// In an API controller or gRPC interceptor:
return result.Match(
    onSuccess: value => Ok(value),
    onFailure: errors => Problem(
        title: errors[0].Type.ToString(),
        detail: messageResolver.Resolve(errors[0], CultureInfo.CurrentUICulture)
    )
);

Benefits:

  • Codes stay small and canonical (USER_NOT_FOUND), messages live in resource files.
  • Multiple UIs (REST, gRPC, CLI, internal admin) can render the same code differently.
  • Tests assert on codes, not on English prose.
  • No PII or internal state accidentally leaks into error payloads.

If you need to pass details about a failure (user id, attempted quantity, etc.), emit multiple AxisError values — one per detail — rather than stuffing them into a message. The error list is already the natural collection for this.

Transient Detection

if (error.IsTransient)   // true for ServiceUnavailable, Timeout, TooManyRequests, GatewayTimeout
    await RetryAsync();  // Safe to retry

This is built into the type system. Circuit breakers, retry policies, and health checks can inspect IsTransient without parsing error messages or maintaining string lists.

Cross-Layer Error Transformation

var result = await internalService.ProcessAsync()
    .MapErrorAsync(errors => errors.Select(e => AxisError.InternalServerError($"PROCESSING_{e.Code}")));

Remap error codes and types as they cross architectural boundaries. Use the single-error overload (Func<AxisError, AxisError>) when transforming each error individually, or the list overload (Func<IReadOnlyList<AxisError>, IEnumerable<AxisError>>) when you need to filter or restructure the error collection.


LINQ Query Syntax

For developers who prefer comprehension syntax:

AxisResult<decimal> total =
    from customer in GetCustomer(customerId)
    from order in CreateOrder(customer.Id, productId)
    select order.Total;

Equivalent to:

var total = GetCustomer(customerId)
    .Then(customer => CreateOrder(customer.Id, productId))
    .Map(order => order.Total);

Both styles are first-class. Use whichever reads better for your team. For async pipelines, use the fluent ThenAsync/MapAsync chain instead.


Cancellation

Every core pipeline operator has a CancellationToken-aware overload. The delegate receives the token as a second parameter and the extension method forwards it automatically, so the token flows through the whole chain without polluting closures:

public Task<AxisResult<CreateOrderResponse>> HandleAsync(CreateOrderCommand cmd, CancellationToken ct)
    => customerFactory.GetByIdAsync(cmd.CustomerId, ct)
        .ThenAsync((customer, ct) => orderFactory.CreateAsync(new()
        {
            CustomerId = customer.CustomerId,
            ProductId = cmd.ProductId,
            Quantity = cmd.Quantity
        }, ct), ct)
        .ActionAsync((order, ct) => unitOfWork.SaveChangesAsync(ct), ct)
        .MapAsync((order, ct) => Task.FromResult(new CreateOrderResponse { OrderId = order.Id }), ct);

The CT-less overloads are preserved — you can still close over a token via lambda if that's your style, or mix both patterns in the same chain. Overloads exist for ThenAsync, MapAsync, TapAsync, EnsureAsync, ZipAsync, ActionAsync and ZipParallelAsync, on both Task<AxisResult<T>> and ValueTask<AxisResult<T>>.

Preferred pattern: when using DI, the CancellationToken of the current request can be registered as a scoped service (resolved from IHttpContextAccessor or a custom ambient context) so handlers don't have to thread it through every method. The CT-aware overloads exist for cases where explicit threading is preferred, or where the scoped-CT pattern isn't available.


Parallel Zip

ZipAsync is sequential and fail-fast. When two operations are independent (neither depends on the other's result), use ZipParallelAsync to run them concurrently via Task.WhenAll and accumulate errors if both sides fail:

var dashboard = await GetUserAsync(userId)
    .ZipParallelAsync(() => GetRecentOrdersAsync(userId))   // runs in parallel
    .MapAsync((user, orders) => new DashboardResponse
    {
        UserName = user.Name,
        OrderCount = orders.Count
    });

Semantics:

  • Both succeed → tuple (T1, T2).
  • One fails → that side's errors propagate.
  • Both fail → errors are accumulated (concatenated), so the caller sees every failure at once.

A CT-aware overload is available: ZipParallelAsync(ct => ..., ct).


ValueTask: Zero-Allocation Async

Every async method has both Task and ValueTask variants:

// Task (standard)
public Task<AxisResult<User>> GetUserAsync(UserId id) => ...;

// ValueTask (hot path, zero allocation when synchronous)
public ValueTask<AxisResult<User>> GetUserAsync(UserId id) => ...;

On hot paths where the result is often cached or synchronous, ValueTask avoids heap allocations entirely. All composition methods (ThenAsync, MapAsync, TapAsync, etc.) work identically with both.

Benchmark reference: The zero-allocation behavior of ValueTask<T> on synchronous completions is documented by the .NET team. See Stephen Toub's Understanding the Whys, Whats, and Whens of ValueTask (Microsoft .NET Blog) for the allocation profile and BCL benchmarks that back this claim.


API Reference

Creating Results

Method Description
AxisResult.Ok() Success with no value
AxisResult.Ok(value) Success with a value
AxisResult.Error(error) Failure from a single error
AxisResult.Error(errors) Failure from multiple errors
AxisResult.Try(action) Wrap an action that might throw
AxisResult.Try(func) Wrap a function that might throw
AxisResult.TryAsync(action) Async version of Try
AxisResult.TryAsync(func) Async version of Try with return value
Implicit: value Assign any value where AxisResult<T> is expected
Implicit: AxisError Assign an error where AxisResult is expected

Transforming (Success Rail)

Method Signature Description
Map T -> TNew Transform the value (pure, cannot fail)
MapAsync T -> Task<TNew> Async transform
Then T -> AxisResult<TNew> Chain to a failable operation returning a new value
Then T -> AxisResult Chain to a failable side effect, preserves original value
ThenAsync Async versions of both Then overloads
ToAxisResult T -> AxisResult Chain to a failable side effect, returns an AxisResult with no value
ToAxisResultAsync Async version of ToAxisResult

Side Effects

Method Description
Tap(action) Run side effect on success, return original result
TapAsync(func) Async side effect on success
TapError(action) Run side effect on failure (logging, metrics)
TapErrorAsync(func) Async side effect on failure

Validation

Method Description
Ensure(predicate, error) Guard clause — fails if predicate is false
Ensure(func) Delegated validation — func returns AxisResult
EnsureAsync Async versions

Failable Side Effects

Method Description
ActionAsync(func) Run a failable operation (T -> Task<AxisResult>) and preserve the original value on success. Unlike ThenAsync, which replaces the value, ActionAsync keeps it — ideal for domain validation, persistence, or any step where you need the value downstream

Combining Values

Method Description
Zip(func) Combine current value with a new one into a tuple
ZipAsync(func) Async version
Chained: .Zip().Zip() Build tuples up to (T1, T2, T3, T4)
MapAsync((a, b) => ...) Destructure tuples in the mapper

Aggregation

Method Description
Combine(results...) Join N results — collects all errors
CombineAsync(tasks) Async version
All(results) Join N typed results into IReadOnlyList<T>
AllAsync(tasks) Async version
ZipParallelAsync(() => other) Run an independent op concurrently, zip into tuple, accumulate errors if both fail
ZipParallelAsync(ct => other, ct) CT-aware variant

Recovery and Fallbacks

Method Description
Recover(value) On failure, replace with a default
Recover(func) On failure, compute a fallback
RecoverWhen(predicate, func) Recover only if errors match a condition
RecoverWhen(AxisErrorType, func) Recover only for a specific error type
RecoverWhen(code, func) Recover only for a specific error code
RecoverNotFound(func) Recover only if all errors are NotFound
OrElse(fallback) Try an alternative operation
OrElse(fallback, combineErrors: true) Alternative with error accumulation

Existence Guards

Method Description
RequireNotFound(error) Found → error, NotFound → Ok, other errors → propagate
RequireNotFoundAsync(error) Async version
WithValueAsync(value) Promote AxisResult to AxisResult<T> with a value

Error Transformation

Method Description
MapError(func) Transform each error individually
MapError(func<list>) Transform/filter the entire error list
MapErrorAsync Async versions

Terminal

Method Description
Match(onSuccess, onFailure) Convert to a final type — runs exactly one branch
MatchAsync Async version

LINQ

Syntax Equivalent
from x in result select f(x) result.Map(f)
from x in r1 from y in r2 select ... r1.Then(x => r2).Map(...)
SelectManyAsync Async LINQ chaining

Conversion

Method Description
AsTaskAsync() Wrap sync result in Task
AsValueTaskAsync() Wrap sync result in ValueTask

Cancellation

Every core async operator has a CT-aware overload whose delegate receives the token as a second parameter. Available on both Task<AxisResult<T>> and ValueTask<AxisResult<T>>:

Method Delegate shape
ThenAsync (T, CancellationToken) => Task<AxisResult<TNew>>
ThenAsync (T, CancellationToken) => Task<AxisResult> (preserves value)
ToAxisResultAsync (T, CancellationToken) => Task<AxisResult>
MapAsync (T, CancellationToken) => Task<TNew>
TapAsync (T, CancellationToken) => Task
EnsureAsync (T, CancellationToken) => Task<bool>
EnsureAsync (T, CancellationToken) => Task<AxisResult>
ZipAsync (T, CancellationToken) => Task<TNew>
ZipAsync (T, CancellationToken) => Task<AxisResult<TNew>>
ActionAsync (T, CancellationToken) => Task<AxisResult> (preserves value)
ZipParallelAsync (CancellationToken) => Task<AxisResult<TNew>>

Deconstruction

Syntax Yields
var (isSuccess, errors) = result AxisResult
var (isSuccess, value, errors) = result AxisResult<T> (value is default on failure)

Ergonomics

Deconstruction

Both AxisResult and AxisResult<T> support positional deconstruction:

var (isSuccess, errors) = AxisResult.Ok();                 // non-generic
var (isSuccess, value, errors) = AxisResult.Ok(42);        // generic

// Also usable in positional patterns:
if (result is (true, var v, _))
    Console.WriteLine($"got {v}");

On a failed AxisResult<T>, value is default(T) (not an exception) — the deconstruction never throws, so it's safe to use in pattern matching without pre-checking IsSuccess.

Debugger Experience

AxisResult, AxisResult<T> and AxisError all carry [DebuggerDisplay] attributes. In the debugger you see:

  • Ok / Ok(42) for success
  • Error[2]: USER_NOT_FOUND, INVALID_EMAIL for failures
  • NotFound USER_NOT_FOUND for individual errors

No more inspecting private fields or expanding every node.


Design Principles

  1. Errors are values, not exceptions. An operation that can fail says so in its return type. No surprises, no invisible control flow.

  2. The type system is the documentation. When you see Task<AxisResult<User>>, you know exactly what can happen: you'll get a User, or you'll get errors. No guessing.

  3. Composition over ceremony. Small, focused operations that compose into complex workflows. Each piece is testable in isolation.

  4. Fail fast, recover deliberately. Errors propagate automatically (fail fast), but recovery is always explicit (Recover, RecoverWhen, OrElse).

  5. Exceptions at the boundary, results everywhere else. Use AxisResult.Try() at infrastructure edges (HTTP clients, databases, file I/O). Everything above that is exception-free.


License

Apache 2.0


AxisResult turns your error handling from a liability into a feature. Every failure path is visible, testable, and composable. Your future self will thank you.

Product Compatible and additional computed target framework versions.
.NET net6.0 is compatible.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net10.0

    • No dependencies.
  • net6.0

    • No dependencies.
  • net8.0

    • No dependencies.
  • net9.0

    • No dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.1.1 109 4/28/2026
1.1.0 103 4/27/2026
1.0.1 115 4/15/2026
1.0.0 120 4/12/2026 1.0.0 is deprecated because it is no longer maintained.
0.0.1-rc.1 73 4/12/2026 0.0.1-rc.1 is deprecated because it is no longer maintained.