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.