.NET Steps – Exception Handling

Introduction

Every time I end up at new project there is something wrong with exception handling. Maybe it’s just bad luck?

And it’s always for the same reasons: it’s too complex, we didn’t have time, we are going to do it later.

Maaaaaaaaaaaaaan, it’s getting old…

It’s not complex. The minimum amount of work you have to do is add some attributes on your properties. Voila, you did more work than what was done on the most projects I was a part of.

And please, don’t try to sell me that bullshit that you are going to do it later because, you won’t.

P.S. What do you mean by: you are OK if we don’t put any validation? Eh živote robijo.

Implementation

Status codes

When implementing REST API you have to return Status Codes and in most cases, you are just going to use:

  • 200 – OK
  • 201 – Created
  • 202 – Accepted
  • 204 – No Content
  • 400 – Bad request
  • 401 – Unauthorized
  • 403 – Forbidden
  • 404 – Not found
  • 500 – Internal server error
400 – Bad request

This status code will be used for all validation messages. For example, when someone sends a POST request with some invalid data, we are going to return 400 with a message describing the error.

401 – Unauthorized

If you have authorization pipeline, this is already handled nicely. I mean, returning 401 with a message “Unauthorized” is enough.

403 – Forbidden

If you have authentication pipeline, this is already handled nicely. Again, returning 403 with message “Forbidden” is enough.

404 – Not found

I create a custom exception that I handle in pipeline when I’m not able to fetch something from database. Eg. you are trying to get a car by a name and there isn’t any car with that name in database. Science fiction right?

500 – Internal Server Error

Everything else.

If we get this error, it means that something is wrong with the system. It can be a bug, a connection timeout, network error, etc.

Standardized error response

Next thing we need to do is to standardize our error response.

But oh wait, in Microsoft.AspNetCore.Mvc there is something called ProblemDetails.cs and would you look at that, there is an actual specification about it. What a coincidence! Someone already did everything. What a fucking surprise.

Flow

In this tutorial, we are going to use MediatR for sending commands/queries that we are going to validate using FluentValidation. Additionally, we are going to add some custom exception for different use-cases. If an error happens, we are going to catch it with Hellang Middleware. That’s it.

400

If an error occurs in our validator, we are going to throw custom exception with a list of error messages. If an error that we are interested in occurred somewhere else in a code we are also going to throw a custom exception. At the end, we are going to map those exceptions to ProblemDetails.cs response using Hellang Middleware.

So, let’s start with our ValidationException.cs

namespace Company.Shorts.Blocks.Common.Exceptions
{
    using System;
    using System.Collections.Generic;

    public class ValidationException : ApplicationException
    {
        private const string TitleMessage = "One or more validation errors occurred.";
        private const string DetailMessage = "Check the errors for more details.";
        private const string GeneralKey = "General";

        /// <summary>
        /// Creates new instance of <see cref="ValidationException"/>.
        /// </summary>
        /// <remarks>
        /// Creates a new dictionary of errors with "General" key.
        /// </remarks>
        /// <param name="message">Error message.</param>
        public ValidationException(string message)
        {
            this.Title = TitleMessage;
            this.Detail = DetailMessage;
            this.Errors = new Dictionary<string, string[]>
            {
                {
                    GeneralKey , new string[] { message }
                }
            };
        }

        /// <summary>
        /// Creates new instance of <see cref="ValidationException"/>.
        /// </summary>
        /// <param name="errors">Errors.</param>
        public ValidationException(Dictionary<string, string[]> errors)
        {
            this.Title = TitleMessage;
            this.Detail = DetailMessage;
            this.Errors = errors;
        }

        /// <summary>
        /// Error title.
        /// </summary>
        public string Title { get; }

        /// <summary>
        /// Error details.
        /// </summary>
        public string Detail { get; }

        /// <summary>
        /// Errors.
        /// </summary>
        public Dictionary<string, string[]> Errors { get; }
    }
}

