javaspringspring-boottomcataxios

Spring MVC List<Enum> binding: Why does arr[]=HELLO&arr[]=WORLD require @Setter, but arr=HELLO&arr=WORLD doesn’t?


Spring MVC List binding: Why does arr[]=HELLO&arr[]=WORLD require @Setter, but arr=HELLO&arr=WORLD doesn’t?

In our frontend, we use Axios, which by default serializes array parameters like this:

arr[]=HELLO&arr[]=WORLD

However, this caused 400 Bad Request errors on our Spring Boot (Tomcat) backend. To fix that, we added the following configuration:

server:
  tomcat:
    relaxed-query-chars: ['[', ']', '{', '}']

After applying this setting, everything appeared to work fine — until we encountered a strange case.


⚠️ The problem

We have the following request DTO:

@Getter
@AllArgsConstructor
@ToString
@ParameterObject
public class ExampleRequestDto {

    @Parameter(description = "example")
    private List<SomeEnum> arr;

}

Our frontend sends a request like:

GET /api/example?arr[]=HELLO&arr[]=WORLD

✅ Expected log:

ExampleRequestDto(arr=[HELLO, WORLD])

❌ Actual log:

ExampleRequestDto(arr=[HELLO])

Only the first value is present; the rest are silently ignored. No exceptions or warnings were thrown.


Further investigation

We changed the frontend to send:

GET /api/example?arr=HELLO&arr=WORLD

This works perfectly even without @Setter on the DTO.

Then, we added @Setter to the DTO, and the original arr[]=... format also worked correctly — all values were bound.


My questions

  1. Why does arr[]=HELLO&arr[]=WORLD require @Setter to bind correctly, but arr=HELLO&arr=WORLD does not?
  2. Is there a difference in how Spring interprets these two formats during @ModelAttribute binding?
  3. Is this behavior caused by Spring MVC’s internal binder or by Tomcat’s parameter parser?
  4. Why is the issue silent (i.e., no exception or binding error), even when values are missing?

Context


I suspect this is due to Spring’s reflection mechanism, but I’m not sure.

Any official documentation, related GitHub issues, or explanations would be highly appreciated.


Solution

  • The difference comes from internal implementation of Spring's ServletRequestDataBinder

    There are two steps relevant for creating the object:

    1. ServletRequestDataBinder.construct() - This method scans the constructors of the DTO and creates the object. If the available constructor has arguments, it uses request.getParameterValues(...) to find appropriate values. Note that request.getParameterValues("arr") returns null if request parameter is arr[]

    2. ServletRequestDataBinder.bind() - This method parses request parameters, normalizes them ("arr[]" becomes "arr"), then scans for setters and applies the values using setters. In this phase it doesn't matter if request param is called "arr[]" or "arr". But it is only applicable if setters exist

    Note: if no-args constructor exists, the behavior will be the same because 1st phase will construct object with null values and only setters become relevant.

    To answer your questions:

    1.Why does arr[]=HELLO&arr[]=WORLD require @Setter to bind correctly, but arr=HELLO&arr=WORLD does not?

    arr=HELLO&arr=WORLD can be handled by constructor

    arr[]=HELLO&arr[]=WORLD can't be handled by constructor, and is handled by attribute processing. During attribute processing the request parameters are normalized so it doesn't matter if they are called "arr[]" or "arr"

    2.Is there a difference in how Spring interprets these two formats during @ModelAttribute binding?

    No. This is handled by bind attribute which normalizes array parameters so it doesn't matter if they are called "arr[]" or "arr"

    3.Is this behavior caused by Spring MVC’s internal binder or by Tomcat’s parameter parser?

    Spring internal binder

    4.Why is the issue silent (i.e., no exception or binding error), even when values are missing?

    By default, all DTO attributes are considered optional. Values are only populated for accessible fields using values received in request. If no appropriate value is found, value will be null. To change this behavior, you can use Bean Validations to specify which attributes are required to have a value when reaching controller.