The Monad Invasion - Part 3: Railway-Oriented Programming
最后更新 November 14, 2024

Hello friends,

Our previous post, "The Monad Invasion - Part 2: Monads in action!", introduced a set of various Monads and how they can fit in real-world scenarios.

Today, we will focus on Railway-Oriented Programming (ROP), a functional approach to error handling.

Spoiler: If you've read our previous posts, you observed ROP without actually being aware of it.

Quick Recap

We used our Two-Factor Authentication (2FA) as a real example. This two-steps workflow requires initiating a verification first and then verifying the code sent to the user.

var myPhoneNumber = ...
var result = await StartVerificationRequest.Build()
    .WithBrand("Monads Inc.")
    .WithWorkflow(SmsWorkflow.Parse(myPhoneNumber))
    .Create() // Build the authentication request
    .BindAsync(request => verifyClient.StartVerificationAsync(request)) // Process the authentication request
    .BindAsync(VerifyCodeAsync) // Start the second step if it's a success
    .Match(AuthenticationSuccessful, AuthenticationFailed);

private async Task<Result<Unit>> VerifyCodeAsync(StartVerificationResponse response)
{
    var myCode = ... // Receive verification code based on the specified workflow
    return await response
        .BuildVerificationRequest(myCode) // Build the verification request
        .BindAsync(request => verifyClient.VerifyCodeAsync(request)); // Process the verification request
}

As suggested previously, this snippet already applies ROP. You probably have a lot of questions right now. What is ROP? How does it work? Why is it useful?

Let's start from the beginning, shall we?

Failure Is Expected

A pile of dynamiteIt is strictly impossible to build software without handling errors. In the real world, failure can occur at any point during the execution, and we cannot ignore them.

What if we had the same workflow, written in an imperative style?

var myPhoneNumber = ...
var request = await StartVerificationRequest.Build().WithBrand("Monads Inc.").WithWorkflow(SmsWorkflow.Parse(myPhoneNumber)).Create();  
var response = verifyClient.StartVerificationAsync(request);
var myCode = ... // Receive verification code based on the specified workflow
var verificationRequest = response.BuildVerificationRequest(myCode)
var result = verifyClient.VerifyCodeAsync(verificationRequest);    
this.AuthenticationSuccessful();

It looks similar but something important is missing - error handling.
Indeed, this snippet only shows the happy path, where everything goes as expected.

Of course, this is not enough - we aim for feature parity between these snippets. In its current state, any failure would cause our system to crash. As we previously mentioned, this flow can fail at various locations:

  • When building the authentication request

  • When processing the authentication request

  • When building the verification request

  • When processing the verification request

What's the difference if we decide to handle errors then?

var myPhoneNumber = ...
var request = await StartVerificationRequest.Build().WithBrand("Monads Inc.").WithWorkflow(SmsWorkflow.Parse(myPhoneNumber)).Create();
if (request.IsFailure)
{
    // Can fail when Brand is invalid
    // Can fail when PhoneNumber is invalid
    return this.AuthenticationFailed(new Failure("Invalid input."));
}

var response = verifyClient.StartVerificationAsync(request);
if (response.IsFailure)
{
     // Can fail when process can't be initiated
     return this.AuthenticationFailed(new Failure("Cannot initiate verification process."));
}

var myCode = ... // Receive verification code based on the specified workflow
var verificationRequest = response.BuildVerificationRequest(myCode)
if (response.IsFailure)
{
    // Can fail when the code input is invalid
    return this.AuthenticationFailed(new Failure("Invalid code."));
}

var result = verifyClient.VerifyCodeAsync(verificationRequest);    
if (result.IsFailure)
{
    // Can fail when the verification fails
    return this.AuthenticationFailed(new Failure("Verification failed."));
}

return this.AuthenticationSuccessful();

"That escalated quickly" memeWe need to add an important boilerplate code to deal with potential failure. This code is dense and hard to read. We now have more code to handle failures than the happy path itself, which is not a coincidence. We only have a single happy path but multiple reasons to fail.

