Spring Boot
Request Body
@RequestBody binds the HTTP request body to a method parameter, deserializing it from JSON (or XML) into a Java object using Spring's HttpMessageConverters. It is the standard mechanism for receiving structured data in POST, PUT, and PATCH requests — and works in tandem with Bean Validation to reject malformed or invalid payloads before they reach service code.
@RequestBody Basics
@RequestBody instructs Spring to read the entire HTTP request body and deserialize it into the declared parameter type. Jackson's ObjectMapper handles JSON deserialization by default — no extra configuration is needed when spring-boot-starter-web is on the classpath. The Content-Type header must match a supported media type, and the body must be valid JSON (or the configured format) or Spring returns 400.
Java
@RestController
@RequestMapping("/users")
public class UserController {
// ── Basic @RequestBody — deserializes JSON body into the DTO ───────
@PostMapping
public ResponseEntity<UserResponse> create(
@RequestBody CreateUserRequest request) {
// POST /users
// Content-Type: application/json
// Body: { "name": "Alice", "email": "alice@example.com" }
//
// Spring reads the body → Jackson maps JSON → CreateUserRequest instance
UserResponse created = userService.create(request);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
// ── PUT — full replacement ─────────────────────────────────────────
@PutMapping("/{id}")
public UserResponse update(
@PathVariable Long id,
@RequestBody UpdateUserRequest request) {
return userService.update(id, request);
}
// ── PATCH — partial update ─────────────────────────────────────────
@PatchMapping("/{id}")
public UserResponse patch(
@PathVariable Long id,
@RequestBody PatchUserRequest request) {
return userService.patch(id, request);
}
// ── Deserialize into a Map — when structure is dynamic ────────────
@PostMapping("/dynamic")
public ResponseEntity<Void> handleDynamic(
@RequestBody Map<String, Object> payload) {
String name = (String) payload.get("name");
dynamicService.process(payload);
return ResponseEntity.accepted().build();
}
// ── Deserialize into a List ────────────────────────────────────────
@PostMapping("/batch")
public List<UserResponse> createBatch(
@RequestBody List<CreateUserRequest> requests) {
return userService.createBatch(requests);
}
}Validation with @Valid and @Validated
Add @Valid (JSR-380) or @Validated (Spring) alongside @RequestBody to trigger Bean Validation on the deserialized object. Validation runs after deserialization — a structurally invalid JSON body fails at deserialization (400), while a structurally valid but semantically invalid body fails at validation (also 400, but with field-level detail).
Java
// ── DTO with validation constraints ──────────────────────────────────
public record CreateUserRequest(
@NotBlank(message = "Name is required")
@Size(min = 2, max = 100, message = "Name must be 2-100 characters")
String name,
@NotBlank(message = "Email is required")
@Email(message = "Must be a valid email address")
String email,
@NotNull(message = "Role is required")
User.Role role,
@Valid // cascade into nested object
@NotNull
AddressRequest address
) { }
public record AddressRequest(
@NotBlank String street,
@NotBlank String city,
@NotBlank @Size(min = 2, max = 2) String countryCode
) { }
// ── Controller — @Valid triggers validation after deserialization ──────
@RestController
@RequestMapping("/users")
public class UserController {
@PostMapping
public ResponseEntity<UserResponse> create(
@RequestBody @Valid CreateUserRequest request) {
// If validation fails → MethodArgumentNotValidException
// → handled by @RestControllerAdvice → 400
return ResponseEntity.status(201).body(userService.create(request));
}
}
// ── Global handler for validation failures ────────────────────────────
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(
MethodArgumentNotValidException ex) {
Map<String, String> fieldErrors = ex.getBindingResult()
.getFieldErrors().stream()
.collect(Collectors.toMap(
FieldError::getField,
fe -> fe.getDefaultMessage() != null
? fe.getDefaultMessage() : "Invalid",
(a, b) -> a // keep first message on duplicate field
));
return ResponseEntity.badRequest().body(
new ErrorResponse(400, "Validation Failed",
"One or more fields failed validation",
Instant.now(), fieldErrors));
}
}
// ── Validation failure response (400 Bad Request): ────────────────────
// {
// "status": 400,
// "error": "Validation Failed",
// "message": "One or more fields failed validation",
// "fieldErrors": {
// "email": "Must be a valid email address",
// "address.countryCode": "size must be between 2 and 2"
// }
// }Jackson Deserialization Behaviour
Jackson's ObjectMapper controls how JSON maps to Java types. Understanding its default behaviour — and how to override it — prevents common bugs around unknown fields, null values, date formats, and snake_case vs camelCase.
Java
// ── Unknown fields (default: ignored) ────────────────────────────────
// By default Jackson silently ignores JSON keys that have no matching field.
// To fail on unknown fields (useful for strict APIs):
@JsonIgnoreProperties(allowSetters = true, ignoreUnknown = false)
public record StrictRequest(String name, String email) { }
// Or globally in application.yml:
spring:
jackson:
deserialization:
fail-on-unknown-properties: true
// ── Null values ───────────────────────────────────────────────────────
// JSON null → Java null (field is set to null)
// Missing key → Java null for objects, 0/false for primitives
// Use @NotNull to reject null explicitly.
// ── Field naming — camelCase (default) vs snake_case ──────────────────
// Default: JSON "firstName" → Java firstName
// To accept snake_case JSON ("first_name" → firstName):
spring:
jackson:
property-naming-strategy: SNAKE_CASE
// Or per-class:
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public record UserRequest(String firstName, String lastName) { }
// Accepts: { "first_name": "Alice", "last_name": "Smith" }
// ── Date and time ─────────────────────────────────────────────────────
// Default: timestamps as arrays [2024,3,15] — rarely wanted.
// Configure ISO-8601 strings globally:
spring:
jackson:
serialization:
write-dates-as-timestamps: false
// Or per-field:
public record EventRequest(
@JsonFormat(pattern = "yyyy-MM-dd")
LocalDate date,
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
LocalDateTime scheduledAt
) { }
// ── Custom field name in JSON ──────────────────────────────────────────
public record ProductRequest(
@JsonProperty("product_name") String name, // JSON key differs from field
@JsonProperty("unit_price") BigDecimal price
) { }
// Accepts: { "product_name": "Widget", "unit_price": 9.99 }
// ── Ignore a field during deserialization ─────────────────────────────
public record UserRequest(
String name,
String email,
@JsonIgnore String internalCode // accepted in body but ignored
) { }required = false and Optional Body
By default @RequestBody is required — a missing or empty body returns 400. Set required = false to allow an empty body, or handle the case where clients send no body at all.
Java
@RestController
@RequestMapping("/events")
public class EventController {
// ── required = false — body is optional ───────────────────────────
@PostMapping("/{id}/publish")
public ResponseEntity<EventResponse> publish(
@PathVariable Long id,
@RequestBody(required = false) PublishOptions options) {
// Body may be absent — options will be null if no body sent
PublishOptions effective = options != null
? options
: PublishOptions.defaults();
return ResponseEntity.ok(eventService.publish(id, effective));
}
// ── Optional<T> — explicit empty handling ──────────────────────────
@PostMapping("/{id}/schedule")
public ResponseEntity<EventResponse> schedule(
@PathVariable Long id,
@RequestBody Optional<ScheduleRequest> request) {
ScheduleRequest schedule = request.orElse(ScheduleRequest.immediate());
return ResponseEntity.ok(eventService.schedule(id, schedule));
}
}
// NOTE: required = false does not disable Content-Type checking.
// If a body IS sent, the Content-Type must still be application/json
// or Spring returns 415 Unsupported Media Type.Reading Raw Body as String or Bytes
When the body format is dynamic, unknown, or binary, declare the parameter as String or byte[] to receive the raw body without deserialization.
Java
@RestController
@RequestMapping("/webhooks")
public class WebhookController {
// ── Raw String body — useful for webhook signature verification ────
@PostMapping("/stripe")
public ResponseEntity<Void> stripeWebhook(
@RequestBody String rawBody,
@RequestHeader("Stripe-Signature") String signature) {
// Verify HMAC signature against raw body BEFORE parsing:
if (!stripeService.verifySignature(rawBody, signature)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
stripeService.process(rawBody);
return ResponseEntity.ok().build();
}
// ── Raw byte[] body — for binary payloads ─────────────────────────
@PostMapping(value = "/binary",
consumes = MediaType.APPLICATION_OCTET_STREAM_VALUE)
public ResponseEntity<Void> binaryUpload(
@RequestBody byte[] data) {
fileService.store(data);
return ResponseEntity.ok().build();
}
// ── HttpEntity<String> — access both headers and raw body ─────────
@PostMapping("/github")
public ResponseEntity<Void> githubWebhook(
HttpEntity<String> entity) {
HttpHeaders headers = entity.getHeaders();
String body = entity.getBody();
String event = headers.getFirst("X-GitHub-Event");
githubService.handle(event, body);
return ResponseEntity.ok().build();
}
}Common Errors and How to Handle Them
Three exceptions cover the most common @RequestBody failure modes. Mapping each to the correct HTTP status code in a @RestControllerAdvice produces consistent, actionable error responses.
Java
@RestControllerAdvice
public class GlobalExceptionHandler {
// 400 — malformed JSON, wrong type, missing required field ─────────
// Thrown when Jackson cannot parse or map the body at all.
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ErrorResponse> handleUnreadable(
HttpMessageNotReadableException ex) {
String message = "Malformed request body";
// Surface more specific cause when available:
Throwable cause = ex.getCause();
if (cause instanceof JsonParseException jpe) {
message = "Invalid JSON: " + jpe.getOriginalMessage();
} else if (cause instanceof InvalidFormatException ife) {
message = String.format(
"Invalid value '%s' for field '%s' — expected %s",
ife.getValue(),
ife.getPath().stream()
.map(JsonMappingException.Reference::getFieldName)
.collect(Collectors.joining(".")),
ife.getTargetType().getSimpleName()
);
}
return ResponseEntity.badRequest()
.body(ErrorResponse.of(400, "Bad Request", message));
}
// 400 — Bean Validation failure on @RequestBody @Valid ─────────────
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(
MethodArgumentNotValidException ex) {
Map<String, String> fieldErrors = ex.getBindingResult()
.getFieldErrors().stream()
.collect(Collectors.toMap(
FieldError::getField,
fe -> fe.getDefaultMessage() != null
? fe.getDefaultMessage() : "Invalid",
(a, b) -> a
));
return ResponseEntity.badRequest().body(
new ErrorResponse(400, "Validation Failed",
"One or more fields failed validation",
Instant.now(), fieldErrors));
}
// 415 — Content-Type not supported ────────────────────────────────
@ExceptionHandler(HttpMediaTypeNotSupportedException.class)
public ResponseEntity<ErrorResponse> handleUnsupportedMedia(
HttpMediaTypeNotSupportedException ex) {
String message = String.format(
"Content-Type '%s' is not supported. Supported types: %s",
ex.getContentType(),
ex.getSupportedMediaTypes()
);
return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
.body(ErrorResponse.of(415, "Unsupported Media Type", message));
}
}