Error Handling on a Spring Boot Native microservice using WebFlux, Bean Validations, and i18n.

Andres Solorzano
16 min readFeb 15, 2023

--

In my previous tutorial, I created a Native Spring Boot microservice using TDD with Tescontainers that deploy Postgres and DynamoDB containers to execute the integration tests. The business logic was developed using Spring WebFlux for reactive programming and Quartz to create timed tasks. Now, it’s time to add validations to the source code, centralize the control for the exceptions produced, and return the correct HTTP error in a standardized way. Finally, we use internationalization to yield an adequate error message using the user locale received in the HTTP request.

To complete this guide, you’ll need the following tools:

This guide is divided into 4 significant sections. The first is for all changes needed to make Exception Handling. The other is for implementing Bean Validation. The third is for internationalization or i18n over the Bean Validations and the custom exceptions. And the final is to deploy all these changes in a native executable using Spring Native. So let’s get started.

NOTE: You can download the project’s source code from my GitHub repository to review the latest changes made in this tutorial.

Dockerfile for Native Executable.

In my previous tutorial, I created a <Dockerfile> for the native executable. But generating the docker container using that image takes some time. So I renamed this file to <Dockerfile-native>, and I added the old <Dockerfile> file with the standard Jar executable as I did in previous tutorials:

This is to make tests quickly. When we stay satisfied with the results of our error-handling business logic, we can use the <Dockerfile-native> in our <docker-compose.yml> file to generate our native executable docker image.

1. Error Handling.

Let’s start with the error-handling section. Following are the steps we must implement in our code base to complete this objective.

Exception classes.

We’ve already created 2 runtime exception classes in our code base: <ResourceNotFoundException> and <TaskException>.

These classes have a Spring <@ResponseStatus> annotation with a specific HTTP error code. So now, we need to delete those annotations and let the classes as simple POJO classes:

That’s because we need to centralize the exception handler for all these errors in our application. So let’s continue with the improvements.

Error Codes and Messages Enum.

Let’s start creating an enumeration with some initial error codes and their messages:

As you can see in this example, these are the initial error codes we can use in our business logic. For this type of error, there are 2 places in our code where we can call the <ResourceNotFoundException> class. One of these is the following:

We sent the enum type and the ID object of the task that doesn’t exist. The exception class builds the corresponding message and sets the error code based on the enumeration parameter.

The enum plays an essential role in constructing the exception object. So it’s time to create a class that manages our exceptions in a standardized way.

Global Exception Handler class.

It’s an ideal practice to handle all the exceptions produced by the microservice in one place. So first, create the <ErrorDetailsVO> class where we can set the exception details for the final user:

Then, create the class <GlobalExceptionHandler>, an essential component that handles all defined exceptions in our application:

Here we can notice 3 essential things.

  • The first one uses the annotation <@ControlAdvice> provided by Spring to manage all the exceptions defined in this class. Furthermore, we must annotate each method with <@ExceptionHandler> to specify the type of exception we need to handle in that method.
  • The second one is the extension of the <ResponseEntityExceptionHandler> class which contains a variety of standard handler methods to be managed by Spring. It’s essential to note this class belongs to the WebFlux library. That’s because our exception handler returns a Mono<ResponseEntity> object.
  • The third is the use of our <ErrorDetailsVO> class. This object is constructed inside each exception handler method and returned in the response as an HTTP body with its corresponding error code. We are building this object with the required values we need to send to our users.

Also, remember that our Spring Boot microservice has the property <city.tasks.zone.id> defined in the <application.properties> file. We can use this property value to inject the field <errorDate> in the <ErrorDetailsVO> class for the user’s appropriate time zone.

Integration tests.

Remember that we used TDD from the beginning of our project, and we used Testcontainers to run integration tests for each application layer. So I can be 99% sure that if any of the integration tests fail, the microservice fails at runtime too. So let’s run all tests using our IDE to see the results:

All clear!! So always recall the importance of integration tests. Remember that our apps must try to cover at least the principal use cases. This will be the topic of another tutorial when we review the code coverage of our app using a CI/CD pipeline in AWS.

So as our integration tests are working as expected, let’s continue.

Functional Tests (Executable JAR).

It’s time to make some tests of our defined exception. So let’s deploy our docker cluster:

$ docker compose up --build

Then, open the Postman tool and try to find a task that doesn’t exist:

The HTTP status code is 404, and the response body has the values we determined in the <ErrorDetailsVO> class. Also, notice that the <errorDate> key has a date value with the time zone ID specified in the <application.properties> file. So our exception handler class is working.

As this is an iterative process, repeat the steps shown so far for the rest of the exception classes or places where you consider throwing an exception. To recap, these would be the suggested steps:

  1. Create an enum with the required error codes and messages.
  2. Modify the exception constructor to generate the required exception message and set the error code.
  3. Add a new handler method in the <GlobalExceptionHandler> class for this new exception.
  4. Run the integration tests to validate that all are operating successfully.
  5. Execute the functional test to validate that the exception is produced and handled adequately.

