Capstone: A Complete REST API
Reviewed & published by Brayan K
Everything comes together: a CRUD REST API with a controller → service → repository → JPA flow, validation, error handling, security, and tests — the anatomy of a production Spring Boot service.
Part of the free Java course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
Before You Start
This capstone draws on the whole track: dependency injection, security, testing, and containerization. Skim any you're unsure of first.
What You'll Learn
- The controller -> service -> repository flow
- Persisting with JPA repositories
- Validating input with @Valid and Bean Validation
- Centralized error handling with @RestControllerAdvice
- Securing the API with a stateless filter chain
- Slice-testing controllers and the data layer
The Big Idea — A Restaurant's Front of House and Kitchen
💡 Analogy: A REST API runs like a restaurant. The waiter (controller) takes your order, checks it makes sense (validation), and never cooks anything themselves. They pass it to the chef (service), who knows the recipes and the rules (business logic). The chef fetches ingredients from the pantry (repository / JPA / database). The doorman (security) checks who may enter and which tables they can use. If something's wrong — no such dish — the manager (@RestControllerAdvice) sends a polite, consistent message back (a 404), not chaos. And the health inspectors (tests) visit regularly to make sure every station does its job.
Clear roles, clean hand-offs, and consistent responses — that's a well-built API.
1️⃣ The Flow — Controller → Service → Repository
Each layer has one job: the controller handles HTTP, the service holds logic, the repository handles data. The plain-Java model below shows the flow — including a missing resource becoming a 404.
import java.util.*;
public class Main {
// THE SHAPE OF A REST API: request flows Controller -> Service -> Repository.
// Each layer has ONE job. We model the flow in plain Java so the separation
// of concerns is concrete before seeing the annotated Spring version.
record Book(Long id, String title) {}
// Repository: data access only.
static class BookRepository {
private final Map<Long, Book> rows = new LinkedHashMap<>();
Optional<Book> findById(Long id) { return Optional.ofNullable(rows.get(id)); }
Book save(Book b) { rows.put(b.id(), b); return b; }
}
// Service: business logic + orchestration.
static class BookService {
private final BookRepository repo;
BookService(BookRepository repo) { this.repo = repo; }
Book get(Long id) {
return repo.findById(id)
.orElseThrow(() -> new NoSuchElementException("book " + id));
}
Book create(String title) { return repo.save(new Book((long) (title.hashCode() & 7), title)); }
}
// Controller: maps "HTTP" to service calls (here just method calls).
static class BookController {
private final BookService service;
BookController(BookService service) { this.service = service; }
String getBook(Long id) {
try { return "200 OK " + service.get(id).title(); }
catch (NoSuchElementException e) { return "404 Not Found"; } // error handling
}
}
public static void main(String[] args) {
BookController api = new BookController(new BookService(new BookRepository()));
// (no books saved yet)
System.out.println("GET /books/1 -> " + api.getBook(1L));
}
}2️⃣ Validation and Error Handling
Reject bad input with a 400 before it reaches your logic, and turn missing resources into a 404. Spring uses @Valid + Bean Validation and @RestControllerAdvice; the demo hand-rolls the same idea.
import java.util.*;
public class Main {
// VALIDATION + ERROR HANDLING: reject bad input with a 400 before it reaches
// the service. Spring does this with @Valid + Bean Validation; we hand-roll
// the same idea to show what a validated create endpoint actually does.
record CreateBookRequest(String title, int pages) {}
static List<String> validate(CreateBookRequest r) {
List<String> errors = new ArrayList<>();
if (r.title() == null || r.title().isBlank()) errors.add("title: must not be blank");
if (r.pages() <= 0) errors.add("pages: must be positive");
return errors;
}
static String createBook(CreateBookRequest r) {
List<String> errors = validate(r);
if (!errors.isEmpty()) return "400 Bad Request " + errors; // client error
return "201 Created '" + r.title() + "'"; // success
}
public static void main(String[] args) {
System.out.println(createBook(new CreateBookRequest("Clean Code", 464)));
System.out.println(createBook(new CreateBookRequest("", -5)));
}
}3️⃣ The Spring Version — Controller, Service, JPA
Now the real thing: a @RestController delegating to a @Service backed by a Spring Data JpaRepository, with @Valid for validation and @RestControllerAdvice for centralized error handling.
@RestController
@RequestMapping("/api/books")
class BookController {
@GetMapping("/{id}") Book getOne(@PathVariable Long id) { return service.get(id); }
}// THE SPRING VERSION (needs spring-boot-starter-web + data-jpa + validation).
// Controller -> Service -> Repository -> JPA, with validation, error handling,
// and security all tied together — the capstone of this track.
@Entity
class Book {
@Id @GeneratedValue Long id;
String title;
int pages;
// getters/setters omitted
}
interface BookRepository extends JpaRepository<Book, Long> { } // data layer
@Service
class BookService {
private final BookRepository repo;
BookService(BookRepository repo) { this.repo = repo; } // constructor DI
Book get(Long id) {
return repo.findById(id)
.orElseThrow(() -> new BookNotFoundException(id)); // -> 404
}
Book create(Book b) { return repo.save(b); }
}
@RestController
@RequestMapping("/api/books")
class BookController {
private final BookService service;
BookController(BookService service) { this.service = service; }
@GetMapping("/{id}")
Book getOne(@PathVariable Long id) { return service.get(id); }
@PostMapping
@ResponseStatus(HttpStatus.CREATED) // 201 on create
Book create(@Valid @RequestBody Book book) { // @Valid -> 400
return service.create(book);
}
}
// Centralized error handling: turn exceptions into clean HTTP responses.
@RestControllerAdvice
class ApiExceptionHandler {
@ExceptionHandler(BookNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND) // 404
Map<String, String> notFound(BookNotFoundException ex) {
return Map.of("error", ex.getMessage());
}
}4️⃣ Security and Tests — Completing the Picture
A stateless SecurityFilterChain guards the API, and @WebMvcTest + MockMvc verify the controller — including the 404 path — with the service mocked.
// SECURITY + TESTS, completing the picture.
// 1) Lock down the API with a stateless, JWT-aware filter chain.
@Configuration
@EnableWebSecurity
class SecurityConfig {
@Bean SecurityFilterChain chain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable())
.sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
.authorizeHttpRequests(a -> a
.requestMatchers(HttpMethod.GET, "/api/books/**").permitAll()
.anyRequest().authenticated());
return http.build();
}
}
// 2) Slice-test the web layer with MockMvc + a mocked service.
@WebMvcTest(BookController.class)
class BookControllerTest {
@Autowired MockMvc mockMvc;
@MockBean BookService service;
@Test void getReturnsBookJson() throws Exception {
Mockito.when(service.get(1L)).thenReturn(new Book(1L, "Clean Code", 464));
mockMvc.perform(get("/api/books/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.title").value("Clean Code"));
}
@Test void missingBookReturns404() throws Exception {
Mockito.when(service.get(99L)).thenThrow(new BookNotFoundException(99L));
mockMvc.perform(get("/api/books/99")).andExpect(status().isNotFound());
}
}🧠 Quick Recall
Predict before you peek.
Answer: the service layer — controllers stay thin (HTTP) and repositories stay focused on data access.
Answer: 400 Bad Request — triggered by @Valid failing Bean Validation constraints.
Answer: throw a not-found exception and map it with @ExceptionHandler in a @RestControllerAdvice.
🎯 YOUR TURN — A Service Lookup with 404
Complete get(id) so a missing id throws (the controller would map it to 404).
import java.util.*;
public class Main {
// YOUR TURN: complete the service method that returns a book or signals 404.
// Use Optional.orElseThrow so a missing id becomes a NoSuchElementException
// (which the controller maps to 404).
//
// Expected output:
// found: Refactoring
// missing -> 404
static Map<Long, String> rows = new HashMap<>(Map.of(1L, "Refactoring"));
static String get(Long id) {
// return Optional.ofNullable(rows.get(id)).orElseThrow(...);
return null;
}
public static void main(String[] args) {
System.out.println("found: " + get(1L));
try { get(2L); } catch (RuntimeException e) { System.out.println("missing -> 404"); }
}
}🧩 MINI-CHALLENGE — A Full Create Flow
Validate the request, then persist and return 201 with an id, or return 400 with the errors.
Common Errors
- ❌ Fat controllers. Business logic leaks into the web layer. Fix: push logic into the service.
- ❌ Returning 200 for failures. Clients can't detect errors. Fix: use correct status codes (400/404/409).
- ❌ No @Valid on request bodies. Bad input reaches the service. Fix: validate at the boundary.
- ❌ Leaking entities/stack traces. Exposes internals. Fix: use DTOs and @RestControllerAdvice.
- ❌ Stateful sessions in a token API. Breaks scaling. Fix: set SessionCreationPolicy.STATELESS.
- ❌ Only full-context tests. Slow, hard to diagnose. Fix: favour slice tests, add a few integration tests.
Pro Tips
- 💡 Keep layers honest: controllers never touch repositories directly — always go through the service.
- 💡 Return a Location header with 201 Created so clients can find the new resource.
- 💡 Centralize error responses in one @RestControllerAdvice for a consistent contract.
- 💡 Expose /actuator/health and ship the app as a container to round out a production-ready service.
📋 Quick-Reference Recap — The Whole Track
| Concern | Key annotation / idea | Note |
|---|---|---|
| wiring | constructor DI, @Service | Decoupled, testable |
| web layer | @RestController, @GetMapping | HTTP only |
| data layer | JpaRepository | Persistence |
| validation | @Valid + constraints | 400 on failure |
| error handling | @RestControllerAdvice | e.g. 404, 409 |
| security | SecurityFilterChain | Stateless + JWT |
| web tests | @WebMvcTest + MockMvc | Mock the service |
| data tests | @DataJpaTest | Rolled back |
| monitoring | Actuator + Micrometer | /health, /metrics |
| caching | @Cacheable / @CacheEvict | Speed + freshness |
| deploy | Docker / buildpacks | Env-var config |
📚 Resources / Keep Learning
- 📘 Building REST services with Spring — tutorial
- 📗 Web (Servlet) applications — Spring Boot reference
- 📗 Building a RESTful Web Service — Spring guide
🎉 Track Complete!
You've assembled a complete REST API: a controller → service → repository → JPA flow with validation, centralized error handling, stateless security, and a layered test strategy — and you know how to monitor, cache, and containerize it. That's the full anatomy of a production Spring Boot service.
Congratulations on finishing the Spring Boot & Enterprise Java track. Keep building — the best way to cement these ideas is to ship a small API of your own.
Practice quiz
In the classic layered architecture, which layer talks to the database?
- Controller
- Service
- Repository
- DTO
Answer: Repository. The repository (data access) layer is responsible for persistence; the service orchestrates and the controller handles HTTP.
What is the controller layer responsible for?
- Persisting entities
- Handling HTTP requests/responses and delegating to the service
- Defining the database schema
- Hashing passwords
Answer: Handling HTTP requests/responses and delegating to the service. Controllers map HTTP to method calls, validate input, and delegate business logic to the service layer.
Where does business logic belong in this design?
- The controller
- The repository
- The service layer
- The entity getters
Answer: The service layer. The service layer holds business rules and coordinates repositories, keeping controllers thin and repositories focused on data.
Which annotation triggers Bean Validation on a request body?
- @Valid
- @Cacheable
- @Transactional
- @Bean
Answer: @Valid. Annotating a @RequestBody parameter with @Valid runs Bean Validation constraints like @NotBlank and @Size.
What is a good way to return a 404 when a resource is missing?
- Return null
- Log and continue
- Return an empty 200
- Throw an exception handled by @ExceptionHandler/@ControllerAdvice mapping to 404
Answer: Throw an exception handled by @ExceptionHandler/@ControllerAdvice mapping to 404. Throw a not-found exception and map it to HTTP 404 via @ExceptionHandler or @ControllerAdvice for consistent error handling.
Which HTTP status best fits a successful resource creation?
- 200 OK
- 201 Created
- 204 No Content
- 400 Bad Request
Answer: 201 Created. 201 Created signals a new resource was created, typically with a Location header pointing to it.
@ControllerAdvice is used to...
- create beans
- configure caching
- centralize cross-cutting exception handling for controllers
- define entities
Answer: centralize cross-cutting exception handling for controllers. @ControllerAdvice (or @RestControllerAdvice) centralizes exception handling and other advice across controllers.
To slice-test only the web layer of this API you would use...
- @DataJpaTest
- @WebMvcTest with MockMvc and @MockBean
- @SpringBootTest only
- no annotation
Answer: @WebMvcTest with MockMvc and @MockBean. @WebMvcTest loads just the controller; MockMvc drives requests and @MockBean stubs the service.
In a secured REST API, a stateless token is typically sent as...
- a query string password
- a hidden form field
- an X-Powered-By header
- Authorization: Bearer <jwt>
Answer: Authorization: Bearer <jwt>. Stateless APIs carry a JWT in the Authorization header as a Bearer token, validated by a security filter.
Why map JPA entities to DTOs at the API boundary?
- To slow responses
- To decouple the API contract from the persistence model and avoid leaking internals
- Because entities can't be serialized
- To skip validation
Answer: To decouple the API contract from the persistence model and avoid leaking internals. DTOs keep the external contract stable and prevent exposing or over-binding internal entity fields.
Continue this course
- Previous: Containerizing a Spring Boot App
- Next: Database Migrations with Flyway — Versioned schema migrations that run on startup — safer than ddl-auto in production
- Quick reference: Java cheat sheet
Frequently asked questions
What is the controller-service-repository architecture, and why use it?
It is a layered design that separates responsibilities: the controller handles HTTP (routing, request/response, validation, status codes) and delegates to the service; the service holds business logic and orchestrates work, calling one or more repositories; the repository handles data access against the database via JPA. Each layer has a single clear job, which makes the code easier to test (you can mock the layer below), easier to change (swap a repository implementation without touching controllers), and easier to reason about. Keeping controllers thin and pushing logic into services is the backbone of a maintainable Spring REST API.
How do validation and error handling fit together in a REST API?
Validation rejects bad input early: you annotate the request object with Bean Validation constraints (@NotBlank, @Size, @Positive) and mark the controller parameter with @Valid, so an invalid body produces a 400 Bad Request before any business logic runs. Error handling translates exceptions into clean HTTP responses: you throw domain exceptions (e.g. BookNotFoundException) from the service and map them centrally with @RestControllerAdvice and @ExceptionHandler — for instance to 404 Not Found. Together they give clients consistent, meaningful status codes and messages instead of leaking stack traces or returning 200 for failures.
How does security wrap around the rest of the API?
Security is applied as a filter chain that runs before your controllers. In a stateless REST API you configure a SecurityFilterChain bean, set the session policy to STATELESS, and add a JWT authentication filter that validates the Bearer token and populates the SecurityContext. Authorization rules decide which endpoints are public and which require authentication or specific roles. Because security sits in the filter chain rather than inside controllers, your business code stays clean, and you can test the web layer independently while trusting that protected routes are guarded.
What does a healthy test strategy for this API look like?
Use the test pyramid. Plain unit tests cover service business logic with mocked repositories. Slice tests cover each layer fast: @WebMvcTest with MockMvc and @MockBean for controllers (asserting status codes and JSON), and @DataJpaTest for repositories (asserting queries against an embedded database, rolled back per test). A smaller number of @SpringBootTest integration tests verify that the wired-together application behaves correctly end to end, and Testcontainers can provide a real database when fidelity matters. Most tests should be fast slices; a few should be full integration.
Why map entities to DTOs at the API boundary?
A DTO (data transfer object) is the shape your API exposes, distinct from the JPA @Entity that maps to the database. Mapping between them decouples your public contract from your internal model: you can evolve the database schema without breaking clients, avoid accidentally serializing sensitive or lazy-loaded fields, and prevent over-binding where a client sets fields it shouldn't. While very small apps sometimes return entities directly for speed, DTOs are the safer default for any API you intend to maintain, and they pair naturally with the validation you apply on incoming request DTOs.