Let’s take an example of calculating the delivery estimate given a username. Let’s walk through that journey together and see how a mysterious functional programming concept called a monad can turn nightmare-level null-checks spaghetti into clean, fluent, and safe code.
Note: The code snippets here are simplified for clarity and flow—they might not compile straight out of the gate (we're focusing on ideas, not IDE battles). The goal? To trace the structure and discover how monads sweep away those pesky null checks like a gentle code broom.
The Classic Imperative Way (and Why It Hurts)
Here’s the usual object-oriented chain we write:
DeliveryEstimate GetDeliveryEstimateForUser(string username)
{
var user = new UserDetails().GetUser(username);
var address = user.GetAddress();
var estimate = address.GetDeliveryEstimate();
return estimate;
}
Works great… until one of those methods returns null. Boom! NullReferenceException at runtime. We’ve all been there.
So we add defensive checks:
DeliveryEstimate? GetDeliveryEstimateForUser(string username)
{
var user = new UserDetails().GetUser(username);
if (user == null) return null;
var address = user.GetAddress();
if (address == null) return null;
return address.GetDeliveryEstimate();
}
Ugh. That’s a lot of noise. And every method now has to return nullable types. The code starts looking like a minefield of ? and if (x == null).
Enter the Null-Conditional Operator (C# 6+ to the Rescue)
C# gave us the ?. operator, so we can at least shorten it:
DeliveryEstimate? GetDeliveryEstimateForUser(string username)
{
return new UserDetails()
?.GetUser(username)
?.GetAddress()
?.GetDeliveryEstimate();
}
Much better! One line, readable, short-circuits on the first null. But… something still feels off when we scale this up.
What Happens When We Deal With Collections?
Real-world apps rarely fetch one user. We usually fetch many.
Now every method returns a list:
List<DeliveryEstimate> GetEstimates(List<string> usernames)
{
return new UserDetails()
.GetUsers(usernames) // List<User>
.SelectMany(user => user.GetAddresses()) // List<Address>
.SelectMany(addr => addr.GetDeliveryEstimates()) // List<DeliveryEstimate>
.ToList();
}
Look at that SelectMany chain. It’s almost… poetic.
We have two very similar-looking patterns:
Single value (nullable):
?.GetUser(username)
?.GetAddress()
?.GetDeliveryEstimate()
Collections:
.GetUsers(usernames)
.SelectMany(x => x.GetAddresses())
.SelectMany(x => x.GetDeliveryEstimates())
Both are chaining operations that might “fail” or produce zero-to-many results, and both give us a clean, fluent way to compose them.
The Hidden Pattern Emerges
There’s a deeper pattern lurking here, waiting to be uncovered. Take a closer look at those chains.
In the nullable flow, the ?. glues each step together, whispering, “Only proceed if you’ve got something solid.” In the collection flow, SelectMany does the heavy lifting, flattening and chaining without the drama of empty lists derailing the whole thing.
DeliveryEstimate? GetDeliveryEstimateForUser(string username)
{
return new UserDetails() ?
.GetUser(username) ?
.GetAddress() ?
.GetDeliveryEstimate();
}
List<DeliveryEstimate> GetDeliveryEstimatesForUsers(List<string> usernames)
{
return new UserDetails()
.GetUsers(usernames) .SelectMany(x => x
.GetAddresses() ).SelectMany(x => x
.GetDeliveryEstimates() ).ToList();
}
Both are quietly enforcing the same rule: sequence your computations, but only if the previous one succeeded — otherwise, bail gracefully and keep the flow intact.
The Pattern Revealed
To pull this abstraction into the light, we generalize it into a workflow that treats each step as a function returning “wrapped” results. Instead of raw types, everything flows through a container that knows how to chain safely:
class Workflow<T>
{
Workflow<U> Bind(Func<T, Workflow<U>> f);
}
Suddenly, both our single-user and batch scenarios collapse into the same elegant shape:
Workflow<DeliveryEstimate> GetDeliveryEstimateForUsers(string username)
{
return new UserDetails()
.GetUser(username)
.Bind(x => x.GetAddress())
.Bind(x => x.GetDeliveryEstimates());
}
(Pro tip: If you’re chaining deep into legacy code, watch for the gotcha where Bind assumes your functions return the wrapped type — a quick Select or adapter lambda can bridge that gap without refactoring everything.)
Monads
The name of this pattern is Monad.
class Monad<T>
{
Monad(T instance);
Monad<U> Bind(Func<T, Monad<U>> f);
}
A monad has two operations, one which takes the real type and wraps it into its type and Bind allows for chaining by taking a function that takes T as input and returns Monad<U> as output not T.
we could also have a function within the monad to unwrap the value for us.
Monad<U> Unwrap(Monad<Monad<U>> nested);
How to operate over the nullable types?
In C# we have Nullable<T> and ?. which are the closest alternatives to remove the null checks in the code.
But to do this thing in functional style, we have an Option or MayBe, which could either return a value or none.
Here’s Option monad (without using any libraries):
public readonly struct Option<T>
{
private readonly T _value;
private readonly bool _hasValue { get; }
private Option(T value)
{
_value = value;
_hasValue = true;
}
public bool IsNone => !_hasValue;
public bool IsSome => _hasValue;
public static Option<T> Some(T value) => new (value);
public static Option<T> None => new();
public Option<U> Bind<U>(Func<T, Option<U>> bind) =>
IsSome ? bind(_value) : Option<U>.None;
public T Value
{
get
{
if (IsNone) throw new ValueIsNoneException("Option is None");
return _value;
}
}
}
and here is our delivery estimates for users method using Option monad
Option<DeliveryEstimate> GetDeliveryEstimateForUsers(string username)
{
return new UserDetails()
.GetUser(username)
.Bind(user => user.GetAddress())
.Bind(user => user.GetDeliveryEstimate())
;
}
This is how we chain these methods together and as you can see there’s no need to check for nulls by using the Option monad.
And how do we get the value out of the result of the above call? Just use .Value on it.
var deliveryEstimate = GetDeliveryEstimateForUsers(username);
Console.WriteLine(deliveryEstimate.Value);
How short-circuiting works in monads?
Let’s also examine what will happen if the GetUser returns a None instead of a value.
If GetUser() method returns a None, then we do a
.Bind(user => user.GetAddress())
xAnd if we look at the .Bind function carefully
public Option<U> Bind<U>(Func<T, Option<U>> bind) =>
IsSome ? bind(_value) : Option<U>.None;
it has IsSome ? bind(_value) : Option<U>.None;. So, if it has some value, then we compute the func delegate over the value otherwise we just return the value as Option<U>.None so any further chaining does nothing but returning a None. The .Bind is the secret sauce here.
Conclusion
Monads are great if you are starting to look at functional programming languages like Haskell, Clojure, F# etc. And a good way to start learning/using functional programming is with Option monad.
However, there are also downsides to it (not really a downside but). If you employ the Option monad or any monadic pattern for that matter, you have to do it all the way otherwise you’d end up again checking if the Option has a value or not (trying to unwrap from a monad) which is again similar to how you check for nulls.
So, when you have a huge code base and you want to introduce a monadic pattern then I’d say go with nullable types (Nullable<T>) instead of Monadic way. But, if your vision has monads eventually sometime in the future in your code base then you can have them without a doubt.
Reference
- Huge thanks to Mikhail for his blog which inspired me


Leave a Reply