FunctionalDdd.RailwayOrientedProgramming
3.0.0-alpha.3
dotnet add package FunctionalDdd.RailwayOrientedProgramming --version 3.0.0-alpha.3
NuGet\Install-Package FunctionalDdd.RailwayOrientedProgramming -Version 3.0.0-alpha.3
<PackageReference Include="FunctionalDdd.RailwayOrientedProgramming" Version="3.0.0-alpha.3" />
<PackageVersion Include="FunctionalDdd.RailwayOrientedProgramming" Version="3.0.0-alpha.3" />
<PackageReference Include="FunctionalDdd.RailwayOrientedProgramming" />
paket add FunctionalDdd.RailwayOrientedProgramming --version 3.0.0-alpha.3
#r "nuget: FunctionalDdd.RailwayOrientedProgramming, 3.0.0-alpha.3"
#:package FunctionalDdd.RailwayOrientedProgramming@3.0.0-alpha.3
#addin nuget:?package=FunctionalDdd.RailwayOrientedProgramming&version=3.0.0-alpha.3&prerelease
#tool nuget:?package=FunctionalDdd.RailwayOrientedProgramming&version=3.0.0-alpha.3&prerelease
Railway Oriented Programming
Railway Oriented Programming (ROP) is a functional approach to error handling that treats your code like a railway track. Operations either succeed (staying on the success track) or fail (switching to the error track). This library provides the core types and extension methods to implement ROP in C#.
Table of Contents
- Installation
- Core Concepts
- Getting Started
- Core Operations
- Advanced Features
- Common Patterns
- Debugging Railway Oriented Programming
- Best Practices
Installation
Install via NuGet:
dotnet add package FunctionalDDD.RailwayOrientedProgramming
Core Concepts
Result Type
The Result<TValue> type represents either a successful computation (with a value) or a failure (with an error).
public readonly struct Result<TValue>
{
public TValue Value { get; } // Throws if IsFailure
public Error Error { get; } // Throws if IsSuccess
public bool IsSuccess { get; }
public bool IsFailure { get; }
// Implicit conversions
public static implicit operator Result<TValue>(TValue value);
public static implicit operator Result<TValue>(Error error);
}
Basic Usage:
using FunctionalDdd;
// Success result
Result<int> success = Result.Success(42);
Result<int> alsoSuccess = 42; // Implicit conversion
// Failure result
Result<int> failure = Result.Failure<int>(Error.NotFound("Item not found"));
Result<int> alsoFailure = Error.NotFound("Item not found"); // Implicit conversion
// Checking state
if (success.IsSuccess)
{
var value = success.Value; // 42
}
if (failure.IsFailure)
{
var error = failure.Error; // Error object
}
Maybe Type
The Maybe<T> type represents an optional value that may or may not exist.
public readonly struct Maybe<T> : IEquatable<T>, IEquatable<Maybe<T>>
where T : notnull
{
public T Value { get; }
public bool HasValue { get; }
public bool HasNoValue { get; }
}
Basic Usage:
// Create Maybe with value
Maybe<string> some = Maybe.From("hello");
Maybe<string> alsoSome = "hello"; // Implicit conversion
// Create Maybe without value
Maybe<string> none = Maybe.None<string>();
Maybe<string> alsoNone = null; // For reference types
// Check and use
if (some.HasValue)
{
Console.WriteLine(some.Value); // "hello"
}
// Get value or default
string result = none.GetValueOrDefault("default"); // "default"
Error Types
The library provides several built-in error types, each with a specific purpose and default HTTP status code mapping:
| Error Type | Factory Method | Use When | HTTP Status | Code |
|---|---|---|---|---|
ValidationError |
Error.Validation() |
Input data fails validation rules | 400 Bad Request | validation.error |
BadRequestError |
Error.BadRequest() |
Request is malformed or syntactically invalid | 400 Bad Request | bad.request.error |
UnauthorizedError |
Error.Unauthorized() |
User is not authenticated (not logged in) | 401 Unauthorized | unauthorized.error |
ForbiddenError |
Error.Forbidden() |
User lacks permission (authenticated but forbidden) | 403 Forbidden | forbidden.error |
NotFoundError |
Error.NotFound() |
Requested resource doesn't exist | 404 Not Found | not.found.error |
ConflictError |
Error.Conflict() |
Operation conflicts with current state | 409 Conflict | conflict.error |
DomainError |
Error.Domain() |
Business rule or domain logic violation | 422 Unprocessable Entity | domain.error |
RateLimitError |
Error.RateLimit() |
Too many requests (quota exceeded) | 429 Too Many Requests | rate.limit.error |
UnexpectedError |
Error.Unexpected() |
Unexpected system error or exception | 500 Internal Server Error | unexpected.error |
ServiceUnavailableError |
Error.ServiceUnavailable() |
Service temporarily unavailable | 503 Service Unavailable | service.unavailable.error |
AggregateError |
(created via Combine()) |
Multiple non-validation errors combined | Varies | aggregate.error |
Common Usage Examples:
// ValidationError - field-level validation failures
var validation = Error.Validation("Email format is invalid", "email");
var multiField = Error.Validation("Password too short", "password")
.And("email", "Email is required");
// BadRequestError - malformed requests
var badRequest = Error.BadRequest("Invalid JSON payload");
// NotFoundError - resource not found
var notFound = Error.NotFound($"User {userId} not found", userId);
// ConflictError - state conflicts
var conflict = Error.Conflict("Email address already in use");
// UnauthorizedError - authentication required
var unauthorized = Error.Unauthorized("Login required to access this resource");
// ForbiddenError - insufficient permissions
var forbidden = Error.Forbidden("Admin access required");
// DomainError - business rule violations
var domain = Error.Domain("Cannot withdraw more than account balance");
// RateLimitError - quota exceeded
var rateLimit = Error.RateLimit("API rate limit exceeded. Retry in 60 seconds");
// ServiceUnavailableError - temporary unavailability
var unavailable = Error.ServiceUnavailable("Service under maintenance");
// UnexpectedError - system errors
var unexpected = Error.Unexpected("Database connection failed");
Choosing the Right Error Type:
- Use ValidationError for field-level input validation (e.g., invalid email format, missing required fields)
- Use BadRequestError for syntactic/structural issues (e.g., malformed JSON, invalid query parameters)
- Use DomainError for business logic violations (e.g., insufficient funds, order quantity limits)
- Use ConflictError for state-based conflicts (e.g., duplicate email, concurrent modification)
- Use UnexpectedError for infrastructure/system failures (e.g., database errors, network timeouts)
Error Combining:
When multiple errors occur, they are intelligently combined:
- Multiple
ValidationErrorinstances ? Merged into a singleValidationErrorwith all field errors - Mixing
ValidationErrorwith other error types ? Creates anAggregateError - Multiple non-validation errors ? Creates an
AggregateError
// Validation errors are merged
var error1 = Error.Validation("Email required", "email");
var error2 = Error.Validation("Password required", "password");
var combined = error1.Combine(error2); // Single ValidationError with both fields
// Mixed error types create AggregateError
var validation = Error.Validation("Invalid input", "field");
var notFound = Error.NotFound("Resource not found");
var aggregate = validation.Combine(notFound); // AggregateError with 2 errors
Getting Started
Here's a simple example demonstrating the power of Railway Oriented Programming:
public record User(string Id, string Email, bool IsActive);
public Result<User> GetActiveUser(string userId)
{
return GetUserById(userId)
.ToResult(Error.NotFound($"User {userId} not found"))
.Ensure(user => user.IsActive,
Error.Validation("User account is not active"))
.Tap(user => LogUserAccess(user.Id));
}
private User? GetUserById(string id) { /* ... */ }
private void LogUserAccess(string userId) { /* ... */ }
Core Operations
Bind
Bind chains operations that return Result. It calls the function only if the current result is successful.
Use when: You need to chain operations where each step can fail.
// Basic bind
Result<int> ParseAge(string input) =>
int.TryParse(input, out var age)
? Result.Success(age)
: Error.Validation("Invalid age");
Result<string> ValidateAge(int age) =>
age >= 18
? Result.Success($"Age {age} is valid")
: Error.Validation("Must be 18 or older");
var result = ParseAge("25")
.Bind(age => ValidateAge(age)); // Success("Age 25 is valid")
var invalid = ParseAge("15")
.Bind(age => ValidateAge(age)); // Failure
Async variant:
async Task<Result<User>> GetUserAsync(string id) { /* ... */ }
async Task<Result<Order>> GetLastOrderAsync(User user) { /* ... */ }
var result = await GetUserAsync("123")
.BindAsync(user => GetLastOrderAsync(user));
Async with CancellationToken:
async Task<Result<User>> GetUserAsync(string id, CancellationToken ct) { /* ... */ }
async Task<Result<Order>> GetLastOrderAsync(User user, CancellationToken ct) { /* ... */ }
var result = await GetUserAsync("123", cancellationToken)
.BindAsync((user, ct) => GetLastOrderAsync(user, ct), cancellationToken);
Tuple-based operations with CancellationToken:
When working with multiple values from combined results, you can use CancellationToken with tuple operations:
// Combine multiple results into a tuple
var result = EmailAddress.TryCreate("user@example.com")
.Combine(UserId.TryCreate("123"))
.Combine(OrderId.TryCreate("456"));
// Bind with tuple parameters and CancellationToken
var orderResult = await result
.BindAsync(
(email, userId, orderId, ct) => FetchOrderAsync(email, userId, orderId, ct),
cancellationToken
);
// Works with tuples of 2-9 parameters
var complexResult = await GetUserDataAsync()
.BindAsync(
(id, name, email, phone, ct) => ProcessUserAsync(id, name, email, phone, ct),
cancellationToken
);
Map
Map transforms the value inside a successful Result. Unlike Bind, the transformation function returns a plain value, not a Result.
Use when: You need to transform a value without introducing failure.
var result = Result.Success(5)
.Map(x => x * 2) // Success(10)
.Map(x => x.ToString()); // Success("10")
// With failure
var failure = Result.Failure<int>(Error.NotFound("Number not found"))
.Map(x => x * 2); // Still Failure, Map is not called
Async variant:
var result = await GetUserAsync("123")
.MapAsync(user => user.Email.ToLowerInvariant());
Tap
Tap executes a side effect (like logging) on success without changing the result. It returns the same Result.
Use when: You need to perform side effects (logging, metrics, etc.) without transforming the value.
var result = Result.Success(42)
.Tap(x => Console.WriteLine($"Value: {x}")) // Logs "Value: 42"
.Tap(x => _metrics.IncrementCounter()) // Records metric
.Map(x => x * 2); // Success(84)
// With failure - Tap is skipped
var failure = Result.Failure<int>(Error.NotFound("Not found"))
.Tap(x => Console.WriteLine("This won't run"))
.Map(x => x * 2); // Still Failure
Async variant:
var result = await GetUserAsync("123")
.TapAsync(async user => await AuditLogAsync(user.Id))
.TapAsync(user => SendWelcomeEmail(user.Email));
Async with CancellationToken:
var result = await GetUserAsync("123", cancellationToken)
.TapAsync(
async (user, ct) => await AuditLogAsync(user.Id, ct),
cancellationToken
)
.TapAsync(
async (user, ct) => await SendWelcomeEmailAsync(user.Email, ct),
cancellationToken
);
Tuple-based operations with CancellationToken:
When working with tuples, you can use CancellationToken for side effects on multiple values:
// Tap with tuple parameters and CancellationToken
var result = EmailAddress.TryCreate("user@example.com")
.Combine(UserId.TryCreate("123"))
.TapAsync(
async (email, userId, ct) => await LogUserCreationAsync(email, userId, ct),
cancellationToken
)
.TapAsync(
async (email, userId, ct) => await NotifyAdminAsync(email, userId, ct),
cancellationToken
);
// Works with tuples of 2-9 parameters
var complexTap = await GetOrderDetailsAsync()
.TapAsync(
async (orderId, customerId, total, status, ct) =>
await SendOrderNotificationAsync(orderId, customerId, total, status, ct),
cancellationToken
);
Ensure
Ensure validates a condition on success. If the condition is false, it returns a failure with the specified error.
Use when: You need to validate business rules or conditions.
Result<User> CreatePremiumUser(string name, int age)
{
return User.Create(name, age)
.Ensure(user => user.Age >= 18,
Error.Validation("Must be 18 or older"))
.Ensure(user => !string.IsNullOrEmpty(user.Name),
Error.Validation("Name is required"))
.Tap(user => user.GrantPremiumAccess());
}
Multiple conditions:
var result = GetProduct(productId)
.Ensure(p => p.Stock > 0, Error.Validation("Out of stock"))
.Ensure(p => p.Price > 0, Error.Validation("Invalid price"))
.Ensure(p => !p.IsDiscontinued, Error.Validation("Product discontinued"));
Async variant:
var result = await GetUserAsync("123")
.EnsureAsync(async user => await IsEmailVerifiedAsync(user.Email),
Error.Validation("Email not verified"));
Compensate
Compensate provides error recovery by calling a fallback function when a result fails. Useful for providing default values or alternative paths.
Use when: You need fallback behavior or error recovery.
Basic compensation:
// Compensate without accessing the error
Result<User> result = GetUser(userId)
.Compensate(() => CreateGuestUser());
// Compensate with access to the error
Result<User> result = GetUser(userId)
.Compensate(error => CreateUserFromError(error));
Conditional compensation with predicate:
Compensate only when specific error conditions are met:
// Compensate only for NotFound errors
Result<User> result = GetUser(userId)
.Compensate(
predicate: error => error is NotFoundError,
func: () => CreateDefaultUser()
);
// Compensate with error context
Result<User> result = GetUser(userId)
.Compensate(
predicate: error => error is NotFoundError,
func: error => CreateUserFromError(error)
);
// Compensate based on error code
Result<Data> result = FetchData(id)
.Compensate(
predicate: error => error.Code == "not.found.error",
func: () => GetCachedData(id)
);
// Compensate for multiple error types
Result<Config> result = LoadConfig()
.Compensate(
predicate: error => error is NotFoundError or UnauthorizedError,
func: () => GetDefaultConfig()
);
Async variant:
var result = await GetUserAsync(userId)
.CompensateAsync(async error => await GetFromCacheAsync(userId));
Combine
Combine aggregates multiple Result objects. If all succeed, returns success with all values. If any fail, returns all errors combined.
Use when: You need to validate multiple independent operations before proceeding.
// Combine multiple validations
var result = EmailAddress.TryCreate("user@example.com")
.Combine(FirstName.TryCreate("John"))
.Combine(LastName.TryCreate("Doe"))
.Bind((email, firstName, lastName) =>
User.Create(email, firstName, lastName));
// All validations must pass
if (result.IsSuccess)
{
var user = result.Value; // All inputs were valid
}
else
{
var errors = result.Error; // Contains all validation errors
}
With optional values:
In this scenario, firstName is optional. If provided, it will be validated; if not, it will be skipped.
In other words, FirstName.TryCreate is only called if firstName is not null.
string? firstName = null; // Optional
string email = "user@example.com";
string? lastName = "Doe";
var result = EmailAddress.TryCreate(email)
.Combine(Maybe.Optional(firstName, FirstName.TryCreate))
.Combine(Maybe.Optional(lastName, LastName.TryCreate))
.Bind((e, f, l) => CreateProfile(e, f, l));
Advanced Features
LINQ Query Syntax
You can use C# query expressions with Result via Select, SelectMany, and Where:
// Chaining operations with query syntax
var total = from a in Result.Success(2)
from b in Result.Success(3)
from c in Result.Success(5)
select a + b + c; // Success(10)
// With failure
var result = from x in Result.Success(5)
where x > 10 // Predicate fails -> UnexpectedError
select x;
// Practical example
var userOrder = from user in GetUser(userId)
from order in GetOrder(orderId)
where order.UserId == user.Id
select (user, order);
Note: where uses an UnexpectedError if the predicate fails. For domain-specific errors, prefer Ensure.
Pattern Matching
Use Match to handle both success and failure cases inline:
// Synchronous match
var description = GetUser("123").Match(
onSuccess: user => $"User: {user.Name}",
onFailure: error => $"Error: {error.Code}"
);
// Async match
await ProcessOrderAsync(order).MatchAsync(
onSuccess: async order => await SendConfirmationAsync(order),
onFailure: async error => await LogErrorAsync(error)
);
// With return value
var httpResult = SaveData(data).Match(
onSuccess: data => Results.Ok(data),
onFailure: error => error.ToErrorResult()
);
Exception Capture
Use Try and TryAsync to safely capture exceptions and convert them to Result:
Use when: Integrating with code that throws exceptions.
// Synchronous
Result<string> LoadFile(string path)
{
return Result.Try(() => File.ReadAllText(path));
}
// Async
async Task<Result<User>> FetchUserAsync(string url)
{
return await Result.TryAsync(async () =>
await _httpClient.GetFromJsonAsync<User>(url));
}
// Usage
var content = LoadFile("config.json")
.Ensure(c => !string.IsNullOrEmpty(c),
Error.Validation("File is empty"))
.Bind(ParseConfig);
Parallel Operations
Run multiple async operations in parallel and combine their results:
var result = await GetStudentInfoAsync(studentId)
.ParallelAsync(GetStudentGradesAsync(studentId))
.ParallelAsync(GetLibraryBooksAsync(studentId))
.AwaitAsync()
.BindAsync((info, grades, books) =>
PrepareReport(info, grades, books));
Error Transformation
Transform errors while preserving success values:
Result<int> GetUserPoints(string userId) { /* ... */ }
var apiResult = GetUserPoints(userId)
.MapError(err => Error.NotFound($"Points for user {userId} not found"));
// Success values pass through unchanged
// Failure errors are replaced with the new error
Common Patterns
Validation Pipeline
public Result<Order> ProcessOrder(OrderRequest request)
{
return ValidateRequest(request)
.Bind(req => CheckInventory(req.ProductId, req.Quantity))
.Bind(product => ValidatePayment(request.PaymentInfo))
.Bind(payment => CreateOrder(request, payment))
.Tap(order => SendConfirmationEmail(order))
.TapError(error => LogOrderFailure(error));
}
Error Recovery with Fallbacks
public Result<Config> LoadConfiguration()
{
return LoadFromFile("config.json")
.Compensate(error => error is NotFoundError,
() => LoadFromEnvironment())
.Compensate(error => error is NotFoundError,
() => GetDefaultConfig())
.Ensure(cfg => cfg.IsValid,
Error.Validation("Invalid configuration"));
}
Multi-Field Validation
public Result<User> RegisterUser(string email, string firstName, string lastName, int age)
{
return EmailAddress.TryCreate(email)
.Combine(FirstName.TryCreate(firstName))
.Combine(LastName.TryCreate(lastName))
.Combine(EnsureExtensions.Ensure(age >= 18,
Error.Validation("Must be 18 or older", "age")))
.Bind((e, f, l) => User.Create(e, f, l, age));
}
Async Chain with Side Effects
public async Task<Result<string>> PromoteCustomerAsync(string customerId)
{
return await GetCustomerByIdAsync(customerId)
.ToResultAsync(Error.NotFound($"Customer {customerId} not found"))
.EnsureAsync(customer => customer.CanBePromoted,
Error.Validation("Customer has highest status"))
.TapAsync(customer => customer.PromoteAsync())
.BindAsync(customer => SendPromotionEmailAsync(customer.Email))
.MatchAsync(
onSuccess: _ => "Promotion successful",
onFailure: error => error.Detail
);
}
Debugging Railway Oriented Programming
One of the challenges with functional programming and chained operations is debugging. When a chain fails, it can be difficult to determine which step caused the failure. This section provides strategies and techniques to effectively debug ROP code.
Understanding the Railway Track
Railway Oriented Programming creates a chain of operations where:
- Success Track: Operations continue flowing through the chain
- Failure Track: Once an error occurs, the chain short-circuits and subsequent operations are skipped
When debugging, understand that only the first failure in a chain matters - everything after that failure is bypassed.
Common Debugging Challenges
1. Which Step Failed?
Problem: A long chain fails, but you don't know which operation caused the failure.
// Which of these 5 operations failed?
var result = await GetUserAsync(id)
.ToResultAsync(Error.NotFound("User not found"))
.EnsureAsync(u => u.IsActive, Error.Validation("User inactive"))
.BindAsync(u => GetOrdersAsync(u.Id))
.EnsureAsync(orders => orders.Any(), Error.NotFound("No orders"))
.MapAsync(orders => orders.Sum(o => o.Total));
Solution 1: Use Tap or TapError to add logging at each step:
var result = await GetUserAsync(id)
.Tap(u => _logger.LogDebug("Found user: {UserId}", u.Id))
.ToResultAsync(Error.NotFound("User not found"))
.TapError(err => _logger.LogWarning("Failed to find user: {Error}", err))
.EnsureAsync(u => u.IsActive, Error.Validation("User inactive"))
.Tap(u => _logger.LogDebug("User {UserId} is active", u.Id))
.TapError(err => _logger.LogWarning("User validation failed: {Error}", err))
.BindAsync(u => GetOrdersAsync(u.Id))
.Tap(orders => _logger.LogDebug("Found {Count} orders", orders.Count))
.TapError(err => _logger.LogWarning("Failed to get orders: {Error}", err))
.EnsureAsync(orders => orders.Any(), Error.NotFound("No orders"))
.MapAsync(orders => orders.Sum(o => o.Total))
.Tap(total => _logger.LogDebug("Calculated total: {Total}", total));
Solution 2: Break the chain into smaller, named steps:
var userResult = await GetUserAsync(id)
.ToResultAsync(Error.NotFound("User not found"));
if (userResult.IsFailure)
{
_logger.LogWarning("GetUser failed: {Error}", userResult.Error);
return userResult.Error;
}
var activeUserResult = userResult
.Ensure(u => u.IsActive, Error.Validation("User inactive"));
if (activeUserResult.IsFailure)
{
_logger.LogWarning("User validation failed: {Error}", activeUserResult.Error);
return activeUserResult.Error;
}
var ordersResult = await GetOrdersAsync(activeUserResult.Value.Id);
// ... continue with clearer breakpoints
Solution 3: Use descriptive error messages with context:
var result = await GetUserAsync(id)
.ToResultAsync(Error.NotFound($"User {id} not found in database"))
.EnsureAsync(u => u.IsActive,
Error.Validation($"User {id} account is inactive since {u.DeactivatedAt}"))
.BindAsync(u => GetOrdersAsync(u.Id))
.EnsureAsync(orders => orders.Any(),
Error.NotFound($"No orders found for user {id}"))
.MapAsync(orders => orders.Sum(o => o.Total));
// When this fails, the error message tells you exactly where it failed
2. Inspecting Values Mid-Chain
Problem: You want to see what value is flowing through the chain at a specific point.
Solution 1: Use Tap with a breakpoint:
var result = await GetUserAsync(id)
.Tap(user =>
{
// Set breakpoint here to inspect 'user'
var debug = new { user.Id, user.Name, user.Email };
_logger.LogDebug("User state: {@User}", debug);
})
.BindAsync(u => ProcessUserAsync(u));
Solution 2: Use Tap to capture values for assertions in tests:
[Fact]
public async Task Should_Process_Valid_User()
{
User? capturedUser = null;
var result = await GetUserAsync("123")
.Tap(user => capturedUser = user) // Capture the value
.BindAsync(u => ProcessUserAsync(u));
Assert.NotNull(capturedUser);
Assert.Equal("123", capturedUser.Id);
result.Should().BeSuccess();
}
Solution 3: Use Map to temporarily transform for inspection:
var result = await GetOrdersAsync(userId)
.Map(orders =>
{
_logger.LogDebug("Order count: {Count}, Total: {Total}",
orders.Count, orders.Sum(o => o.Total));
return orders; // Return unchanged for the chain
})
.BindAsync(orders => ProcessOrdersAsync(orders));
3. Async Debugging
Problem: Async chains are harder to step through in the debugger.
Solution 1: Add .ConfigureAwait(false) when appropriate and use named variables:
// Instead of one long chain
var result = await GetUserAsync(id)
.BindAsync(u => GetOrdersAsync(u.Id))
.MapAsync(orders => ProcessOrders(orders));
// Break it up
var userResult = await GetUserAsync(id); // Can set breakpoint and inspect
if (userResult.IsFailure) return userResult.Error;
var ordersResult = await GetOrdersAsync(userResult.Value.Id); // Another breakpoint
if (ordersResult.IsFailure) return ordersResult.Error;
var processed = ordersResult.Map(orders => ProcessOrders(orders)); // Inspect here
return processed;
Solution 2: Use TapAsync with logging for async side effects:
var result = await GetUserAsync(id)
.TapAsync(async user =>
{
await Task.Delay(1); // Simulate async
_logger.LogDebug("Processing user {UserId} at {Time}", user.Id, DateTime.UtcNow);
})
.BindAsync(u => GetOrdersAsync(u.Id));
4. Testing Individual Steps
Problem: A complex chain makes it hard to test individual operations.
Solution: Extract operations into testable methods:
// Instead of inline operations
public Result<User> ValidateAndProcessUser(string id)
{
return GetUser(id)
.Ensure(u => u.IsActive, Error.Validation("Inactive"))
.Ensure(u => u.Email.Contains("@"), Error.Validation("Invalid email"))
.Tap(u => u.LastLoginAt = DateTime.UtcNow);
}
// Extract testable pieces
public Result<User> GetActiveUser(string id) =>
GetUser(id)
.Ensure(u => u.IsActive, Error.Validation("User is inactive"));
public Result<User> ValidateUserEmail(User user) =>
user.Email.Contains("@")
? Result.Success(user)
: Error.Validation("Invalid email format");
public void UpdateLastLogin(User user) =>
user.LastLoginAt = DateTime.UtcNow;
// Now compose and test separately
public Result<User> ValidateAndProcessUser(string id) =>
GetActiveUser(id)
.Bind(ValidateUserEmail)
.Tap(UpdateLastLogin);
// Easy to test each part
[Fact]
public void GetActiveUser_Should_Fail_For_Inactive_User()
{
var result = GetActiveUser("inactive-user-id");
result.Should().BeFailure();
result.Error.Code.Should().Be("validation.error");
}
5. Combine Errors Are Aggregated
Problem: When using Combine, all errors are collected. Understanding which validations failed requires inspecting the aggregated error.
var result = EmailAddress.TryCreate("invalid-email")
.Combine(FirstName.TryCreate(""))
.Combine(Age.Ensure(age => age >= 18, Error.Validation("Must be 18+")));
// This might fail with 3 errors - which ones?
Solution: Use TapError to log all aggregated errors:
var result = EmailAddress.TryCreate(email)
.Combine(FirstName.TryCreate(firstName))
.Combine(Age.TryCreate(age))
.TapError(error =>
{
if (error is AggregatedError aggregated)
{
foreach (var err in aggregated.Errors)
{
_logger.LogWarning("Validation failed: {Property} - {Message}",
err.Property, err.Message);
}
}
else
{
_logger.LogWarning("Validation failed: {Message}", error.Message);
}
});
Or in tests, check individual errors:
[Fact]
public void Combine_Should_Return_All_Validation_Errors()
{
var result = EmailAddress.TryCreate("bad-email")
.Combine(FirstName.TryCreate(""))
.Combine(Age.Ensure(15, e => e >= 18, Error.Validation("Age requirement")));
result.Should().BeFailure();
result.Error.Should().BeOfType<AggregatedError>();
var aggregated = (AggregatedError)result.Error;
aggregated.Errors.Should().HaveCount(3);
aggregated.Errors.Should().Contain(e => e.Property == "email");
aggregated.Errors.Should().Contain(e => e.Property == "firstName");
aggregated.Errors.Should().Contain(e => e.Message.Contains("Age"));
}
Debugging Tools & Techniques
1. Conditional Breakpoints
Set conditional breakpoints in Tap operations:
var result = ProcessUsers(users)
.Tap(user =>
{
// Set breakpoint here with condition: user.Id == "problem-id"
if (user.Id == "problem-id")
{
Console.WriteLine("Found problem user");
}
});
2. Built-in Debug Extension Methods
The library includes Debug extension methods that are only compiled in DEBUG builds:
// Basic debug output
var result = GetUser(id)
.Debug("After GetUser")
.Ensure(u => u.IsActive, Error.Validation("Inactive"))
.Debug("After Ensure")
.Bind(ProcessUser)
.Debug("After ProcessUser");
// Detailed debug output (includes error properties and aggregated errors)
var result = EmailAddress.TryCreate(email)
.Combine(FirstName.TryCreate(firstName))
.Combine(LastName.TryCreate(lastName))
.DebugDetailed("After validation");
// Debug with stack trace
var result = ProcessOrder(orderId)
.DebugWithStack("Processing order", includeStackTrace: true);
// Custom debug actions
var result = GetUser(id)
.DebugOnSuccess(user =>
{
Console.WriteLine($"User: {user.Id}, Email: {user.Email}");
Console.WriteLine($"IsActive: {user.IsActive}");
})
.DebugOnFailure(error =>
{
Console.WriteLine($"Error Type: {error.GetType().Name}");
Console.WriteLine($"Message: {error.Message}");
});
// Async variants available
var result = await GetUserAsync(id)
.DebugAsync("After GetUser")
.BindAsync(u => GetOrdersAsync(u.Id))
.DebugDetailedAsync("After GetOrders");
Note: These methods are automatically excluded from RELEASE builds, so there's no performance impact in production.
3. Result Inspection in Tests
Use FluentAssertions (or similar) for readable test assertions:
using FluentAssertions;
[Fact]
public void Should_Fail_With_Validation_Error()
{
var result = ProcessOrder(invalidOrder);
result.Should().BeFailure();
result.Error.Should().BeOfType<ValidationError>();
result.Error.Code.Should().Be("validation.error");
result.Error.Message.Should().Contain("invalid quantity");
}
[Fact]
public void Should_Return_Processed_User()
{
var result = ProcessUser(validUserId);
result.Should().BeSuccess();
result.Value.Should().NotBeNull();
result.Value.Status.Should().Be(UserStatus.Active);
}
4. Logging Strategies
Create a logging policy for your ROP chains:
public static class ResultLoggingExtensions
{
public static Result<T> LogOnFailure<T>(
this Result<T> result,
ILogger logger,
string operation)
{
return result.TapError(error =>
logger.LogWarning("Operation {Operation} failed: {ErrorCode} - {Message}",
operation, error.Code, error.Message));
}
public static Result<T> LogOnSuccess<T>(
this Result<T> result,
ILogger logger,
string operation)
{
return result.Tap(value =>
logger.LogInformation("Operation {Operation} succeeded with value: {Value}",
operation, value));
}
}
// Usage
var result = await GetUserAsync(id)
.LogOnFailure(_logger, "GetUser")
.LogOnSuccess(_logger, "GetUser")
.BindAsync(u => GetOrdersAsync(u.Id))
.LogOnFailure(_logger, "GetOrders")
.LogOnSuccess(_logger, "GetOrders");
5. Tracing with OpenTelemetry
Enable distributed tracing for ROP operations:
services.AddOpenTelemetryTracing(builder =>
{
builder
.AddFunctionalDddRopInstrumentation() // Built-in instrumentation
.AddOtlpExporter();
});
// This automatically traces your ROP chains
var result = await GetUserAsync(id) // Traced as "GetUserAsync"
.BindAsync(u => GetOrdersAsync(u.Id)) // Traced as "GetOrdersAsync"
.MapAsync(orders => ProcessOrders(orders)); // Traced as "ProcessOrders"
// View the trace in your APM tool (Jaeger, Zipkin, Application Insights, etc.)
Best Practices for Debuggable ROP Code
Use descriptive error messages with context (IDs, parameters, timestamps)
Error.NotFound($"Order {orderId} not found for user {userId} at {DateTime.UtcNow}")Add
Tapcalls at key decision points in long chains.Tap(x => _logger.LogDebug("Validated: {Value}", x))Break complex chains into smaller, named methods for better stack traces
var userResult = await GetActiveUserAsync(id); var ordersResult = await GetUserOrdersAsync(userResult); // vs one giant chainTest each operation independently before composing
[Fact] public void Validate_Email_Format() { /* test */ } [Fact] public void Validate_Age_Requirement() { /* test */ } [Fact] public void Combine_All_Validations() { /* integration test */ }Use structured logging with correlation IDs
.Tap(user => _logger.LogDebug("Processing user {UserId} in request {RequestId}", user.Id, _correlationId))Include property names in validation errors for easier debugging
Error.Validation("Email format is invalid", "email") // Better than: Error.Validation("Invalid format")Use built-in debug extension methods for development
// Automatically excluded from RELEASE builds
var result = GetUser(id)
.Debug("After GetUser")
.Bind(ProcessUser)
.DebugDetailed("Final result");
- Use
Matchat boundaries to handle all cases explicitlyreturn result.Match( onSuccess: user => Ok(user), onFailure: error => error switch { NotFoundError => NotFound(error), ValidationError => BadRequest(error), _ => StatusCode(500, error) } );
Debugging Checklist
When debugging a failing ROP chain, ask yourself:
- What error am I getting? Check
result.Error.Code,.Message,.Property - Where did it fail? Add
Tap/TapErrorto narrow down the step - What was the input? Log the initial parameters
- What's the value at each step? Use
Tapto inspect intermediate values - Is it always failing? Test with different inputs to isolate the pattern
- Are errors aggregated? Check if
ErrorisAggregatedErrorwith multiple failures - Is cancellation involved? Verify
CancellationTokenis passed correctly - Is timing an issue? Add timestamps to
Tapoperations for async chains - Can I reproduce in a test? Write a unit test to isolate the problem
- Do I need better error messages? Add context (IDs, parameters) to error creation
Common Pitfalls
Forgetting that chains short-circuit - Once an error occurs, subsequent operations don't run
var result = GetUser(id) // Returns error .Tap(u => Console.WriteLine("This never runs!")) // Skipped .Map(u => u.Name); // Also skippedNot checking IsFailure before accessing Value - Will throw exception
var result = GetUser(id); var name = result.Value; // Throws if IsFailure! // Always check first or use Match/Tap if (result.IsSuccess) var name = result.Value;Mixing Try/Catch with ROP - Defeats the purpose
// Avoid try { var result = GetUser(id); return result.Value; } catch { /* handle */ } // Prefer return GetUser(id).Match( onSuccess: user => user, onFailure: error => HandleError(error) );Not using
TryAsyncfor exception-throwing code - Unhandled exceptions will crash// Avoid var result = await Result.Success(url) .BindAsync(async u => await _httpClient.GetStringAsync(u)); // Can throw! // Prefer var result = await Result.TryAsync(async () => await _httpClient.GetStringAsync(url));
Best Practices
Use
Bindfor operations that can fail,Mapfor pure transformations// Good GetUser(id) .Map(user => user.Name) // Pure transformation .Bind(name => ValidateName(name)) // Can fail // Avoid GetUser(id) .Bind(user => Result.Success(user.Name)) // Unnecessary Result wrappingPrefer
EnsureoverBindfor simple validations// Good GetUser(id) .Ensure(user => user.IsActive, Error.Validation("User not active")) // Avoid GetUser(id) .Bind(user => user.IsActive ? Result.Success(user) : Error.Validation("User not active"))Use
Tapfor side effects (logging, metrics, notifications)ProcessOrder(order) .Tap(o => _logger.LogInfo($"Order {o.Id} processed")) .Tap(o => _metrics.RecordOrder(o)) .TapError(err => _logger.LogError(err.Message))Combine independent validations instead of nesting
// Good Email.TryCreate(email) .Combine(Name.TryCreate(name)) .Combine(Age.TryCreate(age)) .Bind((e, n, a) => User.Create(e, n, a)) // Avoid Email.TryCreate(email) .Bind(e => Name.TryCreate(name) .Bind(n => Age.TryCreate(age) .Bind(a => User.Create(e, n, a))))Use domain-specific errors instead of generic ones
// Good Error.Validation("Email format is invalid", "email") // Avoid Error.Unexpected("Something went wrong")Handle errors at boundaries (controllers, entry points)
[HttpPost] public ActionResult<User> Register(RegisterRequest request) => RegisterUser(request) .ToActionResult(this); // Converts Result to ActionResultUse
Try/TryAsyncfor exception boundariesResult<Data> LoadData() => Result.Try(() => File.ReadAllText(path)) .Bind(json => ParseJson(json));Use CancellationToken with async operations for proper cancellation support
// Single-parameter operations var result = await GetUserAsync(id, cancellationToken) .BindAsync((user, ct) => GetOrderAsync(user.Id, ct), cancellationToken) .TapAsync(async (order, ct) => await LogOrderAsync(order, ct), cancellationToken); // Tuple-based operations var complexResult = EmailAddress.TryCreate(email) .Combine(UserId.TryCreate(userId)) .BindAsync( async (email, userId, ct) => await CreateUserAsync(email, userId, ct), cancellationToken );Provide CancellationToken parameter when calling async operations to enable timeouts and graceful shutdown
// Good - supports cancellation async Task<Result<User>> ProcessUserAsync(string id, CancellationToken ct) { return await GetUserAsync(id, ct) .BindAsync((user, ct) => ValidateAsync(user, ct), ct) .TapAsync(async (user, ct) => await NotifyAsync(user, ct), ct); } // Avoid - no cancellation support async Task<Result<User>> ProcessUserAsync(string id) { return await GetUserAsync(id) .BindAsync(user => ValidateAsync(user)) .TapAsync(async user => await NotifyAsync(user)); }
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | 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. |
-
net10.0
- OpenTelemetry.Api (>= 1.14.0)
- T4.Build (>= 0.2.5)
NuGet packages (3)
Showing the top 3 NuGet packages that depend on FunctionalDdd.RailwayOrientedProgramming:
| Package | Downloads |
|---|---|
|
FunctionalDdd.FluentValidation
Convert fluent validation errors to FunctionalDdd Validation errors. |
|
|
FunctionalDdd.Asp
These extension methods are used to convert the ROP Result object to ActionResult. If the Result is in a failed state, it returns the corresponding HTTP error code. |
|
|
FunctionalDdd.CommonValueObjects
To avoid passing around strings, it is recommended to use RequiredString to obtain strongly typed properties. The source code generator will automate the implementation process. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 3.0.0-alpha.3 | 122 | 12/20/2025 |
| 2.1.10 | 711 | 12/3/2025 |
| 2.1.9 | 307 | 11/21/2025 |
| 2.1.1 | 265 | 4/26/2025 |
| 2.1.0-preview.3 | 91 | 4/26/2025 |
| 2.0.1 | 262 | 1/23/2025 |
| 2.0.0-alpha.62 | 80 | 1/8/2025 |
| 2.0.0-alpha.61 | 80 | 1/7/2025 |
| 2.0.0-alpha.60 | 96 | 12/7/2024 |
| 2.0.0-alpha.55 | 88 | 11/22/2024 |
| 2.0.0-alpha.52 | 98 | 11/7/2024 |
| 2.0.0-alpha.48 | 86 | 11/2/2024 |
| 2.0.0-alpha.47 | 87 | 10/30/2024 |
| 2.0.0-alpha.44 | 154 | 10/18/2024 |
| 2.0.0-alpha.42 | 108 | 10/14/2024 |
| 2.0.0-alpha.39 | 126 | 6/27/2024 |
| 2.0.0-alpha.38 | 108 | 4/24/2024 |
| 2.0.0-alpha.33 | 103 | 4/17/2024 |
| 2.0.0-alpha.26 | 125 | 4/9/2024 |
| 2.0.0-alpha.21 | 113 | 4/1/2024 |
| 2.0.0-alpha.19 | 100 | 3/5/2024 |
| 2.0.0-alpha.18 | 101 | 2/28/2024 |
| 2.0.0-alpha.17 | 104 | 2/26/2024 |
| 2.0.0-alpha.15 | 114 | 1/30/2024 |
| 2.0.0-alpha.8 | 99 | 1/27/2024 |
| 2.0.0-alpha.6 | 132 | 1/5/2024 |
| 1.1.1 | 1,109 | 11/15/2023 |
| 1.1.0-alpha.32 | 155 | 11/2/2023 |
| 1.1.0-alpha.30 | 244 | 11/1/2023 |
| 1.1.0-alpha.28 | 128 | 10/28/2023 |
| 1.1.0-alpha.27 | 129 | 10/28/2023 |
| 1.1.0-alpha.24 | 118 | 10/20/2023 |
| 1.1.0-alpha.23 | 127 | 10/13/2023 |
| 1.1.0-alpha.21 | 154 | 10/1/2023 |
| 1.1.0-alpha.20 | 127 | 9/30/2023 |
| 1.1.0-alpha.19 | 154 | 9/30/2023 |
| 1.1.0-alpha.18 | 133 | 9/29/2023 |
| 1.1.0-alpha.17 | 120 | 9/22/2023 |
| 1.1.0-alpha.13 | 103 | 9/16/2023 |
| 1.1.0-alpha.4 | 232 | 6/9/2023 |
| 1.1.0-alpha.3 | 168 | 6/8/2023 |
| 1.0.1 | 1,351 | 5/12/2023 |
| 0.1.0-alpha.40 | 219 | 4/6/2023 |
| 0.1.0-alpha.39 | 218 | 4/3/2023 |
| 0.1.0-alpha.38 | 249 | 4/2/2023 |
| 0.1.0-alpha.37 | 219 | 3/31/2023 |
| 0.1.0-alpha.35 | 224 | 3/29/2023 |
| 0.1.0-alpha.34 | 200 | 3/28/2023 |
| 0.1.0-alpha.32 | 238 | 3/18/2023 |
| 0.1.0-alpha.30 | 220 | 3/11/2023 |
| 0.1.0-alpha.27 | 216 | 3/7/2023 |
| 0.1.0-alpha.24 | 227 | 2/15/2023 |
| 0.1.0-alpha.22 | 224 | 2/15/2023 |
| 0.1.0-alpha.20 | 232 | 2/13/2023 |
| 0.0.1-alpha.14 | 232 | 1/4/2023 |
| 0.0.1-alpha.4 | 223 | 12/30/2022 |