Request Bodies and JSON
Read a JSON request body into a record with @RequestBody, validate it, and understand the 400 and 415 responses clients get when it goes wrong.
@PostMapping("/api/orders")
OrderResponse create(@Valid @RequestBody CreateOrderRequest request) { ... }@RequestBody tells Spring MVC to read the whole request body and convert it to the parameter's type. With the web starter on the classpath the converter for application/json is backed by Jackson 3, which Spring Boot 4 configures for you. A record is the natural target: it has a canonical constructor Jackson can call, no setters, and it documents exactly what the endpoint accepts.
record CreateOrderRequest(String customerEmail, List<OrderLine> lines) {}
@PostMapping("/api/orders")
OrderResponse create(@RequestBody CreateOrderRequest request) { ... }
What can go wrong, and the status the client sees
| Problem | Exception | Status |
|---|---|---|
| Body is not valid JSON, or a field has the wrong type | HttpMessageNotReadableException | 400 |
Body is missing on a required @RequestBody | HttpMessageNotReadableException | 400 |
Content-Type is not JSON (for example form data) | HttpMediaTypeNotSupportedException | 415 |
@Valid constraints fail | MethodArgumentNotValidException | 400 |
The 415 catches people out when testing with tools that default to form encoding. Send Content-Type: application/json.
Separate request types from entities
Binding the body straight to a JPA entity is the most expensive shortcut in this area. Any field on the entity becomes writable by the client, including id, role or balance. This is called mass assignment. A dedicated request record lists only what the client may send, and a response record lists only what you mean to reveal.
Validation
Add @Valid before @RequestBody and put Jakarta Bean Validation constraints on the record components. Nested records are validated only if the component is also marked @Valid. Validation needs spring-boot-starter-validation, which brings Hibernate Validator. If only the jakarta.validation API jar reaches the classpath through some other dependency, the annotations compile but nothing checks them, which is an easy mistake to ship.
Size and safety
The whole body is read into memory before your method runs. Limit body size at the proxy or server, and cap list sizes with @Size, so one request with a million order lines cannot exhaust the heap. Do not log raw bodies: they often contain passwords and personal data.
Example
import java.math.BigDecimal;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
record OrderLine(String sku, int quantity, BigDecimal unitPrice) {}
// What the client may send: no id, no status, no totals.
record CreateOrderRequest(String customerEmail, List<OrderLine> lines) {}
// What the client gets back.
record OrderResponse(long id, String customerEmail, int lineCount, BigDecimal total, String status) {}
public class OrderCommandController {
private final AtomicLong ids = new AtomicLong();
(path = "/api/orders", consumes = MediaType.APPLICATION_JSON_VALUE)
(HttpStatus.CREATED)
public OrderResponse create( CreateOrderRequest request) {
BigDecimal total = request.lines().stream()
.map(l -> l.unitPrice().multiply(BigDecimal.valueOf(l.quantity())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
// The server decides the id and the status, never the client.
return new OrderResponse(ids.incrementAndGet(), request.customerEmail(),
request.lines().size(), total, "PENDING");
}
}When to use it
- Accept a new order with its lines as JSON and return the created order without letting the client set its id or status.
- Reject an order with an invalid email or an empty line list with 400 before any service code runs.
- Receive a payment provider's snake_case webhook into a record with idiomatic Java names.
More examples
The request on the wire, and the 415 trap
JSON property names match record component names exactly, so <code>customerEmail</code> in the record is <code>customerEmail</code> on the wire. The second request never reaches the controller: the mapping declares <code>consumes</code> JSON, and even without it no converter can turn form data into a record via <code>@RequestBody</code>. When a client reports 415, check the header before the code.
POST /api/orders HTTP/1.1
Host: localhost:8080
Content-Type: application/json
{
"customerEmail": "[email protected]",
"lines": [
{ "sku": "KET-0001", "quantity": 2, "unitPrice": 29.99 }
]
}
HTTP/1.1 201 Created
Content-Type: application/json
{"id":1,"customerEmail":"[email protected]","lineCount":1,"total":59.98,"status":"PENDING"}
### The same body without a JSON Content-Type
POST /api/orders HTTP/1.1
Content-Type: application/x-www-form-urlencoded
HTTP/1.1 415 Unsupported Media TypeValidating the body
Constraints sit on the record components and run before the method body. <code>@Size(max = 50)</code> is a cheap guard against oversized payloads. The <code>@Valid</code> inside <code>List<@Valid LineRequest></code> is what makes Spring check each line; leave it out and an order line with quantity zero sails through. The validation category covers turning the errors into a useful response.
import java.util.List;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
record LineRequest(@NotBlank String sku, @Min(1) int quantity) {}
record PlaceOrder(
@NotBlank @Email String customerEmail,
// @Valid on the list makes each element's constraints run too.
@NotEmpty @Size(max = 50) List<@Valid LineRequest> lines) {}
@RestController
public class ValidatedOrderController {
// Invalid input never reaches the method: Spring answers 400 first.
@PostMapping("/api/v2/orders")
public String place(@Valid @RequestBody PlaceOrder order) {
return "accepted " + order.lines().size() + " line(s)";
}
}Accepting snake_case from an external caller
Jackson 3 lives in <code>tools.jackson.*</code>, but the annotations such as <code>@JsonProperty</code> are still in <code>com.fasterxml.jackson.annotation</code>, so this import is correct in Spring Boot 4. Renaming per field keeps your Java names idiomatic while matching someone else's JSON. If every field is snake_case, a global naming strategy is less noise; see the Jackson lessons. The endpoint itself is only a sketch: a real webhook must verify the provider's signature header against the raw body before trusting anything in it, and should log through a logger rather than <code>System.out</code>.
import java.time.Instant;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
// A payment provider posts {"payment_id": "...", "amount_pence": 1999, "paid_at": "..."}
record PaymentWebhook(
@JsonProperty("payment_id") String paymentId,
@JsonProperty("amount_pence") long amountPence,
@JsonProperty("paid_at") Instant paidAt) {}
@RestController
public class PaymentWebhookController {
@PostMapping("/webhooks/payments")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void received(@RequestBody PaymentWebhook event) {
System.out.printf("payment %s for %d pence at %s%n",
event.paymentId(), event.amountPence(), event.paidAt());
}
}
Discussion