There's something important to observe here:

  • This snippet offers the same behaviour as the one from the Quick Recap section, except it takes roughly three times more code to do it.

  • The cyclomatic complexity also increases significantly, from 1 to 6, in a blink of an eye.

Have I piqued your interest yet? It's time to introduce Railway-Oriented Programming.

Railway-Oriented Programming

Railways

Note: This section shares resources from the talk "Railway-Oriented Programming — error handling in a functional way" by Scott Wlaschin. More details are available on F#ForFunAndProfit.

Railway-Oriented Programming is a functional approach to error handling. The main idea is to consider the "Happy Path" (Green) as the main track, providing our expected behaviour.

Exposing both success and failure tracksIn the example above, our workflow is composed of three steps:

  • Validate an input.

  • Update a record - if the input is valid.

  • Send a notification - if the record update is successful.

Here's what it looks like in an imperative fashion:

if (this.Validate(input))
{
    try
    {
        var record = this.Update(input);
        this.SendNotification(record);
    }
    catch (Exception)
    {
        ...        
    }
}

We consider our workflow successful only if these three steps are successful.

We remain on that "Happy Path" until we face an error, which leads us to a "Failure Path" (Red). When this happens, we carry the failure until the end of the flow. It's crucial that we stick to the main path, as it's the only way to ensure the full execution of our workflow.

In the example above, we skip the update step when the validation fails. This is only possible because our functions return multiple possible states: a Success or a Failure.

Meme skeptical faceDoes it ring a bell? What a coincidence, we use Monads for that! You didn't see that one coming - or did you?

In our previous post, we saw how to change the status of a Monad from one state to another using the .Bind() method. This fundamental concept allows us to create this branching between tracks.

There's no magic here. The branching didn't disappear with a magic wand or anything like that. The branching is now internal to our Monad.

Have a look at how Bind is implemented on our custom Monad:

return this.IsFailure ? Result<TB>.FromFailure(this.failure) : bind(this.success);

Our decision mechanism - a ternary operator - verifies if the current state is a failure or a success.

  • If it is a failure, we return the current failure; nothing happens.

  • If it is a success, we call the bind function to continue the flow.

As such, we can chain our operations together, and the flow will automatically switch to the failure path if any of the operations fail.

Here's the same workflow as before, but this time using ROP:

var result = input.Bind(Validate).Bind(Update).Bind(SendNotification);

Impressive, right? We reduced our code to a single line while covering the same features.

Let's Play a Little Game

Here's a code sample from our .NET SDK that allows us to authenticate for one of our Network APIs. Can you find out what the railway tracks look like? Where can our workflow fail?

You don't need to know what's happening here; there's no logic. Focus on the flow.

public Task<Result<AuthenticateResponse>> AuthenticateAsync(Result<AuthenticateRequest> request) =>
    request.Map(BuildAuthorizeRequest)
        .BindAsync(this.SendAuthorizeRequest)
        .Map(BuildGetTokenRequest)
        .BindAsync(this.SendGetTokenRequest)
        .Map(BuildAuthenticateResponse);

The answer is quite simple - our railway contains two points of branching:

  • When sending the authorize request.

  • When sending the get token request.

This is explicit because we rely on the Bind method for those operations.

For another comparison, here's the same code written in an imperative style:

public async Task<Result<AuthenticateResponse>> AuthenticateAsync(AuthenticateRequest request)
{
    var authorizeRequest = BuildAuthorizeRequest(request);
    var authorizeResponse = await this.SendAuthorizeRequest(authorizeRequest);
    if (authorizeResponse.IsFailure)
    {
        return Result<AuthenticateResponse>.FromFailure(authorizeResponse.Failure);
    }

    var getTokenRequest = BuildGetTokenRequest(authorizeResponse.Success);
    var getTokenResponse = await this.SendGetTokenRequest(getTokenRequest);
    if (getTokenResponse.IsFailure)
    {
        return Result<AuthenticateResponse>.FromFailure(getTokenResponse.Failure);
    }

    return BuildAuthenticateResponse(getTokenResponse.Success);
}

