4 Advanced Techniques for Designing Spring Boot Controllers

This article examines four common shortcomings in typical Spring Boot controller APIs—lack of idempotency, misuse of PUT for partial updates, missing optimistic concurrency control, and always returning full objects—and demonstrates concrete solutions using Idempotency-Key headers, PATCH with JsonMergePatch, ETag handling, and sparse field selection.

Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
4 Advanced Techniques for Designing Spring Boot Controllers

Most developers who have moved beyond the basics write APIs like the following controller, which already improves over beginner code but still suffers from serious issues:

@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {
  @PostMapping
  public ResponseEntity<OrderResponse> createOrder(@Valid @RequestBody CreateOrderRequest request) {
    Order order = orderService.create(request);
    return ResponseEntity.status(HttpStatus.CREATED).body(OrderResponse.from(order));
  }

  @PutMapping("/{id}")
  public ResponseEntity<OrderResponse> updateOrder(@PathVariable Long id, @Valid @RequestBody UpdateOrderRequest request) {
    Order order = orderService.update(id, request);
    return ResponseEntity.ok(OrderResponse.from(order));
  }
}

The problems are:

No idempotency protection: retrying a POST creates duplicate orders.

PUT performs a full replacement, causing unintended nulling of omitted fields.

No concurrent‑update control, so simultaneous edits silently overwrite each other.

All fields are always returned, even when the client only needs a subset.

Below are four advanced patterns that address each issue.

2.1 Idempotent write operations

Problem: a mobile client retries a payment request after a network timeout, resulting in two orders and double charging.

// ❌ No idempotency – each retry creates a new order
@PostMapping("/orders")
public ResponseEntity<OrderResponse> createOrder(@Valid @RequestBody CreateOrderRequest request) {
  Order order = orderService.create(request);
  return ResponseEntity.status(HttpStatus.CREATED).body(OrderResponse.from(order));
}

Solution: require the client to send an Idempotency-Key header, cache the first result, and return the cached response for duplicate keys.

// ✅ Idempotency key prevents duplicate processing
@PostMapping("/orders")
public ResponseEntity<OrderResponse> createOrder(
    @RequestHeader("Idempotency-Key") String idempotencyKey,
    @Valid @RequestBody CreateOrderRequest request) {
  return idempotencyService.findResult(idempotencyKey)
      .map(cached -> ResponseEntity.ok(cached))
      .orElseGet(() -> {
        Order order = orderService.create(request);
        OrderResponse response = OrderResponse.from(order);
        idempotencyService.save(idempotencyKey, response);
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
      });
}

@Service
public class IdempotencyService {
  private final IdempotencyRepository idempotencyRepository;
  public IdempotencyService(IdempotencyRepository repo) { this.idempotencyRepository = repo; }
  public Optional<OrderResponse> findResult(String key) {
    return idempotencyRepository.findByKey(key).map(IdempotencyRecord::getResponse);
  }
  public void save(String key, OrderResponse response) {
    idempotencyRepository.save(new IdempotencyRecord(key, response,
        LocalDateTime.now().plusHours(24)));
  }
}

Clients and load balancers can safely retry failed requests because the server distinguishes retries by the key rather than by request content.

2.2 Use PATCH instead of PUT for partial updates

Problem: PUT replaces the entire resource, silently setting missing fields to null. For example, sending only {"email":"[email protected]"} clears username, phone, etc.

// ❌ PUT replaces the whole resource – omitted fields become null
@PutMapping("/users/{id}")
public ResponseEntity<UserResponse> updateUser(@PathVariable Long id, @RequestBody UpdateUserRequest request) {
  // client sent only email
  // username, phone, address are silently set to null
  return ResponseEntity.ok(UserResponse.from(userService.update(id, request)));
}

Solution: use @PatchMapping together with JsonMergePatch to merge only the fields present in the request.

