Building a REST API
A complete @RestController CRUD example with @RequestBody, @PathVariable, @RequestParam and ResponseEntity status codes.
@RestController
@RestController combines @Controller and @ResponseBody — every method's return value is serialized directly to the HTTP response body (as JSON, by default, via Jackson) instead of being resolved to a view name. It's the standard choice for building a REST API in Spring Boot.
This tutorial builds a complete CRUD API for a simple Book resource.
The model and a repository
public record Book(Long id, String title, String author) {
}
@Repository
public class BookRepository {
private final Map<Long, Book> books = new ConcurrentHashMap<>();
private final AtomicLong nextId = new AtomicLong(1);
public List<Book> findAll() {
return new ArrayList<>(books.values());
}
public Optional<Book> findById(Long id) {
return Optional.ofNullable(books.get(id));
}
public Book save(String title, String author) {
long id = nextId.getAndIncrement();
Book book = new Book(id, title, author);
books.put(id, book);
return book;
}
public Optional<Book> update(Long id, String title, String author) {
if (!books.containsKey(id)) return Optional.empty();
Book updated = new Book(id, title, author);
books.put(id, updated);
return Optional.of(updated);
}
public boolean deleteById(Long id) {
return books.remove(id) != null;
}
}
(A real application would back this with Spring Data JPA instead of an in-memory map — see the Spring Data JPA track — but keeping persistence out of the picture here keeps the controller the focus.)
Request/response DTOs
Records are a natural fit for request bodies — immutable, concise, and Jackson deserializes JSON straight into them:
public record CreateBookRequest(String title, String author) {
}
The controller
@RestController
@RequestMapping("/api/books")
public class BookController {
private final BookRepository bookRepository;
public BookController(BookRepository bookRepository) {
this.bookRepository = bookRepository;
}
@GetMapping
public List<Book> getAllBooks() {
return bookRepository.findAll();
}
@GetMapping("/{id}")
public ResponseEntity<Book> getBook(@PathVariable Long id) {
return bookRepository.findById(id)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<Book> createBook(@RequestBody CreateBookRequest request) {
Book created = bookRepository.save(request.title(), request.author());
URI location = URI.create("/api/books/" + created.id());
return ResponseEntity.created(location).body(created);
}
@PutMapping("/{id}")
public ResponseEntity<Book> updateBook(@PathVariable Long id, @RequestBody CreateBookRequest request) {
return bookRepository.update(id, request.title(), request.author())
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteBook(@PathVariable Long id) {
boolean deleted = bookRepository.deleteById(id);
return deleted ? ResponseEntity.noContent().build() : ResponseEntity.notFound().build();
}
@GetMapping("/search")
public List<Book> searchByAuthor(@RequestParam String author) {
return bookRepository.findAll().stream()
.filter(b -> b.author().equalsIgnoreCase(author))
.toList();
}
}
Request parameter annotations
| Annotation | Source | Example |
|---|---|---|
@PathVariable |
A segment of the URL path | /api/books/{id} → @PathVariable Long id |
@RequestParam |
A query string parameter | /api/books/search?author=Orwell → @RequestParam String author |
@RequestBody |
The deserialized JSON request body | POST with a JSON payload → a record or class |
@RequestParam can declare a default and be made optional:
@GetMapping("/search")
public List<Book> search(@RequestParam(required = false, defaultValue = "") String author) {
// ...
}
Returning correct HTTP status codes with ResponseEntity
Returning a plain object from a controller method always responds 200 OK. ResponseEntity<T> lets you control the status code explicitly, which matters for a well-behaved API:
| Situation | Status | ResponseEntity call |
|---|---|---|
| Resource created | 201 Created |
ResponseEntity.created(location).body(resource) |
| Resource found | 200 OK |
ResponseEntity.ok(resource) |
| Resource not found | 404 Not Found |
ResponseEntity.notFound().build() |
| Deleted successfully, nothing to return | 204 No Content |
ResponseEntity.noContent().build() |
| Invalid request | 400 Bad Request |
ResponseEntity.badRequest().body(errorDetails) |
A quick manual test of the running application:
curl -X POST localhost:8080/api/books \
-H "Content-Type: application/json" \
-d '{"title":"1984","author":"George Orwell"}'
{ "id": 1, "title": "1984", "author": "George Orwell" }
Common mistakes
- Returning the domain object directly instead of
ResponseEntitywhen the status code actually needs to vary (e.g.404on a missing resource) — a plain return type can only ever answer200. - Reusing one DTO for both request and response bodies — an incoming
CreateBookRequestrarely needs (or should accept) a client-suppliedid, but a response typically does need one. - Forgetting
@RequestMappingat the class level and repeating the full path on every method. - Letting the controller talk directly to a raw data structure in a real app instead of a repository/service layer — fine for a minimal example, but it couples HTTP concerns to persistence details as the application grows.