FunctionalDdd.RailwayOrientedProgramming 3.0.0-alpha.3

This is a prerelease version of FunctionalDdd.RailwayOrientedProgramming.
dotnet add package FunctionalDdd.RailwayOrientedProgramming --version 3.0.0-alpha.3
                    
NuGet\Install-Package FunctionalDdd.RailwayOrientedProgramming -Version 3.0.0-alpha.3
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="FunctionalDdd.RailwayOrientedProgramming" Version="3.0.0-alpha.3" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="FunctionalDdd.RailwayOrientedProgramming" Version="3.0.0-alpha.3" />
                    
Directory.Packages.props
<PackageReference Include="FunctionalDdd.RailwayOrientedProgramming" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add FunctionalDdd.RailwayOrientedProgramming --version 3.0.0-alpha.3
                    
#r "nuget: FunctionalDdd.RailwayOrientedProgramming, 3.0.0-alpha.3"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package FunctionalDdd.RailwayOrientedProgramming@3.0.0-alpha.3
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=FunctionalDdd.RailwayOrientedProgramming&version=3.0.0-alpha.3&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=FunctionalDdd.RailwayOrientedProgramming&version=3.0.0-alpha.3&prerelease
                    
Install as a Cake Tool

Railway Oriented Programming

NuGet Package

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

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 ValidationError instances ? Merged into a single ValidationError with all field errors
  • Mixing ValidationError with other error types ? Creates an AggregateError
  • 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

  1. Use descriptive error messages with context (IDs, parameters, timestamps)

    Error.NotFound($"Order {orderId} not found for user {userId} at {DateTime.UtcNow}")
    
  2. Add Tap calls at key decision points in long chains

    .Tap(x => _logger.LogDebug("Validated: {Value}", x))
    
  3. Break complex chains into smaller, named methods for better stack traces

    var userResult = await GetActiveUserAsync(id);
    var ordersResult = await GetUserOrdersAsync(userResult);
    // vs one giant chain
    
  4. Test 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 */ }
    
  5. Use structured logging with correlation IDs

    .Tap(user => _logger.LogDebug("Processing user {UserId} in request {RequestId}", 
        user.Id, _correlationId))
    
  6. Include property names in validation errors for easier debugging

    Error.Validation("Email format is invalid", "email")
    // Better than: Error.Validation("Invalid format")
    
  7. 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");
  1. Use Match at boundaries to handle all cases explicitly
    return 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/TapError to narrow down the step
  • What was the input? Log the initial parameters
  • What's the value at each step? Use Tap to inspect intermediate values
  • Is it always failing? Test with different inputs to isolate the pattern
  • Are errors aggregated? Check if Error is AggregatedError with multiple failures
  • Is cancellation involved? Verify CancellationToken is passed correctly
  • Is timing an issue? Add timestamps to Tap operations 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

  1. 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 skipped
    
  2. Not 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;
    
  3. 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)
    );
    
  4. Not using TryAsync for 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

  1. Use Bind for operations that can fail, Map for 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 wrapping
    
  2. Prefer Ensure over Bind for 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"))
    
  3. Use Tap for 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))
    
  4. 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))))
    
  5. Use domain-specific errors instead of generic ones

    // Good
    Error.Validation("Email format is invalid", "email")
    
    // Avoid
    Error.Unexpected("Something went wrong")
    
  6. Handle errors at boundaries (controllers, entry points)

    [HttpPost]
    public ActionResult<User> Register(RegisterRequest request) =>
        RegisterUser(request)
            .ToActionResult(this);  // Converts Result to ActionResult
    
  7. Use Try/TryAsync for exception boundaries

    Result<Data> LoadData() =>
        Result.Try(() => File.ReadAllText(path))
            .Bind(json => ParseJson(json));
    
  8. 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
        );
    
  9. 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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