Our errors will look like this

// Error from fluent validation

{
  "errors": {
    "name": [
      "'Name' must not be empty."
    ]
  },
  "title": "One or more validation errors occurred.",
  "status": 400,
  "detail": "Check the errors for more details.",
  "traceId": "00-67309667b8b702a4686edae0758dcf90-e414c7f362074249-00"
}

// Custom error
{
  "errors": {
    "general": [
      "This is a custom error."
    ]
  },
  "title": "One or more validation errors occurred.",
  "status": 400,
  "detail": "Check the errors for more details.",
  "traceId": "00-6d060c0280c69ea485c55a30f25d7241-a9e41edabeb57778-00"
}

404

namespace Company.Shorts.Blocks.Common.Exceptions
{
    using System;
    using System.Collections.Generic;

    public class NotFoundException : ApplicationException
    {
        /// <summary>
        /// Creates an instance of <see cref="NotFoundException"/>.
        /// </summary>
        /// <param name="message">Error message.</param>
        public NotFoundException(string message)
        {
            this.Title = message;
            this.Detail = "Unable to find requested resource.";
            this.Errors = new Dictionary<string, string[]>();
        }

        /// <summary>
        /// Error title.
        /// </summary>
        public string Title { get; }

        /// <summary>
        /// Error detail.
        /// </summary>
        public string Detail { get; }

