FunctionalDdd.RailwayOrientedProgramming
3.0.0-alpha.72
dotnet add package FunctionalDdd.RailwayOrientedProgramming --version 3.0.0-alpha.72
NuGet\Install-Package FunctionalDdd.RailwayOrientedProgramming -Version 3.0.0-alpha.72
<PackageReference Include="FunctionalDdd.RailwayOrientedProgramming" Version="3.0.0-alpha.72" />
<PackageVersion Include="FunctionalDdd.RailwayOrientedProgramming" Version="3.0.0-alpha.72" />
<PackageReference Include="FunctionalDdd.RailwayOrientedProgramming" />
paket add FunctionalDdd.RailwayOrientedProgramming --version 3.0.0-alpha.72
#r "nuget: FunctionalDdd.RailwayOrientedProgramming, 3.0.0-alpha.72"
#:package FunctionalDdd.RailwayOrientedProgramming@3.0.0-alpha.72
#addin nuget:?package=FunctionalDdd.RailwayOrientedProgramming&version=3.0.0-alpha.72&prerelease
#tool nuget:?package=FunctionalDdd.RailwayOrientedProgramming&version=3.0.0-alpha.72&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
- 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"
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"
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 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
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 ct = cancellationToken;
// Single parameter
var result = await GetUserAsync("123", ct)
.TapAsync(user => AuditLogAsync(user.Id, ct))
.TapAsync(user => SendWelcomeEmailAsync(user.Email, ct));
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"));
RecoverOnFailure
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
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 Result.ParallelAsync(
() => GetStudentInfoAsync(studentId),
() => GetStudentGradesAsync(studentId),
() => GetLibraryBooksAsync(studentId))
.WhenAllAsync()
.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")
.RecoverOnFailure(error => error is NotFoundError,
() => LoadFromEnvironment())
.RecoverOnFailure(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
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 result- Break up chains — Assign intermediate results to named variables for breakpoints
- Descriptive errors — Include IDs and context:
Error.NotFound($"User {userId} not found") - Debug extensions — Use
.Debug("label")in development (excluded from RELEASE builds) - OpenTelemetry — Built-in activity tracing for distributed debugging
📖 Full Debugging Guide — Comprehensive strategies, code samples, and checklist.
Best Practices
Use descriptive error messages with context
Error.NotFound($"Order {orderId} not found for user {userId}")Include
fieldNamein validation errors for easier debuggingError.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/TryAsyncfor exception boundariesResult<Data> LoadData() => Result.Try(() => File.ReadAllText(path)) .Bind(json => ParseJson(json));Provide
CancellationTokenwith async operationsvar ct = cancellationToken; var result = await GetUserAsync(id, ct) .BindAsync(user => GetOrderAsync(user.Id, ct)) .TapAsync(order => LogOrderAsync(order, ct));
| 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.15.0)
NuGet packages (8)
Showing the top 5 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. |
|
|
FunctionalDdd.DomainDrivenDesign
Building blocks for implementing Domain-Driven Design tactical patterns in C# with functional programming principles. Create Aggregate, Entity, and ValueObject classes with Result-based validation. For simple value objects with a single value, ScalarValueObject can be used. For strongly-typed primitives, see FunctionalDdd.PrimitiveValueObjects. |
|
|
FunctionalDdd.Http
HTTP client extensions for Railway Oriented Programming. Provides fluent extension methods to handle HttpResponseMessage with Result and Maybe monads, including error handling for specific status codes and JSON deserialization. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 3.0.0-alpha.72 | 58 | 2/8/2026 |
| 3.0.0-alpha.60 | 58 | 2/4/2026 |
| 3.0.0-alpha.59 | 60 | 2/1/2026 |
| 3.0.0-alpha.56 | 69 | 1/31/2026 |
| 3.0.0-alpha.44 | 64 | 1/13/2026 |
| 3.0.0-alpha.20 | 67 | 1/6/2026 |
| 3.0.0-alpha.19 | 62 | 1/5/2026 |
| 3.0.0-alpha.13 | 77 | 1/5/2026 |
| 3.0.0-alpha.3 | 168 | 12/20/2025 |
| 2.1.10 | 761 | 12/3/2025 |
| 2.1.9 | 357 | 11/21/2025 |
| 2.1.1 | 297 | 4/26/2025 |
| 2.1.0-preview.3 | 113 | 4/26/2025 |
| 2.0.1 | 296 | 1/23/2025 |
| 2.0.0-alpha.62 | 103 | 1/8/2025 |
| 2.0.0-alpha.61 | 101 | 1/7/2025 |
| 2.0.0-alpha.60 | 119 | 12/7/2024 |
| 2.0.0-alpha.55 | 123 | 11/22/2024 |
| 2.0.0-alpha.52 | 126 | 11/7/2024 |
| 2.0.0-alpha.48 | 110 | 11/2/2024 |