Advanced Features
Advanced Railway Oriented Programming patterns for complex scenarios.
Table of Contents
- Pattern Matching
- Tuple Destructuring
- Exception Capture
- Parallel Operations
- LINQ Query Syntax
- Maybe Type
Pattern Matching
Match handles both success and failure cases elegantly:
Basic Pattern Matching
var description = GetUser("123").Match(
onSuccess: user => $"User: {user.Name}",
onFailure: error => $"Error: {error.Code}"
);
Async Pattern Matching
await ProcessOrderAsync(order).MatchAsync(
onSuccess: async order => await SendConfirmationAsync(order),
onFailure: async error => await LogErrorAsync(error)
);
With HTTP Results
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()
);
});
Tuple Destructuring
Automatically destructure tuples in Match and Bind operations:
Automatic Destructuring
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}"
);
Tuple Destructuring in Bind
var result = FirstName.TryCreate("John")
.Combine(LastName.TryCreate("Smith"))
.Combine(EmailAddress.TryCreate("john@example.com"))
.Bind((firstName, lastName, email) =>
CreateUser(firstName, lastName, email)
);
Support for 2-9 Parameters
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(...));
Exception Capture
Convert exception-throwing code into Results using Try and TryAsync:
Synchronous Exception Capture
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 Exception Capture
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));
Custom Exception Mapping
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)
}
);
}
Parallel Operations
Execute multiple async operations in parallel while maintaining ROP style using Result.ParallelAsync:
Parallel Execution with 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
Multi-Stage Parallel Execution
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
Real-World Example: Fraud Detection
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!
Error Handling in Parallel Operations
// 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" error
Multiple 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 combined
Parallel vs Sequential Comparison
Sequential (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!
When to Use Parallel Execution
✅ 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)
Best Practices
- 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
LINQ Query Syntax
Use C#'s LINQ query syntax for readable multi-step operations:
Basic LINQ Query
var result =
from user in GetUser(userId)
from order in GetLastOrder(user)
from payment in ProcessPayment(order)
select new OrderConfirmation(user, order, payment);
LINQ with Where Clause
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));
LINQ with Async Operations
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 Type
Maybe<T> represents an optional value that may or may not exist, without implying an error:
Creating Maybe Values
// From nullable value
Maybe<User> user = Maybe.From(nullableUser);
// Implicit conversion
Maybe<string> some = "value";
Maybe<string> none = Maybe.None<string>();
// Checking for value
if (user.HasValue)
{
Console.WriteLine($"Hello {user.Value.Name}");
}
else
{
Console.WriteLine("No user found");
}
Maybe vs Result
Use Maybe<T> when absence is not an error (optional data):
Maybe<string> middleName = GetMiddleName(user); // OK to be missing
Use Result<T> when you need to track why something failed:
Result<User> user = GetUser(id); // Need to know why it failed
Converting Maybe to Result
Maybe<User> maybeUser = FindUserInCache(id);
Result<User> result = maybeUser
.ToResult(Error.NotFound($"User {id} not found in cache"));
Using Maybe Operations
Maybe<User> maybeUser = GetUserById(id);
// Convert to Result for chaining
var result = maybeUser
.ToResult(Error.NotFound($"User {id} not found"))
.Bind(user => GetEmailPreferences(user.Email));
// Or use directly with HasValue
string preferences = maybeUser.HasValue
? GetEmailPreferences(maybeUser.Value.Email)
: "No preferences found";
Best Practices
- 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
Next Steps
- Learn about Error Handling for discriminated error matching
- See Working with Async Operations for CancellationToken support
- Check Integration for ASP.NET and FluentValidation usage