Dynamic Field Validation in Spring Boot via @DynamicValid and Argument Resolver
This article demonstrates how to implement per-endpoint dynamic field validation in Spring Boot 3.5.0 using a custom @DynamicValid annotation and HandlerMethodArgumentResolver, allowing flexible control over required, optional, and unknown fields for Map, JsonNode, or POJO parameters, with support for custom validators and integration with @Validated.
Introduction
In backend development, clients may submit extra or illegal fields that can cause data pollution, security vulnerabilities, or business logic errors. Different endpoints or callers may require different field sets for the same parameter object (e.g., phone number required on user creation but optional on update). Traditional validation lacks dynamic control over field presence and source, making fine-grained validation difficult.
The article presents a solution using a custom parameter resolver that enables dynamic field validation per endpoint via the @DynamicValid annotation.
Custom Annotation: @DynamicValid
The annotation is applied to method parameters (JsonNode, Map, or POJO) and defines three attributes: required – array of mandatory field names optional – array of allowed optional field names check – boolean flag to reject unknown fields (fields not in required or optional) validator – custom Validator implementation class (default: Validator.class)
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface DynamicValid {
String[] required() default {};
String[] optional() default {};
boolean check() default false;
Class<? extends Validator> validator() default Validator.class;
}Helper Classes
ValidationException
public class ValidationException extends RuntimeException {
public ValidationException(String message) { super(message); }
public ValidationException(String message, Throwable cause) { super(message, cause); }
}Global Exception Handler
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
public ResponseEntity<?> error(Exception e) {
if (e instanceof MethodArgumentNotValidException ae) {
List<String> errMsg = ae.getFieldErrors().stream()
.map(err -> err.getField() + "," + err.getDefaultMessage())
.toList();
return ResponseEntity.ok(errMsg);
}
return ResponseEntity.ok(e.getMessage());
}
}Argument Resolver: DynamicValidArgumentResolver
Implements HandlerMethodArgumentResolver to intercept parameters annotated with @DynamicValid. Key steps in resolveArgument:
Read request input stream into a JsonNode using ObjectMapper.
Call validateFields to enforce required fields and optionally reject unknown fields.
Convert JsonNode to the target parameter type (JsonNode, Map, or POJO).
If the parameter also has @Validated or a custom validator is specified, perform additional validation via WebDataBinder.
Return the resolved object.
@Component
public class DynamicValidArgumentResolver implements HandlerMethodArgumentResolver {
private final ObjectMapper objectMapper;
public DynamicValidArgumentResolver(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(DynamicValid.class);
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
DynamicValid annotation = parameter.getParameterAnnotation(DynamicValid.class);
Class<?> targetType = parameter.getParameterType();
JsonNode jsonNode = objectMapper.readTree(request.getInputStream());
validateFields(jsonNode, annotation);
if (JsonNode.class.isAssignableFrom(targetType)) {
return jsonNode;
}
Object result = objectMapper.treeToValue(jsonNode, targetType);
String name = Conventions.getVariableNameForParameter(parameter);
ResolvableType type = ResolvableType.forMethodParameter(parameter);
WebDataBinder binder = binderFactory.createBinder(webRequest, result, name, type);
if (result != null) {
validateIfApplicable(binder, parameter);
if (binder.getBindingResult().hasErrors() && isBindExceptionRequired(binder, parameter)) {
throw new MethodArgumentNotValidException(parameter, binder.getBindingResult());
}
}
Class<? extends Validator> validator = annotation.validator();
if (validator != Validator.class) {
Validator validatorBean = this.context.getBean(validator);
if (validatorBean.supports(targetType)) {
BindingResult errors = null;
if (Map.class.isAssignableFrom(targetType)) {
errors = new MapBindingResult((Map) result, name);
} else {
errors = new DirectFieldBindingResult(result, name);
}
validatorBean.validate(result, errors);
if (errors.hasErrors()) {
throw new MethodArgumentNotValidException(parameter, errors);
}
}
}
if (mavContainer != null) {
mavContainer.addAttribute(BindingResult.MODEL_KEY_PREFIX + name, binder.getBindingResult());
}
return result;
}
private void validateFields(JsonNode jsonNode, DynamicValid annotation) {
Set<String> requiredFields = new HashSet<>(Arrays.asList(annotation.required()));
Set<String> allFields = new HashSet<>(requiredFields);
allFields.addAll(Arrays.asList(annotation.optional()));
requiredFields.forEach(field -> {
JsonNode value = jsonNode.get(field);
if (value == null || value.isNull()) {
throw new ValidationException("缺少必填字段: " + field);
}
});
if (annotation.check()) {
jsonNode.fieldNames().forEachRemaining(field -> {
if (!allFields.contains(field)) {
throw new ValidationException("包含非法字段: " + field);
}
});
}
}
private void validateIfApplicable(WebDataBinder binder, MethodParameter parameter) {
Annotation[] annotations = parameter.getParameterAnnotations();
for (Annotation ann : annotations) {
Object[] validationHints = ValidationAnnotationUtils.determineValidationHints(ann);
if (validationHints != null) {
binder.validate(validationHints);
break;
}
}
}
private boolean isBindExceptionRequired(WebDataBinder binder, MethodParameter parameter) {
int i = parameter.getParameterIndex();
Class<?>[] paramTypes = parameter.getExecutable().getParameterTypes();
boolean hasBindingResult = (paramTypes.length > (i + 1) && Errors.class.isAssignableFrom(paramTypes[i + 1]));
return !hasBindingResult;
}
}Configuration
Register the resolver in a WebMvcConfigurer implementation:
@Component
public class WebConfig implements WebMvcConfigurer {
private final DynamicValidArgumentResolver argumentResolver;
public WebConfig(DynamicValidArgumentResolver argumentResolver) {
this.argumentResolver = argumentResolver;
}
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(this.argumentResolver);
}
}Usage Examples
Map Parameter (not recommended)
@PostMapping("/s")
public ResponseEntity<?> search(
@DynamicValid(
required = {"keyword"},
optional = {"page", "pageSize"}
)
Map<String, Object> params) {
return ResponseEntity.ok(params);
}Only validates presence of keys in the Map.
JsonNode Parameter (for full control)
@PostMapping("/node")
public ResponseEntity<?> node(
@DynamicValid(
required = {"type", "platform"},
optional = {"page", "pageSize"},
check = true
)
JsonNode node) {
return ResponseEntity.ok(node);
}Enables unknown-field rejection via check = true.
POJO with @Validated
@PostMapping("/create")
public ResponseEntity<?> create(
@Validated
@DynamicValid(
required = {"name", "email"},
check = true
)
User user, Errors error) {
if (error.hasErrors()) {
List<String> errMsg = error.getFieldErrors().stream()
.map(err -> err.getField() + "," + err.getDefaultMessage())
.toList();
return ResponseEntity.ok(errMsg);
}
return ResponseEntity.ok(user);
}
public record User(
Long id,
@NotEmpty(message = "用户名不能为空") String name,
String password,
String idNo,
String email
) {}Combines dynamic field presence checks with bean validation annotations.
Custom Validator for Map
@Component
public class MapValidator implements Validator {
@Override
public boolean supports(Class<?> clazz) {
return Map.class.isAssignableFrom(clazz);
}
@SuppressWarnings("unchecked")
@Override
public void validate(Object target, Errors errors) {
Map<Object, Object> map = (Map<Object, Object>) target;
Object pageObj = map.get("page");
if (pageObj != null) {
if (pageObj instanceof Number) {
int page = ((Number) pageObj).intValue();
if (page > 100) {
errors.rejectValue("page", "page.too.large", "页码不能超过 100");
}
} else {
errors.rejectValue("page", "page.invalid", "页码必须为数字");
}
} else {
errors.rejectValue("page", "page.missing", "页码是必填项");
}
}
}Used by specifying validator = MapValidator.class in @DynamicValid.
Test Results
The article includes screenshots demonstrating successful validation for each example (required field missing, unknown field rejected, custom validator triggering). These confirm the resolver works as intended.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Spring Full-Stack Practical Cases
Full-stack Java development with Vue 2/3 front-end suite; hands-on examples and source code analysis for Spring, Spring Boot 2/3, and Spring Cloud.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