@PatchMapping("/{id}")
public ResponseEntity<User> updateUser(@PathVariable Long id, @RequestBody String content) throws JsonProcessingException {
  User user = this.userService.findById(id);
  // 1. original entity → JSON string → JsonObject
  String originJsonStr = objectMapper.writeValueAsString(user);
  JsonValue originValue;
  try (StringReader reader = new StringReader(originJsonStr);
       jakarta.json.JsonReader jsonReader = Json.createReader(reader)) {
    originValue = jsonReader.readValue();
  }
  // 2. request body → JsonObject
  JsonValue patchJson;
  try (StringReader reader = new StringReader(content);
       jakarta.json.JsonReader jsonReader = Json.createReader(reader)) {
    patchJson = jsonReader.readValue();
  }
  // 3. apply merge patch
  JsonMergePatch mergePatch = Json.createMergePatch(patchJson);
  JsonValue result = mergePatch.apply(originValue);
  User patchedUser = objectMapper.readValue(result.toString(), User.class);
  return ResponseEntity.ok(patchedUser);
}

public User findById(Long id) {
  return User.builder()
      .id(id)
      .address("重庆")
      .email("[email protected]")
      .phone("18199456555")
      .username("pack_xg")
      .build();
}

2.3 Optimistic concurrency control with ETag

Problem: two clients load the same product, modify different fields, and the second save silently overwrites the first, causing data loss.

// No concurrency control – second update overwrites first silently
@PutMapping("/products/{id}")
public ResponseEntity<ProductResponse> updateProduct(@PathVariable Long id, @RequestBody UpdateProductRequest request) {
  // client A changes price, client B changes stock; B's save loses A's change
  return ResponseEntity.ok(ProductResponse.from(productService.update(id, request)));
}

Solution: return an ETag header on GET and require the client to send If-Match on PUT. If the tag does not match, respond with 412.

@GetMapping("/products/{id}")
public ResponseEntity<ProductResponse> getProduct(@PathVariable Long id) {
  Product product = productService.findById(id);
  String eTag = "\"" + product.getVersion() + "\"";
  return ResponseEntity.ok()
      .eTag(eTag) // ETag: "5"
      .body(ProductResponse.from(product));
}

@PutMapping("/products/{id}")
public ResponseEntity<ProductResponse> updateProduct(
    @PathVariable Long id,
    @RequestHeader("If-Match") String ifMatch,
    @Valid @RequestBody UpdateProductRequest request) {
  Product product = productService.findById(id);
  String currentETag = "\"" + product.getVersion() + "\"";
  if (!currentETag.equals(ifMatch)) {
    return ResponseEntity.status(HttpStatus.PRECONDITION_FAILED).body(null); // 412
  }
  return ResponseEntity.ok(ProductResponse.from(productService.update(id, request)));
}

2.4 Sparse field selection

Problem: a mobile client fetching a user list only needs id and name, but the API returns all 15 fields, wasting bandwidth and processing.

// ❌ Returns all fields regardless of client needs
@GetMapping("/users")
public ResponseEntity<Page<UserResponse>> getUsers(Pageable pageable) {
  return ResponseEntity.ok(userService.findAll(pageable).map(UserResponse::from));
}

Solution: accept an optional fields query parameter and return only the requested properties.

// ✅ Sparse field set – client receives only needed fields
@GetMapping("/users")
public ResponseEntity<Page<Map<String, Object>>> getUsers(
    Pageable pageable,
    @RequestParam(required = false) Set<String> fields) {
  Page<User> users = userService.findAll(pageable);
  Page<Map<String, Object>> response = users.map(user -> {
    Map<String, Object> full = Map.of(
        "id", user.getId(),
        "name", user.getName(),
        "email", user.getEmail(),
        "role", user.getRole(),
        "createdAt", user.getCreatedAt()
    );
    if (fields == null || fields.isEmpty()) {
      return full;
    }
    return full.entrySet().stream()
        .filter(e -> fields.contains(e.getKey()))
        .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
  });
  return ResponseEntity.ok(response);
}

These four patterns—idempotency keys, PATCH with JsonMergePatch, ETag‑based optimistic locking, and dynamic field selection—elevate a basic Spring Boot controller to a production‑ready API.

Test result illustration:

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 BootidempotencyControllerPATCHETagSparse fields
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.