I did the previous 5 steps and found an easy-to-use scenario to reproduce an error. We need an identifier to update or delete a Quartz Job from the database. That identifier is stored in the <Task> table for reference. So when a <Task> registry must be deleted or updated, we need to find the Quartz Job associated with that task to be deleted or updated respectively.

So let’s create a task in the first place:

Notice that every created task has an associated <jobId> field. So try manually updating the <jobId> field from the database.

Open the pgAdmin tool. I added a docker image in the <docker-compose.yml> if you want to employ it. Use the credentials provided in the <docker-compose.yml> file to access:

Once inside the application, connect to the Postgres database using <tasks-postgres> value in the <Host name/address> field:

Then, find and query our task table (<hip_cty_tasks>):

Update the <jobId> value with another one. Then re-run the query again:

Also, use the Postman tool to execute a GET operation by task ID to see the results:

The service also returns the task with the updated <jobId> field. So finally, try to delete this task using the Postman tool:

And that’s it!! Our microservice handles the exceptions we throw in the course code using a central component to construct a generic response object for the final users.

But what happens with the errors produced by the users when sending incorrect information to our API?? And that’s the answer we must respond to in the next section.

2. Bean Validation.

To accomplish this objective, we need to add the following dependency in our POM file:

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

Then we can use the Jakarta validation annotations in our Task class. The following is the improved <Task> class with some validation annotations. I removed the JPA ones only for space reasons:

Then we are ready to test our service but wait a moment. We need to do something meaningful before because only with these changes we’re not getting the results we want.

TDD and more Integration Tests.

Maybe you’re thinking that we’re not doing TDD so far, and in some manner, it’s true, but for the previous section. In this section, instead, we need to perform TDD because the changes will be meaningful.

In the previous section, we centralized the exception handling in a new component. Still, the exceptions were in place before I started this tutorial. We only executed the integration tests to validate that the new feature was coupled successfully with our existing code and that the integration tests were not broken. Now, we added new functionality that we need to test professionally ;).

As the bean validations entry point starts in the controller layer (commonly), begin writing an integration test in the <TaskControllerTest> class, for example:

If you run this test, these are the results:

We are getting an HTTP 400 error, which we expected, but the error code doesn’t. In general, we expect our <ErrorDetailsDTO> object, so let’s will add it to the <GlobalExceptionHandler> class:

Notice that this method was overridden. That’s because we are using an exception controlled by Spring validation, and we’re personalizing using our <ErrorDetailsDTO> class.

So let’s execute our integration test one more time:

We’re receiving our <ErrorDetailsDTO> in the HTTP response. The error code is the expected one, but the message error is generic. We’re not getting the default message we set in the <@NotEmpty> annotation. So we modify the <handleWebExchangeBindException> method to obtain the required message:

So let’s execute our integration test one more time:

Now our test passed. So let’s create the remainder integration tests to cover the necessary use cases for the rest of the validations. Make the required code optimizations and run all integrations tests to validate that everything is working successfully:

So as our tests are working with our changes, let’s add another exciting feature, internationalization.

3. Internationalization (i18n).

Well, the idea is to respond to the exception errors using the natural language provided by the client, in this case, the Postman tool. So we need to obtain the locale code from the HTTP request header to use the adequate error message to the user. If the request does not provide the required HTTP header, then we must use the default locale, English.

TDD one more time.

Let’s start creating the <TaskControllerValidationTest> class. Then, move the earlier integration tests we made in the previous sections to this new test class. That’s because our test scenarios for validations and i18n will increase.

After that, create an integration test that tests a custom validation message, but this time, let’s assume that the error message must be in Spanish:

And let’s run this integration test:

As you can see, the error message differs from what we expected. So let’s update the method that generates the generic request for the integration tests and add the required HTTP header parameter:

Notice that we added a header parameter called <Accept-Language> with the value of an enumeration that contains the language code we want to support in our application:

Then, let’s create the required property files containing the i18n messages for our validation messages. These files must be placed in the <resources> directory:

  1. messages.properties.
  2. messages_es.properties.

So, let’s add the message key and value to the <messages_es.properties>:

validation.task.name.NotEmpty.message=El nombre de la tarea no puede estar vacío.

Then, inject the Spring <MessageSource> class in the <GlobalExceptionHandler> class:

In the <Task> class, replace the default message we put in the <@NotNull> annotation, with the <key> value of our validation message in the <messages_es.properties> file:

@NotEmpty(message = "validation.task.name.NotEmpty.message")
private String name;

Add the same property key to the <messages.properties> file but with the message in English. Remember that this is our default language:

validation.task.name.NotEmpty.message=Task name cannot be empty.

Run the integration test for the English version to verify that our changes are working as expected:

Cool, it’s working. Our goal is to provide our custom error messages for all the controlled exceptions (including the validations) using a standard object, in our case, the <ErrorDeatilsDTO>. So, the idea is not to use the provided Jakarta message properties included in each validation annotation because this could be 50% of our objective.

