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.

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. Composing multiple fallible operations means nested try/catch blocks.


The Result<T> 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");
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 automatically.

Match — handle both tracks at the boundary

// In a controller — the only place you unwrap
return 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 — each maps to an HTTP status code automatically
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")

// Enrich with context at any point in the pipeline
Error.Create("Payment gateway timeout")
    .WithMetadata("gatewayId", gateway.Id)
    .WithMetadata("attemptedAt", DateTime.UtcNow)
    .WithInnerError(originalException)
Enter fullscreen mode Exit fullscreen mode

Collecting all validation errors

Most Result libraries 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 with SubErrors containing each individual failure
Enter fullscreen mode Exit fullscreen mode

Async pipelines

MonadicSharp's .Bind() and .Map() work seamlessly with Task<Result<T>>:

public async Task<Result<OrderConfirmation>> PlaceOrderAsync(PlaceOrderRequest request)
{
    return await ValidateRequest(request)
        .Bind(req   => _inventory.ReserveAsync(req))
        .Bind(inv   => _payment.ChargeAsync(inv))
        .Bind(pay   => _orders.CommitAsync(pay))
        .Map(order  => new OrderConfirmation(order.Id, order.EstimatedDelivery));
}
Enter fullscreen mode Exit fullscreen mode

No await in the middle of the chain. No if (result.IsSuccess) checks. The pipeline reads like a specification of what should happen — and handles every failure path automatically.


The Amber track — self-healing pipelines

Standard ROP has two tracks: Green (success) and Red (failure). MonadicSharp adds a third: Amber — the self-healing track.

GREEN  ──── Bind → Bind → Map ─────────────────────────────► Success
                │ error matches predicate
                ▼
AMBER  ──── RescueAsync / StartFixBranchAsync ──────────────► merged → GREEN
                │ all recovery attempts fail
                ▼
RED    ──── original error preserved ───────────────────────► Failure
Enter fullscreen mode Exit fullscreen mode

Install the package:

dotnet add package MonadicSharp.Recovery
Enter fullscreen mode Exit fullscreen mode

Use it to intercept specific failures and attempt recovery without breaking the pipeline:

// Single recovery attempt
var result = await CallExternalApiAsync()
    .RescueAsync(
        when:     ErrorPredicates.HasCode("TIMEOUT"),
        recovery: _ => CallFallbackApiAsync());

// Multi-attempt fix branch with exponential backoff
var result = await GenerateWithLlmAsync()
    .StartFixBranchAsync(
        when:                 ErrorPredicates.HasAnyCode("AI_JSON_INVALID", "AI_TIMEOUT"),
        recovery:             (error, attempt) => RetryWithDifferentStrategyAsync(error, attempt),
        maxAttempts:          3,
        delayBetweenAttempts: TimeSpan.FromSeconds(1));
Enter fullscreen mode Exit fullscreen mode

The key rule: if recovery fails, the original error is propagated to the Red track. The caller always sees the root cause.


MonadicSharp for AI agents

This is where the library really earns its place in modern .NET. LLM APIs fail in predictable, typed ways — rate limits, timeouts, invalid JSON output. MonadicSharp.AI gives you typed error handling for all of them:

dotnet add package MonadicSharp.AI
Enter fullscreen mode Exit fullscreen mode
// Typed AI errors — retriable vs terminal
AiError.RateLimit()           // 429 — retriable with backoff
AiError.ModelTimeout()        // timeout — retriable
AiError.ModelUnavailable()    // 503 — retriable
AiError.TokenLimitExceeded()  // context full — terminal, do not retry
AiError.ContentFiltered()     // policy violation — terminal
AiError.InvalidOutput()       // bad JSON — self-healable via Amber track

// Retry with exponential backoff + jitter
var result = await Result.TryAsync(() => _llm.CompleteAsync(prompt))
    .WithRetry(maxAttempts: 3, initialDelay: TimeSpan.FromSeconds(2));

// Multi-step agent with execution tracing built in
var agentResult = await AgentResult
    .StartTrace("DocumentSummaryAgent", userInput)
    .Step("Retrieve", q   => _search.FindChunksAsync(q))
    .Step("Generate", ctx => _llm.CompleteAsync(ctx))
    .Step("Validate", out => ParseAndValidate(out))
    .ExecuteAsync();

// Self-healing JSON output — Amber track for AI
var output = await GenerateStructuredOutputAsync()
    .StartFixBranchAsync(
        when:     AiRecoveryPredicates.InvalidOutput(),
        recovery: (error, attempt) => RepairWithLlmAsync(error, attempt),
        maxAttempts: 3);
Enter fullscreen mode Exit fullscreen mode

Instead of a fragile agent that crashes on the first malformed JSON response, you get a pipeline that automatically attempts repair — and surfaces the original error only if all repair attempts fail.


Compared to LanguageExt

MonadicSharp LanguageExt
Dependencies Zero Heavy (Reactive, etc.)
Learning curve Low — builds on C# idioms High — requires FP fluency
Existing code Drop-in, incrementally adoptable Often requires full rewrite
AI/LLM support First-class (MonadicSharp.AI) General purpose
Azure integration 7 focused packages None
Templates dotnet new monadic-api None

LanguageExt is excellent if you want the full Haskell-in-C# experience. MonadicSharp is for teams that want the benefits of ROP without abandoning the .NET idioms their codebase is built on.


Your AI Code Assistant writes it automatically

Here's something no other C# library offers yet: a kit that teaches Copilot, Cursor, and Claude to generate MonadicSharp-first code from the moment you open a file.

Add one file to your project root:

# Cursor
curl -o .cursorrules \
  https://raw.githubusercontent.com/Danny4897/MonadicSharp/main/.cursorrules

# GitHub Copilot
curl -o .github/copilot-instructions.md \
  https://raw.githubusercontent.com/Danny4897/MonadicSharp/main/.github/copilot-instructions.md
Enter fullscreen mode Exit fullscreen mode

After that, when you write a method stub and ask your AI assistant to complete it, you get Result<T> pipelines instead of try/catch blocks — automatically, on every suggestion.


Full ecosystem

Package What it does
MonadicSharp Core ROP — Result<T>, Option<T>, Error
MonadicSharp.AI Typed LLM errors, retry, agent tracing, self-healing JSON
MonadicSharp.Recovery Amber track — RescueAsync, StartFixBranchAsync
MonadicSharp.Agents Multi-agent orchestration, capability sandboxing, circuit breaker
MonadicSharp.Http Result-aware HTTP client with typed retry
MonadicSharp.Persistence Result-aware repository + Unit of Work (EF Core 8)
MonadicSharp.Security Prompt injection detection, secret masking, audit trail
MonadicSharp.Telemetry OpenTelemetry tracing for agent pipelines
MonadicSharp.Azure.* CosmosDB, Service Bus, Blob, Key Vault, OpenAI, Functions
MonadicSharp.Framework Meta-package — everything in one dotnet add

Or scaffold a full project from scratch:

dotnet new install MonadicSharp.Templates
dotnet new monadic-api    # REST API with ROP wired end to end
dotnet new monadic-clean  # Clean Architecture starter
Enter fullscreen mode Exit fullscreen mode

Get started

dotnet add package MonadicSharp
Enter fullscreen mode Exit fullscreen mode

If this lands for you — drop a ⭐ on GitHub. It helps a lot.


Built with .NET 8 · MIT License · Zero dependencies on the core package

Top comments (0)