Custom XXXModelAttributeResolver to Handle Snake_case Request Parameters with CamelCase Object Properties
1. Introduction
In SpringBoot, @ModelAttribute can bind request parameters to Java objects, for example:
@PostMapping("add")
public ResponseMessage add(@ModelAttribute UserInfo userInfo){
However, if the request header is Content-Type:application/x-www-form-urlencoded and the request parameters are in snake_case format, such as: user_id:1
In this case, @ModelAttribute cannot bind the user_id:1 value to your object's Integer userId; property.
To solve this problem, we can create a custom XXXModelAttributeResolver, then check for custom annotations and bind the snake_case parameters mapped by the annotation to the object properties.
Below are the specific steps.
2. Custom Annotations @SnakeModelAttribute and @SnakeParam
@SnakeModelAttribute
@SnakeModelAttribute replaces @ModelAttribute. When we get values and bind them to objects, we use this annotation for determination.
import java.lang.annotation.*;
@Target({ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SnakeModelAttribute {
}
@SnakeParam
@SnakeParam is used during binding to get the snake_case request parameter corresponding to the camelCase property.
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface SnakeParam {
String name() default "";
}
Usage looks like this:
@SnakeParam(name = "name")
private String topicId;
3. Custom SnakeModelAttributeResolver
SnakeModelAttributeResolver extends ModelAttributeMethodProcessor to implement custom property binding logic.
The general code execution flow looks like this:

The code is as follows. I'll explain the meaning of the highlighted key parts shortly:
import org.springframework.beans.MutablePropertyValues;
import org.springframework.beans.PropertyValue;
import org.springframework.core.MethodParameter;
import org.springframework.web.bind.ServletRequestParameterPropertyValues;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.web.method.annotation.ModelAttributeMethodProcessor;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* SnakeModelAttributeResolver
* For application/x-www-form-urlencoded requests, convert snake_case parameters to camelCase
*/
public class SnakeModelAttributeResolver extends ModelAttributeMethodProcessor {
public SnakeModelAttributeResolver() {
super(false);
}
@Override
public boolean supportsParameter(MethodParameter parameter) {
// When ModelAttribute annotation is present, it will enter resolveArgument for conversion
return parameter.hasParameterAnnotation(SnakeModelAttribute.class);
}
@Override
protected void bindRequestParameters(WebDataBinder binder, NativeWebRequest request) {
HttpServletRequest servletRequest = request.getNativeRequest(HttpServletRequest.class);
MutablePropertyValues mpvs = new ServletRequestParameterPropertyValues(servletRequest);
Object target = binder.getTarget();
if (target != null) {
Class<?> targetClass = target.getClass();
Map<String, String> snakeToCamel = new HashMap<>();
for (Field field : targetClass.getDeclaredFields()) {
SnakeParam annotation = field.getAnnotation(SnakeParam.class);
if (annotation != null) {
String snakeName = annotation.name();
if (!snakeName.isEmpty()) {
snakeToCamel.put(snakeName, field.getName());
}
}
}
List<PropertyValue> updated = new ArrayList<>();
for (PropertyValue pv : mpvs.getPropertyValueList()) {
String originalName = pv.getName();
if (snakeToCamel.containsKey(originalName)) {
updated.add(new PropertyValue(snakeToCamel.get(originalName), pv.getValue()));
} else {
updated.add(pv);
}
}
mpvs = new MutablePropertyValues(updated);
}
binder.bind(mpvs);
}
}
3.1 SnakeModelAttributeResolver
super(false); means when initializing SnakeModelAttributeResolver, the internal property annotationNotRequired is set to false.
private final boolean annotationNotRequired;
This property is used in the parent class to determine whether to use this class for processing:
@Override
public boolean supportsParameter(MethodParameter parameter) {
return (parameter.hasParameterAnnotation(ModelAttribute.class) ||
(this.annotationNotRequired && !BeanUtils.isSimpleProperty(parameter.getParameterType())));
}
3.2 supportsParameter
This is straightforward - it checks whether the bound object uses the custom annotation @SnakeModelAttribute.
3.3 bindRequestParameters
The processing here checks whether the object's properties use the @SnakeParam annotation. If present and not empty, it adds the corresponding binding relationship to snakeToCamel.
4. Add Custom Parameter Resolver to WebMvcConfigurer
Implement the WebMvcConfigurer interface, then override addArgumentResolvers to add SnakeModelAttributeResolver:
@Configuration
public class ApplicationResolverConfig implements WebMvcConfigurer {
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new SnakeModelAttributeResolver());
}
}
5. Q&A
5.1 Why create a custom @SnakeModelAttribute annotation? Can't we use the original @ModelAttribute for checking?
No, because after the project starts, the default ServletModelAttributeMethodProcessor has higher priority and will be used for parameter binding, so the code won't execute your custom SnakeModelAttributeResolver.
5.2 When does Spring determine which xxxModelAttributeMethodProcessor to use?
The code location is as follows:
@Nullable
public HandlerMethodArgumentResolver getArgumentResolver(MethodParameter parameter) {
HandlerMethodArgumentResolver result = this.argumentResolverCache.get(parameter);
if (result == null) {
for (HandlerMethodArgumentResolver resolver : this.argumentResolvers) {
if (resolver.supportsParameter(parameter)) {
result = resolver;
this.argumentResolverCache.put(parameter, result);
break;
}
}
}
return result;
}
As you can see, once a resolver is found, it breaks immediately after execution, not giving subsequent resolvers a chance.
5.3 Would resolvers.add(0, new SnakeModelAttributeResolver()) work then?
Like this:
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(0, new SnakeModelAttributeResolver());
}
No, it still won't work. The framework-initialized ServletModelAttributeMethodProcessor has higher priority.