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.

Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Stop Writing Manual if‑else: Master Spring Boot Partial Updates with @JsonMerge

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.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Spring BootJacksonPartial Update@JsonMergeArgument Resolver
Spring Full-Stack Practical Cases
Written by

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.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.