        /// <summary>
        /// Errors.
        /// </summary>
        public Dictionary<string, string[]> Errors { get; }
    }
}
{
  "errors": {},
  "title": "Resource not found.",
  "status": 404,
  "detail": "Unable to find requested resource.",
  "exceptionDetails": [ // Removed for brevity]
  "traceId": "00-04a96c59c7a491bdc17e80fb7a9a09fa-45f01bf1e1ebe026-00"
}

500

Everything else is 500. Since this is an unexpected exception, we don’t need to do anything because middleware will take care of it.

{
  "type": "https://httpstatuses.io/500",
  "title": "Internal Server Error",
  "status": 500,
  "detail": "Unhandled exception",
  "exceptionDetails": [ // Removed from brevity ],
  "traceId": "00-66791fb6bd4aa9670beeab6691269531-d3c21494b67086b9-00"
}

Now, lets take a quick look at our implementation of validators and handlers

namespace Company.Shorts.Application.ExampleAggregate.Command
{
    using Company.Shorts.Application.Contracts.Http;
    using Company.Shorts.Domain;
    using FluentValidation;
    using MediatR;
    using System.Threading;
    using System.Threading.Tasks;

    public record CreateExampleCommand(string Name, bool ThrowCustomException, bool ThrowNotFoundException, bool ThorwInternalException) : IRequest;

    public class CreateExampleCommandValidator : AbstractValidator<CreateExampleCommand>
    {
        public CreateExampleCommandValidator()
        {
            RuleFor(x => x.Name)
                .NotEmpty();
        }
    }

    internal sealed class CreateExampleCommandHandler : AsyncRequestHandler<CreateExampleCommand>
    {
        private readonly IExampleAdapter exampleAdapter;

        public CreateExampleCommandHandler(IExampleAdapter exampleAdapter)
        {
            this.exampleAdapter = exampleAdapter;
        }

        protected override async Task Handle(CreateExampleCommand request, CancellationToken cancellationToken)
        {
            if (request.ThrowCustomException)
            {
                throw new Blocks.Common.Exceptions.ValidationException("This is a custom error.");
            }

            if (request.ThrowNotFoundException)
            {
                throw new Blocks.Common.Exceptions.NotFoundException("Resource not found.");
            }

            if (request.ThorwInternalException)
            {
                throw new Exception("Unhandled exception");
            }

            Example example = new(Guid.NewGuid(), request.Name);

            await this.exampleAdapter.CreateAsync(example, cancellationToken);
        }
    }
}

Like mentioned before, command will first go through validator, if there are errors, it will collect them and map them to our ValidationException.cs. In order to set up our validators, we need to create ValidationBehavior.cs and connect it with MediatR.

namespace Company.Shorts.Blocks.Application.Core.Behaviors
{
    using FluentValidation;
    using MediatR;
    using System.Threading;
    using System.Threading.Tasks;

    public class ValidationBehavior<TRequest, TResponse>
        : IPipelineBehavior<TRequest, TResponse>
        where TRequest : IRequest<TResponse>
    {
        private readonly IEnumerable<IValidator<TRequest>> _validators;

        public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
        {
            _validators = validators;
        }

        public async Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken, RequestHandlerDelegate<TResponse> next)
        {
            var failures = _validators
                .Select(validator => validator.Validate(request))
                .SelectMany(result => result.Errors)
                .Where(failure => failure is not null)
                .GroupBy(failure => failure.PropertyName)
                .ToDictionary(
                    group => group.Key,
                    group => group.Select(failure => failure.ErrorMessage).ToArray());

            if (failures.Any())
            {
                throw new Common.Exceptions.ValidationException(failures);
            }

            return await next();
        }
    }
}

We are injecting IEnumerable<IValidator<TRequest>> in our constructor since extension method AddValidatorsFromAssemblies in DependecyInjection.cs will add all validators to dependency injection container. Then, we are getting the correct validator for request, triggering Validate method which will collect errors. If there are errors, we are mapping them to ValidationException.cs class. With next() method we are moving to our next pipeline block, which in our case is CreateExampleCommandHandler.

To register MediatR pipeline we need:

namespace Company.Shorts.Application
{
    using Company.Shorts.Blocks.Application.Contracts;
    using Company.Shorts.Blocks.Application.Core.Adapters;
    using Company.Shorts.Blocks.Application.Core.Behaviors;
    using FluentValidation;
    using MediatR;
    using Microsoft.Extensions.DependencyInjection;
    using Microsoft.Extensions.DependencyInjection.Extensions;
    using System.Reflection;

    public static class DependecyInjection
    {
        public static IServiceCollection AddApplicationLayer(this IServiceCollection services)
        {
            services.AddApplicationConfiguration(Assembly.GetExecutingAssembly());

            return services;
        }

        private static IServiceCollection AddApplicationConfiguration(
            this IServiceCollection services,
            params Assembly[] assemblies)
        {
            services.AddMediatR(assemblies);
            services.AddValidatorsFromAssemblies(assemblies, includeInternalTypes: true);

            services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));

            services.AddHttpContextAccessor();

            services.TryAddSingleton<IHttpContextAccessorAdapter, HttpContextAccessorAdapter>();

            return services;
        }
    }
}

The last step is to map those exceptions to ProblemDetails.cs. Since we are using Hellang Middleware it’s pretty easy thing to do. In Presentation Layer just add:

namespace Company.Shorts.Blocks.Presentation.Api.Configuration
{
    using Company.Shorts.Blocks.Common.Exceptions;
    using Hellang.Middleware.ProblemDetails;
    using Microsoft.AspNetCore.Http;
    using Microsoft.AspNetCore.Mvc;
    using Microsoft.Extensions.DependencyInjection;
    using Microsoft.Extensions.Hosting;
    using Newtonsoft.Json;
    using Newtonsoft.Json.Serialization;
    using System;
    using System.Linq;

    public static class ServiceCollectionExtensions
    {
        public static IServiceCollection AddRestApiConfiguration(
            this IServiceCollection services,
            IHostEnvironment enviroment)
        {
            services
                .AddRouting()
                .AddProblemDetails(options => SetProblemDetailsOptions(options, enviroment))
                .AddControllers()
                .AddNewtonsoftJson(options =>
                {
                    options.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver();
                    options.SerializerSettings.MissingMemberHandling = MissingMemberHandling.Ignore;
                    options.SerializerSettings.NullValueHandling = NullValueHandling.Ignore;
                    options.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore;
                    options.SerializerSettings.Converters.Add(new ValueTrimConverter());
                });

            return services;
        }

