Error Handling and Validation Architecture in .NET Core
We cover ways to make a maintainable validation architecture. The main goal of this post is to clean up business, presentation, and data access logic.
Join the DZone community and get the full member experience.
Join For FreeIn many projects, error handling and validation are distributed across business logic, API controllers, and data access layers in the form of conditions (“if-else” sequences). This leads to the violation of the Separation of Concerns Principle and results in “Spaghetti code,” like in the example below.
....
if (user != null)
{
if (subscription != null)
{
if (term == Term.Annually)
{
// code 1
}
else if (term == Term.Monthly)
{
// code 2
}
else
{
throw new InvalidArgumentException(nameof(term));
}
}
else
{
throw new ArgumentNullException(nameof(subscription));
}
}
else
{
throw new ArgumentNullException(nameof(user));
}
.....
In this article, I describe an approach for sepearting validation and error handling logic from the other application layers.
Architecture Overview
For simplicity I use N-tire architecture, however, the approaches can be reused in CQRS, event-driven, microservices, SOA, and other architectures.
Example architecture includes the following layers:
- Presentation Layer — UI/API
- Business Logic Layer — Services or Domain Services (in case you have DDD architecture)
- Data Layer/Data Access Layer
The diagram below shows the components and modules which belong to different layers and contain a presentation/API layer, business logic layer, data access (on the right side), and related validation and error handling logic on the left side.
The validation and error handling architecture contain several components which I will describe in the next few sections.
API Validation Level
API controllers may contain a lot of validations, such as parameter checks, model state checks, etc., like in the example below. I will use declarative programming to move the validation logic out frthe om API controller.
[HttpGet]
[SwaggerOperation("GetDevices")]
[ValidateActionParameters]
public IActionResult Get([FromQuery][Required]int page, [FromQuery][Required]int pageSize)
{
if (!this.ModelState.IsValid)
{
return new BadRequestObjectResult(this.ModelState);
}
return new ObjectResult(deviceService.GetDevices(page, pageSize));
}
API controllers can be easily cleaned by creating a validation model attribute. The example below contains a simple model validation check.
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace DeviceManager.Api.ActionFilters
{
/// <summary>
/// Intriduces Model state auto validation to reduce code duplication
/// </summary>
/// <seealso cref="Microsoft.AspNetCore.Mvc.Filters.ActionFilterAttribute" />
public class ValidateModelStateAttribute : ActionFilterAttribute
{
/// <summary>
/// Validates Model automaticaly
/// </summary>
/// <param name="context"></param>
/// <inheritdoc />
public override void OnActionExecuting(ActionExecutingContext context)
{
if (!context.ModelState.IsValid)
{
context.Result = new BadRequestObjectResult(context.ModelState);
}
}
}
}
Just add this attribute to the startup.cs file.
services.AddMvc(options =>
{
options.Filters.Add(typeof(ValidateModelStateAttribute));
});
To validate the parameters of the API action methods, I will create an attribute and move the validation logic. The logic inside this attribute checks if the parameters contain validation attributes and validate the value.
Now the attribute can be added to the action method, if necessary (examples below):
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Mvc.Filters;
namespace DeviceManager.Api.ActionFilters
{
/// <inheritdoc />
public class ValidateActionParametersAttribute : ActionFilterAttribute
{
private const string RequiredAttributeKey = "RequiredAttribute";
/// <inheritdoc />
public override void OnActionExecuting(ActionExecutingContext context)
{
var descriptor = context.ActionDescriptor as ControllerActionDescriptor;
if (descriptor != null)
{
var parameters = descriptor.MethodInfo.GetParameters();
CheckParameterRequired(context, parameters);
}
base.OnActionExecuting(context);
}
private static void CheckParameterRequired(ActionExecutingContext context, IEnumerable<ParameterInfo> parameters)
{
foreach (var parameter in parameters)
{
if (parameter.CustomAttributes.Any() && parameter.CustomAttributes.Select(item => item.AttributeType
.ToString()
.Contains(RequiredAttributeKey)).Any())
{
if (!context.ActionArguments.Keys.Contains(parameter.Name))
{
context.ModelState.AddModelError(parameter.Name, $"Parameter {parameter.Name} is required");
}
}
}
if (context.ModelState.ErrorCount != 0)
{
context.Result = new BadRequestObjectResult(context.ModelState);
}
}
}
}
Now the attribute can be added to the API method, like in the example below.
[HttpGet]
[SwaggerOperation("GetDevices")]
[ValidateActionParameters]
public IActionResult Get(
[FromQuery, Required]int page,
[FromQuery, Required]int pageSize)
{
return new ObjectResult(deviceService.GetDevices(page, pageSize));
}
Business Layer Validation
Business layer validation consists of two components: validation service and validation rules.
In the device validation services, I’ve moved all custom validation and rule-based validation logic from the service (device service in the example below). This idea is quite similar to using the Guard pattern. The below example shows this validation service.
using System;
using DeviceManager.Api.Model;
using DeviceManager.Api.Validation;
using FluentValidation;
namespace DeviceManager.Api.Services
{
/// <inheritdoc />
public class DeviceValidationService : IDeviceValidationService
{
private readonly IDeviceViewModelValidationRules deviceViewModelValidationRules;
/// <summary>
/// Initializes a new instance of the <see cref="DeviceValidationService"/> class.
/// </summary>
/// <param name="deviceViewModelValidationRules">The device view model validation rules.</param>
public DeviceValidationService(
IDeviceViewModelValidationRules deviceViewModelValidationRules)
{
this.deviceViewModelValidationRules = deviceViewModelValidationRules;
}
/// <summary>
/// Validates the specified device view model.
/// </summary>
/// <param name="deviceViewModel">The device view model.</param>
/// <returns></returns>
/// <exception cref="ValidationException"></exception>
public IDeviceValidationService Validate(DeviceViewModel deviceViewModel)
{
var validationResult = deviceViewModelValidationRules.Validate(deviceViewModel);
if (!validationResult.IsValid)
{
throw new ValidationException(validationResult.Errors);
}
return this;
}
/// <summary>
/// Validates the device identifier.
/// </summary>
/// <param name="deviceId">The device identifier.</param>
/// <returns></returns>
/// <exception cref="ValidationException">Shuld not be empty</exception>
public IDeviceValidationService ValidateDeviceId(Guid deviceId)
{
if (deviceId == Guid.Empty)
{
throw new ValidationException("Should not be empty");
}
return this;
}
}
}
In the rules, I’ve moved all possible validation checks related to the view or API models. In the example below, you can see the device's view model validation rules. The validation itself triggers inside the the validation service.
The validation rules based on the FluentValidation framework allow you to build rules in the fluent format.
using DeviceManager.Api.Model;
using FluentValidation;
namespace DeviceManager.Api.Validation
{
/// <summary>
/// Validation rules related to Device controller
/// </summary>
public class DeviceViewModelValidationRules : AbstractValidator<DeviceViewModel>, IDeviceViewModelValidationRules
{
/// <summary>
/// Initializes a new instance of the <see cref="DeviceViewModelValidationRules"/> class.
/// <example>
/// All validation rules can be found here: https://github.com/JeremySkinner/FluentValidation/wiki/a.-Index
/// </example>
/// </summary>
public DeviceViewModelValidationRules()
{
RuleFor(device => device.DeviceCode)
.NotEmpty()
.Length(5, 10);
RuleFor(device => device.DeviceCode)
.NotEmpty();
RuleFor(device => device.Title)
.NotEmpty();
}
}
}
Exception Handling Middleware
The last thing I will cover is errors/exception handling. I waited to address this topic at the end as all validation components generate exceptions and the centralized component that handles them and provides proper JSON error objects is required.
In the example below I’ve used .NET Core Middleware to catch all exceptions and created an HTTP error status according to Exception Type (in the ConfigurationExceptionType
method) and build an error JSON object.
Also, the middleware can be used to log all exception in one place.
using System;
using System.Net;
using System.Threading.Tasks;
using DeviceManager.Api.Model;
using FluentValidation;
using Microsoft.AspNetCore.Http;
using Newtonsoft.Json;
namespace DeviceManager.Api.Middlewares
{
/// <summary>
/// Central error/exception handler Middleware
/// </summary>
public class ExceptionHandlerMiddleware
{
private const string JsonContentType = "application/json";
private readonly RequestDelegate request;
/// <summary>
/// Initializes a new instance of the <see cref="ExceptionHandlerMiddleware"/> class.
/// </summary>
/// <param name="next">The next.</param>
public ExceptionHandlerMiddleware(RequestDelegate next)
{
this.request = next;
}
/// <summary>
/// Invokes the specified context.
/// </summary>
/// <param name="context">The context.</param>
/// <returns></returns>
public Task Invoke(HttpContext context) => this.InvokeAsync(context);
async Task InvokeAsync(HttpContext context)
{
try
{
await this.request(context);
}
catch (Exception exception)
{
var httpStatusCode = ConfigurateExceptionTypes(exception);
// set http status code and content type
context.Response.StatusCode = httpStatusCode;
context.Response.ContentType = JsonContentType;
// writes / returns error model to the response
await context.Response.WriteAsync(
JsonConvert.SerializeObject(new ErrorModelViewModel
{
Message = exception.Message
}));
context.Response.Headers.Clear();
}
}
/// <summary>
/// Configurates/maps exception to the proper HTTP error Type
/// </summary>
/// <param name="exception">The exception.</param>
/// <returns></returns>
private static int ConfigurateExceptionTypes(Exception exception)
{
int httpStatusCode;
// Exception type To Http Status configuration
switch (exception)
{
case var _ when exception is ValidationException:
httpStatusCode = (int) HttpStatusCode.BadRequest;
break;
default:
httpStatusCode = (int) HttpStatusCode.InternalServerError;
break;
}
return httpStatusCode;
}
}
}
Conclusion
In this article, I covered several options to create a maintainable validation architecture. The main goal of this article is to clean up business, presentation, and data access logic. I would not recommend considering these approaches as “silver bullets” as, along with some advantages, they have several disadvantages.
For example:
- Middleware — overrides existing response flow which is a good option for the API and may be a disadvantage for web solutions. You may need to have two middlewares for different solution types.
Source Code
All examples can be found implemented in the ready-to-go framework here.
Opinions expressed by DZone contributors are their own.
Comments