DEV Community

Daniele Frau
Daniele Frau

Posted on

# Railway-Oriented Programming in C# — without LanguageExt

If you've ever tried to bring functional error handling into a C# codebase, you've probably landed on LanguageExt. It's powerful. It's also 42 million downloads worth of a paradigm shift that turns your entire codebase inside out.

This article is about a lighter path: Railway-Oriented Programming with MonadicSharp, a zero-dependency library that fits into your existing .NET 8 code without asking you to rewrite everything.


The railway metaphor

Scott Wlaschin's Railway-Oriented Programming describes a simple idea: every operation in your system runs on two tracks — a success track and a failure track. Once you leave the success track, you stay on the failure track. No re-entry.

In practice this means:

Input ──► Validate ──► Fetch from DB ──► Process ──► Save ──► Output
             │               │               │           │
             └── Error ──────┴───────────────┴───────────┘
                   (failure propagates automatically)
Enter fullscreen mode Exit fullscreen mode

Every step either passes the value forward, or switches to the failure track. You only handle the failure once — at the end.


What's wrong with exceptions for control flow?

Nothing, when they represent unexpected conditions. But in most .NET codebases, exceptions are used for expected failures: validation errors, not-found entities, business rule violations. This causes real problems:

// What does this throw? When? Under what conditions?
// You have to read the implementation to find out.
public User CreateUser(CreateUserRequest request)
{
    ValidateRequest(request);       // throws ValidationException?
    CheckEmailUniqueness(request);  // throws ConflictException?
    return _repo.Save(MapToUser(request)); // throws DbException?
}
Enter fullscreen mode Exit fullscreen mode

The method signature lies. Callers don't know what to expect. Test coverage for the failure paths requires exception interception. And composing multiple fallible operations means nested try/catch blocks.


The Result type

Result<T> makes failure explicit in the type system:

public Result<User> CreateUser(CreateUserRequest request)
{
    return ValidateName(request.Name)
        .Bind(_ => ValidateEmail(request.Email))
        .Bind(_ => CheckEmailNotTaken(request.Email))
        .Map(_ => new User(request.Name, request.Email))
        .Bind(user => _repo.SaveAsync(user));
}
Enter fullscreen mode Exit fullscreen mode

Now the signature is honest. The caller knows this can fail. There are no hidden branches. Each step only runs if the previous one succeeded.

Install it:

dotnet add package MonadicSharp
Enter fullscreen mode Exit fullscreen mode

Building blocks

Map — transform the value

Result<int> parsed = Parse("42");         // Success(42)
Result<string> formatted = parsed.Map(n => $"Value: {n}"); // Success("Value: 42")
Enter fullscreen mode Exit fullscreen mode

Bind — chain fallible operations

Result<User> GetActiveUser(int id) =>
    FindUser(id)                           // Result<User>
        .Bind(ValidateActive)              // Result<User>
        .Bind(LoadPermissions);            // Result<User>
Enter fullscreen mode Exit fullscreen mode

If FindUser fails, ValidateActive never runs. The error propagates.

Match — handle both tracks

var response = result.Match(
    onSuccess: user  => Ok(user),
    onFailure: error => error.Type switch
    {
        ErrorType.NotFound   => NotFound(error.Message),
        ErrorType.Validation => BadRequest(error.Message),
        ErrorType.Forbidden  => Forbid(),
        _                    => Problem(error.Message)
    }
);
Enter fullscreen mode Exit fullscreen mode

Structured errors

One of MonadicSharp's differentiators over simpler Result libraries is the Error type. It's not just a string — it carries semantic type, error code, metadata, sub-errors, and inner errors:

// Semantic constructors
Error.Validation("Email is invalid", field: "email")
Error.NotFound("Order", identifier: orderId.ToString())
Error.Forbidden("Requires admin role")
Error.Conflict("Username already taken", resource: "username")

// Context enrichment
Error.Create("Payment gateway timeout")
    .WithMetadata("gatewayId", gateway.Id)
    .WithMetadata("attemptedAt", DateTime.UtcNow)
    .WithInnerError(originalException)
Enter fullscreen mode Exit fullscreen mode

This means your API can map errors to HTTP status codes automatically — no string parsing, no custom exception hierarchy.


Collecting all validation errors

Other libraries (like CSharpFunctionalExtensions) stop at the first error. With Sequence, you collect them all:

var result = new[]
{
    ValidateName(request.Name),
    ValidateEmail(request.Email),
    ValidateAge(request.Age)
}.Sequence();

// result is either:
//   Success([name, email, age])
//   Failure(MULTIPLE_ERRORS with SubErrors containing each failure)
Enter fullscreen mode Exit fullscreen mode

Async pipelines with retry

This is where MonadicSharp goes beyond basic Result libraries. The PipelineExtensions let you compose async steps with conditional execution and built-in retry:

var result = await GetOrder(orderId)
    .Then(ValidateInventory)
    .ThenIf(o => o.Total > 500, ApplyDiscount)
    .ThenWithRetry(
        operation:   CallPaymentGateway,
        maxAttempts: 3,
        delay:       TimeSpan.FromSeconds(1))
    .Then(SendConfirmation)
    .ExecuteAsync();
Enter fullscreen mode Exit fullscreen mode

No Polly, no Refit, no external retry library needed for the common case. Three lines.


Entity Framework integration

DbSetExtensions wraps EF Core operations to return Result<T> and Option<T>:

// No null check needed — FindAsync returns Option<User>
var user = await _db.Users.FindAsync(id);

// Composable
var result = await _db.Users.FindAsync(userId)
    .ToResult(Error.NotFound("User", userId.ToString()))
    .BindAsync(user => UpdateUserFields(user, request))
    .BindAsync(updated => _db.Users.Update(updated))
    .BindAsync(_ => _db.SaveChangesAsync());
Enter fullscreen mode Exit fullscreen mode

Minimal API integration

In an ASP.NET Core Minimal API, the pattern becomes extremely clean:

app.MapPost("/users", async (CreateUserRequest req, UserService svc) =>
    (await svc.CreateUser(req)).Match(
        onSuccess: user  => Results.Created($"/users/{user.Id}", user),
        onFailure: error => error.Type switch
        {
            ErrorType.Validation => Results.BadRequest(error),
            ErrorType.Conflict   => Results.Conflict(error),
            _                    => Results.Problem(error.Message)
        }
    ));
Enter fullscreen mode Exit fullscreen mode

When to use MonadicSharp vs alternatives

MonadicSharp CSharpFunctionalExtensions LanguageExt ErrorOr
Learning curve Low Low High Low
Structured errors Yes Partial Yes Partial
Built-in retry Yes No No No
EF Core integration Yes No No No
Project templates Yes No No No
Full functional paradigm No No Yes No

If you want to add functional error handling to an existing codebase without a paradigm shift, MonadicSharp fits. If you're building a new system and want full Haskell-style FP in C#, LanguageExt is your answer.


Getting started

dotnet add package MonadicSharp
Enter fullscreen mode Exit fullscreen mode

Or scaffold a full project:

dotnet new install MonadicSharp.Templates
dotnet new monadic-api   -n MyApi
dotnet new monadic-clean -n MyApp
Enter fullscreen mode Exit fullscreen mode

Source and docs: github.com/Danny4897/MonadicSharp


Built by Danny4897. Contributions welcome.

Top comments (0)