Is the result pattern worth it?: Working with the result pattern - Part 4 : Andrew Lock
by: Andrew Lock
blow post content copied from Andrew Lock | .NET Escapades
click here to view original post
In this series I've been discussing one particular version of using the result pattern to replace exceptions as flow control, showing how using LINQ's query syntax can dramatically improve the readability of your code.
This post serves as somewhat of a conclusion to the series, addressing some of the comments and concerns about the pattern in general, the pattern as I discussed in this post, and the "solution" of using LINQ to avoid endless nested lambda methods.
The different types of result pattern
Some of the main take-aways I got from the discourse around (and tangential to) this series are:
- People often mean quite different things when they say "result pattern"
- A lot of people have opinions on the result pattern that they are referring to
- Those opinions are visceral and strongly held 😅
The starting point for this series was Jeremy Miller's tweet in which he expressed a dislike for the result pattern in general.
Funny, I’m recommending a client start to rip this strategy out of their codebase because of the extra complexity, code noise, and overhead it adds. https://t.co/6BGSAF9sVS
— Jeremy D. Miller (@jeremydmiller) July 11, 2024
His argument (expressed in various threads) is that the result pattern adds a lot of ceremony (and therefore complexity) which is unnecessary. A specific example he gives his where you have Mediator handlers calling other mediator handlers, where each nested handler is a "step" in the overall flow, each returning a result that needs to be checked and handled.
That's an extremely "heavy" version of the result pattern, which is tied to all sorts of infrastructure, likely with onion architecture mechanics layered over it; I can totally see where the aversion comes from.
If you're interested in which approach Jeremy does like, you should take a look at his Wolverine framework which is built around a philosophy of low ceremony and using code generation to fill in the gaps.
At the other end of the spectrum, at around the same time, Aaron Stannard was arguing for the result pattern, but in this case a much more lightweight version:
I read this thread yesterday https://t.co/o333vEdype and was blown away by
— Aaron Stannard (@Aaronontheweb) October 10, 2024
1. How many devs still use exceptions for flow control
2. Devs w/ this pattern _use a library_ for it instead of just defining a simple class
3. How many devs believe C# needs DUs to make this workable
Personally, what Aaron describes in his post is not what I typically think of when you say "result pattern". In his example he essentially returns a "status" with the return value:
public enum QueryResponseStatus
{
Success,
NotFound,
Error,
}
public abstract record QueryResponse<T>(QueryResponseStatus Status, T? Result, string Message)
and then uses C# pattern matching to handle the statuses. Don't get me wrong, this is absolutely how I handle things in most of the code I write. In the vast majority of cases you don't need a framework, and something like this is absolutely good enough, and is arguably what you should be using.
An important distinction between Aaron's version of the result pattern and the one I showed in this series is that Aaron uses a Status
to describe the error, whereas I used a standard type Result<T>
where the "error" type was Exception
. I naturally took this approach as an obvious progression from "exceptions as flow control" (and because it makes the LINQ side easier to demonstrate) but in hindsight, this was probably detrimental to series overall.
In my defence, I did mention several times in this series that the
Result<T>
I was using is probably not what you would want to use in practice.
As several people (me included, in the series) pointed out, using Exception
as the error turns this result pattern into more of a glorified try-catch replacement. That's a lot less useful than having a real union of possibilities, as this makes it easier to handle different, non-error, states, as well as including additional information for the error case. Aaron's solution emulates unions, and handles simple cases just as well. But you can't even hint at unions without someone saying "you should just use F#".
Why not "just" use F#?
Don't get me wrong, from what little knowledge I have of F#, I like it. I really enjoyed Domain Modeling Made Functional by Scott Wlaschin, and many of the "result pattern" concepts are explicitly described in that book. But suggesting that if you want to use something resembling the result pattern, then you should go and use F# seems somewhat excessive.
Integrating any other programming language into your toolchain comes with overhead. There's cognitive overhead for all the C# programmers that now need to know F# to fully understand your app. There's an overhead around local tooling, as you need to learn how to build and debug the new language. There's an overhead around your build pipeline, which now needs to handle both C# and F#. There's an overhead in the interop boundaries between C# and F#; although it's all .NET those boundaries aren't as seamless as we'd like. All of this is likely a lot simpler than if you were trying to integrate a non-.NET language, but it's still a barrier.
So without a doubt, F# can more elegantly express some of the patterns we're emulating in C#, but is it really worth throwing the baby out with the bathwater and moving to F#? I think that's going to be a hard sell.
A lot of people, including Scott Wlaschin and others I believe, have described having an F# core domain, an area where F# really shines, with a C# outer shell. I do like the idea of this in principle, but I feel like the overhead argument will always mean it's restricted to niche code bases. I'd be interested to hear how many people have tried this and how they got on with it.
At the other end of the spectrum we have people joking that C# programmers have finally found Sequence()
and Traverse()
. And I get it, they're elegant concepts, but the fact that you kind of have to learn about them to remove a bunch of the cruft in the patterns shown in this series is definitely a mark against the whole approach. While they may be standard tools for functional programmers, they're absolutely not for standard C# devs. Instead they're yet another confusingly-named concept to try to grasp.
A common complaint people raised in this series is regarding the "non-standard" use of LINQ (although that was literally the point of this series 😅). Some of the complaints are valid; some are less so. I'll tackle some of these below.
Addressing misconceptions and misgivings about LINQ
As a reminder, this post was primarily about showing how you can turn the common, ugly, nested-lambda style "result pattern" code into something much more readable. i.e. turning something like this:
return GetClaimValues(info)
.Switch(
onSuccess: claims =>
{
Result<Claim[]> validatedClaimsResult = ValidateClaims(claims);
return validatedClaimsResult.Switch(
onSuccess: validatedClaims =>
{
Result<Guid> tenantIdResult = GetTenantId(claims);
return tenantIdResult.Switch(
onSuccess: tenantId =>
{
Result<ProvisionUserRequest> createRequestResult =
CreateProvisionUserRequest(tenantId, validatedClaims);
return createRequestResult.Switch<Result<UserAccount>>(
onSuccess: createRequest =>
{
return createUserService.GetOrCreateAccount(createRequest);
},
onFailure: ex => Result<UserAccount>.Fail(ex));
},
onFailure: ex => Result<UserAccount>.Fail(ex));
},
onFailure: ex => Result<UserAccount>.Fail(ex));
},
onFailure: ex => Result<UserAccount>.Fail(ex));
into something like this:
from claims in GetClaimValues(info)
from validatedClaims in ValidateClaims(claims)
from tenantId in GetTenantId(validatedClaims)
from createRequest in CreateProvisionUserRequest(tenantId, validatedClaims)
from userAccount in createUserService.GetOrCreateAccount2(createRequest)
select userAccount;
If you genuinely have code like the former, then I really struggle to see any argument that it is "better" than the latter, on virtually any metric 😅 Functionality wise, the above two examples are identical.
I think where people get hung up is in thinking that LINQ is used for SQL. Yes, that was obviously the origin, and it was designed to work with SQL, but it's not tied to SQL. So the above code has none of the "N+1 issues" or "deferred evaluation" that you might associate with LINQ and SQL.
What I will absolutely grant, is that this isn't idiomatic C# or LINQ; you don't often see code like this in most code bases. When I first ran into it it I had to ask a teammate what was going on. That's absolutely going to happen, and it's going to happen for virtually every C# developer that runs into it. That's a big negative for it.
On the other hand, it's very easy to explain to people who understand railway oriented programming: The LINQ operators "unpack" the T
from the Result<T>
for the success cases, and short-circuit with an error in the failure case. And you essentially read it as:
var claims = GetClaimValues(info);
var validatedClaims = ValidateClaims(claims);
var tenantId = GetTenantId(validatedClaims);
var createRequest = CreateProvisionUserRequest(tenantId, validatedClaims);
var userAccount = createUserService.GetOrCreateAccount2(createRequest);
return userAccount; // or return error if anything above failed
Sure it requires getting used to, but would you rather go back to the big nested lambdas? I think the best argument is that your answer should be "I don't want either one" (which is pretty much what Jeremy and Aaron have said).
The big question: to Result<T>
or not to Result<T>
?
At this point, I think it's worth taking a step back. The code in the previous section demonstrated a simple procedural (or functional, depending on your perspective)-style of method chaining, which I think it's safe to say is easy to read and understand. And more importantly it's easier to read than the nested lambda approach.
However, in my previous post, I showed that if you have anything remotely more complicated, such as async
/await
or collection of Result<T>
to handle, then you end up in a whole world of additional complexity and confusion. The question is: when things start getting more complicated—mixed Result<T>
and T
, async Task
code, IEnumerable<Result<T>>
vs Result<IEnumerable<T>>
—does the payoff remain worth it?
The LINQ query syntax and the "magic" it uses undoubtedly simplifies the reading of the method, as everything is very regimented and easy to follow. But there's no denying it can be infuriating to write: errors often manifest as errors inferring types, and consequently the error messages are very often impenetrable, as you've seen:
That can mean that an apparent simple change to a dependent method, perhaps switching the method from returning a T
to Result<T>
, or vice versa, can lead you struggling to understand the changes you need to make in the calling LINQ code. Over time you can anticipate and understand the implications of your changes, but is it worth the effort?
Fundamentally, I think we're left with the question "is this code simple?" I believe the actual LINQ code is, generally, simple. The infrastructure of extension methods and functional concepts (bind vs apply, monadic vs applicative, sequence vs traverse) are, I think, not simple to the average C# developer.
Does the average C# developer need to understand these concepts? Maybe not. But it feels like a bad idea to not understand why your code compiles, and if you don't understand them, you're likely going to end up reinventing the wheel somewhere along the line.
So where does all this musing leave us? I still think the LINQ code is preferable to the nested lambdas of the result pattern. And I still believe using exceptions as control flow is a bad idea. And I still thing Aaron's basic version of the "result pattern" is what you want most of the time.
So I guess this series is probably more a musing on the things you can do with LINQ, rather than what you should do. Sorry to everyone that was hoping for something different😳😅
Summary
The original intention of this series was to attempt to show how you use the result pattern to reduce boilerplate. I still believe that can be the case, but reminding myself of all the edge cases in the LINQ solution, as well as all the alternatives, really reinforced the fact that this doesn't mean you should use this style of result pattern everywhere. For the vast majority of cases, you can get a way with something much simpler!
October 29, 2024 at 02:30PM
Click here for more details...
=============================
The original post is available in Andrew Lock | .NET Escapades by Andrew Lock
this post has been published as it is through automation. Automation script brings all the top bloggers post under a single umbrella.
The purpose of this blog, Follow the top Salesforce bloggers and collect all blogs in a single place through automation.
============================
Post a Comment