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)
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?
}
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));
}
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
Building blocks
Map — transform the value
Result<int> parsed = Parse("42");
Result<string> formatted = parsed.Map(n => $"Value: {n}"); // Success("Value: 42")
Bind — chain fallible operations
Result<User> GetActiveUser(int id) =>
FindUser(id) // Result<User>
.Bind(ValidateActive) // Result<User>
.Bind(LoadPermissions); // Result<User>
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)
});
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)
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
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));
}
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
Install the package:
dotnet add package MonadicSharp.Recovery
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));
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
// 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);
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
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
Get started
dotnet add package MonadicSharp
- GitHub: github.com/Danny4897/MonadicSharp
- NuGet: nuget.org/profiles/Klexir
- AI Kit setup: AI-ASSISTANT-SETUP.md
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)