Keeping @JsonProperty and Bean Validation field error messages in sync in a Spring Boot project

Damn, this one bit me very recently. So imagine you have a Java API and because of whatever reasons you agreed that the JSON properties for the requests should be in snake_case_form. Cool, cool, that just wants a simple Jackson @JsonProperty annotation on a POJO (Plain Old Java Object) field. Now imagine you are sending validation error messages to the client, and they discover that the validation messages refer to the fields using camelCaseForm - now you have to reassure them that their request with snake_case_form fields is supposed to be that way, except for there being validation errors, of course.

This very thing happened to me recently and I wanted to document solutions to the problem, I will tell you which I went with at the end.

Let's create a new Spring Boot project to get a better picture of the issue at hand.

Let's add a Person class that looks like this. Notice that we have thrown in Hibernate Validator annotations to validate the data, like the good backend developers we are.

import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotEmpty;
import org.hibernate.validator.constraints.Length;

public class Person {

    @NotEmpty
    @Length(min = 2, max = 120)
    @JsonProperty("full_name")
    private String fullName;

    @Email
    @JsonProperty("email")
    private String email;

    @NotEmpty
    @Length(min = 8, max = 50)
    @JsonProperty("phone_number")
    private String phoneNumber;


    @JsonProperty("country_iso")
    private String countryISO;

    // getters and setters omitted ...
}

And a Spring Boot controller that just validates the data and returns it

// imports omitted for brevity

@RestController
@RequestMapping("/people")
public class PeopleController {

    @PostMapping
    public ResponseEntity<Person> handlePost(@Validated @RequestBody Person request) {
        return ResponseEntity.ok(request);
    }
}

We want our clients to send us data that looks like this

{
  "full_name": "John Banda",
  "phone_number": "",
  "email": "john.banda@example.com",
  "country_iso": "MWI"
}

Now the client sends a request with invalid data (let's assume it's missing the full_name and phone_number fields) and the API, like any good API, returns a good ol' 400 Bad Request to let them know the error of their ways. To figure out how they may have screwed that request up, the client checks the response body and discovers the following:

{
    "timestamp": "2023-09-27T20:22:29.627+00:00",
    "status": 400,
    "error": "Bad Request",
    "path": "/people"
}

From the status code, they can guess it's an error on their end, but the response doesn't show if it's a validation error or something else wrong with the request data. Now the client isn't sure if they sent the request with the rightly named fields in the first place or if it's our API that's gone off the Rails (if there is a pun here, it is intended ;).

Sending validation error response

We would rather give the client a bit more information about why their request is bad. A good way to do that is to catch the validation errors and return a 400 BadRequest with actual details about the validation errors. Let's create a ValidationErrorsResponse class with the following code:

public class ValidationErrorsResponse {
    private int code;
    private String message;
    private Map<String, List<String>> validationErrors;

    public ValidationErrorsResponse(int code, String message) {
        this.code = code;
        this.message = message;
        this.validationErrors = new HashMap<>();
    }

    public void put(String field, String error) {
        validationErrors.computeIfAbsent(field, s -> new ArrayList<>()).add(error);
    }

    // getters and setters omitted ...   
}

Now we will need to register an Exception handler for the validation error which we can register against a MethodArgumentNotValidException

public class ExceptionHandlers {

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public final ResponseEntity<ValidationErrorsResponse> handleExceptions(MethodArgumentNotValidException ex) {
        final List<FieldError> fieldErrors = ex.getBindingResult().getFieldErrors();

        ValidationErrorsResponse errorResponse = new ValidationErrorsResponse(
                HttpStatus.BAD_REQUEST.value(),
                HttpStatus.BAD_REQUEST.getReasonPhrase()
        );

        for (FieldError fieldError : fieldErrors) {
            errorResponse.put(fieldError.getField(), fieldError.getDefaultMessage());
        }

        return ResponseEntity.badRequest().body(errorResponse);
    }
}

It's at this point that if we try to send a POST with an empty or invalid object {} to the /people endpoint, we will get a response similar to the one below, which as you can see shows the fields in camelCase form.

