In a previous post we looked at the benefits of returning failures instead of throwing exceptions.
We came up with a Result
type that could represent an failure or a success of a given operation, either containing a string
error message or the value of our operation.
This custom Result
type met most of our requirements, but to really unlock its power ๐ฆพ we need to turn it into the Result
monad.
Note: This is part 3 of a 3 part series on handling failures.
๐ What Will We Learn?
A Refresher on Monads
If you are unsure ๐ of what a monad is, I give a description with some analogies in my previous post Kentico Xperience Design Patterns: Modeling Missing Data - The Maybe Monad.
However, if you don't feel like navigating away and want a quick explanation, think of a monad as a container ๐ฅซ, in the same way that Task<T>
and List<T>
are containers in C# (they are also monads).
You can manipulate these containers in a standard way, independent of what's inside and each type of monad represents a unique concept ๐ง . A Task<T>
represents a value T
that will be available at some point in the future and List<T>
represents a collection of 0 or more of some type T
.
It's worth noting that monads can contain other monads, because they don't care what kind of data they contain (ex:
Task<List<T>>
orList<Task<T>>
).
Let's consider the Result<TValue, TError>
monad ๐ฎ.
It is a container that represents an operation that either succeeded or failed (past tense). If it succeeded then it will have a value of type TValue
and if it failed it will have an error of type TError
.
The specific implementation of the Result
monad that we will be using comes from the C# library CSharpFunctionalExtensions ๐ and we will use the simplified Result<T>
where the TError
error type is a string
and doesn't need to be specified.
Modeling Data Access
Now we can answer the question of when and where do we actually use Result<T>
.
I recommend starting somewhere our application is performing an 'operation' which could succeed or fail and we're currently modeling the failure case with a throw new Exception(...);
or by ignoring it altogether.
Here's an example that might look similar to some Kentico Xperience code we have written:
public async Task<IActionResult> Index()
{
BlogPost page = pageDataContextRetriever
.Retrieve<BlogPost>()
.Page;
Author author = await authorService.GetAuthor(page);
Image bgImage = await blogService.GetDefaultBackgroundImage();
Image heroImage = await blogService.GetHero(page);
return new BlogPostViewModel(
page,
author,
bgImage,
heroImage);
}
In the applications I work on, it's pretty common to grab bits of data from various pages, custom module classes, the media library, ect... to gather all the content needed to render a page ๐ค.
However, each of these operations needs to go to the database, an external web service, or the file system to look for something. It might even have some business rules ๐ around how the data is retrieved.
If something goes wrong in the sample code above we can assume that an exception is going to thrown and centralized exception handling will catch it and display an error page.
We end up skipping the rest of the operations, even if they would have succeeded ๐. With centralized exception handling we typically never partially display a page that experienced some failures.
Let's update the code to use Result<T>
and see where that gets us:
public async Task<IActionResult> Index()
{
BlogPost page = pageDataContextRetriever
.Retrieve<BlogPost>()
.Page;
Result<Author> author = await authorService.GetAuthor(page);
Result<Image> bgImage = await blogService
.GetDefaultBackgroundImage();
Result<Image> heroImage = await blogService.GetHero(page);
return new BlogPostViewModel(...); // ๐ค
}
Our potential failures are no longer hidden as exceptions behind a method signature. Instead, we're requiring consumers of the authorService
and blogService
to deal with both the success and failure cases.
However, we now have a bunch of Result<T>
instances that are useless to our BlogPostViewModel
๐ฉ, so we'll need to do something to get the data they potentially contain to our Razor views.
Handling Results
One option for rendering when using Result<T>
is to update the BlogPostViewModel
to use Result
properties and 'unwrap' them in the View:
public record BlogPostViewModel(
BlogPost Post,
Result<Author> Author,
Result<Image> HeroImage,
Result<Image> BgImage);
In this case, we'd pass our results directly to the BlogPostViewModel
constructor in our Index()
method:
public async Task<IActionResult> Index()
{
BlogPost page = // ...
Result<Author> author = // ...
Result<Image> bgImage = // ...
Result<Image> heroImage = // ...
return new BlogPostViewModel(
page, author, heroImage, bgImage);
}
Then, in our View we can unwrap conditionally to access the values or errors that were the outcomes of each operation:
@model Sandbox.BlogPostViewModel
@if (Model.HeroImage.TryGetValue(out var img))
{
<!-- We retrieved content successfully -->
<div>
<img src="img.Path" alt="img.AltText" />
</div>
}
else if (Model.HeroImage.TryGetError(out string err))
{
<!-- Content retrieval failed - show error to admins -->
<page-builder-mode exclude="Live">
<p>Could not retrieve hero image:</p>
<p>@err</p>
</page-builder-mode>
}
This is an interesting approach because it allows us to gracefully ๐๐ฝ display error information for any operations that failed, while continuing to show the correct content for those that succeed.
In this example we were able to treat all the Result<T>
as independent because none of the operations were conditional on the others, but that's not always the case ๐ฎ.
Let's enhance our BlogPostViewModel
to include related posts (by author) and those post's taxonomies:
public async Task<IActionResult> Index()
{
BlogPost page = // ...
Result<Author> author = // ...
Result<Image> bgImage = // ...
Result<Image> heroImage = // ...
Result<List<BlogPost>> relatedPosts = await blogService
.GetRelatedPostsByAuthor(page, author);
Result<List<Taxonomy>> taxonomies = await taxonomyService
.GetForPages(relatedPosts);
return new BlogPostViewModel(...);
}
We're now an in awkward situation where we have to pass Result<T>
to our services. Those service methods will have to do some checks to see if the results succeeded or failed and conditionally perform the operations.
The monad is 'infecting' ๐ท our code, and not in a good way.
Ideally we'd only call our services if the depending operations (getting the Author
and related BlogPost
s) succeeded. As with most monads, we'll have a better experience by "lifting" our code up into the Result
instead of bringing the Result
down into our code ๐.
This is similar to how we work with
Task<T>
. We don't often pass values of this type as arguments to methods. Instead weawait
them to get their values and pass those to our methods.We can also compare this to creating methods that operate on a single value of type
T
vs updating all of them to acceptList<T>
.
Fortunately there's an API for that. We want to use Result<T>.Bind()
and Result<T>.Map()
:
public async Task<IActionResult> Index()
{
BlogPost page = // ...
Result<Image> bgImage = // ...
Result<Image> heroImage = // ...
// result is Result<(Author, List<BlogPost>, List<Taxonomy>)>
var result = await authorService.GetAuthor(page)
.Bind((Author author) =>
{
return blogService
.GetRelatedPostsByAuthor(page, author)
.Map((List<BlogPost> posts) =>
{
return (author, posts);
});
})
.Bind(((Author, List<BlogPost>) t) =>
{
return taxonomyService
.GetForPages(t.posts)
.Map((List<Taxonomy> taxonomies) =>
{
return (t.author, t.posts, taxonomies);
});
});
return new BlogPostViewModel(...);
}
So, what's going on here ๐ต๐ต?
Let's break it down piece by piece ๐ค.
authorService.GetAuthor()
, blogService.GetRelatedPostsByAuthor()
and taxonomyService.GetForPages()
return Result<T>
, so we can chain off them with Result<T>
's extension methods.
The Map()
and Bind()
extension methods will only execute their delegate parameter if the Result
is in a successful state - that is, if the previous operation didn't fail ๐๐พ.
This means we only get related blog posts if we were able to get the current post's author. And, we only get the taxonomies if we were able to get related blog posts.
If any part of this dependency chain fails, all later operations are skipped and the failed Result
is returned from the final extension method ๐๐ผ.
Map and Bind
To help make the above code a bit more transparent, let's review the functionality of Map()
and Bind()
:
Map()
is like LINQ's Select method, which converts the contents of the Result<T>
from one value to another (it could be to the same or different types).
We often use Map()
when we want to transform the data returned from a method call to something else - like converting a DTO to a view model. We don't know if the method successfully completed its operation ๐, we assume it did and declare how to transform the result. Our transformation is skipped if the data retrieval failed.
Bind()
is like LINQ's SelectMany, which flattens out a nested Result
(ex IEnumerable<IEnumerable<T>>
or Result<Result<T>>
).
We'll typically use Bind()
when we have dependent operations.
When one service returns a Result
and we only want to call another service if the first one succeeded we will write something like:
Result<OtherData> result = service1
.GetData()
.Bind(data => service2.GetOtherData())
You'll notice ๐ง we have a common pattern of Bind()
followed by a nested Map()
. Bind()
is calling the dependent operation and Map()
lets us continue to gather up the data from each operation into a new C# tuple.
We can skip the braces, type annotations, and return
keywords and use expressions to keep our code terse and declarative - reading like a recipe ๐ฉ๐ปโ๐ณ:
public async Task<IActionResult> Index()
{
BlogPost page = // ...
Result<Image> bgImage = // ...
Result<Image> heroImage = // ...
// result is Result<(Author, List<BlogPost>, List<Taxonomy>)
var result = await authorService.GetAuthor(page)
.Bind(author => blogService
.GetRelatedPostsByAuthor(page, author)
.Map(posts => (author, posts)))
.Bind(set => taxonomyService
.GetForPages(set.posts)
.Map(taxonomies => (set.author, set.posts, taxonomies)));
return new BlogPostViewModel(...);
}
If this feels too unfamiliar, and we want a place to add breakpoints for debuggability, we can extract each delegate to a method and pass the method group, which can be even more readable ๐:
public async Task<IActionResult> Index()
{
BlogPost page = // ...
Result<Image> bgImage = // ...
Result<Image> heroImage = // ...
// Here is our recipe of operations
var result = await Result.Success(page)
.Bind(GetAuthor)
.Bind(GetRelatedPosts)
.Bind(GetTaxonomies);
return new BlogPostViewModel(...);
}
private Task<Result<(BlogPost, Author)>> GetAuthor(BlogPost page)
{
return authorService.GetAuthor(page)
.Map(author => (page, author));
}
private Task<Result<(List<BlogPost>, Author)>> GetRelatedPosts(
(BlogPost page, Author author) t)
{
return blogService.GetRelatedPostsByAuthor(
t.page, t.author)
.Map(posts => (posts, t.author));
}
private Task<Result<(Author, List<BlogPost>, List<Taxonomy>)>> GetTaxonomies(
(List<BlogPost> posts, Author author) t)
{
return taxonomyService
.GetForPages(t.posts)
.Map(taxonomies => (t.author, t.posts, taxonomies));
}
Handling Failures
If our entire pipeline of operations are dependent and we should skip trying to render content if we can't access everything we need, we can use the Match()
extension and provide delegates that define what should happen for both success and failure scenarios:
public async Task<IActionResult> Index()
{
BlogPost page = // ...
return await authorService.GetAuthor(page)
.Bind(author => blogService
.GetRelatedPostsByAuthor(page, author)
.Map(posts => (author, posts)))
.Bind(t => taxonomyService
.GetForPages(t.posts)
.Map(taxonomies => new BlogPostViewModel(t.author, t.posts, taxonomies)))
.Match(
viewModel => View(viewModel),
error => View("Error", error));
}
Now we're really seeing the power of composing Result<T>
๐ช๐ฝ. Each of our services can fail, but that doesn't complicate our logic that composes the data returned by each service.
Without Result<T>
we could use exceptions to signal errors - hidden from method signatures and used as secret control flow ๐.
Or, we have a bunch of conditional statements ๐, checking to see what 'state' we are in (success/failure), often modeled with Nullable Reference Types.
With Result<T>
we let the monad maintain the internal success/failure state and write our code as a series of steps that clearly defines what we need to proceed.
If we have operations that aren't dependent, we can gather up all the various Result<T>
values, put them in our view model and handle the conditional rendering in the view ๐ค๐ผ.
My general recommendation is to separate independent sets of operations into View Components ๐ค. We treat each View Component as a boundary for errors or failures instead of populating a view model with a bunch of Result<T>
values, potentially making our views overly complex.
We can create a ViewComponent
extension method to help us easily convert a failed Result
to an error View:
public static class ViewComponentResultExtensions
{
public static Task<IViewComponentResult> View<T>(
this Task<Result<T>> result,
ViewComponent vc)
{
return result.Match(
value => vc.View(value),
error => vc.View("ComponentFailure", error));
}
}
We can place the failure View in a shared location ~/Views/Shared/ComponentFailure.cshtml
and have it show an error message in the Page Builder and Preview modes, but hide everything on the Live site ๐:
@model string
<page-builder-mode exclude="Live">
<p>There was a problem loading this component.</p>
<p>@Model</p>
</page-builder-mode>
If we're following the PTVC pattern, we can use this in a View Component as follows:
public class BlogViewComponent : ViewComponent
{
public Task<IViewComponentResult> InvokeAsync(
BlogPost post) =>
authorService.GetAuthor(page)
.Bind(author => blogService
.GetRelatedPostsByAuthor(page, author)
.Map(posts => (author, posts)))
.Bind(t => taxonomyService
.GetForPages(t.posts)
.Map(taxonomies => new BlogPostViewModel(t.author, t.posts, taxonomies)))
.View(this);
}
}
We now have the added benefit of an expression bodied member as a method implementation ๐ฒ!
Conclusion
Moving from C# exceptions to the Result
monad can take some getting used to. It's also a change that should be discussed with out team members and implemented where appropriate (Exceptions still have their place! ๐ง).
If we do decide it's an option worth exploring, what do we gain?
- Honest methods that don't hide the possibility of failures from their signatures.
- A consistent set of patterns and tools for combining
Result
s. - Local, targeted handling of failures that prevents them from failing an entire page (unlike centralized exception handling).
- A recipe of operations that reads like a set of instructions on how to gather up the data we need to proceed through our app.
- No repeated boilerplate if/else statements to handle various failures.
If you think your Kentico Xperience site might benefit from returning failures and using the Result
monad, checkout the CSharpFunctionalExtensions library and my library-in-progress XperienceCommunity.CQRS. It codifies these patterns for data retrieval and integrates cross-cutting concerns like logging and caching.
I'd love to hear your thoughts ๐!
...
As always, thanks for reading ๐!
References
- Kentico Xperience Design Patterns: Handling Failures - Return Errors, Don't Throw Them
- Monad - Wikipedia
- Kentico Xperience Design Patterns: Modeling Missing Data - The Maybe Monad
- CSharpFunctionalExtensions
- Kentico Xperience Design Patterns: Handling Failures - Centralized Exception Handling
- LINQ Select - Microsoft Docs
- LINQ SelectMany - Microsoft Docs
- C# Tuple - Microsoft Docs
- C# Method Groups - Stack Overflow
- Nullable Reference Types - Microsoft Docs
- Kentico Xperience Design Patterns: MVC is Dead, Long Live PTVC
- C# Expression Bodied Member - Microsoft Docs
- XperienceCommunity.CQRS - GitHub
We've put together a list over on Kentico's GitHub account of developer resources. Go check it out!
If you are looking for additional Kentico content, checkout the Kentico or Xperience tags here on DEV.
Or my Kentico Xperience blog series, like:
Top comments (0)