The key to developing successful software is good architectural design. Good design not only allows developers to easily write new features, but also to adapt to changes in a silky way.
Good design should focus on the core of the application, the domain.
Unfortunately, it’s easy to confuse domains with responsibilities that don’t belong in this layer. Each additional feature makes it more difficult to understand the core area. Just as bad, it will be harder to refactor in the future.
Therefore, it is important to protect the domain layer from application logic. One of these optimizations is validation of incoming requests. To prevent validation logic from infiltrating the domain level, we want to validate requests before they reach the domain level.
In this article, we’ll learn how to extract validation from the domain layer. Before we begin, this article assumes that the API uses the Command pattern to convert incoming requests into commands or queries. All the code snippets in this article use MediatR.
The advantage of the Command pattern is that it separates the core logic from the API layer. Most libraries that implement the Command pattern also expose middleware that can be connected to it. This is useful because it provides a solution to add the application logic that needs to be executed with each command.
MediatR request
Using the record type introduced in C# 9, it can turn a request into a single line of code. Another benefit is that instances are immutable, which makes everything predictable and reliable.
record AddProductToCartCommand(Guid CartId, string Sku, int Amount) : MediatR.IRequest;
Copy the code
To distribute the above commands, the incoming request can be mapped to the controller.
[ApiController] [Route("[controller]")] public class CustomerCartsController : ControllerBase { private readonly IMediator _mediator; public CustomerCartsController(IMediator mediator) => _mediator = mediator; [HttpPost("{cartId}")] public async Task<IActionResult> AddProductToCart(Guid cartId, [FromBody] CartProduct cartProduct) { await _mediator.Send(new AddProductToCartCommand(cartId, cartProduct.Sku, cartProduct.Amount)); return Ok(); }}Copy the code
MediatR validation
Instead of verifying AddProductToCartCommand in the controller, we will use the MediatR pipe.
By using pipes, you can perform some logic before or after a handler processes a command. In this case, provide a centralized location where the command is validated before it reaches the handler (realm). When the command reaches its handler, we no longer need to worry about whether the command is valid.
While this may seem like a trivial change, it cleans up every handler in the domain layer.
Ideally, we only want to deal with business logic in the realm. Removing validation logic frees up our minds so we can focus more on business logic. Because the validation logic is centralized, it ensures that all commands are validated and that no command escapes the hole.
In the code snippet below, we created a ValidatorPipelineBehavior to validate the command. When the command is sent, ValidatorPipelineBehavior handler before it reaches the domain layer receive commands. ValidatorPipelineBehavior by invoking the corresponding to the type of the validator to verify whether the command is effective. Only if the request is valid is it allowed to pass on to the next handler. If not, an InputValidationException is thrown.
We’ll look at how to use FluentValidation to create validators within validation. Now, it’s important to know that when a request is invalid, a validation message is returned. The validation details are added to the exception and are later used to create the response.
public class ValidatorPipelineBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public ValidatorPipelineBehavior(IEnumerable<IValidator<TRequest>> validators)
=> _validators = validators;
public Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken, RequestHandlerDelegate<TResponse> next)
{
// Invoke the validators
var failures = _validators
.Select(validator => validator.Validate(request))
.SelectMany(result => result.Errors)
.ToArray();
if (failures.Length > 0)
{
// Map the validation failures and throw an error,
// this stops the execution of the request
var errors = failures
.GroupBy(x => x.PropertyName)
.ToDictionary(k => k.Key, v => v.Select(x => x.ErrorMessage).ToArray());
throw new InputValidationException(errors);
}
// Invoke the next handler
// (can be another pipeline behavior or the request handler)
return next();
}
}
Copy the code
useFluentValidationTo verify
To validate a request, I like to use the FluentValidation library. Use FluentValidation to define “validation rules” for each “IRequest” by implementing an AbstractValidator abstract class.
I like using FluentValidation because:
-
Validation rules are separate from the model
-
Easy to write, easy to read
-
In addition to the many built-in validators, you can create your own (reusable) custom rules
-
scalability
public class AddProductToCartCommandValidator : FluentValidation.AbstractValidator { public AddProductToCartCommandValidator() { RuleFor(x => x.CartId) .NotEmpty(); RuleFor(x => x.Sku) .NotEmpty(); RuleFor(x => x.Amount) .GreaterThan(0); }}
Register MediatR and ****FluentValidation
Now that we have the methods to validate and created a validator, we can register them with the DI container.
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers();
// Register all Mediatr Handlers
services.AddMediatR(typeof(Startup));
// Register custom pipeline behaviors
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidatorPipelineBehavior<,>));
// Register all Fluent Validators
services
.AddMvc()
.AddFluentValidation(s => s.RegisterValidatorsFromAssemblyContaining<Startup>());
}
Copy the code
HTTP API problem details
Now everything is ready to make the first request. When we try to send an invalid request, we receive an internal server error (500) response. That’s good, but it’s not a good experience.
In order to create a better experience for the user (user interface), the developer (or yourself), or even a third party, the optimized results will make it clear why the request failed. This approach makes integration with apis easier, better, and possibly faster.
When I had to integrate with third party services, they didn’t take that into account. This led to a lot of frustration for me, and I was glad when the integration finally ended. I’m sure the implementation would have been faster and the end result better if more thought had been given to responding to failed requests. Unfortunately, most integration with third-party services is a bad experience.
Because of this experience, I tried my best to help my future self and other developers by providing better responses. Better yet, a standardized response to what I call the HTTP API problem details.
The.net framework already provides a class to implement the ProblemDetails specification, ProblemDetails. In fact, the.NET API will return a problem detail response for some invalid requests. For example, when an invalid parameter is used in a route,.NET returns the following response.
{" type ":" https://tools.ietf.org/html/rfc7231#section-6.5.1 ", "title" : "One or more validation errors occurred.", "status": 400, "traceId": "00-6aac4e84d1d4054f92ac1d4334c48902-25e69ea91f518045-00", "errors": { "id": ["The value 'one' is not valid."] } }Copy the code
Map the response (exception) to the problem details
To standardize our problem details, the response can be overridden with exception middleware or exception filters.
In the code snippet below, we use middleware to retrieve the details of the exception when it occurs in the application. From these exception details, problem details objects are built.
All exceptions thrown are caught by the middleware, so you can create specific problem details for each exception. In the example below, only InputValidationException is mapped; the rest are treated equally.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { app.UseExceptionHandler(errorApp => { errorApp.Run(async context => { var errorFeature = context.Features.Get<IExceptionHandlerFeature>(); var exception = errorFeature.Error; / / https://tools.ietf.org/html/rfc7807#section-3.1 var problemDetails = new problemDetails {Type = $"https://example.com/problem-types/{exception.GetType().Name}", Title = "An unexpected error occurred!" , Detail = "Something went wrong", Instance = errorFeature switch { ExceptionHandlerFeature e => e.Path, _ => "unknown" }, Status = StatusCodes.Status400BadRequest, Extensions = { ["trace"] = Activity.Current? .Id ?? context? .TraceIdentifier } }; switch (exception) { case InputValidationException validationException: problemDetails.Status = StatusCodes.Status403Forbidden; problemDetails.Title = "One or more validation errors occurred"; problemDetails.Detail = "The request contains invalid parameters. More information can be found in the errors."; problemDetails.Extensions["errors"] = validationException.Errors; break; } context.Response.ContentType = "application/problem+json"; context.Response.StatusCode = problemDetails.Status.Value; context.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() { NoCache = true, }; await JsonSerializer.SerializeAsync(context.Response.Body, problemDetails); }); }); app.UseHttpsRedirection(); app.UseRouting(); app.UseEndpoints(endpoints => { endpoints.MapControllers(); }); }Copy the code
With an exception handler, the following response is returned when an invalid command is detected. For example, when the AddProductToCartCommand command (see MediatR command) is sent as a negative number.
{
"type": "https://example.com/problem-types/InputValidationException",
"title": "One or more validation errors occurred",
"status": 403,
"detail": "The request contains invalid parameters. More information can be found in the errors.",
"instance": "/customercarts",
"trace": "00-22fde64da9b70a4691e8c536aafb2c49-f90b88a19f1dca47-00",
"errors": {
"Amount": ["'Amount' must be greater than '0'."]
}
}
Copy the code
In addition to creating custom exception handler and abnormal will map to the problem details, you can also use Hellang. Middleware. ProblemDetails package. Hellang. Middleware. ProblemDetails package can easily be anomaly map to the problem details, almost do not need any code.
Consistent problem details
One last question. The code snippet above expects the application to create a MediatR request in the controller. The API endpoint that contains the command in the body is automatically validated by the.NET model validator. When an endpoint receives an invalid command, our pipe and exception handling does not process the request. This means that the default.NET response will be returned instead of our problem details.
For example, AddProductToCart receives the AddProductToCartCommand command directly and sends the command to the MediatR pipe.
[ApiController] [Route("[controller]")] public class CustomerCartsController : ControllerBase { private readonly IMediator _mediator; public CustomerCartsController(IMediator mediator) => _mediator = mediator; [HttpPost] public async Task<IActionResult> AddProductToCart(AddProductToCartCommand command) { await _mediator.Send(command); return Ok(); }}Copy the code
I wasn’t expecting this at first, and it took a while to figure out why this was happening and how to make sure the response objects were consistent. As a possible fix, we can suppress this default behavior so that invalid requests are handled by our pipe.
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers();
// Register all Mediatr Handlers
services.AddMediatR(typeof(Startup));
// Register custom pipeline behaviors
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidatorPipelineBehavior<,>));
// Register all Fluent Validators
services
.AddMvc()
.AddFluentValidation(s => s.RegisterValidatorsFromAssemblyContaining<Startup>());
services.Configure<ApiBehaviorOptions>(options => {
options.SuppressModelStateInvalidFilter = true;
});
}
Copy the code
But there is a downside. Invalid data types cannot be caught. Therefore, turning off invalid model filters can lead to unexpected errors. Previously, this operation resulted in a bad Request (400). This is why I prefer to throw an InputValidationException when I receive incorrect input.
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers();
// Register all Mediatr Handlers
services.AddMediatR(typeof(Startup));
// Register custom pipeline behaviors
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidatorPipelineBehavior<,>));
// Register all Fluent Validators
services
.AddMvc()
.AddFluentValidation(s => s.RegisterValidatorsFromAssemblyContaining<Startup>());
services.Configure<ApiBehaviorOptions>(options => {
options.InvalidModelStateResponseFactory = context => {
var problemDetails = new ValidationProblemDetails(context.ModelState);
throw new InputValidationException(problemDetails.Errors);
};
});
}
Copy the code
conclusion
In this article, we have seen how the MediatR pipeline behavior can centrally validate logic before the command reaches the domain layer. The advantage of this is that all commands are valid, and when a command reaches its handler, it will be valid. In other words, the domain will be kept clean and simple.
Because there is a clear separation, developers only need to focus on the obvious tasks. During development, it also ensures that unit tests are more targeted and easier to write.
In the future, it will be easier to replace the validation layer if necessary.
Welcome to pay attention to my public number – code non translation station, if you have a favorite foreign language technical articles, you can recommend to me through the public number message.