        private static void SetProblemDetailsOptions(ProblemDetailsOptions options, IHostEnvironment enviroment)
        {
            Type[] knownExceptionTypes = new[] { typeof(ValidationException), typeof(NotFoundException) };

            options.IncludeExceptionDetails = (_, exception) =>
                enviroment.IsDevelopment() &&
                !knownExceptionTypes.Contains(exception.GetType());

            options.Map<ValidationException>(exception =>
                new ValidationProblemDetails(exception.Errors)
                {
                    Title = exception.Title,
                    Detail = exception.Detail,
                    Status = StatusCodes.Status400BadRequest
                });

            options.Map<NotFoundException>(exception =>
                new ValidationProblemDetails
                {
                    Title = exception.Title,
                    Detail = exception.Detail,
                    Status = StatusCodes.Status404NotFound
                });
        }
    }
}

Note that we need to use Newtonsoft.Json to have keys of errors dictionary in camel case. Since we have everything extracted in our Blocks, lets add DependencyInjection.cs for our Presentation Layer:

namespace Company.Shorts.Presentation.Api
{
    using Company.Shorts.Blocks.Common.Swagger.Configuration;
    using Company.Shorts.Blocks.Presentation.Api.Configuration;
    using Microsoft.Extensions.DependencyInjection;
    using Microsoft.Extensions.Hosting;
    using System.Reflection;

    public static class DependecyInjection
    {
        public static IServiceCollection AddPresentationLayer(this IServiceCollection services, IHostEnvironment environment)
        {
            services
                .AddSwaggerConfiguration(swaggerExampleAssemblies: Assembly.GetExecutingAssembly());

            services
                .AddRestApiConfiguration(environment);

            return services;
        }
    }
}

And at the end, lets call AddPresentationLayer and UseProblemDetails in our Startup.cs

namespace Company.Shorts
{
    using Company.Shorts.Application;
    using Company.Shorts.Blocks.Common.Mapping.Configuration;
    using Company.Shorts.Blocks.Common.Serilog.Configuration;
    using Company.Shorts.Blocks.Common.Swagger.Configuration;
    using Company.Shorts.Blocks.Presentation.Api.Configuration;
    using Company.Shorts.Infrastructure.ExampleAdapter;
    using Company.Shorts.Presentation.Api;
    using Hellang.Middleware.ProblemDetails;

    public sealed class Startup
    {
        public Startup(
            IConfiguration configuration,
            IWebHostEnvironment environment)
        {
            Configuration = configuration;
            Environment = environment;
        }

        public IConfiguration Configuration { get; }

        public IWebHostEnvironment Environment { get; }

        public ExampleAdapterSettings ExampleAdapterSettings =>
            Configuration
                .GetSection(ExampleAdapterSettings.Key)
                .Get<ExampleAdapterSettings>();

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddCors();
            services.AddHealthChecks();
            services.AddInfrastructureExampleAdapter(ExampleAdapterSettings);
            services.AddApplicationLayer();
            services.AddPresentationLayer(Environment);
            services.AddAutoMapperConfiguration(AppDomain.CurrentDomain);
        }

        public void Configure(IApplicationBuilder app)
        {
            app.UseProblemDetails();

            if (!Environment.IsDevelopment())
            {
                app.UseHsts();
            }

            app.UseSwaggerConfiguration();

            app.UseHttpsRedirection();

            app.UseCors(options => options
                .AllowAnyOrigin()
                .AllowAnyMethod()
                .AllowAnyHeader());

            app.UseSerilogConfiguration();

            app.UseRouting();

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapDefaultControllerRoute();
                endpoints.MapDefaultHealthCheckRoute();
            });
        }
    }
}

Github

Branch: steps/exception_handling