Even perfectly crafted code can result in runtime failures for reasons that are out of our control ๐ซ. Flaky network connections, data schemas that don't match the deployed code, or the accidental deletion of required data.
As software engineers, we are expected to design for the happy ๐ paths and the 'exceptional' unhappy โน ones.
Let's look at a common approach for handling these failures in ASP.NET Core applications, how it applies to Kentico Xperience sites, and where it falls short.
Note: This is part 1 of a 3 part series on handling failures.
๐ What Will We Learn?
- Representing failures as Exceptions
- How to handle Exceptions
- Exception handling as a cross-cutting concern
- The problems with using Exceptions as control flow
Representing Failures as Exceptions
.NET developers are familiar with exceptions. They are part of the Base Class Library (BCL) and can be used to signal that applications have entered into an invalid state, by using the throw new Exception()
syntax.
However, throwing exceptions is just half of the story. We can also catch exceptions by wrapping some code in the try/catch
syntax:
try
{
someBusinessOperation();
}
catch (Exception ex)
{
// log and handle the exception
}
Of course, to be able to do the correct thing with a caught exception, we need to know ๐ง what type of exception it is.
We can use the built-in exceptions, like ArgumentNullException
and InvalidOperationException
, or even the basic Exception
type to halt execution when something's gone wrong.
We can also create our own custom exception types, like the following BadRequestException
:
public class BadRequestException : Exception
{
public BadRequestException(string message) : base(message) { }
}
But why go through the effort of making a custom exception type?
Custom exceptions can more closely model our application's domain language and use-cases. The exception types in the BCL have to be use-case agnostic, by definition, because they are 'common' and usable in any .NET application ๐ค.
If we were to use the base Exception
type for all of our applications exceptions, it's going to be hard to determine what we should do when we catch the exception.
When we create domain specific exception types, we can use exception filters - a specific kind of C# pattern matching used with catch
statements ๐ค - to make sure we execute specific code when different kinds of exceptions happen.
Imagine this scenario...
In a Kentico Xperience application, we have site-wide settings in a custom Page Type, which we'll call SiteContent
. If this SiteContent
Page is deleted from the Content Tree, how should our application respond ๐คท๐ฝโโ๏ธ?
When we try to query for the SiteContent
Page, we could throw a custom MissingContent
exception if it's not found.
Here's our example exception type:
public class MissingContentException : Exception
{
public string PageType { get; }
public MissingContentException(string pageType)
base($"Could not find [{pageType}] Page") =>
PageType = pageType;
}
And, here's our content querying logic:
var pages = await pageRetriever.RetrieveAsync<SiteContent>();
if (!pages.Any())
{
throw new MissingContentException(SiteContent.CLASS_NAME);
}
// ...
The nice thing about throwing an exception is that it lets us abort our application execution immediately, preventing additional code errors or failures ๐ช๐พ.
We also don't have to change the type signature of our methods that throw exceptions. This is convenient for blocks of code that could fail for any number of reasons. Our method return type represents the happy path, and the exceptions which the method throws represent all the failure scenarios ๐.
How to Handle Exceptions
If we throw an exception somewhere, we need to eventually catch that exception and respond accordingly:
try
{
var siteContent = await GetSiteContentPage();
}
catch (MissingContentException ex)
when (ex.PageType == SiteContent.CLASS_NAME)
{
// log and handle
}
With this approach we're representing our failed operation (querying for the SiteContent Page) with a custom exception type, and only catching and responding to the exceptions we are interested in - all other exceptions will need to be caught at some higher level in our code.
This all leads to the question - how do we want to handle exceptions we've caught? What information do we want to collect and what do we want to present to the site visitor that was unfortunate enough to trigger this failure ๐ค?
In a Kentico Xperience site, the most common way of handling exceptions is going to be logging the failure to the Xperience Event Log and displaying an error page to the visitor ๐๐ป.
We can usually achieve this with two middlewares - UseStatusCodePagesWithReExecute()
and UseExceptionHandler()
.
Status Code Pages With Re-Execute
UseStatusCodePagesWithReExecute
will handle results from Controllers that have 'error' HTTP status codes (eg 400, 500) and let you execute another route, passing the status code as a parameter:
public void Configure(IApplicationBuilder app)
{
// earlier middleware
app.UseStatusCodePagesWithReExecute(
"/status-code-error",
"?code={0}");
// later middleware
}
The site visitor won't see /status-code-error
in their address bar ๐คจ, but the Controller action associated with that route can return some content explaining to the user what happened, based on the code
query parameter. (/status-code-error
can be any path as long as it's one your application can handle).
Read another explanation on these middlewares on StackOverflow.
This middleware will typically be used when we've caught an exception later in the request pipeline - like in an MVC filter or Controller action - and returned a result representing the error, like StatusCode()
or NotFound()
:
public class HomeController : Controller
{
public async Task<IActionResult> Index()
{
try
{
var data = await myService.GetData();
return View(new HomeViewModel(data));
}
catch (Exception ex)
{
eventLogService.LogException(...);
return StatusCode(500);
}
}
}
Note: The
UseStatusCodePagesWithReExecute
middleware does not catch exceptions ๐ฒ. It helps to keep the presentation of 'error' status codes separate from the Controllers that return them.
While this lets us handle and log each exception exactly as we'd like for every Controller action, it's also a lot of repeated code when our Kentico Xperience site grows beyond a few Pages ๐ฌ.
Fortunately, there's a way handle exceptions as a cross-cutting concern... ๐
Exception Handling as a Cross-Cutting Concern
Exception Handler
The UseExceptionHandler
middleware wraps the entire request pipeline in a try
/catch
, which means any unhandled exceptions will be caught and the developer can specify how they should be handled - like executing a request to another path in the application:
public void Configure(IApplicationBuilder app)
{
// This is the first in / last out middleware
app.UseExceptionHandler(new ExceptionHandlerOptions
{
AllowStatusCode404Response = true,
ExceptionHandlingPath = "/error"
});
// later middleware
}
In this example, when an exception is caught the site will execute the /error
path, which is typically a Controller action, and return its result.
We'll often use the UseExceptionHandler
and UseStatusCodePagesWithReExecute
middleware together. The former might return a 'Not Found' Page, keeping the HTTP status code as 404, and we wouldn't want that to be treated as an unhandled exception for the UseExceptionHandler
middleware to process. AllowStatusCode404Response = true
ensures the 404 response is allowed through ๐ฎ.
The Controller that handles our failure can grab the original Exception
and request data out of the IExceptionHandlerPathFeature
, which is where the middleware stores it before calling the Controller action:
public class ErrorController : Controller
{
public IActionResult Index()
{
var exceptionFeature = HttpContext
.Features
.Get<IExceptionHandlerPathFeature>();
Exception ex = exceptionFeature.Error;
string originalPath = exceptionFeature.Path;
eventLogService.LogException(
"ErrorController",
"ASPNET_EXCEPTION",
exception);
var (message, statusCode) = ex switch
{
BadRequestException b =>
("The page had some problems", 400);
NotAuthorizedException na =>
("You don't have permissions to view this", 403);
_ =>
("We've hit a snag loading the page!", 500);
};
HttpContext.Response.StatusCode = statusCode;
return View(new ErrorViewModel(originalPath, message));
}
}
In this example, we are logging the exception in Xperience's Event Log, creating a friendly, contextual message and HTTP status code based on the type of exception that was caught, and returning a View to the user - all under the URL that caused the original problem - no redirects here ๐๐พ!
We've effectively created a central cross-cutting Global Exception Handler that guarantees any exception thrown in our application will be caught, logged, handled, and presented to the visitor as a normal site Page - not an incomprehensible 'developer' error Page.
This seems great... ๐ doesn't it ๐!?
The Problems with Using Exceptions as Control Flow
The convenience of throwing (or allowing) exceptions anywhere in our application and then using a Global Exception Handler comes with a real cost and a potential cost.
It's To Late, Baby
The real cost is that by the time we're at the Exception Handler, it's too late to do anything to recover. We've consigned ourselves to a failed request and the best we can give to the site visitor is a friendly error and log the Exception.
The problem is not all exceptions are created equal ๐.
What if an exception was thrown trying to get the URL for an image in an image gallery on the Page? Do we want to throw away everything else that was correctly retrieved from the database and show an error page, just because of this single failure? Probably not.
Building a Kentico Xperience application is about choosing the best ways to manage and present content in the system, and keep visitors coming back to the site. We need to more nuanced ๐ with handling failures and treat our approach as a spectrum - some failures are ok, others are not, some require backup content, others can just result in content missing from the Page.
Unfortunately, we lose the ability to be more nuanced if we turn every failure into an exception and then rely on a single, late (in the request lifecycle) point to deal with them.
Exception handling is a cross-cutting concern (like authorization, logging, or caching), which aligns with the UseExceptionHandler
middleware ๐. At the same time, cross-cutting concerns should be applied with multiple layers, not as a single blanket thrown over the application to hide the ugly parts ๐.
Abusing Control Flow
While some exceptions represent application states that should immediately halt the request, many are business logic situations we can gracefully (or at least intelligently) recover from.
There are many articles about using exceptions to control application flow ... most recommend against it.
If you choose not to consider this advice, problems will show up in your code base over time ๐ง.
A developer throws an exception when an array index is a negative number. Then, another developer later throws an exception when the credentials submitted by the login form are invalid. Eventually, half the methods in your app throw exceptions, either directly, or by some method they call, and all of those method signatures have become untrustworthy ๐.
What do you think this method does?
string[] GetUserRoles();
It probably returns all the roles a User is in when the current request is from an authenticated user. But what about when the user hasn't finished sign-up? Or if the request is for an unauthenticated User ๐ค?
We'd hope it would return an empty array or maybe an array with a "Anonymous"
Role... but there's nothing preventing it from throwing an exception! Even if it didn't, it could call other methods and they could throw exceptions!
Method return types should tell you what happens during normal use, and exceptional cases should really be exceptional. We want to trust ๐ค the code we write and the code written by others.
Using exceptions as control flow is a leaky abstraction because it forces us to know a lot more about a method than just its signature - potentially its internals, which breaks the idea of encapsulation. It also slowly reinforces our reliance on the Global Exception Handler pattern since any code in our application can throw
without us knowing ๐คฆโโ๏ธ!
Conclusion
In a Kentico Xperience site, we will inevitably run into situations that cause our code to fail to produce the desired result. These could be from exceptions thrown by libraries we are using, or our own code, or they could be from data not matching what we require ๐คท๐ฝโโ๏ธ.
In these cases we can use exceptions to quickly stop the current execution and bubble ๐งผ that failure up to another part of our application that is responsible for catching them.
If we make custom exception types, we can match them to the specific scenarios our sites might encounter. And, we can react to each type of exception intelligently ๐ง.
In order to not repeat our try
/catch
logic across the entire code base, we can centralize exception handling with the UseExceptionHandler
ASP.NET Core middleware. This gives us a convenient place to convert uncaught exceptions into friendly error pages and log entries ๐๐ฟ.
This convenience comes with some negatives. Our centralized exception handling will often not be located where we need it to if we want to compensate for some failures and let the request continue processing. It also encourages using exceptions as control flow, which results in method signatures only representing the 'happy path' and code that is harder to understand and trust ๐.
In my next post, we'll look at a way of including failures in our method signatures/return types, which keeps our code honest ๐ and allows us to better handle failures.
...
As always, thanks for reading ๐!
References
- .NET Base Class Library
- C# Exception Filters
- Kentico Xperience Event Log
- Status Code vs Exception Handling middleware in ASP.NET Core
- .NET Documentation - Exception Management
- Exceptions vs Error Structures in F#
- Exceptions for Control Flow
- Stop Using Exceptions for Control Flow
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)