Stop Writing Manual if‑else: Master Spring Boot Partial Updates with @JsonMerge
This article explains how to avoid null‑overwrites during partial updates in Spring Boot by using Jackson's @JsonMerge annotation, demonstrates handling of nested objects and null values with @JsonSetter, and shows how to create a custom @JsonMergePatch argument resolver for clean controller code.
In microservice development, partial updates (PATCH) are common; for example, a client may send { "phone": "13899988888" } to modify only a user's phone number.
Using BeanUtils.copyProperties in Spring Boot causes null fields from the request to overwrite existing database values, leading developers to write many if (dto.getPhone() != null) checks.
Example code that prepares an entity and uses objectMapper.readerForUpdating(user).readValue(jsonPatch) results in all default values—including objects, maps, and collections—being overwritten, as shown in the following screenshot:
From Jackson 2.9 (the default JSON processor in Spring Boot 3) onward, the @JsonMerge annotation provides an elegant solution for deep, incremental merging without writing extra code.
By annotating nested fields with @JsonMerge , the same controller now merges incoming JSON into the existing object, preserving unchanged fields. The updated User class looks like this:
public class User {
private String name;
@JsonMerge
private Address address;
@JsonMerge
private List<String> hobbies;
@JsonMerge
private Map<String, Object> extras = new HashMap<>();
@JsonMerge
private List<Order> orders = new ArrayList<>();
}Running the endpoint after adding @JsonMerge produces a merged result, as illustrated below:
When a nested object is sent as null, it is still overwritten. To skip nulls, combine @JsonMerge with @JsonSetter(nulls = Nulls.SKIP) on the field:
public class User {
private String name;
@JsonMerge
@JsonSetter(nulls = Nulls.SKIP)
private Address address;
@JsonMerge
private List<String> hobbies;
// ...
}The resulting behavior, demonstrated in the following image, shows that null values no longer replace existing data:
To avoid repeatedly calling objectMapper.readerForUpdating(...).readValue(...), a custom annotation @JsonMergePatch and a corresponding argument resolver can be created.
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface JsonMergePatch {
// Service interface to fetch the entity for update
Class<? extends BaseQueryService<?, ?>> service();
// Name of the path variable containing the ID
String idPathVariableName() default "id";
// Type of the ID
Class<?> idType() default Long.class;
}The resolver extracts the ID from the request path, obtains the existing entity via the specified service, reads the JSON patch, and merges it:
@Component
public class JsonMergePatchArgumentResolver implements HandlerMethodArgumentResolver {
private final ObjectMapper objectMapper;
private final ApplicationContext context;
public JsonMergePatchArgumentResolver(ObjectMapper objectMapper, ApplicationContext context) {
this.objectMapper = objectMapper;
this.context = context;
}
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(JsonMergePatch.class);
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
JsonMergePatch annotation = parameter.getParameterAnnotation(JsonMergePatch.class);
if (annotation == null) return null;
HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
Map<String, String> pathVariables = (Map<String, String>) request.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE);
String idStr = pathVariables.get(annotation.idPathVariableName());
if (idStr == null) {
throw new IllegalArgumentException("Unable to obtain ID variable from path: " + annotation.idPathVariableName());
}
Object id = null;
AutowireCapableBeanFactory factory = this.context.getAutowireCapableBeanFactory();
if (factory instanceof ConfigurableBeanFactory cbf) {
id = cbf.getConversionService().convert(idStr, annotation.idType());
}
if (id == null) {
throw new RuntimeException("Path parameter ID is missing");
}
BaseQueryService serviceBean = context.getBean(annotation.service());
Object dbEntity = serviceBean.findByIdForUpdate(id);
if (dbEntity == null) {
throw new RuntimeException(String.format("Error fetching ID: %s", id));
}
String jsonPatch = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);
return objectMapper.readerForUpdating(dbEntity).readValue(jsonPatch);
}
}The resolver is registered via WebMvcConfigurer:
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final JsonMergePatchArgumentResolver jsonMergePatchArgumentResolver;
public WebConfig(JsonMergePatchArgumentResolver jsonMergePatchArgumentResolver) {
this.jsonMergePatchArgumentResolver = jsonMergePatchArgumentResolver;
}
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(jsonMergePatchArgumentResolver);
}
}A simple service interface and its implementation provide the entity for update:
public interface BaseQueryService<T, ID> {
T findByIdForUpdate(ID id);
}
@Service
public class UserQueryService implements BaseQueryService<User, Long> {
@Override
public User findByIdForUpdate(Long id) {
return new User("张三", new Address("北京", "朝阳路"),
new ArrayList<>(List.of("读书", "跑步")),
new HashMap<>(Map.of("profile", "xxx", "info", "ooo")),
new ArrayList<>(List.of(new Order(1L, "S-0001", BigDecimal.valueOf(66)))));
}
}Finally, the controller method uses the custom annotation, letting the resolver supply a merged User instance automatically:
@PatchMapping("/2/{id}")
public ResponseEntity<User> updateMergeUser(@PathVariable Long id,
@JsonMergePatch(service = UserQueryService.class) User user) {
return ResponseEntity.ok(user);
}The endpoint returns the correctly merged object, as shown in the result image below:
All core functionality works as expected.
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.
