The key to successful long-term software is a good architectural design. A good design doesn't only allow developers to come in and write new features with ease, but it's also is adaptive to changes without resistance.
To have a project accelerate as development proceeds—rather than get weighed down by its own legacy—demands a design that is a pleasure to work with, inviting to change. A supple design.
A good design focuses on the core of the application, the domain.
Sadly, it's easy to clutter the domain with responsibilities (meaning more code) that don't belong in this layer. With each addition, it becomes harder to read and understand the core domain. Equally bad is that each addition makes it harder to move things around in the future.
Therefore, it's important to guard the domain layer against application logic. One of the culprits is the validation of incoming requests. To prevent that this logic percolates into the domain level, we want to validate the request before it reaches the domain level.
In this blog post, we'll learn how to extract the validation out of the domain layer. Before we start, this blog post assumes that the API uses the command pattern to translate incoming requests to commands or queries. All the snippets throughout this blog post are all using the MediatR package, another popular alternative is Brighter.
The command pattern has the benefit to decouple the core domain logic from the API layer (we don't want thick controllers). Most of the libraries that implement the command pattern, also expose a middleware pipeline that can be hooked into. This is useful because it offers a solution and a centralized location to add application logic that needs to be executed with each dispatched command.
With the new
record type, introduced in C# 9, defining a request becomes a one-liner.
The additional benefit is that an instance is immutable and side-effect free, this makes the command predictable and reliable.
To dispatch the above command, an incoming request is mapped to a command in the controller.
Instead of validating the
AddProductToCartCommand command in the controller or worse in the domain that processes this command, we'll make use of the MediatR pipeline.
By using a pipeline behavior, it's possible to execute some logic before or after the command is handled by its handler. In this case, provides a centralized location where the command is validated before it reaches the handler (the domain). When the command reaches its handler, we don't have to worry anymore if the command is valid. It also enforces a standardized resolution on how invalid commands are dealt with.
While this seems like a trivial change, it declutters every handler in the domain layer.
Ideally, we only want to deal with business logic when we're working on the domain. Removing the validation logic frees up the mind so we can only focus on the business logic without having to read and write the orchestration code. Because the validation logic is centralized it makes sure that all commands are validated and not a single command slips through the cracks.
In the snippet below, we create a new pipeline behavior
ValidatorPipelineBehavior to validate the commands.
When a command is sent, the
ValidatorPipelineBehavior handler receives the command before it reaches the command handler. The
ValidatorPipelineBehavior validates if that command is valid by invoking the validators corresponding to that type.
Only if the request is valid, the request is allowed to pass to the next handler.
If not, an
InputValidationException exception is thrown.
We'll look at how we create our validators in Validation with FluentValidation. For now, it's important to know that when a request is invalid, the validation messages are returned (with a reference to the invalid property). The validation details are added to the exception and will be used later to create the response.
To validate the request, I like to use the FluentValidation library.
With FluentValidation, "rules" are defined per "
IRequest" record by implementing the
AbstractValidator abstract class.
The reasons why I like to use FluentValidation are:
- there's a separation between the validation rules from the models
- easy to write and read
- besides the many built-in validators, you can create your own (reusable) custom rules
- it's extensible
Now that we have our validation pipeline behavior and we've also created a validator, we can register them to the DI container.
Everything is now ready to make the first request. When we try it out and send an invalid request, we receive an Internal Server Error (500) response. This is good, but this doesn't reflect a good experience for the consumers. I'm sure we can do better.
To create a better experience for the consumers, either the user (interface), a colleague-developer (or yourself), or even a 3rd party, an enhanced result will make it clear why a request fails. This practice makes the integration with your API easier, better, and probably also faster.
I had to integrate with 3rd party services that didn't keep this in mind. This lead to many frustrations on my end and I was happy when the integration was finally over. I'm sure that the implementation would have been faster, and the end result could have been better when there was given more thought to the response of the failed request. Sadly, most of the integrations with services provide a subpar experience.
Because of this experience, I try my best to help my future-self and another developer by providing a better response. Even better, a standardized response, also known as Problem Details for HTTP APIs.
The .NET framework already provides a class that implements the specifications of a problem detail, ProblemDetails.
In fact, a .NET API returns a problem detail response for some invalid requests.
For example, when an invalid parameter is used in a route (the word
one instead of number 1), .NET returns the following response.
In the snippet below, we're using the middleware to retrieve the details of an exception when one is raised in the application. Based on these exception details, the problem detail object is build up.
All thrown exceptions are caught by the middleware, so you can create specific problem details for each exception.
In the following example, only the
InputValidationException exception is mapped, the rest of the exceptions are all treated the same.
With the exception handler in place, the following response is returned when the pipeline behavior detects an invalid command.
For example, when the
AddProductToCartCommand command (see the MediatR Command) is send with a negative amount.
Instead of creating a custom exception handler and mapping the exception to problem details, it's also possible to use the Hellang.Middleware.ProblemDetails package. The
Hellang.Middleware.ProblemDetails package makes it easy to map exceptions to problem details, with almost no code.
There's one last problem. The above snippets expect that a MediatR request is created by the application, in the controller's methods. An API endpoint that contains the command in the body will automatically be validated by the .NET Model Validator. When the endpoint receives an invalid command, the request isn't handled by our pipeline and exception handling. This means that the default .NET response will be returned, and not our problem details.
For example, the
AddProductToCart endpoint directly receives an
AddProductToCartCommand command and just sends that command to the MediatR pipeline.
I didn't expect this at first and it took me a while to figure out why this happens and how to make sure the response objects stay consistent. As a possible fix, we can suppress this default behavior so the invalid request will be handled by our pipeline.
But this has a drawback. By suppressing the invalid model filter, invalid primitive types aren't caught any more. For an endpoint that expects a number but receives a string, the expected primitive type (in this case the number) is assigned to the default value (0 in this case). Turning off the invalid model filter may lead to unexpected bugs due to this. Previously, this action would lead to a bad request (400).
That's why I prefer to throw the
InputValidationException exception when the endpoint receives a bad input.
In this post, we've seen how to centralize the validation logic before a command reaches the domain layer by using a MediatR pipeline behavior. This has the benefit that all of the commands are validated, and when a command reaches its handler (the domain) it will be valid. In other words, the domain will remain clean and simple.
Because there's a clear separation, the developer only has to focus on the task that's in plain sight. During the development, you'll also notice that unit tests are more focused and easier to write. In the future, it's also easier to replace the validation layer, if needed.
We've also learned that there's a standardized response to specify errors with Problem Details. By following the specification we don't have to reinvent the wheel and we create a better experience for the APIs consumer.
Please consider supporting me if have you enjoyed this post and found it useful: