Spring Boot REST API
Most modern applications talk to each other through REST APIs: a mobile app loads your orders, a web front end saves a form, one microservice asks another for data. Spring Boot is one of the most popular ways to build these APIs in Java, because it handles the web server, JSON conversion and wiring for you.
In this tutorial you build a complete, working API for managing tasks, a simple to-do list, with create, read, update and delete operations, validation, error handling, pagination, tests and documentation.
1. What Is a REST API?
An API (Application Programming Interface) is a way for programs to talk to each other. A REST API does this over HTTP, the same protocol your browser uses, following a style called REST (Representational State Transfer).
A REST API is organised around resources: the things your application manages, such as tasks, customers or orders. Each resource has an address (a URL), and clients use standard HTTP methods to work with it:
GET /api/tasks -> list all tasks
GET /api/tasks/42 -> get task 42
POST /api/tasks -> create a new task
DELETE /api/tasks/42 -> delete task 42
Data travels as JSON, a simple text format that almost every programming language can read and write.
2. REST Principles and HTTP Methods
| Principle | Meaning |
|---|---|
| Client–server | The client (app, website) and the server (API) are separate and can change independently |
| Stateless | Each request carries everything the server needs; the server does not remember previous requests |
| Resource-based | URLs name things (nouns), such as /tasks, not actions such as /createTask |
| Uniform interface | The same standard HTTP methods and status codes are used everywhere |
| Cacheable | Responses can say whether they may be cached, which saves repeated work |
| Method | Purpose | Safe? | Idempotent? | Example |
|---|---|---|---|---|
GET | Read data | Yes | Yes | GET /api/tasks/42 |
POST | Create a new resource | No | No | POST /api/tasks |
PUT | Replace a resource completely | No | Yes | PUT /api/tasks/42 |
PATCH | Change part of a resource | No | Usually | PATCH /api/tasks/42/complete |
DELETE | Remove a resource | No | Yes | DELETE /api/tasks/42 |
Safe means the request does not change anything. Idempotent means sending the same request twice has the same effect as sending it once, which is important when a client retries after a network error.
3. Why Spring Boot for REST APIs?
- Embedded server: Tomcat is built in; run the API with
java -jar. - Automatic JSON: Jackson converts Java objects to and from JSON with no configuration.
- Simple annotations:
@RestControllerand@GetMappingturn methods into endpoints. - Easy database access with Spring Data JPA.
- Built-in validation, error handling and testing support.
- Production features such as health checks and metrics through Actuator.
4. Designing the Task API
Design the endpoints before writing code. Our task API offers:
| Method and URL | Action | Success status |
|---|---|---|
GET /api/tasks | List tasks, optionally only completed or open ones | 200 OK |
GET /api/tasks/{id} | Get one task | 200 OK |
POST /api/tasks | Create a task | 201 Created |
PUT /api/tasks/{id} | Update a task’s details | 200 OK |
PATCH /api/tasks/{id}/complete | Mark a task as done | 200 OK |
DELETE /api/tasks/{id} | Delete a task | 204 No Content |
A few naming rules keep URLs clean and predictable:
- Use plural nouns:
/tasks, not/taskor/getTasks. - Use lowercase with hyphens:
/due-soon, not/dueSoon. - Let the HTTP method express the action; the URL names the resource.
- Use query parameters for filtering:
/tasks?completed=false.
5. Project Setup
Create a project on Spring Initializr with these dependencies: Spring Web, Spring Data JPA, Validation and H2 Database. The resulting pom.xml contains:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
spring.application.name=task-api
spring.datasource.url=jdbc:h2:mem:tasks
spring.jpa.open-in-view=false
H2 is an in-memory database, perfect for learning: no installation needed, and it starts fresh on every run. Switch the URL to PostgreSQL or MySQL later without changing any Java code.
6. Layered Architecture
HTTP request (JSON)
│
▼
┌──────────────────┐ receives requests, validates input, returns responses
│ TaskController │
└────────┬─────────┘
▼
┌──────────────────┐ business rules and transactions
│ TaskService │
└────────┬─────────┘
▼
┌──────────────────┐ reads and writes the database
│ TaskRepository │
└────────┬─────────┘
▼
Database
| Layer | Responsibility | Must not |
|---|---|---|
| Controller | HTTP: URLs, status codes, request and response bodies | Contain business rules or talk to the database |
| Service | Business logic and transactions | Know about HTTP |
| Repository | Database queries | Contain business logic |
Keeping each layer to one job makes the code easier to test, change and understand.
7. The Entity
The entity maps to a database table:
package com.javabytechie.tasks;
import java.time.Instant;
import java.time.LocalDate;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "tasks")
public class Task {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String title;
@Column(length = 500)
private String description;
private boolean completed;
private LocalDate dueDate;
@Column(nullable = false, updatable = false)
private Instant createdAt = Instant.now();
protected Task() {
// required by JPA
}
public Task(String title, String description, LocalDate dueDate) {
this.title = title;
this.description = description;
this.dueDate = dueDate;
}
public void update(String title, String description, LocalDate dueDate) {
this.title = title;
this.description = description;
this.dueDate = dueDate;
}
public void markCompleted() {
this.completed = true;
}
public Long getId() { return id; }
public String getTitle() { return title; }
public String getDescription() { return description; }
public boolean isCompleted() { return completed; }
public LocalDate getDueDate() { return dueDate; }
public Instant getCreatedAt() { return createdAt; }
}
Instead of public setters for every field, the entity has meaningful methods, update() and markCompleted(), so it can only change in ways that make sense.
8. Request and Response DTOs
The API does not expose the entity directly. Instead, it uses DTOs (Data Transfer Objects): one for incoming data and one for outgoing data. Java records keep them short:
public record TaskRequest(
@NotBlank(message = "Title is required")
@Size(max = 100, message = "Title must be at most 100 characters")
String title,
@Size(max = 500, message = "Description must be at most 500 characters")
String description,
@FutureOrPresent(message = "Due date cannot be in the past")
LocalDate dueDate
) {
}
public record TaskResponse(
Long id,
String title,
String description,
boolean completed,
LocalDate dueDate,
Instant createdAt
) {
public static TaskResponse from(Task task) {
return new TaskResponse(task.getId(), task.getTitle(), task.getDescription(),
task.isCompleted(), task.getDueDate(), task.getCreatedAt());
}
}
| Why DTOs? | Explanation |
|---|---|
| Control over input | Clients cannot set fields they should not, such as id, completed or createdAt |
| Stable API | You can rename database columns without breaking clients |
| No hidden problems | Avoids lazy-loading errors and endless loops from JPA relationships in JSON |
| Validation in one place | Input rules live on the request DTO |
9. The Repository
public interface TaskRepository extends JpaRepository<Task, Long> {
List<Task> findByCompleted(boolean completed);
Page<Task> findByCompleted(boolean completed, Pageable pageable);
}
Spring Data JPA writes the implementation for you, including save, findById, findAll and deleteById, and creates queries from method names such as findByCompleted. No @Repository annotation is needed on the interface. Learn more in the Spring Data JPA Overview.
10. The Service Layer
public class TaskNotFoundException extends RuntimeException {
public TaskNotFoundException(Long id) {
super("Task " + id + " does not exist");
}
}
package com.javabytechie.tasks;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional
public class TaskService {
private final TaskRepository taskRepository;
public TaskService(TaskRepository taskRepository) {
this.taskRepository = taskRepository;
}
@Transactional(readOnly = true)
public List<TaskResponse> findAll(Boolean completed) {
List<Task> tasks = (completed == null)
? taskRepository.findAll()
: taskRepository.findByCompleted(completed);
return tasks.stream().map(TaskResponse::from).toList();
}
@Transactional(readOnly = true)
public TaskResponse findById(Long id) {
return TaskResponse.from(getTask(id));
}
public TaskResponse create(TaskRequest request) {
Task task = new Task(request.title(), request.description(), request.dueDate());
return TaskResponse.from(taskRepository.save(task));
}
public TaskResponse update(Long id, TaskRequest request) {
Task task = getTask(id);
task.update(request.title(), request.description(), request.dueDate());
return TaskResponse.from(task); // saved automatically when the transaction commits
}
public TaskResponse complete(Long id) {
Task task = getTask(id);
task.markCompleted();
return TaskResponse.from(task);
}
public void delete(Long id) {
taskRepository.delete(getTask(id));
}
private Task getTask(Long id) {
return taskRepository.findById(id).orElseThrow(() -> new TaskNotFoundException(id));
}
}
Inside a transaction, JPA tracks changes to loaded entities and saves them automatically when the method ends, which is why update() and complete() need no explicit save() call.
11. The Controller
package com.javabytechie.tasks;
import java.net.URI;
import java.util.List;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
@RestController
@RequestMapping("/api/tasks")
public class TaskController {
private final TaskService taskService;
public TaskController(TaskService taskService) {
this.taskService = taskService;
}
@GetMapping
public List<TaskResponse> list(@RequestParam(required = false) Boolean completed) {
return taskService.findAll(completed);
}
@GetMapping("/{id}")
public TaskResponse get(@PathVariable Long id) {
return taskService.findById(id);
}
@PostMapping
public ResponseEntity<TaskResponse> create(@Valid @RequestBody TaskRequest request) {
TaskResponse created = taskService.create(request);
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(created.id())
.toUri();
return ResponseEntity.created(location).body(created); // 201 + Location header
}
@PutMapping("/{id}")
public TaskResponse update(@PathVariable Long id, @Valid @RequestBody TaskRequest request) {
return taskService.update(id, request);
}
@PatchMapping("/{id}/complete")
public TaskResponse complete(@PathVariable Long id) {
return taskService.complete(id);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
taskService.delete(id);
}
}
| Annotation | Role in this controller |
|---|---|
@RestController | Return values are written as JSON response bodies |
@RequestMapping("/api/tasks") | Common URL prefix for every endpoint |
@GetMapping, @PostMapping, … | Map each HTTP method and path to a Java method |
@PathVariable | Reads {id} from the URL |
@RequestParam | Reads ?completed=true from the query string |
@RequestBody | Converts the JSON body into a TaskRequest |
@Valid | Checks the request against its validation annotations |
@ResponseStatus | Sets the status for a successful response, here 204 |
The controller is short: it translates between HTTP and the service, and nothing more. For more on these annotations, see Spring Boot Annotations.
12. Trying the API
Start the application with ./mvnw spring-boot:run, then try each endpoint. Create a task:
curl -i -X POST http://localhost:8080/api/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Write REST tutorial", "description": "Cover CRUD and testing", "dueDate": "2026-12-01"}'
HTTP/1.1 201 Created
Location: http://localhost:8080/api/tasks/1
Content-Type: application/json
{
"id": 1,
"title": "Write REST tutorial",
"description": "Cover CRUD and testing",
"completed": false,
"dueDate": "2026-12-01",
"createdAt": "2026-10-04T09:15:42.381Z"
}
The other operations:
# List all tasks, or only open ones
curl http://localhost:8080/api/tasks
curl "http://localhost:8080/api/tasks?completed=false"
# Get one task
curl http://localhost:8080/api/tasks/1
# Update it
curl -X PUT http://localhost:8080/api/tasks/1 \
-H "Content-Type: application/json" \
-d '{"title": "Write REST API tutorial", "dueDate": "2026-12-05"}'
# Mark it as completed
curl -X PATCH http://localhost:8080/api/tasks/1/complete
# Delete it (prints the 204 status line)
curl -i -X DELETE http://localhost:8080/api/tasks/1
13. HTTP Status Codes
Status codes tell the client what happened, before it even reads the body:
| Code | Meaning | Used in this API when |
|---|---|---|
| 200 OK | Success, with a body | Reading, updating or completing a task |
| 201 Created | A new resource was created | Creating a task (with a Location header) |
| 204 No Content | Success, nothing to return | Deleting a task |
| 400 Bad Request | The request is invalid | Missing title, malformed JSON, past due date |
| 401 Unauthorized | Not logged in | Once security is added |
| 403 Forbidden | Logged in, but not allowed | Once security is added |
| 404 Not Found | The resource does not exist | Unknown task id |
| 405 Method Not Allowed | Wrong HTTP method for the URL | DELETE /api/tasks |
| 409 Conflict | Clashes with the current state | Duplicate data or concurrent changes |
| 500 Internal Server Error | Unexpected server failure | A bug or an unavailable database |
14. Validation and Error Handling
Thanks to @Valid, an invalid request never reaches the service. Add a global handler so that both validation errors and a missing task produce clear JSON responses:
@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(TaskNotFoundException.class)
public ProblemDetail handleNotFound(TaskNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
problem.setTitle("Task not found");
return problem;
}
}
{
"type": "about:blank",
"title": "Task not found",
"status": 404,
"detail": "Task 99 does not exist",
"instance": "/api/tasks/99"
}
Extending ResponseEntityExceptionHandler gives correct status codes for Spring’s own errors, such as malformed JSON, too. Both topics have dedicated tutorials: Spring Boot Validation shows how to include field-level messages, and Spring Boot Exception Handling covers error formats, logging and testing in depth.
15. Pagination and Sorting
Returning thousands of tasks in one response is slow for everyone. Spring Data can return one page at a time. Add a Pageable parameter, and Spring fills it from the query string:
public record PageResponse<T>(
List<T> content,
int page,
int size,
long totalElements,
int totalPages
) {
public static <T> PageResponse<T> from(Page<T> page) {
return new PageResponse<>(page.getContent(), page.getNumber(), page.getSize(),
page.getTotalElements(), page.getTotalPages());
}
}
@GetMapping("/paged")
public PageResponse<TaskResponse> listPaged(
@PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) {
return PageResponse.from(taskService.findPage(pageable));
}
@Transactional(readOnly = true)
public Page<TaskResponse> findPage(Pageable pageable) {
return taskRepository.findAll(pageable).map(TaskResponse::from);
}
curl "http://localhost:8080/api/tasks/paged?page=0&size=10&sort=dueDate,asc"
| Parameter | Meaning | Example |
|---|---|---|
page | Page number, starting at 0 | page=2 |
size | Items per page | size=10 |
sort | Field and direction | sort=dueDate,asc |
Returning your own PageResponse record, rather than Spring Data’s Page object, keeps the JSON format stable and under your control. Also set a maximum page size, for example with spring.data.web.pageable.max-page-size=100, so nobody can request a million items at once.
16. Testing the API
@WebMvcTest starts only the web layer, so tests run fast. The service is replaced with a mock:
@WebMvcTest(TaskController.class)
@Import(ApiExceptionHandler.class)
class TaskControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private TaskService taskService;
@Test
void returnsTaskAsJson() throws Exception {
TaskResponse task = new TaskResponse(1L, "Write tests", null, false, null, Instant.now());
when(taskService.findById(1L)).thenReturn(task);
mockMvc.perform(get("/api/tasks/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.title").value("Write tests"));
}
@Test
void returns404ForUnknownTask() throws Exception {
when(taskService.findById(99L)).thenThrow(new TaskNotFoundException(99L));
mockMvc.perform(get("/api/tasks/99"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.title").value("Task not found"));
}
@Test
void rejectsTaskWithoutTitle() throws Exception {
mockMvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"title": "", "description": "No title"}
"""))
.andExpect(status().isBadRequest());
verifyNoInteractions(taskService); // invalid input never reaches the service
}
}
@MockitoBean is available from Spring Boot 3.4; on older versions, use @MockBean.
17. API Documentation with OpenAPI
Clients need to know which endpoints exist and what they expect. The open-source springdoc-openapi library generates this documentation from your code:
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
Restart the application and open http://localhost:8080/swagger-ui.html. You get an interactive page listing every endpoint, its parameters and its JSON format, where you can also send test requests. The machine-readable OpenAPI description is at /v3/api-docs, which other tools can use to generate client code.
Choose a springdoc version that matches your Spring Boot version, and consider switching the UI off in production with springdoc.swagger-ui.enabled=false.
18. Securing the API
A real API must check who is calling and what they may do. Spring Security handles both:
| Approach | Typical use |
|---|---|
| JWT bearer tokens | Stateless APIs for single-page apps and mobile apps |
| OAuth 2.0 / OpenID Connect | Login through an identity provider, such as Keycloak or a cloud service |
| API keys | Simple server-to-server access |
Learn the concepts in JWT (JSON Web Token) and OAuth 2.0. Whatever you choose, always serve the API over HTTPS.
19. Best Practices
- Name resources with plural nouns and let HTTP methods express actions.
- Return the right status code: 201 with a
Locationheader for creation, 204 for deletion, 404 for unknown ids. - Use DTOs, not entities, in requests and responses.
- Validate all input with
@Validand Bean Validation annotations. - Handle errors in one place and return a consistent error format.
- Keep controllers thin; put business logic in services.
- Paginate collections and cap the page size.
- Version your API, for example
/api/v1/tasks, before external clients depend on it. - Document it with OpenAPI, and test it with
@WebMvcTest. - Secure it and serve it over HTTPS.
20. Summary
A Spring Boot REST API is built in clear layers: an entity for the database, DTOs for the outside world, a repository for queries, a service for business logic, and a thin controller for HTTP. With a handful of annotations you get JSON conversion, validation, proper status codes and centralised error handling. Add pagination for large collections, tests with @WebMvcTest, OpenAPI documentation and security, and you have an API that is ready for real clients.
21. Frequently Asked Questions (FAQ)
Q1. What is the difference between REST and RESTful?
REST is the architectural style: a set of principles such as stateless requests, resources with URLs and standard HTTP methods. RESTful describes an API that follows those principles.
In everyday use, “REST API” and “RESTful API” mean the same thing. Strictly speaking, many APIs are only partly RESTful, for example they use proper URLs and methods but skip features like hypermedia links, and that is perfectly normal.
Q2. When should I use POST, PUT or PATCH?
| Method | Use it to | Send |
|---|---|---|
POST | Create something new, where the server picks the id | The new resource’s data |
PUT | Replace a resource completely | Every field; missing fields become empty |
PATCH | Change only some fields | Just the fields that change |
A simple test: if sending the request twice would create two things, it is POST. If it would leave the same final state, it is PUT or PATCH. Actions such as “complete a task” fit naturally as PATCH on a sub-path, as in this tutorial.
Q3. How should I version my REST API?
Versioning lets you change the API without breaking existing clients. Three common approaches:
| Approach | Example | Notes |
|---|---|---|
| URL path | /api/v1/tasks | Simplest and most visible; the most common choice |
| Header | X-API-Version: 2 | Clean URLs, but harder to test in a browser |
| Media type | Accept: application/vnd.shop.v2+json | Very flexible, but more complex |
For most teams, the URL path is the best start. Create a new version only for breaking changes; adding a new optional field does not need one. The Spring Boot REST API Versioning tutorial covers every strategy in depth.
Q4. What does idempotent mean, and why does it matter?
An operation is idempotent if doing it several times has the same result as doing it once. DELETE /api/tasks/5 is idempotent: after the first call the task is gone, and further calls change nothing.
It matters because networks fail. If a client sends a request and the connection drops before the response arrives, it does not know whether the request worked. Retrying an idempotent request is always safe. Retrying a POST may create a duplicate, so APIs for important actions, like payments, often accept an idempotency key header to detect repeated requests.
Q5. Should deleting a resource that does not exist return 404 or 204?
Both are used, and both are acceptable if you are consistent:
- 404 Not Found tells the client the id was wrong, which helps catch mistakes. This tutorial’s API does this.
- 204 No Content treats “it does not exist afterwards” as success, which makes retries completely painless.
Choose one, document it, and apply it to every resource in your API.
Q6. How do I let a browser front end on another domain call my API?
Browsers block such cross-origin requests unless the API allows them through CORS. Configure the allowed origins in one place:
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://app.example.com")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE");
}
}
Avoid allowing every origin with * on an API that uses login cookies. If you use Spring Security, also enable CORS in the security configuration with http.cors(...).
Q7. How do I accept a file upload in a REST API?
Use a MultipartFile parameter in a multipart/form-data request:
@PostMapping(value = "/{id}/attachments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@ResponseStatus(HttpStatus.CREATED)
public AttachmentResponse upload(@PathVariable Long id, @RequestParam("file") MultipartFile file)
throws IOException {
return attachmentService.store(id, file.getOriginalFilename(), file.getBytes());
}
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=10MB
Always check the file type and size, and never trust the original file name when saving to disk. For large files, store them in object storage, such as S3, rather than in the database.
Q8. Can my API return XML as well as JSON?
Yes. Add the Jackson XML module, and Spring picks the format from the client’s Accept header:
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-xml</artifactId>
</dependency>
curl -H "Accept: application/xml" http://localhost:8080/api/tasks/1
No controller changes are needed. Only add XML support if clients actually need it, because each format is something you must keep working.
Q9. How do I change JSON field names or date formats?
For a single field, use Jackson annotations on the DTO. For the whole API, use properties:
public record TaskResponse(
Long id,
@JsonProperty("task_title") String title, // renamed in JSON
@JsonFormat(pattern = "dd-MM-yyyy") LocalDate dueDate
) { }
# snake_case for every field: dueDate -> due_date
spring.jackson.property-naming-strategy=SNAKE_CASE
# leave out fields that are null
spring.jackson.default-property-inclusion=non_null
By default, Spring Boot writes dates in the ISO-8601 format, such as 2026-10-04, which every client understands. Keep that unless you have a strong reason to change it.
Q10. How does my Spring Boot application call another REST API?
Use RestClient, the modern, fluent HTTP client available since Spring Boot 3.2:
@Component
public class WeatherClient {
private final RestClient restClient;
public WeatherClient(RestClient.Builder builder) {
this.restClient = builder.baseUrl("https://api.weather.example.com").build();
}
public Forecast forecastFor(String city) {
return restClient.get()
.uri("/forecast?city={city}", city)
.retrieve()
.body(Forecast.class);
}
}
WebClient is the reactive alternative, and the older RestTemplate still works but is in maintenance mode. Always configure timeouts for outgoing calls, and consider a circuit breaker for unreliable services.
Q11. Should a controller method return ResponseEntity or the object directly?
Return the object directly when the status is always the same, usually 200. Return ResponseEntity when you need to choose the status or add headers:
// Simple: always 200
@GetMapping("/{id}")
public TaskResponse get(@PathVariable Long id) { ... }
// Needs a status and a header: 201 + Location
@PostMapping
public ResponseEntity<TaskResponse> create(@Valid @RequestBody TaskRequest request) { ... }
For fixed statuses without headers, such as 204 on delete, @ResponseStatus on the method is even shorter. Keep errors out of controllers entirely by throwing exceptions that a global handler turns into responses.
Q12. What is HATEOAS, and do I need it?
HATEOAS (Hypermedia as the Engine of Application State) means responses include links to related actions, so clients can discover what to do next:
{
"id": 1,
"title": "Write REST tutorial",
"_links": {
"self": { "href": "/api/tasks/1" },
"complete": { "href": "/api/tasks/1/complete" }
}
}
Spring HATEOAS (spring-boot-starter-hateoas) helps build such responses. It is useful for public APIs with many independent clients, but most internal APIs work well without it. Start without it, and add it only if your clients benefit.
Q13. How can I log every incoming request?
For development, Spring provides a ready-made logging filter:
@Bean
public CommonsRequestLoggingFilter requestLoggingFilter() {
CommonsRequestLoggingFilter filter = new CommonsRequestLoggingFilter();
filter.setIncludeQueryString(true);
filter.setIncludePayload(true);
filter.setMaxPayloadLength(1000);
return filter;
}
logging.level.org.springframework.web.filter.CommonsRequestLoggingFilter=DEBUG
In production, avoid logging full request bodies, because they may contain passwords or personal data. Request metrics and tracing through Spring Boot Actuator are usually the better tool there.
Q14. How do I limit how often clients can call my API?
Rate limiting protects your API from overload and abuse. Common options:
- At the gateway: an API gateway, such as Spring Cloud Gateway, limits requests before they reach your service. This is the most common setup with several services.
- In the application: a library such as Bucket4j counts requests per client and rejects extras.
When a limit is reached, return 429 Too Many Requests with a Retry-After header. The Rate Limiting Pattern tutorial explains the techniques in detail.
Q15. Can a GET request have a body?
Technically the HTTP standard does not forbid it, but you should not use one. Many proxies, caches and client libraries ignore or drop the body of a GET request, and its meaning is undefined.
Put search criteria in query parameters instead. If a search is too complex for a URL, use a POST to a search resource:
GET /api/tasks?completed=false&dueBefore=2026-12-31
POST /api/tasks/search (complex criteria in the JSON body)
Q16. How can I make my REST API faster?
Start with the changes that usually help most:
- Paginate collections, so responses stay small.
- Fix slow queries: add database indexes, and avoid loading related data in loops.
- Cache data that is read often but changes rarely.
- Return only what clients need, using DTOs or projections.
- Compress responses:
server.compression.enabled=true
server.compression.min-response-size=1KB
Measure before and after each change with metrics, so you improve what actually matters. See the Spring Boot Caching tutorial for caching.
Q17. How do I stop two clients from overwriting each other’s changes?
Use optimistic locking. Add a version field to the entity and the DTO:
@Version
private Long version;
Clients send back the version they read. If someone else saved a newer version in the meantime, JPA detects the mismatch and throws an exception, which your handler turns into 409 Conflict. The client then reloads the data and tries again.
For HTTP-level support, the same idea can be expressed with ETag and If-Match headers, answering a stale update with 412 Precondition Failed.
Q18. How should an API handle an operation that takes minutes?
Do not keep the client waiting on one long request; timeouts and dropped connections will break it. Use the asynchronous request pattern:
- The client sends
POST /api/reports. - The API starts the work in the background and immediately answers 202 Accepted, with a
Locationheader pointing to a status resource, such as/api/reports/jobs/17. - The client checks that URL from time to time, until the status is
DONEand a link to the result is included.
For background processing in Spring Boot, see Spring Boot Async Processing.
Q19. Should I build REST APIs with Spring MVC or Spring WebFlux?
| Spring MVC | Spring WebFlux | |
|---|---|---|
| Programming style | Familiar, step-by-step code | Reactive types (Mono, Flux) |
| Database access | JPA and JDBC | Reactive drivers (R2DBC) |
| Learning curve | Gentle | Steeper; harder to debug |
| Best for | Most applications | Streaming and very high numbers of slow connections |
Start with Spring MVC. On Java 21, Spring MVC with virtual threads (spring.threads.virtual.enabled=true) also handles very many concurrent requests, without the complexity of reactive code.
Q20. How can I test my API by hand while developing?
| Tool | Good for |
|---|---|
curl | Quick requests from a terminal, as in this tutorial |
| Swagger UI (springdoc) | Clicking through every endpoint in the browser |
IDE HTTP client files (.http) | Saving requests next to your code, in IntelliJ IDEA or VS Code |
| Postman or Bruno | Collections of requests shared with a team |
### tasks.http (IntelliJ IDEA / VS Code REST Client)
POST http://localhost:8080/api/tasks
Content-Type: application/json
{ "title": "Try the .http file" }
Manual testing is great for exploring, but keep automated tests, such as the @WebMvcTest examples above, as your safety net.