As you can see, reading and understanding the flow is much easier than in the imperative style, and the function is smaller. We achieved that by reducing the cognitive load of our method — reducing the required quantity of technical information (branching, logic, variables, etc.) helps to make the code "fit in your head" ( see "Code that fits in your head" by Mark Seemann).

Processes Fail For Multiple Reasons

Looking at our previous example, our workflow can fail at two points - this could be a problem for us.

Our failure path carries what we define as a Failure state. We implemented our Monad so that it can carry either a Success or a Failure value using generics. We can picture it as a Result<TFailure, TSuccess. See where I'm going?

Because of how generics work in C#, an instance of Result must always carry the same type of failure. In other terms, all our failures must have the same type - that's a problem as you may decide to treat a parsing failure differently than an API failure.

We cannot blame Monads or ROP for this issue - it is a language limitation. In comparison, this is where a language like F# shines, as it allows us to define a discriminated union type that can carry multiple types of failures. Still, hope is possible - discriminated unions will eventually come to C#.

While we can't ignore this limitation, it doesn't mean we're entirely stuck. One way to group different failures under the same generic type is to use a base class or an interface.

In the .NET SDK, I implemented an IResultFailure interface that all failures must implement. It allows us to group different failures (or reasons to fail - parsing, authentication, etc.) on the same failure path and define dedicated behaviours using the Type property.

public interface IResultFailure
{
    /// <summary>
    ///     The type of failure.
    /// </summary>
    Type Type { get; }

    /// <summary>
    ///     Returns the error message defined in the failure.
    /// </summary>
    /// <returns>The error message.</returns>
    string GetFailureMessage();

    /// <summary>
    ///     Converts the failure to an exception.
    /// </summary>
    /// <returns>The exception.</returns>
    Exception ToException();

    /// <summary>
    ///     Converts the failure to a Result with a Failure state.
    /// </summary>
    /// <typeparam name="T">The underlying type of Result.</typeparam>
    /// <returns>A Result with a Failure state.</returns>
    Result<T> ToResult<T>();
}

This is not a perfect solution and probably not the most elegant one. But this is similar to handling different types of exceptions, like the following example:

try { ... }
catch (ExceptionA) { ... }
catch (ExceptionB) { ... }
catch (ExceptionC) { ... }

// ---

switch (failure)
{
    case HttpFailure httpFailure:
        DoSomethingWithHttpFailure(httpFailure);
        break;
    case ResultFailure resultFailure:
        DoSomethingWithResultFailure(resultFailure);
        break;
    default:
        DoSomethingWithFailure(failure);
        break;
}

As you can see, ROP allows us to use the same functionalities as exceptions - it's not a replacement, just an alternative to error handling which looks a bit different.

Conclusion

And there we have it — our third post in "The Monads Invasion" series. This time, we focused on Railway-Oriented Programming (ROP) to show how you can use Monads even further to handle errors.

The key takeaway? There's no loss when using Monads for error handling compared to a standard exception-based approach; everything you do using exceptions can be done with Monads. You'll likely find your code cleaner, easier to read, and more explicit about its intent.

Now, the real question: are you ready to try it?

I suggest starting small and easing into it. There's a learning curve like anything new, but stick with it, and you'll see the benefits.

If you have any questions or want to chat, feel free to hit me up on my LinkedIn, share your feedback on the .NET SDK repository or join us on the Vonage Developer Slack. You can also message us on @VonageDev on X. We're all in this together, and your voice matters.

Happy coding, and I'll catch you later!

Guillaume FaasSenior .Net Developer Advocate

Guillaume is a Senior .Net Developer Advocate for Vonage. He has been working in .Net for almost 15 years while focusing on advocating Software Craftsmanship in the last few years. His favorite topics include code quality, test automation, mobbing, and code katas. Outside work, he enjoys spending time with his wife & daughter, working out, or gaming.

Ready to start building?

Experience seamless connectivity, real-time messaging, and crystal-clear voice and video calls-all at your fingertips.

Subscribe to Our Developer Newsletter

Subscribe to our monthly newsletter to receive our latest updates on tutorials, releases, and events. No spam.