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

Java
public record Book(Long id, String title, String author) {
}
Java
@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:

Java
public record CreateBookRequest(String title, String author) {
}

The controller

Java
@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:

Java
@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:

Bash
curl -X POST localhost:8080/api/books \
  -H "Content-Type: application/json" \
  -d '{"title":"1984","author":"George Orwell"}'
JSON
{ "id": 1, "title": "1984", "author": "George Orwell" }

Common mistakes

  • Returning the domain object directly instead of ResponseEntity when the status code actually needs to vary (e.g. 404 on a missing resource) — a plain return type can only ever answer 200.
  • Reusing one DTO for both request and response bodies — an incoming CreateBookRequest rarely needs (or should accept) a client-supplied id, but a response typically does need one.
  • Forgetting @RequestMapping at 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.