@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductRepository repository;
private final JsonMergePatchService patchService;
public ProductController(ProductRepository repository, JsonMergePatchService patchService) {
this.repository = repository;
this.patchService = patchService;
}
@PatchMapping(path = "/{id}", consumes = "application/merge-patch+json")
public ResponseEntity<Product> patch(@PathVariable Long id, @RequestBody JsonMergePatch patch) {
Product current = repository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found"));
Product merged = patchService.applyPatch(patch, current, Product.class);
merged.setId(id); // id is immutable regardless of what the patch tried
return ResponseEntity.ok(repository.save(merged));
}
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<String> onInvalid(ConstraintViolationException ex) {
String detail = ex.getConstraintViolations().stream()
.map(v -> v.getPropertyPath() + " " + v.getMessage())
.collect(Collectors.joining("; "));
return ResponseEntity.unprocessableEntity().body(detail);
}
}
@Service
public class JsonMergePatchService {
private final ObjectMapper mapper;
private final Validator validator;
public JsonMergePatchService(ObjectMapper mapper, Validator validator) {
this.mapper = mapper;
this.validator = validator;
}
public <T> T applyPatch(JsonMergePatch patch, T target, Class<T> type) {
JsonValue source = toJsonValue(target);
JsonValue patched = patch.apply(source);
T result = fromJsonValue(patched, type);
Set<ConstraintViolation<T>> violations = validator.validate(result);
if (!violations.isEmpty()) {
throw new ConstraintViolationException(violations);
}
return result;
}
private <T> JsonValue toJsonValue(T target) {
try {
String json = mapper.writeValueAsString(target);
try (JsonReader reader = Json.createReader(new StringReader(json))) {
return reader.readValue();
}
} catch (JsonProcessingException e) {
throw new IllegalStateException("Unable to serialize target for patch", e);
}
}
private <T> T fromJsonValue(JsonValue value, Class<T> type) {
try {
return mapper.readValue(value.toString(), type);
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("Patched document is not valid for " + type.getSimpleName(), e);
}
}
}
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank
@Size(max = 120)
private String name;
@Size(max = 2000)
private String description; // nullable: a patch may legitimately clear it
@NotNull
@DecimalMin(value = "0.0", inclusive = false)
private BigDecimal price;
@Min(0)
private int stock;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public BigDecimal getPrice() { return price; }
public void setPrice(BigDecimal price) { this.price = price; }
public int getStock() { return stock; }
public void setStock(int stock) { this.stock = stock; }
}
@Configuration
public class JacksonConfig {
@Bean
public ObjectMapper objectMapper() {
return new ObjectMapper()
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.registerModule(new JavaTimeModule());
}
@Bean
public HttpMessageConverter<JsonMergePatch> mergePatchConverter() {
return new AbstractHttpMessageConverter<JsonMergePatch>(
MediaType.valueOf("application/merge-patch+json")) {
@Override
protected boolean supports(Class<?> clazz) {
return JsonMergePatch.class.isAssignableFrom(clazz);
}
@Override
protected JsonMergePatch readInternal(Class<? extends JsonMergePatch> clazz,
HttpInputMessage in) throws IOException {
try (JsonReader reader = Json.createReader(in.getBody())) {
return Json.createMergePatch(reader.readValue());
}
}
@Override
protected void writeInternal(JsonMergePatch patch, HttpOutputMessage out) {
throw new UnsupportedOperationException("read-only converter");
}
};
}
}
This snippet implements a proper partial-update endpoint using the HTTP PATCH method with JSON Merge Patch semantics as defined by RFC 7386. Unlike a PUT, which replaces the entire resource, a merge patch describes only the fields that should change, and a JSON null explicitly clears a field. That distinction — omitted vs. present-but-null — is the whole reason a naive @RequestBody Dto approach fails: Jackson cannot tell an absent key from one deserialized to null, so the server can't know whether the client meant "leave it alone" or "erase it".
The ProductController accepts the request with content type application/merge-patch+json and binds the raw body as a JsonMergePatch from the jakarta.json API, deliberately avoiding a typed DTO at the edge. It loads the current entity, delegates the actual merge to JsonMergePatchService, and validates the merged result before persisting. Returning 409 Conflict-free behavior is achieved by re-validating post-merge rather than pre-merge.
In JsonMergePatchService, the current entity is serialized to a JsonValue, the patch is applied with patch.apply(target), and the resulting JSON is deserialized back into a fresh instance of the target class. This round-trip via Jackson and jakarta.json keeps the merge logic entirely generic — the same applyPatch method works for any type, so no per-field mapping code is required. The service also runs Bean Validation through a Validator so that a patch cannot push the entity into an invalid state, throwing a ConstraintViolationException that the controller advice translates.
The Product entity uses standard jakarta.validation constraints; note that @NotBlank on name means a client cannot null it out, while a nullable description may legitimately be set to null. The trade-off of this approach is the double serialization cost and the need for a Jackson ObjectMapper configured to ignore unknown properties. It shines when resources have many optional fields and clients want to send minimal diffs. Pitfalls to watch: merge patch cannot address array elements individually (use JSON Patch / RFC 6902 for that), and the Content-Type must be negotiated correctly or the framework will reject the body.
Related snips
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: String!
GraphQL API with Spring Boot
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
package com.example.starter.config;
import com.example.starter.properties.CustomProperties;
import com.example.starter.service.CustomService;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
Custom Spring Boot starters
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Form Validation Example</title>
<style>
HTML forms with validation and accessibility
package com.example.demo.config;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.apache.kafka.common.serialization.StringSerializer;
Messaging with Apache Kafka
import axios from 'axios';
export type NormalizedErrors = {
fields: Record<string, string>;
formLevel: string | null;
};
Frontend: normalize and display server validation errors
Share this code
Here's the card — post it anywhere.