Building REST APIs with Spring Boot

Request Bodies & @RequestBody

18 min Lesson 4 of 13

Request Bodies & @RequestBody

When a client sends data to your API — creating a new user, updating an order, submitting a form — that data travels inside the HTTP request body, typically as JSON. Spring Boot's @RequestBody annotation tells the framework to deserialise that JSON into a Java object automatically. Understanding how that process works, and why you should bind into a Data Transfer Object (DTO) rather than directly into a JPA entity, is one of the most important design decisions you will make in a REST API.

How @RequestBody Works

When a request arrives at a controller method annotated with @RequestBody, Spring's DispatcherServlet delegates to an HttpMessageConverter. Because Spring Boot auto-configures Jackson (the MappingJackson2HttpMessageConverter), every controller method that accepts a Content-Type: application/json request gets automatic JSON-to-Java deserialization for free.

import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/users") public class UserController { @PostMapping public String createUser(@RequestBody UserCreateRequest request) { // Jackson has already populated `request` from the JSON body return "Created user: " + request.getName(); } }

Spring instantiates the target class, matches each JSON field name to a Java field or setter by name, and fills it in. Fields not present in the JSON are left at their default values (usually null or zero).

@RequestBody is required. Without it Spring treats the parameter as a model attribute (query string / form data). If you forget the annotation and send JSON, all fields will be null. This is a very common beginner bug.

What Is a DTO and Why Does It Matter?

A Data Transfer Object is a plain Java class whose sole purpose is to carry data across a boundary — in this case, between the HTTP wire and your service layer. It has no persistence annotations, no business logic, and no framework dependencies. Compare these two approaches:

Approach A — binding directly into a JPA entity (avoid this):

// DANGER: binding the request body directly into the entity @PostMapping public User createUser(@RequestBody User user) { return userRepository.save(user); }

Approach B — binding into a dedicated DTO (correct approach):

// Safe: client controls only what the DTO exposes @PostMapping public UserResponse createUser(@RequestBody UserCreateRequest request) { User user = userService.create(request); return new UserResponse(user); }

Approach A suffers from mass assignment — a malicious client can set fields like id, role, or isAdmin just by including them in the JSON body. Spring will happily bind them unless you explicitly block every field. Approach B is safe by default: the client can only supply the fields you deliberately put on the DTO.

Never bind @RequestBody directly into a JPA entity. It is a security hole (mass assignment / over-posting) and it tightly couples your API contract to your database schema. Use a DTO; map it in the service layer.

Defining a DTO Class

A DTO is an ordinary POJO. For a create-user request it might look like this:

package com.example.api.dto; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; public class UserCreateRequest { @NotBlank(message = "Name is required") @Size(max = 100) private String name; @NotBlank @Email(message = "Must be a valid email address") private String email; @Size(min = 8, message = "Password must be at least 8 characters") private String password; // standard getters and setters (or use a Java record) public String getName() { return name; } public void setName(String name) { this.name = name; } public String getEmail() { return email; } public void setEmail(String email) { this.email = email; } public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } }

Notice the jakarta.validation.constraints.* annotations. They do nothing by themselves — you must activate them with @Valid in the controller.

Validating the Request Body with @Valid

Add @Valid (from jakarta.validation) immediately before the @RequestBody parameter. Spring Boot will run Bean Validation on the DTO before the method body executes. If any constraint fails, Spring returns a 400 Bad Request automatically.

import jakarta.validation.Valid; @PostMapping public ResponseEntity<UserResponse> createUser(@Valid @RequestBody UserCreateRequest request) { UserResponse response = userService.create(request); return ResponseEntity.status(201).body(response); }
@Valid vs @Validated: @Valid (Jakarta EE) triggers standard Bean Validation on the method parameter. @Validated (Spring) additionally supports validation groups. For most REST endpoints @Valid is the right choice — keep it simple.

Using Java Records as DTOs (Spring Boot 3+)

Java records are immutable, concise, and ideal for DTOs. Jackson 2.12+ deserializes them out of the box — no special configuration required in Spring Boot 3:

package com.example.api.dto; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; public record UserCreateRequest( @NotBlank @Size(max = 100) String name, @NotBlank @Email String email, @Size(min = 8) String password ) {}

The record replaces the boilerplate getters, constructor, and equals/hashCode entirely. Use records for request DTOs; they enforce immutability so nothing can accidentally mutate the incoming data.

Separating Request and Response DTOs

A common pattern is to use separate classes for inbound and outbound data:

  • Request DTO (UserCreateRequest) — fields the client is allowed to send. Carries validation annotations.
  • Response DTO (UserResponse) — fields the client is allowed to see. Omits sensitive data like passwords or internal flags.
public record UserResponse(Long id, String name, String email, String createdAt) { // Convenience constructor: map from the entity public UserResponse(User user) { this(user.getId(), user.getName(), user.getEmail(), user.getCreatedAt().toString()); } }

The service layer creates the entity from the request DTO, saves it, and returns a response DTO built from the saved entity. The client never sees the entity class at all.

Handling Nested and List Bodies

Jackson handles nested objects and lists automatically. If your JSON contains an array at the top level, declare the parameter as a List<YourDto>:

// Accepts: [{"name":"Alice","email":"a@x.com"}, {"name":"Bob","email":"b@x.com"}] @PostMapping("/batch") public ResponseEntity<List<UserResponse>> createBatch( @Valid @RequestBody List<@Valid UserCreateRequest> requests) { List<UserResponse> responses = requests.stream() .map(userService::create) .toList(); return ResponseEntity.status(201).body(responses); }
Validation on list elements: @Valid on the List parameter alone does NOT cascade into the list elements in some Spring configurations. Annotate the generic type parameter (List<@Valid UserCreateRequest>) or validate manually in the service layer.

Summary

@RequestBody tells Spring to deserialize the JSON body into a Java object using Jackson. Always bind into a dedicated DTO — never a JPA entity — to prevent mass assignment attacks and to decouple your API surface from your database schema. Add @Valid to activate Bean Validation before the method runs. Java records are the most concise and safe DTO form in Spring Boot 3. Use separate request and response DTOs to control exactly what the client can send and what it receives back. This pattern is the foundation every professional Spring Boot API is built on.