Building REST APIs with Spring Boot

Project: A Complete REST API

18 min Lesson 10 of 13

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.