00:00

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:

Example requests
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

PrincipleMeaning
Client–serverThe client (app, website) and the server (API) are separate and can change independently
StatelessEach request carries everything the server needs; the server does not remember previous requests
Resource-basedURLs name things (nouns), such as /tasks, not actions such as /createTask
Uniform interfaceThe same standard HTTP methods and status codes are used everywhere
CacheableResponses can say whether they may be cached, which saves repeated work
MethodPurposeSafe?Idempotent?Example
GETRead dataYesYesGET /api/tasks/42
POSTCreate a new resourceNoNoPOST /api/tasks
PUTReplace a resource completelyNoYesPUT /api/tasks/42
PATCHChange part of a resourceNoUsuallyPATCH /api/tasks/42/complete
DELETERemove a resourceNoYesDELETE /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: @RestController and @GetMapping turn 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 URLActionSuccess status
GET /api/tasksList tasks, optionally only completed or open ones200 OK
GET /api/tasks/{id}Get one task200 OK
POST /api/tasksCreate a task201 Created
PUT /api/tasks/{id}Update a task’s details200 OK
PATCH /api/tasks/{id}/completeMark a task as done200 OK
DELETE /api/tasks/{id}Delete a task204 No Content

A few naming rules keep URLs clean and predictable:

  • Use plural nouns: /tasks, not /task or /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:

pom.xml
<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>
application.properties
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

Request flow
HTTP request (JSON)
      │
      ▼
┌──────────────────┐   receives requests, validates input, returns responses
│ TaskController   │
└────────┬─────────┘
         ▼
┌──────────────────┐   business rules and transactions
│ TaskService      │
└────────┬─────────┘
         ▼
┌──────────────────┐   reads and writes the database
│ TaskRepository   │
└────────┬─────────┘
         ▼
     Database
LayerResponsibilityMust not
ControllerHTTP: URLs, status codes, request and response bodiesContain business rules or talk to the database
ServiceBusiness logic and transactionsKnow about HTTP
RepositoryDatabase queriesContain 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:

Task.java
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:

TaskRequest.java
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
) {
}
TaskResponse.java
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 inputClients cannot set fields they should not, such as id, completed or createdAt
Stable APIYou can rename database columns without breaking clients
No hidden problemsAvoids lazy-loading errors and endless loops from JPA relationships in JSON
Validation in one placeInput rules live on the request DTO

9. The Repository

TaskRepository.java
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

TaskNotFoundException.java
public class TaskNotFoundException extends RuntimeException {

    public TaskNotFoundException(Long id) {
        super("Task " + id + " does not exist");
    }
}
TaskService.java
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

TaskController.java
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);
    }
}
AnnotationRole in this controller
@RestControllerReturn 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
@PathVariableReads {id} from the URL
@RequestParamReads ?completed=true from the query string
@RequestBodyConverts the JSON body into a TaskRequest
@ValidChecks the request against its validation annotations
@ResponseStatusSets 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:

Terminal
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"}'
Response
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:

Terminal
# 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:

CodeMeaningUsed in this API when
200 OKSuccess, with a bodyReading, updating or completing a task
201 CreatedA new resource was createdCreating a task (with a Location header)
204 No ContentSuccess, nothing to returnDeleting a task
400 Bad RequestThe request is invalidMissing title, malformed JSON, past due date
401 UnauthorizedNot logged inOnce security is added
403 ForbiddenLogged in, but not allowedOnce security is added
404 Not FoundThe resource does not existUnknown task id
405 Method Not AllowedWrong HTTP method for the URLDELETE /api/tasks
409 ConflictClashes with the current stateDuplicate data or concurrent changes
500 Internal Server ErrorUnexpected server failureA 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:

ApiExceptionHandler.java
@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;
    }
}
GET /api/tasks/99
{
  "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:

PageResponse.java
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());
    }
}
TaskController.java
@GetMapping("/paged")
public PageResponse<TaskResponse> listPaged(
        @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) {
    return PageResponse.from(taskService.findPage(pageable));
}
TaskService.java
@Transactional(readOnly = true)
public Page<TaskResponse> findPage(Pageable pageable) {
    return taskRepository.findAll(pageable).map(TaskResponse::from);
}
Terminal
curl "http://localhost:8080/api/tasks/paged?page=0&size=10&sort=dueDate,asc"
ParameterMeaningExample
pagePage number, starting at 0page=2
sizeItems per pagesize=10
sortField and directionsort=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:

TaskControllerTest.java
@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:

pom.xml
<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:

ApproachTypical use
JWT bearer tokensStateless APIs for single-page apps and mobile apps
OAuth 2.0 / OpenID ConnectLogin through an identity provider, such as Keycloak or a cloud service
API keysSimple 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 Location header for creation, 204 for deletion, 404 for unknown ids.
  • Use DTOs, not entities, in requests and responses.
  • Validate all input with @Valid and 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?
MethodUse it toSend
POSTCreate something new, where the server picks the idThe new resource’s data
PUTReplace a resource completelyEvery field; missing fields become empty
PATCHChange only some fieldsJust 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:

ApproachExampleNotes
URL path/api/v1/tasksSimplest and most visible; the most common choice
HeaderX-API-Version: 2Clean URLs, but harder to test in a browser
Media typeAccept: application/vnd.shop.v2+jsonVery 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:

  1. Paginate collections, so responses stay small.
  2. Fix slow queries: add database indexes, and avoid loading related data in loops.
  3. Cache data that is read often but changes rarely.
  4. Return only what clients need, using DTOs or projections.
  5. 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:

  1. The client sends POST /api/reports.
  2. The API starts the work in the background and immediately answers 202 Accepted, with a Location header pointing to a status resource, such as /api/reports/jobs/17.
  3. The client checks that URL from time to time, until the status is DONE and 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 MVCSpring WebFlux
Programming styleFamiliar, step-by-step codeReactive types (Mono, Flux)
Database accessJPA and JDBCReactive drivers (R2DBC)
Learning curveGentleSteeper; harder to debug
Best forMost applicationsStreaming 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?
ToolGood for
curlQuick 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 BrunoCollections 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.