So now let’s run the Spanish version of our test:

Notice that our API is returning special characters in the error message. So we need to add a configuration for this in our application.

Charset encoding: ISO_8859_1.

With this charset, we can encode some special characters in the Latin dialect and contain accentuations, the <ñ> letter, and more. So for Spring, we need to create our <MessageSource> bean but setting the required properties:

Notice that we are setting the <ISO_8859_1> in the <setDefaultEncoding> method. So now, try to execute the integration tests one more time:

Continuing with our TDD methodology, repeat this activity for the rest of the validation messages, including an integration test for the English and Spanish languages in each iteration ;).

After that, execute all the integration tests that we finally create in the <TaskControllerValidationTest> class:

These are all the integration tests I created. You can add more test scenarios to improve the code coverage ;).

But history not ends here. The last thing we must do is add i18n to the custom exception we created earlier. We only tested the i18n over the Bean Validation properties. So let’s continue.

Custom Exceptions with i18n.

We started this tutorial by handling our custom exceptions in one single component. We tested them without using internationalization. So our objective is to update this exception-handling component to use i18n in our custom exceptions.

Let’s start with the <ResourceNotFoundException> class. Remember that we were using an enumeration for the error codes and their messages:

The first step is to do the same thing as we did with the validation messages. Instead of using a default error message, we put a property key to load the message in the user’s default locale. Let’s do that:

This message key properties must be added in the i18n files, as we did previously with the validation messages:

task.not.found.message=Task not found with ID: {0}.
device.not.found.message=Device not found with ID: {0}.
trigger.not.found.message=Job Trigger not found for Task ID: {0}.

Finally, we need to update the <GlobalExceptionHandler> component to use the <MessageSource> bean to get the correct error message based on the user’s locale:

Move the exceptions tested in the <TaskControllerTest> to a new <TaskControllerExceptionsTest> class. Then, add a response body validation because now we’re expecting an <ErrorDetailsDTO> object:

Notice that I also added the <Accept-Language> header to the request, expecting the error message in English. So, let’s run this test:

Excellent. Now let’s add a new test method but for the Spanish version:

And run this new test method:

Now, repeat the same thing for the rest of the methods and run the test class:

Finally, run all test classes in our project to validate that all changes made so far are working as expected:

That’s it. Everything is working as expected, but as the title of this tutorial statement, we need to execute all these changes using Spring Native. So let’s do that.

Functional Testing (JAR Executable).

Execute the following command to deploy the entire docker cluster locally:

$ docker compose up --build

The whole cluster must be started without problems:

Now, open the Postman tool and make some tests to see if all is working in our Spring Boot microservice.

If we don’t provide the “Accept-Language” HTTP header, our app uses the default locale, English. If we need the error message in Spanish, then we need to provide the “Accept-Language” HTTP header in the request:

Well, it’s working as in our integration tests. But so far, we have been using the JAR version of our microservice. So it’s time to be native.

4. Spring Native.

Let’s update the <docker-compose.yml> file to use the docker file for the native executable:

tasks-api:
image: aosolorzano/city-tasks-spring-boot-error-handling:1.3.0
container_name: city-tasks-spring-boot-error-handling
build:
context: .
dockerfile: Dockerfile-native

Then, deploy the docker cluster to see if any error appears in the console:

$ docker compose up --build

So now let’s make some tests using the Postman tool:

Notice that the response is different from what we expected. It’s an HTTP 500 error, and the message is not what we defined. So let’s go to the console to see what happens:

The error mentioned: “No message found under ‘task.not.found.message’ for locale ‘en_US.’” So Spring Native does not recognize the properties files for i18n. So we need to add another hint for our message resources:

And add it to the <TaskApplication> class:

NOTE: If you need more information about the hints topic, please review my previous tutorial where we worked on this.

Let’s compile and deploy our application again:

$ docker compose up --build

Now if we execute the GET method in the Postman tool, we have a new error in the console:

The Spring Native cannot serialize the <ErrorDetailsDTO> class. So we need to modify our <TaskApplication> class again and add this class for reflection:

And let’s deploy our Task Service again (finger-cross):

$ docker compose up --build

After a successful deployment, go to the Postman and execute the GET operation one more time:

It works!! Let’s repeat this, but for the error message in Spanish :

Excellent, our Spring Native microservice is now working as expected, and you must see that the console is not printing any Spring Framework error, only the logs produced by the application:

Finally, let’s execute a couple of validations sending POST operations to our microservice:

Perfect, it’s still working. And for the Spanish error message:

And that’s it!! I enjoyed these last tutorials using Spring Boot, though the part of Spring Native is very tedious, as you can see, and I’m still thinking that Quarkus is better in this area. So we have alternatives. From my side, I will continue using Spring for the following tutorials, and in the next one, we will be publishing this project on AWS using the Copilot CLI.

So I hope this tutorial was helpful, and I will see you in the next one.

Thanks for your reading.

--

--