Skip to content

Extensions

📡 OpenTelemetry Integration — WithActivity

Enriches an existing Activity span with result outcome metadata — Tap-style, returns the result unchanged:

using var activity = ActivitySource.StartActivity("GetUser");

Result<User> user = await _userService.GetAsync(id)
    .WithActivity(activity);    // or Activity.Current

Tags set on the activity:

Tag Value
result.outcome "success" or "failure"
result.error.type First error type name (on failure)
result.error.message First error message (on failure)
result.error.count Error count (only when > 1)

Activity status is set to ActivityStatusCode.Ok on success, ActivityStatusCode.Error on failure. Null-safe — no-op when activity is null. No extra NuGet dependency — uses BCL System.Diagnostics.Activity.


📝 Structured Logging — WithLogger / LogOnFailure

Tap-style ILogger integration — log result outcomes without breaking the pipeline:

// Log every result: Debug on success, Warning/Error on failure
Result<User> user = await _userService.GetAsync(id)
    .WithLogger(_logger, "GetUser");

// Log only failures — success is silent
Result<Order> order = await _orderService.CreateAsync(dto)
    .LogOnFailure(_logger, "CreateOrder");

// Composable in pipelines
Result<UserDto> dto = await _userService.GetAsync(id)
    .WithLogger(_logger, "GetUser")
    .MapAsync(u => _mapper.Map(u));

Log levels per outcome:

Outcome Level When
Success Debug IsSuccess
Domain failure Warning IsFailure, no ExceptionError
Exception failure Error IsFailure, contains ExceptionError

Structured log properties on every failure entry: {OperationName}, {ErrorType}, {ErrorMessage}, {ErrorCount} (when > 1 error). Task<Result<T>> extensions accept CancellationToken.


🔄 Railway Recovery — Recover / RecoverAsync

The counterpart to Bind: where Bind chains on the success path, Recover chains on the failure path. Transform any failure into a new Result — which can itself succeed or fail:

// Fallback to cache if the primary DB call fails
Result<User> user = await _userRepo.GetAsync(id)
    .Recover(errors => _cache.Get(id));

// Async fallback — secondary data source
Result<User> user = await _userRepo.GetAsync(id)
    .RecoverAsync(errors => _fallbackApi.GetUserAsync(id));

// Context-aware: skip recovery on ForbiddenError
Result<Document> doc = await FetchDocument(id)
    .Recover(errors => errors.Any(e => e is ForbiddenError)
        ? Result<Document>.Fail(errors)
        : _localCache.Get(id));

// Non-generic Result — command recovery
Result result = await DeleteUser(id)
    .Recover(errors => ArchiveUser(id));

The recovery func receives the full ImmutableList<IError> — enabling context-aware branching. Pass-through on success. Distinct from Catch<TException>: Catch targets only ExceptionError wrapping a specific exception type and always returns a failure; Recover handles any failure and can return success.


🔍 Predicate Filtering — Filter / FilterAsync

Convert a successful result to a failure when a predicate on the value is not met. The error factory receives the value — enabling contextual error messages that embed actual data:

// Value-dependent error — the primary Filter use case
Result<User> activeUser = userResult
    .Filter(u => u.IsActive, u => new Error($"User '{u.Name}' is not active."));

// Static error — convenience overload
Result<Order> pending = orderResult
    .Filter(o => o.Status == OrderStatus.Pending, new ConflictError("Order", "Status", OrderStatus.Pending));

// String message — convenience overload
Result<Product> inStock = productResult
    .Filter(p => p.Stock > 0, "Product is out of stock.");

// Async predicate (e.g. external validation service)
Result<Order> valid = await orderResult
    .FilterAsync(async o => await _validator.IsValidAsync(o),
                 o => new ValidationError("Order", o.Id.ToString(), "failed validation"));

Distinct from Ensure: Ensure takes a static Error fixed at the call site. Filter takes Func<T, IError> — the error is built from the value itself, enabling messages like "User 'John' is not active". Predicate exceptions are wrapped in ExceptionError. Pass-through on failure.


🔀 Void Dispatch — Switch / SwitchAsync

Route success and failure to two actions without returning a value. The explicit intent signal for end-of-chain side-effect dispatch — distinct from void Match (same semantics, but Switch signals that the caller explicitly has no interest in a return value). The primary new value is the Task extensions, which don't exist for void Match:

// Sync — named parameters make intent explicit
result.Switch(
    onSuccess: user   => _cache.Set(user.Id, user),
    onFailure: errors => _metrics.Increment("fetch.error"));

// Async — end-of-chain after async pipeline
await GetUserAsync(id)
    .Switch(
        onSuccess: user   => _cache.Set(user.Id, user),
        onFailure: errors => _metrics.Increment("fetch.error"));

// Async actions — Task<Result<T>> extension
await CreateOrderAsync(dto)
    .SwitchAsync(
        onSuccess: async order  => await PublishAsync(order),
        onFailure: async errors => await AlertAsync(errors[0]));

Pass-through: Switch returns void — use Tap/TapOnFailure when you need to continue the chain.


🗺️ Error Path Transform — MapError / MapErrorAsync

Transforms errors in the failure path. The symmetric counterpart to Map: where Map transforms the success value, MapError transforms the error list. Success passes through unchanged; the result state (IsSuccess/IsFailure) never changes:

// Enrich errors with service context — success unchanged
Result<User> result = await userRepository.GetAsync(id)
    .MapError(errors => errors
        .Select(e => (IError)new NotFoundError($"[UserService] {e.Message}"))
        .ToImmutableList());

// Async mapper
Result<Order> result = await orderTask
    .MapErrorAsync(async errors =>
    {
        await _audit.LogAsync(errors);
        return errors.Select(e => (IError)new Error($"[OrderSvc] {e.Message}")).ToImmutableList();
    });

Distinct from Recover: Recover can turn failure into success; MapError always remains a failure. Use MapError to add context or re-wrap errors mid-pipeline without breaking the chain.


🔄 Fallback on Failure — Or / OrElse / OrElseAsync

Return a fallback result when failure occurs. Simpler API than Recover for the common case where you just need a default:

// Or — eager fallback (pre-built result)
Result<User> result = TryGetUser(id).Or(Result<User>.Ok(GuestUser.Instance));

// OrElse — lazy fallback (receives the error list, computed on demand)
Result<User> result = TryGetUser(id)
    .OrElse(errors => _cache.Get(errors[0].Message));

// OrElse — fallback can itself fail
Result<User> result = TryPrimary(id)
    .OrElse(errors => TrySecondary(id));

// Task extension — end-of-chain after async pipeline
Result<User> result = await TryGetUserAsync(id)
    .OrElse(errors => _localCache.Get(id));

// Async factory
Result<User> result = await TryGetUserAsync(id)
    .OrElseAsync(async errors => await FetchFromCacheAsync(id));

Distinct from Recover: semantically identical — Or/OrElse are the discoverable, intention-revealing names. Or is the eager overload (pass the fallback directly), OrElse is lazy (factory only called on failure). The fallback can itself be a failure.