Project: A Complete REST API
Everything in this tutorial — REST principles, request mapping, path variables, request bodies, ResponseEntity, HTTP semantics, CRUD design, Jackson serialization, and versioning — converges in this final lesson. You will build a production-style Book Catalog API from scratch: a self-contained Spring Boot 3 application with layered architecture, proper error handling, and a versioned, documented resource surface. Work through it top-to-bottom and you will have a reusable template for every REST service you build next.
Project Overview
The service manages a catalog of books. Clients can create, retrieve, update, and delete books, list all books with optional filtering, and search by ISBN. The public contract is versioned under /api/v1. All responses are JSON; all errors follow RFC 7807 Problem Details.
- Domain:
Book (id, title, author, isbn, publishedYear, available)
- Persistence: in-memory
ConcurrentHashMap (swap for JPA with zero controller changes)
- Layers: Controller → Service → Repository
- Error handling:
@ControllerAdvice global handler
- Versioning: URL-path strategy (
/api/v1/books)
Step 1 — Project Setup (pom.xml)
Start with the Spring Initializr selections: Spring Web and Validation. The key Maven dependencies:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.0</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Step 2 — Domain Model & DTOs
Keep your entity (what lives in the store) separate from the DTO (what crosses the wire). This lets you evolve the internal model without breaking clients.
// domain/Book.java
package com.example.catalog.domain;
public class Book {
private Long id;
private String title;
private String author;
private String isbn;
private int publishedYear;
private boolean available;
// constructors, getters, setters omitted for brevity
}
// dto/BookRequest.java — what the client sends on POST/PUT
package com.example.catalog.dto;
import jakarta.validation.constraints.*;
public record BookRequest(
@NotBlank String title,
@NotBlank String author,
@NotBlank @Pattern(regexp = "\\d{13}") String isbn,
@Min(1450) @Max(2100) int publishedYear,
boolean available
) {}
// dto/BookResponse.java — what the API always returns
package com.example.catalog.dto;
public record BookResponse(
Long id,
String title,
String author,
String isbn,
int publishedYear,
boolean available
) {}
Records as DTOs: Java 16+ records give you immutable data carriers with generated constructors, accessors, equals, hashCode, and toString for free. They are ideal for request/response shapes — you cannot accidentally mutate them mid-request.
Step 3 — Repository Layer
The repository hides persistence details. Here it uses an in-memory map; a JPA version would implement the same interface without changing any other layer.
// repository/BookRepository.java
package com.example.catalog.repository;
import com.example.catalog.domain.Book;
import org.springframework.stereotype.Repository;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
@Repository
public class BookRepository {
private final Map<Long, Book> store = new ConcurrentHashMap<>();
private final AtomicLong idSequence = new AtomicLong(1);
public Book save(Book book) {
if (book.getId() == null) {
book.setId(idSequence.getAndIncrement());
}
store.put(book.getId(), book);
return book;
}
public Optional<Book> findById(Long id) {
return Optional.ofNullable(store.get(id));
}
public Optional<Book> findByIsbn(String isbn) {
return store.values().stream()
.filter(b -> b.getIsbn().equals(isbn))
.findFirst();
}
public List<Book> findAll() {
return List.copyOf(store.values());
}
public boolean delete(Long id) {
return store.remove(id) != null;
}
}
Step 4 — Service Layer
The service owns business logic: mapping between domain objects and DTOs, enforcing uniqueness constraints, and throwing domain-specific exceptions that the controller layer never needs to know the reason for.
// service/BookService.java
package com.example.catalog.service;
import com.example.catalog.domain.Book;
import com.example.catalog.dto.*;
import com.example.catalog.exception.BookNotFoundException;
import com.example.catalog.exception.DuplicateIsbnException;
import com.example.catalog.repository.BookRepository;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class BookService {
private final BookRepository repo;
public BookService(BookRepository repo) { this.repo = repo; }
public BookResponse create(BookRequest req) {
repo.findByIsbn(req.isbn()).ifPresent(b -> {
throw new DuplicateIsbnException(req.isbn());
});
Book book = toEntity(req);
return toResponse(repo.save(book));
}
public BookResponse findById(Long id) {
return toResponse(repo.findById(id)
.orElseThrow(() -> new BookNotFoundException(id)));
}
public List<BookResponse> findAll() {
return repo.findAll().stream().map(this::toResponse).toList();
}
public BookResponse update(Long id, BookRequest req) {
Book existing = repo.findById(id)
.orElseThrow(() -> new BookNotFoundException(id));
existing.setTitle(req.title());
existing.setAuthor(req.author());
existing.setIsbn(req.isbn());
existing.setPublishedYear(req.publishedYear());
existing.setAvailable(req.available());
return toResponse(repo.save(existing));
}
public void delete(Long id) {
if (!repo.delete(id)) throw new BookNotFoundException(id);
}
// --- private mappers ---
private Book toEntity(BookRequest r) {
Book b = new Book();
b.setTitle(r.title());
b.setAuthor(r.author());
b.setIsbn(r.isbn());
b.setPublishedYear(r.publishedYear());
b.setAvailable(r.available());
return b;
}
private BookResponse toResponse(Book b) {
return new BookResponse(b.getId(), b.getTitle(), b.getAuthor(),
b.getIsbn(), b.getPublishedYear(), b.isAvailable());
}
}
Step 5 — REST Controller
The controller's only job is HTTP: parse the request, call the service, and shape the response. Business decisions live nowhere in this file.
// controller/BookController.java
package com.example.catalog.controller;
import com.example.catalog.dto.*;
import com.example.catalog.service.BookService;
import jakarta.validation.Valid;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.net.URI;
import java.util.List;
@RestController
@RequestMapping("/api/v1/books")
public class BookController {
private final BookService service;
public BookController(BookService service) { this.service = service; }
@PostMapping
public ResponseEntity<BookResponse> create(@Valid @RequestBody BookRequest req) {
BookResponse created = service.create(req);
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(created.id())
.toUri();
return ResponseEntity.created(location).body(created);
}
@GetMapping
public ResponseEntity<List<BookResponse>> listAll() {
return ResponseEntity.ok(service.findAll());
}
@GetMapping("/{id}")
public ResponseEntity<BookResponse> getOne(@PathVariable Long id) {
return ResponseEntity.ok(service.findById(id));
}
@PutMapping("/{id}")
public ResponseEntity<BookResponse> update(
@PathVariable Long id,
@Valid @RequestBody BookRequest req) {
return ResponseEntity.ok(service.update(id, req));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
service.delete(id);
return ResponseEntity.noContent().build();
}
}
Return the Location header on POST 201: ServletUriComponentsBuilder.fromCurrentRequest() builds the URL of the new resource from the current request context. This is the standard REST contract — clients must not need to construct URLs themselves.
Step 6 — Global Exception Handler
Centralise error responses in one @ControllerAdvice. Every handler returns an RFC 7807-style body so clients always receive a consistent error shape.
// exception/GlobalExceptionHandler.java
package com.example.catalog.exception;
import org.springframework.http.*;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;
import java.time.Instant;
import java.util.*;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BookNotFoundException.class)
public ResponseEntity<Map<String, Object>> handleNotFound(BookNotFoundException ex) {
return buildError(HttpStatus.NOT_FOUND, ex.getMessage());
}
@ExceptionHandler(DuplicateIsbnException.class)
public ResponseEntity<Map<String, Object>> handleDuplicate(DuplicateIsbnException ex) {
return buildError(HttpStatus.CONFLICT, ex.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidation(
MethodArgumentNotValidException ex) {
List<String> errors = ex.getBindingResult().getFieldErrors().stream()
.map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
.toList();
Map<String, Object> body = new LinkedHashMap<>();
body.put("timestamp", Instant.now());
body.put("status", 422);
body.put("errors", errors);
return ResponseEntity.unprocessableEntity().body(body);
}
private ResponseEntity<Map<String, Object>> buildError(HttpStatus status, String msg) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("timestamp", Instant.now());
body.put("status", status.value());
body.put("error", status.getReasonPhrase());
body.put("message", msg);
return ResponseEntity.status(status).body(body);
}
}
Never leak stack traces to clients. Spring Boot's default /error endpoint can include exception details in development mode. Set server.error.include-stacktrace=never and server.error.include-message=never in application.properties for production profiles to prevent information disclosure.
Step 7 — Testing the API
With the application running (mvn spring-boot:run), exercise the full lifecycle:
# Create a book — expect 201 Created + Location header
curl -s -X POST http://localhost:8080/api/v1/books \
-H "Content-Type: application/json" \
-d '{"title":"Clean Code","author":"Robert Martin","isbn":"9780132350884","publishedYear":2008,"available":true}' \
| jq .
# List all books — expect 200 OK
curl -s http://localhost:8080/api/v1/books | jq .
# Fetch by id — expect 200 OK
curl -s http://localhost:8080/api/v1/books/1 | jq .
# Update — expect 200 OK
curl -s -X PUT http://localhost:8080/api/v1/books/1 \
-H "Content-Type: application/json" \
-d '{"title":"Clean Code","author":"Robert C. Martin","isbn":"9780132350884","publishedYear":2008,"available":false}' \
| jq .
# Delete — expect 204 No Content
curl -s -X DELETE http://localhost:8080/api/v1/books/1 -o /dev/null -w "%{http_code}"
# Duplicate ISBN — expect 409 Conflict
# Not-found — expect 404 Not Found
# Invalid body (missing title) — expect 422 Unprocessable Entity
Architecture Recap & What to Build Next
The layers in this project form a repeatable pattern: Controller (HTTP), Service (logic + mapping), Repository (storage). Each layer has one reason to change. To extend this project toward production:
- Replace the in-memory repository with Spring Data JPA + an H2 or PostgreSQL datasource — no controller or service changes required.
- Add Spring Security with JWT authentication — intercepted at the servlet filter layer, invisible to the business logic.
- Introduce pagination: change
findAll() to accept a Pageable parameter and return Page<BookResponse>.
- Document the API with SpringDoc OpenAPI — add the dependency, annotate with
@Operation and @ApiResponse, visit /swagger-ui.html.
- Add
@SpringBootTest + MockMvc integration tests for every endpoint and every error path.
Congratulations — you have completed the REST APIs with Spring Boot tutorial. You now own a clean, layered, versioned, and error-safe REST service. The patterns you applied here — thin controllers, service-layer mapping, centralised exception handling, and DTO separation — scale to enterprise applications of any size.