{
    "code": 400,
    "message": "Bad Request",
    "validationErrors": {
        "fullName": [
            "must not be empty"
        ],
        "phoneNumber": [
            "must not be empty"
        ]
    }
}

While this works and in other situations would be sufficient, it is not great for us because the client will now get confused about the naming of the fields; the validation error response shows the fields named in camelCaseForm whereas the request data is expected to be sent in snake_case_form. The very problem we want to avoid!

We have some possible solutions to fix this issue, explained below.

Solution 1: Rename the class fields to the snake case form

So one quick solution to this problem is the naming of the fields, you can just change it to match the @JsonProperty value. This has the downside that your linter or co-worker may shout at you for naming fields "out-of-standard" but fixes the issue without any over-engineering required. The con is that you have to name all the fields in all your other API objects in this way, so don't forget!

public class Person {

    @NotEmpty
    @Length(min = 2, max = 120)
    @JsonProperty("full_name")
    private String full_name;

    @Email
    @JsonProperty("email")
    private String email;

    @NotEmpty
    @Length(min = 8, max = 50)
    @JsonProperty("phone_number")
    private String phone_number;


    @JsonProperty("country_iso")
    private String country_iso;

    // getters and setters omitted ...
}

Solution 2: Improve the exception handler to maintain standards

Another approach to this is to do a little "over-engineering" to solve the problem in a way that maintains your strongly held opinions and standards while ensuring that the client will never be confused about what's what. This approach involves putting a bit more code in the exception handler to check if the field we are dealing with has a @JsonProperty annotation and then uses the value() from that annotation as the field name when building up validation errors, if we can't find the field, we fall back to sending the regular Bean name of the field/property.

We don't have to make any changes to the names of the fields on the Person or any other class. Here is how we could solve the problem with this approach:-

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public final ResponseEntity<ValidationErrorsResponse> handleExceptions(MethodArgumentNotValidException ex) {
        final List<FieldError> fieldErrors = ex.getBindingResult().getFieldErrors();

        ValidationErrorsResponse errorResponse = new ValidationErrorsResponse(
                HttpStatus.BAD_REQUEST.value(),
                HttpStatus.BAD_REQUEST.getReasonPhrase()
        );

        for (FieldError fieldError : fieldErrors) {
            try {
                var beanClazz = ex.getTarget().getClass();
                var field = beanClazz.getDeclaredField(fieldError.getField());
                var jsonPropertyAnnotation = field.getAnnotation(JsonProperty.class);
                if (jsonPropertyAnnotation != null) {
                    errorResponse.put(jsonPropertyAnnotation.value(), fieldError.getDefaultMessage());
                } else {
                    LoggerFactory.getLogger(getClass()).debug("Failed to get annotated field at {}", fieldError.getField());
                    errorResponse.put(fieldError.getField(), fieldError.getDefaultMessage());
                }
            } catch (NoSuchFieldException e) {
                LoggerFactory.getLogger(getClass()).error("Failed to get annotated field", e);
                errorResponse.put(fieldError.getField(), fieldError.getDefaultMessage());
            }
        }

        return ResponseEntity.badRequest().body(errorResponse);
    }

Solution 3: Use a consistent naming convention (i.e. camelCase)

Another option is to update your API data fields + docs to use camelCasedFields as a standard and avoid compromising on Java bean standards and introducing inconsistencies. This is probably not the most practical solution if your API is already used by clients in production unless you manage the change/upgrade to a new version very well. You may also face resistance from API clients who insist on dealing with fields named in snake_case_form.

There is no code for this section because the solution requires communication and coordination with your teams and clients, which you cannot code around. YMMV.


Thanks for reading.

Ohh, I promised to tell you which solution I went with. I went for Solution 1 as a quick fix but after reviewing the other API objects + endpoints in our code, I applied Solution 2 which helped avoid renaming a whole bunch of API object classes. Solution 2 works well with simple flat objects and isn't robust for nested structures. Will write a follow-up soon.

0
Subscribe to my newsletter

Read articles from Zikani Nyirenda Mwase directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Zikani Nyirenda Mwase
Zikani Nyirenda Mwase