Hello friends,
Our previous post, "
It 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();
Note: This section shares resources from the
talk ".
More details are available on
In 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:
CODEif (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.
Does it ring a bell? What a coincidence, we use Monads for that! You didn't see that one coming - or did you?
In 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.
CODEpublic 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
Bindmethod for those operations.
For another comparison, here's the same code written in an imperative style:
CODEpublic 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 . We can picture it as aResult<TFailure, TSuccess.
See where I'm going?
Because of how generics work in C#, an instance of
Resultmust 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 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
Typeproperty.
CODEpublic 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:
CODEtry { ... }
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 or join us on
the . We're all in this together, and your voice matters.
Happy coding, and I'll catch you later!
↗ Original-Artikel auf dev.to lesenVollständiger Original-BerichtAusführliche Details, Code-Beispiele & Hersteller-Stellungnahme auf dev.to.
SOCIAL SHARE CARD GENERATOR