Building a REST API with Spring Boot and @RestController

Introduction

This tutorial builds a complete REST API with Spring Boot and @RestController: a small book catalogue that clients can list, read, create, update and delete over HTTP. We will start with what a REST API is - resources, URLs, HTTP methods and JSON - and how Spring MVC handles a request. We will then write the controller one endpoint at a time and call every endpoint with real HTTP requests, looking at the status codes and JSON that come back. Next we will see how Spring turns Java objects into JSON and back, and what @RestController really is. Lastly, we will handle a missing book with a 404 response and list the design rules that make an API pleasant to use.

(This tutorial starts module 3 of the Spring series, on REST APIs. It assumes the basics from Your First Spring Boot Application with Spring InitializrClick to read tutorial: Your First Spring Boot Application with Spring Initializr; the examples use the same spring-boot-starter-webmvc dependency.)

What is a REST API?

REST (Representational State Transfer) is a style of designing web services around resources - the things an application manages, such as books, orders or customers. A REST API exposes each resource at a URL and lets clients act on it with the standard HTTP methods -
  • GET /api/books - read the collection of books.
  • GET /api/books/2 - read the book with id 2.
  • POST /api/books - create a new book from the data in the request body.
  • PUT /api/books/2 - replace book 2 with the data in the request body.
  • DELETE /api/books/2 - delete book 2.
The data travels as JSON, and the server reports the outcome with an HTTP status code: 200 OK, 201 Created, 204 No Content, 404 Not Found and so on. Each request carries everything the server needs; the server keeps no per-client session between requests. This is what makes REST APIs easy to call from any language, and easy to scale across several servers.

How Spring MVC handles a request

Spring MVC is built around one servlet, the DispatcherServlet, which Spring Boot registers in the embedded Tomcat. For every request it -
  1. finds the handler method whose mapping matches the request's HTTP method and path;
  2. converts the request's path variables, query parameters and JSON body into the method's arguments;
  3. calls the method;
  4. converts the return value into the response body - for a REST controller, JSON - using an HTTP message converter, and sets the status code.
Our code only provides step 3: plain methods that take and return Java objects.

The book catalogue API

The application has three parts: a Book record, a small in-memory repository (a database comes in module 4), and the controller. All of the code is in one file to keep the example easy to follow -
BookApiApp.java - a complete REST API for books
//BookApiApp.java
package com.javabrahman.rest.books;

import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Repository;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

record Book(Long id, String title, String author, int year) {
  Book withId(Long newId) {
    return new Book(newId, title, author, year);
  }
}

@Repository
class BookRepository {
  private final Map<Long, Book> books = new ConcurrentHashMap<>();
  private final AtomicLong nextId = new AtomicLong();

  BookRepository() {
    save(new Book(null, "Effective Java", "Joshua Bloch", 2018));
    save(new Book(null, "Java Concurrency in Practice", "Brian Goetz", 2006));
  }

  List<Book> findAll() {
    return books.values().stream().sorted((a, b) -> a.id().compareTo(b.id())).toList();
  }

  Optional<Book> findById(long id) {
    return Optional.ofNullable(books.get(id));
  }

  Book save(Book book) {
    Book stored = book.id() == null ? book.withId(nextId.incrementAndGet()) : book;
    books.put(stored.id(), stored);
    return stored;
  }

  boolean delete(long id) {
    return books.remove(id) != null;
  }
}

@RestController
@RequestMapping("/api/books")   // common path prefix for every method below
class BookController {
  private final BookRepository repository;

  BookController(BookRepository repository) {
    this.repository = repository;
  }

  @GetMapping
  List<Book> all() {
    return repository.findAll();
  }

  @GetMapping("/{id}")
  Book one(@PathVariable long id) {
    return repository.findById(id)
        .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "No book with id " + id));
  }

  @PostMapping
  @ResponseStatus(HttpStatus.CREATED)
  Book create(@RequestBody Book book) {
    return repository.save(book.withId(null));   // the server, not the client, chooses the id
  }

  @PutMapping("/{id}")
  Book replace(@PathVariable long id, @RequestBody Book book) {
    one(id);   // 404 if it does not exist
    return repository.save(book.withId(id));
  }

  @DeleteMapping("/{id}")
  @ResponseStatus(HttpStatus.NO_CONTENT)
  void delete(@PathVariable long id) {
    if (!repository.delete(id)) {
      throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No book with id " + id);
    }
  }
}

@SpringBootApplication
public class BookApiApp {
  public static void main(String[] args) {
    SpringApplication.run(BookApiApp.class, args);
  }
}

Explanation of the code

  • @RestController marks BookController as a bean whose methods handle web requests and return response bodies.
  • @RequestMapping("/api/books") on the class sets the path prefix; each method's mapping adds to it, so @GetMapping("/{id}") handles GET /api/books/{id}.
  • @GetMapping, @PostMapping, @PutMapping and @DeleteMapping choose the HTTP method. They are shortcuts for @RequestMapping(method = ...).
  • @PathVariable long id takes the {id} part of the path and converts it to a long. @RequestBody Book book converts the JSON request body into a Book. Both are covered in detail in Spring MVC Request MappingClick to read tutorial: Spring MVC Request Mapping: Paths, Params and Bodies.
  • Methods return plain Java objects - a List<Book>, a Book - and Spring writes them as JSON. By default the status is 200 OK; @ResponseStatus changes it to 201 Created for the POST and 204 No Content for the DELETE.
  • ResponseStatusException ends the request with an error status and a reason; Spring Boot turns it into an error response.
  • The repository uses a ConcurrentHashMap and an AtomicLong because the controller is a singletonClick to read tutorial: Spring Bean Scopes: Singleton, Prototype, Request, Session serving many requests on many threads at once.

