FunctionalDdd.RailwayOrientedProgramming
2.1.10
dotnet add package FunctionalDdd.RailwayOrientedProgramming --version 2.1.10
NuGet\Install-Package FunctionalDdd.RailwayOrientedProgramming -Version 2.1.10
<PackageReference Include="FunctionalDdd.RailwayOrientedProgramming" Version="2.1.10" />
<PackageVersion Include="FunctionalDdd.RailwayOrientedProgramming" Version="2.1.10" />
<PackageReference Include="FunctionalDdd.RailwayOrientedProgramming" />
paket add FunctionalDdd.RailwayOrientedProgramming --version 2.1.10
#r "nuget: FunctionalDdd.RailwayOrientedProgramming, 2.1.10"
#:package FunctionalDdd.RailwayOrientedProgramming@2.1.10
#addin nuget:?package=FunctionalDdd.RailwayOrientedProgramming&version=2.1.10
#tool nuget:?package=FunctionalDdd.RailwayOrientedProgramming&version=2.1.10
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
- 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:
Error- Base error classNotFoundError- Resource not foundValidationError- Input validation failureConflictError- Business rule conflictUnauthorizedError- Authentication requiredForbiddenError- Insufficient permissionsUnexpectedError- Unexpected system errorAggregatedError- Multiple errors combined
var notFound = Error.NotFound("User not found", "userId");
var validation = Error.Validation("Email is invalid", "email");
var conflict = Error.Conflict("Email already exists");
var unauthorized = Error.Unauthorized("Login required");
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));
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));
TapError:
var result = GetUser("123")
.TapError(error => _logger.LogError($"Failed: {error.Message}"))
.Tap(user => _logger.LogInfo($"Success: {user.Name}"));
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));
Finally
Finally unwraps a Result by providing handlers for both success and failure cases. It always returns a value (not a Result).
Use when: You need to convert a Result to a concrete value or action.
// Convert to string message
string message = GetUser("123")
.Finally(
ok: user => $"Found: {user.Name}",
err: error => $"Error: {error.Message}"
);
// Convert to HTTP status
int statusCode = SaveData(data)
.Finally(
ok: _ => 200,
err: error => error switch
{
NotFoundError => 404,
ValidationError => 400,
_ => 500
}
);
Async variant:
var response = await ProcessOrderAsync(order)
.FinallyAsync(
ok: async order => await CreateSuccessResponseAsync(order),
err: async error => await CreateErrorResponseAsync(error)
);
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(
ok: user => $"User: {user.Name}",
err: error => $"Error: {error.Code}"
);
// Async match
await ProcessOrderAsync(order).MatchAsync(
ok: async order => await SendConfirmationAsync(order),
err: async error => await LogErrorAsync(error)
);
// With return value
var httpResult = SaveData(data).Match(
ok: data => Results.Ok(data),
err: 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))
.FinallyAsync(
ok: _ => "Promotion successful",
err: error => error.Message
);
}
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));
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. 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. |
-
net8.0
- OpenTelemetry.Api (>= 1.9.0)
- T4.Build (>= 0.2.4)
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 |
|---|---|---|
| 2.1.10 | 703 | 12/3/2025 |
| 2.1.9 | 306 | 11/21/2025 |
| 2.1.1 | 261 | 4/26/2025 |
| 2.1.0-preview.3 | 90 | 4/26/2025 |
| 2.0.1 | 259 | 1/23/2025 |
| 2.0.0-alpha.62 | 80 | 1/8/2025 |
| 2.0.0-alpha.61 | 79 | 1/7/2025 |
| 2.0.0-alpha.60 | 95 | 12/7/2024 |
| 2.0.0-alpha.55 | 88 | 11/22/2024 |
| 2.0.0-alpha.52 | 97 | 11/7/2024 |
| 2.0.0-alpha.48 | 85 | 11/2/2024 |
| 2.0.0-alpha.47 | 86 | 10/30/2024 |
| 2.0.0-alpha.44 | 151 | 10/18/2024 |
| 2.0.0-alpha.42 | 106 | 10/14/2024 |
| 2.0.0-alpha.39 | 125 | 6/27/2024 |
| 2.0.0-alpha.38 | 107 | 4/24/2024 |
| 2.0.0-alpha.33 | 100 | 4/17/2024 |
| 2.0.0-alpha.26 | 123 | 4/9/2024 |
| 2.0.0-alpha.21 | 111 | 4/1/2024 |
| 2.0.0-alpha.19 | 98 | 3/5/2024 |
| 2.0.0-alpha.18 | 100 | 2/28/2024 |
| 2.0.0-alpha.17 | 104 | 2/26/2024 |
| 2.0.0-alpha.15 | 112 | 1/30/2024 |
| 2.0.0-alpha.8 | 98 | 1/27/2024 |
| 2.0.0-alpha.6 | 131 | 1/5/2024 |
| 1.1.1 | 1,108 | 11/15/2023 |
| 1.1.0-alpha.32 | 152 | 11/2/2023 |
| 1.1.0-alpha.30 | 243 | 11/1/2023 |
| 1.1.0-alpha.28 | 128 | 10/28/2023 |
| 1.1.0-alpha.27 | 127 | 10/28/2023 |
| 1.1.0-alpha.24 | 116 | 10/20/2023 |
| 1.1.0-alpha.23 | 125 | 10/13/2023 |
| 1.1.0-alpha.21 | 151 | 10/1/2023 |
| 1.1.0-alpha.20 | 126 | 9/30/2023 |
| 1.1.0-alpha.19 | 153 | 9/30/2023 |
| 1.1.0-alpha.18 | 132 | 9/29/2023 |
| 1.1.0-alpha.17 | 119 | 9/22/2023 |
| 1.1.0-alpha.13 | 103 | 9/16/2023 |
| 1.1.0-alpha.4 | 227 | 6/9/2023 |
| 1.1.0-alpha.3 | 166 | 6/8/2023 |
| 1.0.1 | 1,348 | 5/12/2023 |
| 0.1.0-alpha.40 | 217 | 4/6/2023 |
| 0.1.0-alpha.39 | 215 | 4/3/2023 |
| 0.1.0-alpha.38 | 246 | 4/2/2023 |
| 0.1.0-alpha.37 | 217 | 3/31/2023 |
| 0.1.0-alpha.35 | 222 | 3/29/2023 |
| 0.1.0-alpha.34 | 198 | 3/28/2023 |
| 0.1.0-alpha.32 | 236 | 3/18/2023 |
| 0.1.0-alpha.30 | 219 | 3/11/2023 |
| 0.1.0-alpha.27 | 214 | 3/7/2023 |
| 0.1.0-alpha.24 | 226 | 2/15/2023 |
| 0.1.0-alpha.22 | 222 | 2/15/2023 |
| 0.1.0-alpha.20 | 231 | 2/13/2023 |
| 0.0.1-alpha.14 | 230 | 1/4/2023 |
| 0.0.1-alpha.4 | 222 | 12/30/2022 |