DEV Community

Cover image for Kentico Xperience Design Patterns: Handling Failures - Centralized Exception Handling
Sean G. Wright
Sean G. Wright

Posted on • Edited on

Kentico Xperience Design Patterns: Handling Failures - Centralized Exception Handling

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.

Part 2 and Part 3

๐Ÿ“š What Will We Learn?

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
}
Enter fullscreen mode Exit fullscreen mode

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) { }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

And, here's our content querying logic:

var pages = await pageRetriever.RetrieveAsync<SiteContent>();

if (!pages.Any())
{
    throw new MissingContentException(SiteContent.CLASS_NAME);
}

// ...
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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));
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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


Photo by Jordan Madrid on Unsplash

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.

#kentico

#xperience

Or my Kentico Xperience blog series, like:

Top comments (0)