We’ll revisit the same example from our previous article on the Option<T> monad—a clean way to handle potential failures without drowning in null checks.
Option<DeliveryEstimate> GetDeliveryEstimateForUsers(string username)
{
return new UserDetails()
.GetUser(username)
.Bind(user => user.GetAddress())
.Bind(address => address.GetDeliveryEstimate());
}
By now, you’ve seen how the Option<T> monad lets us chain operations smoothly, sidestepping those tedious null inspections at every step. It’s like assembling a puzzle where missing pieces gracefully halt the process without crashing the whole build.
But what if we want to sprinkle in some logging to track our journey? Do we bury log statements deep inside GetUser, GetAddress, and GetDeliveryEstimate? That could clutter the code and make maintenance a nightmare. Surely there’s a smarter path—one that keeps our core logic pristine while capturing the “what happened” story along the way.
The Writer Monad
Writer monad is a clever pattern that pairs your computation’s result with an accumulated “writer” output, like a log string or even a list of messages. Think of it as a diary for your code—every step jots down notes, and at the end, you get both the answer and the backstory.
To make this concrete, let’s borrow a simple math scenario. Imagine we’re transforming a number through additions and multiplications, but we want to log each transformation for auditing. Here’s a basic Writer monad in C#:
public struct Writer
{
public string Log { get; }
private int Value { get; }
private Writer(int value, string message)
{
Value = value;
Log = message;
}
public Writer Bind(Func<int, Writer> func)
{
var newWriter = func(Value);
return new Writer(newWriter.Value, Log + "\n" + newWriter.Log);
}
public static Writer Some(int value, string message) => new(value, $"{message}: {value}");
}
We can extend this with helper methods for our operations:
public static class Ext
{
public static Writer AddWithOne(int value)
{
return Writer.Some(value + 1, "Added one to it");
}
public static Writer MultiplyByTwo(int value)
{
return Writer.Some(value * 2, "Multiplied by two");
}
}
Now, chaining them feels almost poetic:
var data = Writer.Some(4, "Initial value")
.Bind(Ext.AddWithOne)
.Bind(Ext.MultiplyByTwo);
Console.WriteLine(data.Log);
This outputs:
Initial value: 4
Added one to it: 5
Multiplied by two: 10
It’s reminiscent of the State pattern, but dialed up for transparency—imagine debugging a financial calculation where you need the full trail of how $4 became $10. No more “it just happened”; now you see every twist and turn. This audit trail shines in real-world scenarios, like tracking user workflows in an e-commerce app, where compliance demands you log every state change without bloating your business logic.
The elegance stems from Bind and Some. In Bind, we apply the function to the current value, then weave the new log into the existing one. Some kickstarts the chain with a formatted entry.
While this string-based log works, it’s rigid. For flexibility—say, varying log levels or structured entries—let’s upgrade to a List<string> accumulator:
public struct WriterWithLog
{
public List<string> Logs { get; }
public int Value { get; }
private WriterWithLog(int value, List<string> logMessages)
{
Value = value;
Logs = logMessages ?? new List<string>();
}
public WriterWithLog Bind(Func<int, WriterWithLog> func)
{
var newWriter = func(Value);
var combinedLog = new List<string>(Logs);
combinedLog.AddRange(newWriter.Logs);
return new WriterWithLog(newWriter.Value, combinedLog);
}
public static WriterWithLog Some(int value, string message) => new(value, [message]);
}
This scales better: append entries without concatenation overhead.
Blending Writer with Option: Handling Failures Gracefully
Our delivery estimate flow already uses Option<T> for safe chaining. Now, we need to fuse it with Writer’s logging—creating a monad that carries both a potentially absent value and a log trail. We’ll call it WriterOption<T>, wrapping an Option<T> value alongside the logs.
public struct WriterOption<T>
{
public Option<T> Value { get; }
public List<string> Logs { get; }
private WriterOption(Option<T> value, List<string> logs)
{
Value = value;
Logs = logs;
}
// Overload 1: For functions that operate directly on Option<T>
public WriterOption<U> Bind<U>(Func<Option<T>, WriterOption<U>> func)
{
var newWriter = func(Value);
var combinedLog = new List<string>(Logs);
combinedLog.AddRange(newWriter.Logs);
return Value.IsSome
? new WriterOption<U>(newWriter.Value, combinedLog)
: new WriterOption<U>(Option<U>.None, combinedLog);
}
// Overload 2: For functions that operate directly on T
public WriterOption<U> Bind<U>(Func<T, WriterOption<U>> func)
{
if (Value.IsNone)
{
return new WriterOption<U>(Option<U>.None, new List<string>(Logs));
}
var newWriter = func(Value.Value);
var combinedLog = new List<string>(Logs);
combinedLog.AddRange(newWriter.Logs);
return new WriterOption<U>(newWriter.Value, combinedLog);
}
public static WriterOption<T> Some(Option<T> value, string message) =>
new(value, [$"{message}: {(value.IsSome ? value.Value?.ToString() ?? "<Unknown>" : "<None>")}"]);
}
Notice the dual Bind overloads: the first lets functions inspect the Option explicitly (useful for custom failure logging), while the second extracts T if present and skips the call on None (efficient for success paths). The Some factory auto-formats logs with the value’s ToString().
Let’s test with a numeric example, using an extension that handles Option<int>:
public static WriterOption<int> Add(Option<int> optionVal)
{
// check IsNone to avoid exceptions in real code
if (optionVal.IsNone)
{
return WriterOption<int>.Some(Option<int>.None, "Add: No value to increment");
}
return WriterOption<int>.Some(
Option<int>.Some(optionVal.Value + 1),
"Added one to the value");
}
Run it like so:
var writerWithOption = WriterOption<int>.Some(Option<int>.Some(5), "Initial Value")
.Bind(Add);
writerWithOption.Logs.ForEach(Console.WriteLine);
if (writerWithOption.Value.IsSome)
{
Console.WriteLine(writerWithOption.Value.Value);
}
Output:
Initial Value: 5
Added one to the value: 6
6
Boom—logs flow seamlessly, even as we navigate potential Nones.
Applying WriterOption to Our Delivery Flow
Time to retrofit our use case. We’ll tweak GetUser, GetAddress, and GetDeliveryEstimate to return WriterOption<T>, injecting meaningful log messages.
Now, the rewritten methods:
public class UserDetails
{
public WriterOption<User> GetUser(string username)
{
if (string.IsNullOrWhiteSpace(username))
{
return WriterOption<User>.Some(Option<User>.None, "User name is empty");
}
var user = new User { Username = username };
return WriterOption<User>.Some(Option<User>.Some(user), "User found");
}
}
public partial class User // Assuming partial for extension feel
{
public WriterOption<Address> GetAddress()
{
// Simulate fetching; in reality, hit a DB or API
var address = new Address();
return WriterOption<Address>.Some(Option<Address>.Some(address), "Address retrieved");
}
}
public partial class Address
{
public WriterOption<DeliveryEstimate> GetDeliveryEstimate()
{
// Simulate no estimate available
return WriterOption<DeliveryEstimate>.Some(Option<DeliveryEstimate>.None, "No delivery estimate available");
}
}
Finally, the chained method—leveraging the second Bind overload for that clean, assumption-free flow:
WriterOption<DeliveryEstimate> GetDeliveryEstimateForUsers(string username)
{
return new UserDetails()
.GetUser(username)
.Bind(user => user.GetAddress())
.Bind(address => address.GetDeliveryEstimate());
}
Execute and inspect:
var deliveryEstimate = GetDeliveryEstimateForUsers("karthik");
deliveryEstimate.Logs.ForEach(Console.WriteLine);
if (deliveryEstimate.Value.IsSome)
{
Console.WriteLine($"Estimate: {deliveryEstimate.Value.Value}");
}
else
{
Console.WriteLine("No estimate, but check the logs!");
}
Sample output (assuming default ToString()):
User found: User
Address retrieved: Address
No delivery estimate available: <None>
No estimate, but check the logs!
See how the logs paint a picture? If “karthik” were invalid, it’d short-circuit after “User name is empty: “, skipping downstream noise. It’s like a GPS for your code—reroutes on failure but still narrates the trip.
This setup abstracts logging beautifully, keeping methods focused while enabling rich tracing. Gotcha: If your Option<T> implementations lack null-safety (e.g., custom ones), double-check Value access in Some to avoid NREs.
Wrapping Up
The Writer monad transforms logging from a chore into a feature—audit trails that enhance debuggability without invasive code. Paired with Option<T>, it’s a powerhouse for resilient .NET apps. Next time you’re chaining async services in ASP.NET Core, imagine the confidence from built-in “why did this fail?” stories.


Leave a Reply