Railway Oriented Programming is a coding concept that involves using a library's built-in functions to control program execution flow based on success or error track. By doing so, functional code can be written that allows for chaining of functions without the need for error checking.
$ dotnet add package FunctionalDdd.RailwayOrientedProgrammingRailway 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#.
Install via NuGet:
dotnet add package FunctionalDdd.RailwayOrientedProgramming
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
}
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"
Transformations:
// Map — transform the inner value (returns Maybe<TResult>)
Maybe<string> name = Maybe.From("hello");
Maybe<int> length = name.Map(s => s.Length); // Maybe.From(5)
Maybe<string> empty = Maybe.None<string>();
Maybe<int> noLength = empty.Map(s => s.Length); // Maybe.None
// Match — pattern match to extract a value
string greeting = name.Match(
s => $"Hi, {s}!", // HasValue
() => "No name"); // HasNoValue
// → "Hi, hello!"
string fallback = empty.Match(
s => $"Hi, {s}!",
() => "No name");
// → "No name"
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:
Error Combining:
When multiple errors occur, they are intelligently combined:
ValidationError instances → Merged into a single ValidationError with all field errorsValidationError with other error types → Creates an AggregateErrorAggregateError// 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
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) { /* ... */ }
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 ct = cancellationToken;
// Single parameter
var result = await GetUserAsync("123", ct)
.BindAsync(user => GetLastOrderAsync(user, ct));
// Works with tuples too
var complexResult = EmailAddress.TryCreate("user@example.com")
.Combine(UserId.TryCreate("123"))
.BindAsync((email, userId) => CreateUserAsync(email, userId, ct));
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 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 ct = cancellationToken;
// Single parameter
var result = await GetUserAsync("123", ct)
.TapAsync(user => AuditLogAsync(user.Id, ct))
.TapAsync(user => SendWelcomeEmailAsync(user.Email, ct));
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"));
RecoverOnFailure 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 recovery:
// RecoverOnFailure without accessing the error
Result<User> result = GetUser(userId)
.RecoverOnFailure(() => CreateGuestUser());
// RecoverOnFailure with access to the error
Result<User> result = GetUser(userId)
.RecoverOnFailure(error => CreateUserFromError(error));
Conditional recovery with predicate:
RecoverOnFailure only when specific error conditions are met:
// RecoverOnFailure only for NotFound errors
Result<User> result = GetUser(userId)
.RecoverOnFailure(
predicate: error => error is NotFoundError,
func: () => CreateDefaultUser()
);
// RecoverOnFailure with error context
Result<User> result = GetUser(userId)
.RecoverOnFailure(
predicate: error => error is NotFoundError,
func: error => CreateUserFromError(error)
);
// RecoverOnFailure based on error code
Result<Data> result = FetchData(id)
.RecoverOnFailure(
predicate: error => error.Code == "not.found.error",
func: () => GetCachedData(id)
);
// RecoverOnFailure for multiple error types
Result<Config> result = LoadConfig()
.RecoverOnFailure(
predicate: error => error is NotFoundError or UnauthorizedError,
func: () => GetDefaultConfig()
);
Async variant:
var result = await GetUserAsync(userId)
.RecoverOnFailureAsync(async error => await GetFromCacheAsync(userId));
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));
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.
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()
);
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);
Run multiple async operations in parallel and combine their results:
var result = await Result.ParallelAsync(
() => GetStudentInfoAsync(studentId),
() => GetStudentGradesAsync(studentId),
() => GetLibraryBooksAsync(studentId))
.WhenAllAsync()
.BindAsync((info, grades, books) =>
PrepareReport(info, grades, books));
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
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));
}
public Result<Config> LoadConfiguration()
{
return LoadFromFile("config.json")
.RecoverOnFailure(error => error is NotFoundError,
() => LoadFromEnvironment())
.RecoverOnFailure(error => error is NotFoundError,
() => GetDefaultConfig())
.Ensure(cfg => cfg.IsValid,
Error.Validation("Invalid configuration"));
}
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));
}
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 ROP chains can be tricky — when a chain fails, it's not always obvious which step caused it. Key techniques:
Tap / TapError — Add logging at each step without changing the resultError.NotFound($"User {userId} not found").Debug("label") in development (excluded from RELEASE builds)📖 Full Debugging Guide — Comprehensive strategies, code samples, and checklist.
Use descriptive error messages with context
Error.NotFound($"Order {orderId} not found for user {userId}")
Include fieldName in validation errors for easier debugging
Error.Validation("Email format is invalid", "email")
Handle errors at boundaries (controllers, entry points)
[HttpPost]
public ActionResult<User> Register(RegisterRequest request) =>
RegisterUser(request)
.ToActionResult(this);
Use Try / TryAsync for exception boundaries
Result<Data> LoadData() =>
Result.Try(() => File.ReadAllText(path))
.Bind(json => ParseJson(json));
Provide CancellationToken with async operations
var ct = cancellationToken;
var result = await GetUserAsync(id, ct)
.BindAsync(user => GetOrderAsync(user.Id, ct))
.TapAsync(order => LogOrderAsync(order, ct));