Calling the API

With the application running, these requests exercise every endpoint in turn - list, read, create, read the new book, replace it, delete one, and finally list again. The responses are real, from the running application; each one shows the status line, the headers and the body, as curl -i prints them -  OUTPUT of the HTTP requests
> curl -i http://localhost:8080/api/books
HTTP/1.1 200
Content-Length: 154
Content-Type: application/json

[{"id":1,"title":"Effective Java","author":"Joshua Bloch","year":2018},{"id":2,"title":"Java Concurrency in Practice","author":"Brian Goetz","year":2006}]

> curl -i http://localhost:8080/api/books/1
HTTP/1.1 200
Content-Length: 69
Content-Type: application/json

{"id":1,"title":"Effective Java","author":"Joshua Bloch","year":2018}

> curl -i -X POST http://localhost:8080/api/books -H "Content-Type: application/json" -d '{"title":"Modern Java in Action","author":"Raoul-Gabriel Urma","year":2018}'
HTTP/1.1 201
Content-Length: 82
Content-Type: application/json

{"id":3,"title":"Modern Java in Action","author":"Raoul-Gabriel Urma","year":2018}

> curl -i http://localhost:8080/api/books/3
HTTP/1.1 200
Content-Length: 82
Content-Type: application/json

{"id":3,"title":"Modern Java in Action","author":"Raoul-Gabriel Urma","year":2018}

> curl -i -X PUT http://localhost:8080/api/books/3 -H "Content-Type: application/json" -d '{"title":"Modern Java in Action","author":"Raoul-Gabriel Urma, Mario Fusco, Alan Mycroft","year":2019}'
HTTP/1.1 200
Content-Length: 109
Content-Type: application/json

{"id":3,"title":"Modern Java in Action","author":"Raoul-Gabriel Urma, Mario Fusco, Alan Mycroft","year":2019}

> curl -i -X DELETE http://localhost:8080/api/books/2
HTTP/1.1 204

> curl -i http://localhost:8080/api/books
HTTP/1.1 200
Content-Length: 181
Content-Type: application/json

[{"id":1,"title":"Effective Java","author":"Joshua Bloch","year":2018},{"id":3,"title":"Modern Java in Action","author":"Raoul-Gabriel Urma, Mario Fusco, Alan Mycroft","year":2019}]

Explanation of the responses

  • The first GET returned the two books the repository starts with, as a JSON array, with Content-Type: application/json.
  • The POST sent a book without an id; the API stored it as book 3 and answered 201 with the stored book, id included.
  • The PUT replaced the whole of book 3 with the new author list and year.
  • The DELETE answered 204 with no body - there is nothing to return about a deleted resource.
  • The final GET shows the result of all the changes: book 2 is gone and book 3 has its new data.

From Java objects to JSON and back

Spring MVC does not know JSON itself. It delegates to HTTP message converters, and Spring Boot configures one for JSON backed by Jackson - in Spring Boot 4, Jackson 3. When a method returns a Book, the converter writes its record components as JSON fields; when a method takes a @RequestBody Book, the converter reads the JSON fields into the record's constructor. Records, classes with getters, lists, maps, dates and enums all work out of the box. Which converter is used depends on the request's Content-Type and Accept headers, which is the subject of content negotiation in CORS, Content Negotiation and File UploadsClick to read tutorial: CORS, Content Negotiation and File Uploads in Spring Boot.

What @RestController is

@RestController is a combination of two annotations -
  • @Controller, which makes the class a component that Spring MVC searches for handler methods;
  • @ResponseBody, which tells Spring MVC to write each method's return value straight into the response body, instead of treating it as the name of a view template.
A plain @Controller is used for server-rendered HTML pages with a template engine such as Thymeleaf, where a method returns a view name. For APIs, @RestController is the right choice.

When a book does not exist

Asking for a book that is not there, or deleting it, should answer 404 Not Found rather than 200 with an empty body or a 500 server error. The ResponseStatusException in the controller does exactly that -  OUTPUT of requests for a book that does not exist
> curl -i http://localhost:8080/api/books/99
HTTP/1.1 404
Content-Type: application/json

{"timestamp":"2026-11-06T09:17:30.776Z","status":404,"error":"Not Found","path":"/api/books/99"}

> curl -i -X DELETE http://localhost:8080/api/books/99
HTTP/1.1 404
Content-Type: application/json

{"timestamp":"2026-11-06T09:17:30.882Z","status":404,"error":"Not Found","path":"/api/books/99"}
Spring Boot produced the JSON error body, with a timestamp, the status, the error name and the path. Its reason text is hidden by default, because messages can reveal internal details to clients. The tutorial on REST error handling with @ControllerAdvice and ProblemDetailClick to read tutorial: REST Error Handling with @ControllerAdvice and ProblemDetail replaces this default with a standard, more informative error format.

REST API design rules of thumb

Summary

In this tutorial we first saw what a REST API is - resources at URLs, HTTP methods, JSON and status codes - and how the DispatcherServlet routes a request to a handler method. We then wrote a complete book catalogue API with @RestController and the @GetMapping, @PostMapping, @PutMapping and @DeleteMapping annotations, and called every endpoint with real requests, getting 200, 201 and 204 responses. Next we saw how Jackson-based message converters turn objects into JSON and back, and what @RestController combines. Lastly, we answered 404 for missing books with ResponseStatusException and listed REST design rules of thumb. The next tutorial looks closely at how requests reach a method: request mapping, path variables, parameters and bodiesClick to read tutorial: Spring MVC Request Mapping: Paths, Params and Bodies.