Advanced Railway Oriented Programming patterns for complex scenarios.
- Pattern Matching
- Tuple Destructuring
- Exception Capture
- Parallel Operations
- LINQ Query Syntax
- Maybe Type (see dedicated article)
Match handles both success and failure cases elegantly:
var description = GetUser("123").Match(
onSuccess: user => $"User: {user.Name}",
onFailure: error => $"Error: {error.Code}"
);await ProcessOrderAsync(order).MatchAsync(
onSuccess: async order => await SendConfirmationAsync(order),
onFailure: async error => await LogErrorAsync(error)
);app.MapGet("/users/{id}", async (string id) =>
{
return await GetUserAsync(id)
.ToResultAsync(Error.NotFound($"User {id} not found"))
.MatchAsync(
onSuccess: user => Results.Ok(user),
onFailure: error => error.ToHttpResult()
);
});Automatically destructure tuples in Match and Bind operations:
var result = EmailAddress.TryCreate(email)
.Combine(UserId.TryCreate(userId))
.Combine(OrderId.TryCreate(orderId))
.Match(
// Tuple automatically destructured into named parameters
onSuccess: (email, userId, orderId) =>
$"Order {orderId} for user {userId} at {email}",
onFailure: error =>
$"Validation failed: {error.Detail}"
);var result = FirstName.TryCreate("John")
.Combine(LastName.TryCreate("Smith"))
.Combine(EmailAddress.TryCreate("john@example.com"))
.Bind((firstName, lastName, email) =>
CreateUser(firstName, lastName, email)
);Tuple destructuring supports 2 to 9 combined values:
// Works with any number of combined results
var result = value1.TryCreate()
.Combine(value2.TryCreate())
.Combine(value3.TryCreate())
.Combine(value4.TryCreate())
// ... up to 9 values
.Bind((v1, v2, v3, v4, /* ... */) => ProcessAll(...));Convert exception-throwing code into Results using Try and TryAsync:
Result<string> LoadFile(string path)
{
return Result.Try(() => File.ReadAllText(path));
}
// Usage
var content = LoadFile("config.json")
.Ensure(c => !string.IsNullOrEmpty(c),
Error.Validation("File is empty"))
.Bind(ParseConfig);async Task<Result<User>> FetchUserAsync(string url)
{
return await Result.TryAsync(async () =>
await _httpClient.GetFromJsonAsync<User>(url));
}
// Usage with chaining
var user = await FetchUserAsync(apiUrl)
.EnsureAsync(u => u != null, Error.NotFound("User not found"))
.TapAsync(u => LogUserAccessAsync(u.Id));Result<string> ReadFileWithCustomErrors(string path)
{
return Result.Try(
() => File.ReadAllText(path),
exception => exception switch
{
FileNotFoundException => Error.NotFound($"File not found: {path}"),
UnauthorizedAccessException => Error.Forbidden("Access denied"),
_ => Error.Unexpected(exception.Message)
}
);
}Execute multiple async operations in parallel while maintaining ROP style using Result.ParallelAsync:
// Execute multiple async operations in parallel
var result = await Result.ParallelAsync(
() => GetUserAsync(userId, cancellationToken),
() => GetOrdersAsync(userId, cancellationToken),
() => GetPreferencesAsync(userId, cancellationToken)
)
.WhenAllAsync()
.BindAsync((user, orders, preferences, ct) =>
CreateDashboard(user, orders, preferences, ct),
cancellationToken
);How it works:
Result.ParallelAsyncaccepts factory functions (Func<Task<Result<T>>>)- All operations start immediately and run concurrently
.WhenAllAsync()waits for all operations to complete- Returns
Result<(T1, T2, T3)>tuple containing all values - If any operation fails, returns combined errors
- Tuple is automatically destructured in
BindAsync
Execute dependent operations in stages - parallel within stages, sequential between stages:
// Stage 1: Fetch core data in parallel
var result = await Result.ParallelAsync(
() => FetchUserAsync(userId, ct),
() => CheckInventoryAsync(productId, ct),
() => ValidatePaymentAsync(paymentId, ct)
)
.WhenAllAsync() // Wait for Stage 1 to complete
// Stage 2: Use results from Stage 1 to run fraud & shipping in parallel
.BindAsync((user, inventory, payment, ct) =>
Result.ParallelAsync(
() => RunFraudDetectionAsync(user, payment, inventory, ct),
() => CalculateShippingAsync(address, inventory, ct)
)
.WhenAllAsync()
.BindAsync((fraudCheck, shipping, ct2) =>
Result.Success(new CheckoutResult(user, inventory, payment, fraudCheck, shipping))
),
ct
);Why multi-stage?
- Stage 2 operations depend on Stage 1 results
- Each stage runs in parallel internally
- Stages run sequentially (Stage 2 waits for Stage 1)
- 2-3x performance improvement over sequential execution
public async Task<Result<Transaction>> ProcessTransactionAsync(
Transaction transaction,
CancellationToken ct)
{
// Run all fraud checks in parallel
var result = await Result.ParallelAsync(
() => CheckBlacklistAsync(transaction.AccountId, ct),
() => CheckVelocityLimitsAsync(transaction, ct),
() => CheckAmountThresholdAsync(transaction, ct),
() => CheckGeolocationAsync(transaction, ct)
)
.WhenAllAsync()
.BindAsync((check1, check2, check3, check4, ct) =>
ApproveTransactionAsync(transaction, ct),
ct
);
return result;
}Performance benefit:
- Sequential: 4 checks × 50ms each = 200ms
- Parallel: max(50ms, 50ms, 50ms, 50ms) = 50ms
- 4x faster!
// If ANY operation fails, the entire result fails
var result = await Result.ParallelAsync(
() => GetUserAsync(userId, ct), // ✅ Success
() => GetOrdersAsync(userId, ct), // ❌ Fails (user has no orders)
() => GetPreferencesAsync(userId, ct) // ✅ Success
).WhenAllAsync();
// result.IsFailure == true
// result.Error contains the "no orders" errorMultiple failures:
var result = await Result.ParallelAsync(
() => ValidateEmailAsync("invalid"), // ❌ ValidationError
() => ValidatePhoneAsync("bad"), // ❌ ValidationError
() => ValidateAgeAsync(-5) // ❌ ValidationError
).WhenAllAsync();
// result.Error is ValidationError with all 3 field errors combinedSequential (old way):
var user = await GetUserAsync(userId, ct);
if (user.IsFailure) return user.Error;
var orders = await GetOrdersAsync(userId, ct);
if (orders.IsFailure) return orders.Error;
var prefs = await GetPreferencesAsync(userId, ct);
if (prefs.IsFailure) return prefs.Error;
// Total time: ~150ms (50ms + 50ms + 50ms)Parallel (new way):
var result = await Result.ParallelAsync(
() => GetUserAsync(userId, ct),
() => GetOrdersAsync(userId, ct),
() => GetPreferencesAsync(userId, ct)
).WhenAllAsync();
// Total time: ~50ms (all run concurrently)
// 3x faster!✅ DO use Result.ParallelAsync when:
- Operations are independent (no dependencies between them)
- Operations can run concurrently safely
- Performance matters (user-facing, high-throughput)
- Need to aggregate errors from multiple validations
❌ DON'T use when:
- Operations have dependencies (use
BindAsyncchain) - Operations must run sequentially
- Operations modify shared state (need synchronization)
- Use factory functions - Ensures operations don't start until
ParallelAsyncis called - Always call
.WhenAllAsync()- Waits for all operations to complete - Short-circuit on failure - If one fails, others may still complete but result will be failure
- Combine errors intelligently - ValidationErrors merge field errors, different types create AggregateError
- Pass CancellationToken - Allows graceful cancellation of all operations
Use C#'s LINQ query syntax for readable multi-step operations:
var result =
from user in GetUser(userId)
from order in GetLastOrder(user)
from payment in ProcessPayment(order)
select new OrderConfirmation(user, order, payment);var result =
from email in EmailAddress.TryCreate(emailInput)
from user in GetUserByEmail(email)
where user.IsActive
from orders in GetUserOrders(user.Id)
select new UserSummary(user, orders);Note: The where clause uses a generic "filtered out" error. For domain-specific error messages, use Ensure instead:
// Better: Use Ensure for custom error messages
var result = EmailAddress.TryCreate(emailInput)
.Bind(email => GetUserByEmail(email))
.Ensure(user => user.IsActive, Error.Validation("User account is not active"))
.Bind(user => GetUserOrders(user.Id))
.Map(orders => new UserSummary(user, orders));var result = await (
from userId in UserId.TryCreate(userIdInput)
from user in GetUserAsync(userId)
from permissions in GetPermissionsAsync(user.Id)
select new UserWithPermissions(user, permissions)
).ConfigureAwait(false);Note: LINQ query syntax works best with synchronous operations. For complex async workflows, consider using BindAsync for better readability and cancellation token support.
Maybe<T> also supports LINQ query syntax via Select and SelectMany, enabling composition of optional values:
// Compose multiple optional values
Maybe<string> fullName =
from first in firstName
from last in lastName
select $"{first} {last}";
// If either is None → result is None
// Chain optional lookups
Maybe<Email> managerEmail =
from user in users.FindById(userId)
from manager in users.FindById(user.ManagerId)
from email in manager.Email
select email;ToMaybe converts a Result<T> to a Maybe<T>: success→Some(value), failure→None. The error is intentionally discarded.
Use when: You don't care why something failed — you just want the value if it succeeded.
// Try to load avatar — if it fails, just don't show one
Maybe<Avatar> avatar = await avatarService.GetByUserId(userId).ToMaybeAsync();
// Sync version
Maybe<User> user = GetUser(id).ToMaybe();Maybe<T> represents domain-level optionality — a value that was either provided or intentionally omitted. It composes with Result<T> pipelines and provides type-safe handling of optional value objects.
For a complete guide on why Maybe<T> exists, when to use it vs. T?, and its full API, see the dedicated Why Maybe? article.
Quick example:
// Optional value object on an entity
public class Customer : Entity<CustomerId>
{
public Maybe<PhoneNumber> Phone { get; }
}
// Bridge to Result pipeline
var result = customer.Phone
.ToResult(Error.NotFound("No phone on file"))
.Map(phone => FormatForDisplay(phone))
.Bind(formatted => SendSmsAsync(formatted));- Use Try for Third-Party Code: Wrap exception-throwing code with
Result.TryorResult.TryAsync - Leverage Tuples: Use tuple destructuring for combining multiple validations
- Parallel When Possible: Use
Task.WhenAllfor independent async operations - Choose Maybe vs Result Carefully: Use Maybe for optional data, Result for operations that can fail
- LINQ for Readability: Use LINQ query syntax for complex multi-step operations
- Learn about Error Handling for discriminated error matching
- See Working with Async Operations for CancellationToken support
- Check Integration for ASP.NET and FluentValidation usage