Hands-on course Β· 9 stages

Spring Boot, Stage by Stage

You know Java. By the end of this course you'll have designed, secured, tested, containerized, and split into microservices a real system: a Library Management API. Every stage is hands-on β€” the project already exists on your machine, and you write every line of application code yourself.

The project lives at D:\Mtech RA nitk\Intership Student\springboot-learning\library-api. This page is your guide; the code on disk is your workbench. All nine stages are written into this page β€” each one unlocks when you mark the previous one complete, so you can't accidentally skim ahead of what you've actually built.

The roadmap

One project, nine stages. Each stage adds a capability to the same codebase, the way a real system grows. Stages 8–9 then split it apart. Finish a stage's checkpoint, click Mark Complete, and the next one unlocks.

1
Foundations
How Spring Boot works (DI, auto-configuration, starters), project anatomy, run the app, write your first REST endpoints.
2
REST API design
Controllers done properly: the service layer, DTOs with records, dependency injection in practice, ResponseEntity, full in-memory CRUD for books.
3
Persistence β€” Spring Data JPA
Entities, repositories, H2 then a real database, relationships (Book–Author), pagination and sorting.
4
Validation & error handling
Bean Validation, @RestControllerAdvice, RFC 9457 ProblemDetail responses β€” APIs that fail informatively.
5
Testing
JUnit 5, Mockito, @WebMvcTest for the web slice, @DataJpaTest for the data slice, full @SpringBootTest integration tests.
6
Security & JWT
Spring Security's filter chain, stateless JWT authentication, roles (LIBRARIAN vs MEMBER), method-level authorization.
7
Production-ready
Profiles (dev/prod), externalized configuration, structured logging, actuator in depth, packaging the jar, Docker.
8
Microservices
Split into catalog-service and loan-service, service-to-service calls with RestClient, resilience basics.
9
Messaging
Event-driven communication: a loan event published to RabbitMQ, consumed by a notification-service. Sync vs async trade-offs.

Your toolchain (already checked)

ToolStatus on your machineWhy it matters
JDK 22Installed βœ“Spring Boot needs JDK 17+. The project targets Java 21 bytecode; JDK 22 compiles and runs it fine.
MavenNot installed β€” not neededThe project ships the Maven wrapper (mvnw), which downloads its own Maven on first use. This is the professional norm: the build tool version is pinned per-project.
DockerNot installedNeeded only for Stage 7's optional container step and Stage 9's RabbitMQ broker. Those stages show fully correct configs, marked where they couldn't be run end-to-end on this machine β€” install Docker Desktop before then if you want to execute them yourself.
IDEYour choiceRecommended: IntelliJ IDEA Community (best Spring support) or VS Code + "Extension Pack for Java". Either is free.
Windows note

Commands below are shown for PowerShell. In PowerShell the wrapper is .\mvnw.cmd; in Git Bash it's ./mvnw. Also: PowerShell aliases curl to something else β€” always type curl.exe there.

Stage 1Foundations

Goal: understand what Spring Boot actually does for you, know every file in the generated project, run it, and serve your own JSON from two endpoints you wrote yourself.

1.1 β€” What Spring Boot actually is

Three layers, often confused with each other:

  • Spring Framework β€” a dependency-injection container plus libraries (web MVC, data access, security…). It's powerful but famously needed lots of configuration.
  • Spring Boot β€” opinionated auto-configuration on top of Spring. It looks at what's on your classpath and configures sensible defaults, so a web app is ~10 lines instead of ~200 lines of XML.
  • Starters β€” curated dependency bundles. Adding spring-boot-starter-webmvc pulls in Spring MVC, JSON handling (Jackson), and an embedded Tomcat server β€” your app is a self-contained jar that contains its web server, not a war deployed into one.

The core idea: Inversion of Control. In plain Java you build your object graph by hand:

plain Java β€” you wire everythingjava
// You are responsible for construction order, sharing, lifecycle…
BookRepository repo = new BookRepository(dataSource);
BookService service = new BookService(repo);
BookController controller = new BookController(service);

In Spring, you declare what each class needs, and the container (the ApplicationContext) constructs and connects everything. Objects managed by the container are called beans:

Spring β€” the container wires itjava
@Service                            // "manage me as a bean"
public class BookService {
    private final BookRepository repo;

    // Spring sees this constructor, finds a BookRepository bean,
    // and passes it in. That's dependency injection.
    public BookService(BookRepository repo) {
        this.repo = repo;
    }
}

Why bother? Because the container can now swap implementations (a fake repository in tests, a real one in production), manage lifecycles, and apply cross-cutting behavior (transactions, security) β€” without you touching the wiring code. You'll feel the payoff from Stage 5 (testing) onward.

Version note

Your project uses Spring Boot 4.1 (current generation). Most online tutorials show Boot 2/3, where the web starter was called spring-boot-starter-web. In Boot 4 it's spring-boot-starter-webmvc. Concepts are identical; a few artifact names moved.

1.2 β€” Anatomy of the generated project

This project was generated by start.spring.io (Spring Initializr) β€” the official generator every Spring developer uses. Here is every file that matters:

library-api/
library-api/
β”œβ”€β”€ mvnw, mvnw.cmd            ← Maven wrapper scripts (Bash / Windows)
β”œβ”€β”€ .mvn/wrapper/             ← pins the Maven version the wrapper downloads
β”œβ”€β”€ pom.xml                   ← THE build file: dependencies, Java version, plugins
β”œβ”€β”€ src/main/java/com/library/api/
β”‚   └── LibraryApiApplication.java   ← the entry point
β”œβ”€β”€ src/main/resources/
β”‚   β”œβ”€β”€ application.properties       ← all runtime configuration lives here
β”‚   β”œβ”€β”€ static/                      ← would serve static files (css, js)
β”‚   └── templates/                   ← server-rendered views (we build an API, so unused)
└── src/test/java/com/library/api/
    └── LibraryApiApplicationTests.java  ← one sanity test: "does the context load?"

pom.xml is Maven's project descriptor. Three parts to understand now:

  • <parent> β†’ spring-boot-starter-parent:4.1.0. This is why no dependency below has a version number β€” the parent manages ~1,400 library versions that are tested to work together. This solves "dependency hell".
  • <dependencies> β€” we chose three: webmvc (REST + embedded Tomcat), actuator (ops endpoints, Β§1.4), devtools (auto-restart, Β§1.6).
  • spring-boot-maven-plugin β€” repackages your jar into an executable "fat jar" containing all dependencies + Tomcat. That single file is what you'd deploy.

LibraryApiApplication.java β€” the whole entry point is one annotation and one line:

src/main/java/com/library/api/LibraryApiApplication.javajava
@SpringBootApplication
public class LibraryApiApplication {
    public static void main(String[] args) {
        SpringApplication.run(LibraryApiApplication.class, args);
    }
}

@SpringBootApplication is three annotations in a trench coat:

  • @ComponentScan β€” scan this package and below for @Component/@Service/@RestController classes and register them as beans. This is why your code must live under com.library.api β€” classes outside it are invisible.
  • @EnableAutoConfiguration β€” "look at the classpath and configure accordingly." Tomcat present? Start it on port 8080. Jackson present? Auto-convert objects ↔ JSON.
  • @Configuration β€” this class may itself define beans (you'll do that in Stage 6).

1.3 β€” Hands-on: run it

Hands-on 1 Β· run the application

Open a terminal in the project folder and start the app:

PowerShell
PS> cd "D:\Mtech RA nitk\Intership Student\springboot-learning\library-api"
PS> .\mvnw.cmd spring-boot:run

First run downloads dependencies (a few minutes, once). Then read the log β€” these lines tell the auto-configuration story:

log output (abridged)
:: Spring Boot ::               (v4.1.0)
Starting LibraryApiApplication using Java ... 
Tomcat initialized with port 8080 (http)   ← embedded server, auto-configured
LiveReload server is running on port 35729 ← devtools
Tomcat started on port 8080 (http)
Started LibraryApiApplication in ~2 s      ← app is up

Open http://localhost:8080 in a browser. You'll get a Whitelabel Error Page (404) β€” that is correct: the server runs, but you haven't defined any endpoint yet. Leave the app running; Ctrl+C stops it when needed.

1.4 β€” Hands-on: Actuator, your first real endpoint

The actuator starter contributes production-ops endpoints for free. Only /actuator/health is exposed over HTTP by default.

Hands-on 2 Β· hit the health endpoint

With the app running, in a second terminal:

PowerShell
PS> curl.exe http://localhost:8080/actuator/health
{"groups":["liveness","readiness"],"status":"UP"}

Now expose more endpoints. Open src/main/resources/application.properties and add:

src/main/resources/application.propertiesproperties
spring.application.name=library-api
management.endpoints.web.exposure.include=health,info,beans,mappings

Restart the app (Ctrl+C, run again), then try curl.exe http://localhost:8080/actuator/beans β€” that JSON is the live contents of the dependency-injection container. Search it for libraryApiApplication: your own class, registered as a bean.

1.5 β€” Hands-on: your first controller

A controller is a bean whose methods are mapped to HTTP routes. Time to write one β€” type it, don't paste it; the annotations need to enter your fingers.

Hands-on 3 Β· write HelloController

Create a new file HelloController.java next to LibraryApiApplication.java (same package β€” remember component scan):

src/main/java/com/library/api/HelloController.javajava
package com.library.api;

import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HelloController {

    @GetMapping("/api/hello")
    public Map<String, String> hello() {
        return Map.of(
            "message", "Hello from Spring Boot",
            "app", "library-api"
        );
    }
}

Restart, then visit http://localhost:8080/api/hello. You should see JSON.

What just happened, precisely:

  • @RestController = @Controller (a scannable bean handling web requests) + @ResponseBody (return values are written straight into the HTTP response body, not treated as a view name).
  • @GetMapping("/api/hello") maps GET /api/hello to this method. Siblings exist: @PostMapping, @PutMapping, @DeleteMapping.
  • You returned a Map, the client received JSON β€” Jackson (from the webmvc starter) serializes return values automatically. Content negotiation set Content-Type: application/json.
DevTools tip

With devtools on the classpath, the app restarts automatically whenever compiled classes change. In IntelliJ/VS Code, saving + building triggers it. From a bare terminal, keep the app running and run .\mvnw.cmd compile in a second terminal β€” watch the app restart in ~1 s. That's your edit-refresh loop; full restarts are for pom.xml changes only.

1.6 β€” Hands-on: query parameters and path variables

Real endpoints take input. Two standard ways to receive it in the URL:

Hands-on 4 Β· add two parameterized endpoints

Add these methods inside HelloController (new imports: RequestParam, PathVariable from the same package):

HelloController.java β€” add methodsjava
// GET /api/greet?name=Sharath   β†’ query parameter
@GetMapping("/api/greet")
public Map<String, String> greet(
        @RequestParam(defaultValue = "stranger") String name) {
    return Map.of("greeting", "Hello, " + name + "!");
}

// GET /api/echo/42              β†’ path variable
@GetMapping("/api/echo/{id}")
public Map<String, Object> echo(@PathVariable long id) {
    return Map.of("received", id, "doubled", id * 2);
}

Test all the shapes:

PowerShell
PS> curl.exe "http://localhost:8080/api/greet?name=Sharath"
{"greeting":"Hello, Sharath!"}
PS> curl.exe http://localhost:8080/api/greet
{"greeting":"Hello, stranger!"}          ← defaultValue kicked in
PS> curl.exe http://localhost:8080/api/echo/21
{"received":21,"doubled":42}              ← "21" auto-converted to long
PS> curl.exe http://localhost:8080/api/echo/banana
(400 Bad Request)                         ← type conversion failed β€” free validation

Note that last one: Spring converted the path string to long for you, and rejected garbage with a proper 400 before your method ran. Stage 4 builds on exactly this mechanism.

1.7 β€” Checkpoint

Checkpoint Β· can you answer these cold?
  • Your app has no Tomcat install, yet serves HTTP. What two things make that work? (starter brings embedded Tomcat; auto-configuration starts it)
  • You move HelloController to package com.other. What breaks, and why? (component scan only covers com.library.api and below β†’ the bean is never registered β†’ 404)
  • Why does pom.xml list no version for the webmvc starter? (the parent POM manages versions)
  • @RestController vs @Controller β€” what does the Rest add? (@ResponseBody: return values become the response body via Jackson)
  • Who turns your Map into JSON, and where did that library come from? (Jackson, pulled in by the webmvc starter)

Stretch exercise (no solution given): add GET /api/time returning the server time in ISO format and a query param ?zone=Asia/Kolkata that formats it for a given time zone (look at java.time.ZonedDateTime). If you can build that without help, Stage 1 is truly done.

Before Stage 2

Delete HelloController.java β€” its job (demonstrating GET, params, path variables) is done, and it would otherwise sit oddly next to the real API.

πŸ”’Finish and mark Stage 0 complete to unlock this stage.
Done with Stage 1? Ran the app, hit actuator, and built all three HelloController endpoints yourself.

Stage 2REST API design

Goal: stop writing everything in one class. Learn the layering every real Spring codebase uses, and build full CRUD for books β€” the API this whole project is named after.

2.1 β€” The layered shape

Stage 1's HelloController did everything itself: received the request and built the response. That's fine for a demo, wrong for an application. From here on every feature gets three parts:

LayerAnnotationResponsibility
Controller@RestControllerHTTP only β€” routes, status codes, request/response shapes. No business logic.
Service@ServiceThe actual logic and rules. Knows nothing about HTTP β€” could be called from a controller, a CLI, or a test with equal ease.
Repository@RepositoryData access. Stage 2 fakes this with an in-memory map inside the service; Stage 3 promotes it to a real interface talking to a database.

Why split at all, when one class is fewer keystrokes? Two reasons that will matter concretely in later stages: (1) in Stage 5 you'll unit-test BookService with zero HTTP machinery involved, and swap in a fake repository without touching the controller; (2) in Stage 8 the same service logic gets reused when the project splits into microservices, while the controller layer changes shape entirely. Separating "what HTTP looks like" from "what the business does" is what makes both of those possible.

We also move the feature into its own package β€” com.library.api.book β€” rather than dumping every class at the root. This is package-by-feature: everything about books lives together, so when Stage 3 adds authors, it gets its own package instead of one giant controllers/ folder. Component scan still finds them β€” it scans com.library.api and everything below it.

2.2 β€” Hands-on: the Book record

Hands-on 1 Β· define the shape of a book

Create the package folder book under com.library.api, and inside it, Book.java:

src/main/java/com/library/api/book/Book.javajava
package com.library.api.book;

public record Book(Long id, String title, String author, int year) {}

One line, and it's a complete, immutable data class: constructor, getters (title() not getTitle()), equals/hashCode/toString all generated. Records are the standard choice in modern Spring for anything that's purely "a bag of data flowing across a boundary" β€” request bodies, response bodies, DTOs. Notice id is a boxed Long, not primitive long: a new book arriving from a client won't have one yet, and only a nullable type can represent "no id assigned."

2.3 β€” Hands-on: BookService

Hands-on 2 Β· write the service layer

Same package, BookService.java. This is where CRUD logic and the in-memory store live:

src/main/java/com/library/api/book/BookService.javajava
package com.library.api.book;

import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.stereotype.Service;

@Service
public class BookService {

    private final Map<Long, Book> store = new ConcurrentHashMap<>();
    private final AtomicLong seq = new AtomicLong();

    public List<Book> findAll() {
        return List.copyOf(store.values());
    }

    public Optional<Book> findById(long id) {
        return Optional.ofNullable(store.get(id));
    }

    public Book create(Book book) {
        long id = seq.incrementAndGet();
        Book saved = new Book(id, book.title(), book.author(), book.year());
        store.put(id, saved);
        return saved;
    }

    public Optional<Book> update(long id, Book book) {
        if (!store.containsKey(id)) {
            return Optional.empty();
        }
        Book saved = new Book(id, book.title(), book.author(), book.year());
        store.put(id, saved);
        return Optional.of(saved);
    }

    public boolean delete(long id) {
        return store.remove(id) != null;
    }
}

Three things worth pausing on:

  • @Service is functionally identical to @Component β€” it's the same "manage me as a bean" signal, just named for the layer. Spring doesn't check the name; you use the right one so the codebase reads clearly.
  • ConcurrentHashMap, not HashMap. Your service bean is a singleton β€” one instance shared by every concurrent HTTP request. Two requests can call create() at the same instant; a plain HashMap would corrupt under that, a concurrent one won't. This is your first brush with bean scope, covered properly in Stage 3.
  • Optional<Book> as a return type says, in the signature itself, "this might not find anything" β€” the caller is forced to handle absence rather than risk a NullPointerException. You'll unwrap it in the controller next.

2.4 β€” Hands-on: BookController

Hands-on 3 Β· wire HTTP to the service

Same package, BookController.java:

src/main/java/com/library/api/book/BookController.javajava
package com.library.api.book;

import java.net.URI;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/books")
public class BookController {

    private final BookService service;

    public BookController(BookService service) {   // constructor injection
        this.service = service;
    }

    @GetMapping
    public List<Book> all() {
        return service.findAll();
    }

    @GetMapping("/{id}")
    public ResponseEntity<Book> byId(@PathVariable long id) {
        return service.findById(id)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

    @PostMapping
    public ResponseEntity<Book> create(@RequestBody Book book) {
        Book saved = service.create(book);
        return ResponseEntity
                .created(URI.create("/api/books/" + saved.id()))
                .body(saved);
    }

    @PutMapping("/{id}")
    public ResponseEntity<Book> update(@PathVariable long id, @RequestBody Book book) {
        return service.update(id, book)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable long id) {
        return service.delete(id)
                ? ResponseEntity.noContent().build()
                : ResponseEntity.notFound().build();
    }
}

New ideas here, all load-bearing:

  • Constructor injection. No @Autowired anywhere β€” since Spring 4.3, a single constructor is auto-detected as the injection point. Spring finds the one BookService bean in the context and passes it in when it builds BookController. Marking the field final is deliberate: the controller becomes impossible to construct in a broken half-wired state, and trivial to test later by just calling new BookController(fakeService) yourself.
  • @RequestMapping("/api/books") on the class prefixes every method's mapping β€” @GetMapping("/{id}") below actually means GET /api/books/{id}.
  • ResponseEntity<T> gives you control over the status code, not just the body. Map.of(...) in Stage 1 always returned 200; here, "not found" correctly returns 404, a successful creation returns 201 Created with a Location header pointing at the new resource, and a successful delete returns 204 No Content β€” no body needed to say "gone."
  • @RequestBody is the inverse of Stage 1's return-value magic: Jackson now deserializes the incoming JSON into a Book before your method even runs.

2.5 β€” Hands-on: drive the whole API

Hands-on 4 Β· exercise every verb

Restart the app and run these in order β€” watch the status codes, not just the bodies:

PowerShell
PS> curl.exe http://localhost:8080/api/books
[]

PS> curl.exe -i -X POST http://localhost:8080/api/books `
       -H "Content-Type: application/json" `
       -d '{\"title\":\"Clean Code\",\"author\":\"Robert C. Martin\",\"year\":2008}'
HTTP/1.1 201
Location: /api/books/1
{"id":1,"title":"Clean Code","author":"Robert C. Martin","year":2008}

PS> curl.exe http://localhost:8080/api/books/1
{"id":1,"title":"Clean Code","author":"Robert C. Martin","year":2008}

PS> curl.exe -i http://localhost:8080/api/books/99
HTTP/1.1 404

PS> curl.exe -X PUT http://localhost:8080/api/books/1 `
       -H "Content-Type: application/json" `
       -d '{\"title\":\"Clean Code\",\"author\":\"Robert C. Martin\",\"year\":2009}'
{"id":1,"title":"Clean Code","author":"Robert C. Martin","year":2009}

PS> curl.exe -i -X DELETE http://localhost:8080/api/books/1
HTTP/1.1 204

PS> curl.exe http://localhost:8080/api/books
[]

Note the escaped quotes (\") β€” that's PowerShell's curl.exe alias for a real curl binary, which needs its JSON quotes escaped inside a double-quoted -d string, and the backtick ` is PowerShell's line-continuation character. In Git Bash, drop the backslashes and backticks and use single quotes around the JSON, as shown in Stage 1.

2.6 β€” Exercise: filter by author

Hands-on 5 Β· add a query, no solution given

Add GET /api/books/search?author=Martin, case-insensitive substring match, returning a (possibly empty) list. You'll touch all three layers: a new method on BookService that filters store.values(), and a new @GetMapping("/search") method on the controller that reads an @RequestParam String author and delegates to it. Careful with mapping order β€” /search must not collide with /{id}: Spring matches your literal path /search before it would ever try to bind "search" to a long id, but if a mismatch confuses you, try moving the /search method above byId in the file and see if that changes anything (it won't β€” mapping is by specificity, not declaration order β€” proving this to yourself is the point).

2.7 β€” Checkpoint

Checkpoint Β· can you answer these cold?
  • Why does BookController have no @Autowired annotation anywhere, yet still receives a working BookService? (single-constructor auto-detection since Spring 4.3)
  • BookService is a singleton bean. What bug would appear under concurrent requests if store were a plain HashMap instead of ConcurrentHashMap? (race conditions / corrupted internal state from unsynchronized concurrent writes)
  • What three status codes does BookController return besides 200, and which method produces each? (201 from create, 204 from delete, 404 from byId/update when absent)
  • Why is Book.id() a Long and not a primitive long? (needs to represent "no id yet" for incoming creation requests β€” primitives can't be null)
  • If you moved BookController back out of the book package to the root, would anything break? (no β€” component scan covers the whole subtree; the package split is for humans, not the container)

If all five are easy, Stage 2 is done β€” and so is the first slice of the whole project. You now have a real, working REST API for one resource.

πŸ”’Finish and mark Stage 1 complete to unlock this stage.
Done with Stage 2? Full CRUD for /api/books working, checkpoint answered.

Stage 3Persistence β€” Spring Data JPA

Goal: replace the in-memory Map with a real database. Turn Book into a JPA entity, add an Author relationship, and delete most of the code you wrote in Stage 2's BookService β€” Spring Data writes it for you.

3.1 β€” Add the dependencies

Two new lines in pom.xml, inside <dependencies>:

pom.xml β€” addxml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>

spring-boot-starter-data-jpa brings Hibernate (the JPA implementation) and Spring Data JPA (the repository magic in Β§3.4). H2 is a real relational database that runs embedded, in-process β€” no install, no server to start. It's the standard choice for learning and for tests; Β§3.6 talks about moving to Postgres for real deployment.

3.2 β€” Configure the datasource

Add to application.properties:

src/main/resources/application.properties β€” addproperties
spring.datasource.url=jdbc:h2:mem:librarydb
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
  • jdbc:h2:mem:librarydb β€” mem: means the database lives in RAM and is wiped every time the app stops. Perfect for learning; you'll want a persistent database before Stage 6 (a user login you lose on every restart is no fun).
  • ddl-auto=update β€” Hibernate reads your @Entity classes and creates/updates tables to match, automatically. Convenient for learning, dangerous in production (it will never be this forgiving again β€” production systems use versioned migration tools like Flyway, out of scope here but worth knowing the name).
  • show-sql=true β€” prints every generated SQL statement to the console. Turn this on whenever JPA does something you don't expect; it almost always explains itself.

3.3 β€” Hands-on: two entities

Hands-on 1 Β· Author, the simple one

New package author under com.library.api, file Author.java:

src/main/java/com/library/api/author/Author.javajava
package com.library.api.author;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;

@Entity
public class Author {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    protected Author() {}  // JPA needs a no-arg constructor

    public Author(String name) {
        this.name = name;
    }

    public Long getId() { return id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
}

Why a class with getters, not a Stage-2-style record? JPA entities can't be records. Hibernate needs to construct an entity with no data (the protected no-arg constructor), then populate its fields via reflection after construction, and sometimes replace fields later (lazy-loading proxies). Records are immutable and final by design β€” the opposite of what an ORM needs to do its job. This is exactly why Stage 2 called records "the standard choice for data flowing across a boundary" and not "always" β€” entities are a different kind of object, with a different lifecycle.

Hands-on 2 Β· Book, with a relationship β€” and a real gotcha

New package book, file Book.java:

src/main/java/com/library/api/book/Book.javajava
package com.library.api.book;

import com.library.api.author.Author;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;

@Entity
public class Book {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    @Column(name = "published_year")
    private int year;

    @ManyToOne
    private Author author;

    protected Book() {}  // JPA needs a no-arg constructor

    public Book(String title, int year, Author author) {
        this.title = title;
        this.year = year;
        this.author = author;
    }

    public Long getId() { return id; }
    public String getTitle() { return title; }
    public int getYear() { return year; }
    public Author getAuthor() { return author; }

    public void setTitle(String title) { this.title = title; }
    public void setYear(int year) { this.year = year; }
    public void setAuthor(Author author) { this.author = author; }
}

@ManyToOne reads as "many Books point to one Author" β€” Hibernate adds an author_id foreign-key column to the book table automatically.

A real bug I hit writing this lesson

The first version of this entity had a plain private int year; field with no @Column. The app started fine, but every request that touched the book table failed with Table "BOOK" not found β€” even though the log clearly showed create table book (...) running. The actual error was one line above it: Syntax error in SQL statement ... expected "identifier". YEAR is a reserved word in H2 (it's a SQL date-time function), so create table book (..., year integer, ...) is invalid SQL β€” the table silently failed to create, and every later query against it failed with a confusingly unrelated "table not found." @Column(name = "published_year") renames the column in the database while your Java field stays year. The lesson: when a generated CREATE TABLE fails, read the error above the one that looks scariest β€” and be suspicious of any field named after a common SQL keyword (year, date, order, group, value, user...).

3.4 β€” Hands-on: repositories replace your DAO code

Hands-on 3 Β· one interface, zero implementation

AuthorRepository.java and BookRepository.java, one per package:

src/main/java/com/library/api/author/AuthorRepository.javajava
package com.library.api.author;

import org.springframework.data.jpa.repository.JpaRepository;

public interface AuthorRepository extends JpaRepository<Author, Long> {}
src/main/java/com/library/api/book/BookRepository.javajava
package com.library.api.book;

import java.util.List;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

public interface BookRepository extends JpaRepository<Book, Long> {

    List<Book> findByAuthor_NameContainingIgnoreCase(String authorName);

    Page<Book> findAll(Pageable pageable);
}

No class implements these β€” Spring Data generates the implementation at startup by parsing the interface. JpaRepository<Book, Long> alone already gives you save, findAll, findById, deleteById, count β€” the whole in-memory BookService from Stage 2, gone, replaced by "extends an interface."

findByAuthor_NameContainingIgnoreCase is a derived query method: Spring Data parses the method name into a query. Read it left to right: findBy Β· Author (the author field on Book) Β· _Name (the underscore crosses the relationship into Author.name) Β· Containing (SQL LIKE %...%) Β· IgnoreCase. No SQL, no annotation β€” the method signature is the query. This scales to a point; past 3–4 conditions most teams switch to @Query with JPQL, which is a natural next thing to explore once this feels routine.

3.5 β€” Hands-on: the controller talks to repositories directly

Hands-on 4 Β· AuthorController and a rebuilt BookController

AuthorController.java β€” minimal, just enough to create authors to attach books to:

src/main/java/com/library/api/author/AuthorController.javajava
package com.library.api.author;

import java.util.List;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/authors")
public class AuthorController {

    private final AuthorRepository repo;

    public AuthorController(AuthorRepository repo) {
        this.repo = repo;
    }

    @GetMapping
    public List<Author> all() {
        return repo.findAll();
    }

    @PostMapping
    public Author create(@RequestBody Author author) {
        return repo.save(new Author(author.getName()));
    }
}

Now delete Stage 2's BookController.java and BookService.java entirely, and replace with this β€” the repository takes over what the service used to do:

src/main/java/com/library/api/book/BookController.javajava
package com.library.api.book;

import com.library.api.author.Author;
import com.library.api.author.AuthorRepository;
import java.util.List;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/books")
public class BookController {

    private final BookRepository books;
    private final AuthorRepository authors;

    public BookController(BookRepository books, AuthorRepository authors) {
        this.books = books;
        this.authors = authors;
    }

    @GetMapping
    public Page<Book> all(Pageable pageable) {
        return books.findAll(pageable);
    }

    @GetMapping("/{id}")
    public Book byId(@PathVariable long id) {
        return books.findById(id).orElseThrow();
    }

    @GetMapping("/search")
    public List<Book> byAuthor(@RequestParam String author) {
        return books.findByAuthor_NameContainingIgnoreCase(author);
    }

    record NewBook(String title, int year, Long authorId) {}

    @PostMapping
    public Book create(@RequestBody NewBook body) {
        Author author = authors.findById(body.authorId()).orElseThrow();
        return books.save(new Book(body.title(), body.year(), author));
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable long id) {
        books.deleteById(id);
        return ResponseEntity.noContent().build();
    }
}

Notice NewBook: the client sends an authorId, not a nested Author object, because the client shouldn't need to know or send an author's internal structure β€” just which one it means. This is your first taste of "request shape β‰  entity shape," which becomes the whole subject of DTOs once you look for it after this stage.

Pageable pageable as a controller parameter is another piece of free Spring MVC binding, like @RequestParam in Stage 1 β€” it reads ?page=, ?size=, and ?sort= from the query string automatically and hands you a ready-to-use object.

byId still calls bare .orElseThrow() β€” on a missing book that throws NoSuchElementException, which Spring turns into an ugly 500, not a clean 404. That's deliberate: Stage 4 is entirely about fixing this properly with a global exception handler, so leave it broken for now and you'll feel exactly why Stage 4 exists.

3.6 β€” Hands-on: drive it, and watch the relationship work

Hands-on 5 Β· create authors, then books that reference them
PowerShell
PS> curl.exe -X POST http://localhost:8080/api/authors -H "Content-Type: application/json" -d '{\"name\":\"Robert C. Martin\"}'
{"id":1,"name":"Robert C. Martin"}

PS> curl.exe -X POST http://localhost:8080/api/books -H "Content-Type: application/json" -d '{\"title\":\"Clean Code\",\"year\":2008,\"authorId\":1}'
{"id":1,"title":"Clean Code","year":2008,"author":{"id":1,"name":"Robert C. Martin"}}

PS> curl.exe "http://localhost:8080/api/books?page=0&size=1"
{"content":[{...}],"totalElements":1,"totalPages":1,"number":0,"size":1,...}

PS> curl.exe "http://localhost:8080/api/books/search?author=martin"
[{"id":1,"title":"Clean Code",...}]              ← case-insensitive, cross-relationship

PS> curl.exe -i -X DELETE http://localhost:8080/api/books/1
HTTP/1.1 204

PS> curl.exe -i http://localhost:8080/api/books/1
HTTP/1.1 500          ← ugly, and expected β€” Stage 4 fixes this

Notice the response body embeds the whole author object inside every book. That's Jackson serializing the JPA entity graph exactly as loaded β€” convenient right now, but it means your API response shape is your database schema shape. Real APIs almost always insert a DTO mapping step precisely to break that coupling; we're deliberately deferring it so this stage stays about JPA, not about DTOs on top of JPA.

3.7 β€” Toward a real database

Note Β· not hands-on

Nothing about your @Entity, @Repository, or controller code is H2-specific β€” that's the entire point of JPA as an abstraction. Moving to Postgres in a real deployment is a dependency + three properties change: swap the H2 dependency for org.postgresql:postgresql, and point spring.datasource.url/username/password at a running Postgres instance. Everything above keeps working unmodified. Stage 7 revisits this when profiles separate a dev config (H2) from a prod config (Postgres).

3.8 β€” Checkpoint

Checkpoint Β· can you answer these cold?
  • Why can't Book be a record now that it's a JPA entity, when Book the Stage-2 DTO was one? (Hibernate needs a no-arg constructor and mutable fields to construct-then-populate entities and build lazy proxies; records are immutable)
  • What actually broke when the entity field was called year with no @Column override, and how did you find it? (YEAR is a reserved SQL keyword in H2, so table creation failed with a syntax error one line above the more confusing "table not found" error that followed)
  • BookRepository is an interface with no implementing class anywhere in your code. Who implements it, and when? (Spring Data JPA generates a proxy implementation at application startup)
  • Translate findByAuthor_NameContainingIgnoreCase into English. (find books whose related author's name contains the given text, case-insensitively)
  • Why does BookController.create accept a NewBook record with an authorId instead of accepting a Book directly? (the client shouldn't need to construct or know the internal shape of an Author β€” just reference one by id; also a raw incoming Book would arrive with a null id that you'd have to strip anyway)

Stretch exercise (no solution given): add a derived query findByYearBetween(int start, int end) to BookRepository and a GET /api/books/by-decade?start=2000&end=2010 endpoint on top of it.

πŸ”’Finish and mark Stage 2 complete to unlock this stage.
Done with Stage 3? Authors and books persisted to H2, relationship working, pagination and search both returning real data.

Stage 4Validation & error handling

Goal: fix the two rough edges Stage 3 left on purpose β€” a missing book returning a raw 500, and no protection against garbage input β€” with the two mechanisms every real Spring API uses: Bean Validation and a global exception handler.

4.1 β€” Add the dependency

pom.xml β€” addxml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

This pulls in Hibernate Validator, the reference implementation of Bean Validation (JSR 380) β€” the same specification behind @NotBlank, @Min, @Email, and friends. It's a separate starter from webmvc because plenty of Spring apps validate nothing (surprisingly common, rarely advisable).

4.2 β€” Hands-on: validate the request, not the entity

Hands-on 1 Β· constrain NewBook

Annotate the NewBook record inside BookController from Stage 3, and add @Valid to the create parameter:

BookController.java β€” updatedjava
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.util.NoSuchElementException;
// ...existing imports...

    record NewBook(
            @NotBlank(message = "title must not be blank") String title,
            @Min(value = 1450, message = "year must be 1450 or later") int year,
            @NotNull(message = "authorId is required") Long authorId) {}

    @PostMapping
    public Book create(@Valid @RequestBody NewBook body) {
        Author author = authors.findById(body.authorId())
                .orElseThrow(() -> new NoSuchElementException("No author with id " + body.authorId()));
        return books.save(new Book(body.title(), body.year(), author));
    }

Also give byId a real error message instead of a bare orElseThrow():

BookController.java β€” byIdjava
@GetMapping("/{id}")
public Book byId(@PathVariable long id) {
    return books.findById(id)
            .orElseThrow(() -> new NoSuchElementException("No book with id " + id));
}

Three annotations, three different failure shapes: @NotBlank rejects null, empty, and whitespace-only strings. @Min validates a numeric floor β€” 1450 is deliberately just after Gutenberg's press, so any year in your test data before that is testing the validator, not modeling history. @NotNull is the one you need most often and forget most often: without it, a request body that simply omits authorId sails straight through to a NullPointerException deep inside your service.

Notice where these annotations live: on the request DTO (NewBook), not on the Book entity. This is deliberate and matters β€” the entity represents what's true once data is stored (an existing book always has an id, always has a persisted author); the DTO represents what's required for this specific incoming request. The two have different rules by nature, so they need different annotations, which is one more reason Stage 3 kept them as separate classes.

4.3 β€” Hands-on: one handler for the whole app

Hands-on 2 Β· GlobalExceptionHandler

New file, at the root package (not inside book/ β€” this handles exceptions from every feature, present and future):

src/main/java/com/library/api/GlobalExceptionHandler.javajava
package com.library.api;

import java.util.HashMap;
import java.util.Map;
import java.util.NoSuchElementException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(NoSuchElementException.class)
    public ProblemDetail handleNotFound(NoSuchElementException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
        problem.setTitle("Resource not found");
        return problem;
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
        Map<String, String> fieldErrors = new HashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(
                error -> fieldErrors.put(error.getField(), error.getDefaultMessage()));

        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.BAD_REQUEST, "One or more fields are invalid");
        problem.setTitle("Validation failed");
        problem.setProperty("errors", fieldErrors);
        return problem;
    }
}

The moving parts:

  • @RestControllerAdvice makes this class apply globally, across every @RestController in the app β€” AuthorController and BookController both, without either one knowing this class exists. It's the same "cross-cutting concern" idea Stage 1 promised dependency injection would eventually pay off.
  • @ExceptionHandler(X.class) on a method means "when any controller throws X (or a subclass) and doesn't catch it, run this method instead of letting it propagate to Spring's default handler."
  • MethodArgumentNotValidException is what @Valid throws automatically when a constraint fails β€” you never throw it yourself, Spring's validation machinery does, right before your controller method body would have run.
  • ProblemDetail is Spring's built-in implementation of RFC 9457, the standard shape for HTTP API errors: type, title, status, detail, instance, plus whatever extra properties you attach with setProperty. Using it instead of a hand-rolled error Map means any client library that understands RFC 9457 (a growing number do) parses your errors for free.

4.4 β€” Hands-on: prove both paths

Hands-on 3 Β· trigger both handlers on purpose
PowerShell
PS> curl.exe -i http://localhost:8080/api/books/999
HTTP/1.1 404
Content-Type: application/problem+json

{"type":"about:blank","title":"Resource not found","status":404,
 "detail":"No book with id 999","instance":"/api/books/999"}

PS> curl.exe -i -X POST http://localhost:8080/api/books `
       -H "Content-Type: application/json" `
       -d '{\"title\":\"\",\"year\":1200,\"authorId\":1}'
HTTP/1.1 400
Content-Type: application/problem+json

{"type":"about:blank","title":"Validation failed","status":400,
 "detail":"One or more fields are invalid","instance":"/api/books",
 "errors":{"year":"year must be 1450 or later","title":"title must not be blank"}}

Compare that 404 to what Stage 3 produced for the exact same request: a bare 500 with a stack trace. Same bug trigger, same underlying NoSuchElementException β€” the only thing that changed is that something in the app now catches it deliberately instead of letting Spring's default fallback handler treat every uncaught exception as "unknown server failure." Notice too that both invalid fields (blank title and too-early year) came back in one response β€” Bean Validation collects every failing constraint before returning, so a client fixes all its mistakes in one round trip instead of one server call per mistake.

Also worth confirming: a missing authorId in the JSON body hits @NotNull and returns the same clean 400 shape β€” try omitting it entirely and compare to Stage 3, where that same request would have thrown a NullPointerException three lines deeper in the code, with no @ExceptionHandler written to interpret it as anything other than "server broke."

4.5 β€” Checkpoint

Checkpoint Β· can you answer these cold?
  • Why do the @NotBlank/@Min/@NotNull annotations live on NewBook, not on the Book entity? (the entity describes what's always true of stored data; the DTO describes what's required for this specific request β€” they're different concerns even when the fields overlap)
  • Where does MethodArgumentNotValidException come from β€” do you throw it? (no β€” Spring's validation layer throws it automatically when @Valid finds a constraint violation, before your method body runs)
  • GlobalExceptionHandler is never referenced by name in BookController or AuthorController. How does it end up handling their exceptions? (@RestControllerAdvice registers it globally across every @RestController in the application context β€” the same cross-cutting mechanism Stage 1 introduced with beans)
  • What HTTP header tells a client "this error body follows RFC 9457," and what put it there? (Content-Type: application/problem+json β€” set automatically because the handler methods return ProblemDetail)

Stretch exercise (no solution given): add a catch-all @ExceptionHandler(Exception.class) at the bottom of GlobalExceptionHandler that returns a generic 500 ProblemDetail with a safe, non-leaky message β€” then trigger it deliberately (a divide-by-zero in a throwaway endpoint works) and confirm real stack traces never reach the client, only your log.

πŸ”’Finish and mark Stage 3 complete to unlock this stage.
Done with Stage 4? Both a missing resource and bad input now return clean, structured ProblemDetail responses instead of raw errors.

Stage 5Testing

Goal: four different kinds of test, each proving a different layer, at a different cost. By the end you'll know which one to reach for and why the other three still matter.

First, an honest confession

Stage 3 quietly deleted BookService β€” once BookController could call BookRepository directly, the service layer was pure passthrough boilerplate, and cutting it was a legitimate call. But it leaves nothing with real logic to unit-test. So Stage 5 brings BookService back β€” this time earning its place with one actual business rule: an author can't have two books with the same title. That rule is exactly the kind of thing that belongs in a service, not a controller or a repository, and it's what the first test below exercises.

5.1 β€” Hands-on: give BookService a real rule

Hands-on 1 Β· a request DTO, an exception type, and a service

Pull the NewBook record out of the controller into its own file so both the controller and the service can reference it:

src/main/java/com/library/api/book/NewBookRequest.javajava
package com.library.api.book;

import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;

public record NewBookRequest(
        @NotBlank(message = "title must not be blank") String title,
        @Min(value = 1450, message = "year must be 1450 or later") int year,
        @NotNull(message = "authorId is required") Long authorId) {}

Add one derived query to BookRepository:

BookRepository.java β€” addjava
boolean existsByTitleIgnoreCaseAndAuthor(String title, Author author);

A tiny exception type:

src/main/java/com/library/api/book/DuplicateBookException.javajava
package com.library.api.book;

public class DuplicateBookException extends RuntimeException {
    public DuplicateBookException(String title, String authorName) {
        super("Author " + authorName + " already has a book titled \"" + title + "\"");
    }
}

And the service itself:

src/main/java/com/library/api/book/BookService.javajava
@Service
public class BookService {

    private final BookRepository books;
    private final AuthorRepository authors;

    public BookService(BookRepository books, AuthorRepository authors) {
        this.books = books;
        this.authors = authors;
    }

    public Book create(NewBookRequest request) {
        Author author = authors.findById(request.authorId())
                .orElseThrow(() -> new NoSuchElementException("No author with id " + request.authorId()));

        if (books.existsByTitleIgnoreCaseAndAuthor(request.title(), author)) {
            throw new DuplicateBookException(request.title(), author.getName());
        }
        return books.save(new Book(request.title(), request.year(), author));
    }
}

Update BookController.create to delegate: return bookService.create(body); β€” and add one more @ExceptionHandler(DuplicateBookException.class) to GlobalExceptionHandler, returning 409 Conflict (a new status for you: "your request is valid, but it collides with existing state"). By now you've written three @ExceptionHandler methods and should be able to write this one from memory before checking Stage 4's pattern.

5.2 β€” Hands-on: unit test the rule, with no Spring at all

Hands-on 2 Β· Mockito, no ApplicationContext, no database
src/test/java/com/library/api/book/BookServiceTest.javajava
@ExtendWith(MockitoExtension.class)
class BookServiceTest {

    @Mock private BookRepository books;
    @Mock private AuthorRepository authors;
    @InjectMocks private BookService bookService;

    @Test
    void createsBookWhenAuthorExistsAndTitleIsNew() {
        Author martin = new Author("Robert C. Martin");
        when(authors.findById(1L)).thenReturn(Optional.of(martin));
        when(books.existsByTitleIgnoreCaseAndAuthor("Clean Code", martin)).thenReturn(false);
        when(books.save(any(Book.class))).thenAnswer(inv -> inv.getArgument(0));

        Book saved = bookService.create(new NewBookRequest("Clean Code", 2008, 1L));

        assertThat(saved.getTitle()).isEqualTo("Clean Code");
        verify(books).save(any(Book.class));
    }

    @Test
    void rejectsDuplicateTitleForSameAuthor() {
        Author martin = new Author("Robert C. Martin");
        when(authors.findById(1L)).thenReturn(Optional.of(martin));
        when(books.existsByTitleIgnoreCaseAndAuthor("clean code", martin)).thenReturn(true);

        assertThatThrownBy(() -> bookService.create(new NewBookRequest("clean code", 2008, 1L)))
                .isInstanceOf(DuplicateBookException.class);
    }

    // + rejectsUnknownAuthor(), same shape
}

@Mock creates a fake BookRepository and AuthorRepository β€” no Spring context, no H2, no network, nothing real. @InjectMocks constructs a real BookService and hands it the two mocks through its constructor β€” this is Stage 1's constructor injection, now working in your favor: because BookService declares its dependencies as constructor parameters instead of reaching for them itself, a test can substitute anything it wants without touching production code. when(...).thenReturn(...) scripts what each mock says when called; verify(...) confirms a method actually got called. Run these with ./mvnw test -Dtest=BookServiceTest β€” they finish in milliseconds, because nothing real ever starts.

5.3 β€” Hands-on: test the controller without a real service or database

Hands-on 3 Β· @WebMvcTest, the web slice
src/test/java/com/library/api/book/BookControllerWebMvcTest.javajava
@WebMvcTest(BookController.class)
@Import(GlobalExceptionHandler.class)
class BookControllerWebMvcTest {

    @Autowired private MockMvc mockMvc;
    @MockitoBean private BookRepository books;
    @MockitoBean private BookService bookService;

    @Test
    void getByIdReturns404ProblemDetailWhenMissing() throws Exception {
        when(books.findById(999L)).thenReturn(Optional.empty());

        mockMvc.perform(get("/api/books/999"))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.title", is("Resource not found")));
    }

    @Test
    void createReturns400WhenTitleBlank() throws Exception {
        mockMvc.perform(post("/api/books")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"title\":\"\",\"year\":2020,\"authorId\":1}"))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.errors.title").exists());
    }

    // + createReturns201BodyWhenValid(), listReturnsPageBody()
}

@WebMvcTest(BookController.class) boots only the web layer β€” Spring MVC, JSON conversion, validation β€” for exactly the one controller named, skipping JPA, the datasource, and every other controller entirely. That's the whole point of a "slice" test: fast, focused, and it fails for web-layer reasons only. @MockitoBean replaces BookRepository and BookService in the context with mocks, since the real ones would need a database this test deliberately doesn't start. MockMvc fires HTTP-shaped requests at the controller without opening a real socket, and jsonPath asserts on the JSON response body directly. Notice the first test proves the exact behavior Stage 4 hand-verified with curl β€” same assertion, now automated and re-run on every build forever.

5.4 β€” Hands-on: test the repository against a real database

Hands-on 4 Β· @DataJpaTest, the data slice
src/test/java/com/library/api/book/BookRepositoryDataJpaTest.javajava
@DataJpaTest
class BookRepositoryDataJpaTest {

    @Autowired private BookRepository books;
    @Autowired private EntityManager entityManager;

    @Test
    void existsByTitleIgnoreCaseAndAuthorDetectsDuplicates() {
        Author bloch = new Author("Joshua Bloch");
        entityManager.persist(bloch);
        entityManager.persist(new Book("Effective Java", 2018, bloch));
        entityManager.flush();

        assertThat(books.existsByTitleIgnoreCaseAndAuthor("EFFECTIVE JAVA", bloch)).isTrue();
        assertThat(books.existsByTitleIgnoreCaseAndAuthor("Some Other Book", bloch)).isFalse();
    }
}

@DataJpaTest is the mirror image of @WebMvcTest: it boots only the JPA layer β€” an in-memory H2 database, Hibernate, your repositories β€” with no web server and no controllers at all. It also wraps every test in a transaction that rolls back afterward, so tests never leak data into each other. This is the one test in the whole suite proving existsByTitleIgnoreCaseAndAuthor β€” a method with zero lines of code you wrote β€” actually does what its name claims against a real SQL engine, not just what you assume it does by reading it.

5.5 β€” Hands-on: prove the whole thing together

Hands-on 5 Β· @SpringBootTest, full stack
src/test/java/com/library/api/LibraryApiIntegrationTest.javajava
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureTestRestTemplate
class LibraryApiIntegrationTest {

    @Autowired private TestRestTemplate rest;

    @Test
    void fullCreateAndFetchFlowWorksEndToEnd() {
        var author = rest.postForEntity("/api/authors", new Author("Integration Author"), Author.class);
        Long authorId = author.getBody().getId();

        // ...POST /api/books with that authorId, assert 200 + body contains the title...

        var notFound = rest.getForEntity("/api/books/999999", String.class);
        assertThat(notFound.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
    }
}

No mocks anywhere. @SpringBootTest(webEnvironment = RANDOM_PORT) boots the entire application β€” real embedded Tomcat on a random free port, real H2 database, every bean wired exactly as it would be in production β€” and TestRestTemplate talks to it over actual HTTP. This is the slowest test in the suite and the only one that would catch a wiring mistake spanning multiple layers (say, the controller and service disagreeing about a package rename). Use it sparingly β€” a handful of true end-to-end happy paths β€” and let the three faster, narrower test types below it catch everything else. That ordering (many unit tests, fewer slice tests, fewest integration tests) is the "testing pyramid" you'll see named in almost every serious engineering blog.

A real discovery from writing this stage

Every test-writing tutorial on the internet imports @WebMvcTest from org.springframework.boot.test.autoconfigure.web.servlet, @DataJpaTest from org.springframework.boot.test.autoconfigure.orm.jpa, and TestRestTemplate from org.springframework.boot.test.web.client β€” because that's where they've lived since Spring Boot 1.x. On your Spring Boot 4.1, all three moved: org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest, org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest, and org.springframework.boot.resttestclient.TestRestTemplate β€” each now shipped by its own dedicated starter (spring-boot-starter-data-jpa-test, and spring-boot-starter-restclient for the RestTemplateBuilder that TestRestTemplate needs under the hood) instead of one giant spring-boot-starter-test covering everything. If an import doesn't resolve exactly as some tutorial shows, don't assume you mistyped it β€” check whether Boot 4 relocated the class, the same way spring-boot-starter-web became -webmvc back in Stage 1.

5.6 β€” Checkpoint

Checkpoint Β· can you answer these cold?
  • Why did BookService need to come back in Stage 5 when Stage 3 deleted it on purpose? (unit tests need a class holding real logic to test in isolation; a pure passthrough to the repository has nothing worth testing that @DataJpaTest doesn't already cover)
  • @InjectMocks builds a real BookService with two fake dependencies. What earlier design decision made that possible with zero extra setup code? (constructor injection β€” the constructor is the only way in, so handing it mocks "just works")
  • Rank the four test types from fastest to slowest, and say what each one boots. (unit < @WebMvcTest < @DataJpaTest < @SpringBootTest, roughly β€” unit boots nothing, the two slice tests boot one layer each, integration boots everything)
  • Your @DataJpaTest passed, proving existsByTitleIgnoreCaseAndAuthor works. Your BookServiceTest unit test also "proved" the duplicate-rejection logic works. What would a BookServiceTest alone not have caught, that only the @DataJpaTest could? (that the derived-query method name itself is spelled/parsed correctly by Spring Data β€” mocks assume the method does what you told them to assume, they never touch real SQL)

Stretch exercise (no solution given): write one more @WebMvcTest proving the new 409 Conflict path β€” POST the same title for the same author twice through MockMvc with bookService mocked to throw DuplicateBookException, and assert status().isConflict().

πŸ”’Finish and mark Stage 4 complete to unlock this stage.
Done with Stage 5? All four test types passing β€” run .\mvnw.cmd test and confirm BUILD SUCCESS.

Stage 6Security & JWT

Goal: nobody can create or delete a book without proving who they are. Members can browse; only librarians can mutate. You'll build stateless authentication with hand-rolled JWTs β€” no session, no cookie, just a signed token the client presents on every request β€” and see method-level authorization decide who's allowed to do what.

6.1 β€” Sessions vs. tokens, in one paragraph

A traditional web app logs you in, then the server remembers you via a session β€” an ID in a cookie, pointing at server-side state. That doesn't fit a REST API cleanly: sessions mean server-side storage, sticky load-balancing headaches, and a mismatch with "each request is independent" that's been this API's shape since Stage 1. The alternative: the server issues a token after login β€” a signed blob of claims (who you are, what role you have, when this expires) β€” and the client sends it back on every request in an Authorization header. The server verifies the signature and trusts the claims inside, with zero database lookups per request. That's a JWT (JSON Web Token), and "stateless" is the whole point: any instance of your app, anywhere, can verify a token alone.

6.2 β€” Add the dependencies

pom.xml β€” addxml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.12.6</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.12.6</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.12.6</version>
    <scope>runtime</scope>
</dependency>

spring-boot-starter-security brings Spring Security's filter chain, PasswordEncoder implementations, and method-security annotations. Spring Security has no opinion on JWTs specifically β€” issuing and verifying them is your job, which is exactly why jjwt is here: jjwt-api for the code you compile against, jjwt-impl/jjwt-jackson as runtime-only implementation details you never import directly.

Also add two properties β€” a signing secret and a token lifetime:

src/main/resources/application.properties β€” addproperties
library.jwt.secret=dev-only-secret-change-me-dev-only-secret-change-me
library.jwt.expiration-minutes=60

That secret is deliberately checked into a properties file for this course. In any real deployment it comes from an environment variable or a secrets manager, never from source control β€” Stage 7's externalized configuration is precisely the mechanism that makes that swap trivial.

6.3 β€” Hands-on: who a user is

Hands-on 1 Β· Role, User, UserRepository β€” and Stage 3's gotcha returns

New package auth. A two-value role:

src/main/java/com/library/api/auth/Role.javajava
package com.library.api.auth;

public enum Role {
    LIBRARIAN,
    MEMBER
}

And the entity β€” note the @Table annotation before you even try leaving it off:

src/main/java/com/library/api/auth/User.javajava
package com.library.api.auth;

import jakarta.persistence.*;

@Entity
@Table(name = "app_user")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String username;
    private String passwordHash;

    @Enumerated(EnumType.STRING)
    private Role role;

    protected User() {}  // JPA needs a no-arg constructor

    public User(String username, String passwordHash, Role role) {
        this.username = username;
        this.passwordHash = passwordHash;
        this.role = role;
    }

    public Long getId() { return id; }
    public String getUsername() { return username; }
    public String getPasswordHash() { return passwordHash; }
    public Role getRole() { return role; }
}
Another reserved word, right on schedule

Writing this lesson, leaving off @Table(name = "app_user") produced the exact same failure signature as Stage 3's year field: create table user (...) threw a syntax error one line above a confusing "table not found," because USER is also a reserved word in H2 (and in most SQL dialects β€” it's tied to session/authorization functions). The pattern from Stage 3 generalizes: any field or entity name that doubles as a common English noun is worth a second glance before you trust a schema-generation failure to explain itself clearly.

Repository, same one-liner shape as Stage 3:

src/main/java/com/library/api/auth/UserRepository.javajava
package com.library.api.auth;

import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByUsername(String username);
    boolean existsByUsername(String username);
}

6.4 β€” Hands-on: issuing and reading tokens

Hands-on 2 Β· JwtService
src/main/java/com/library/api/auth/JwtService.javajava
package com.library.api.auth;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import java.security.Key;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

@Service
public class JwtService {

    private final Key key;
    private final long expirationMinutes;

    public JwtService(
            @Value("${library.jwt.secret}") String secret,
            @Value("${library.jwt.expiration-minutes}") long expirationMinutes) {
        this.key = Keys.hmacShaKeyFor(secret.getBytes());
        this.expirationMinutes = expirationMinutes;
    }

    public String generateToken(User user) {
        Instant now = Instant.now();
        return Jwts.builder()
                .subject(user.getUsername())
                .claim("role", user.getRole().name())
                .issuedAt(Date.from(now))
                .expiration(Date.from(now.plus(expirationMinutes, ChronoUnit.MINUTES)))
                .signWith(key)
                .compact();
    }

    public Claims parseClaims(String token) {
        return Jwts.parser()
                .verifyWith((javax.crypto.SecretKey) key)
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }
}

A JWT has three dot-separated, Base64-encoded parts: a header (algorithm used), a payload (your claims β€” subject, role, issuedAt, expiration, all visible to anyone who decodes it, which is easy β€” never put secrets in claims), and a signature (HMAC-SHA of the first two parts, using your secret key). signWith(key) produces that signature; parseSignedClaims recomputes it from the token's own header+payload and compares. Change one character of the payload and the recomputed signature won't match β€” that mismatch, not any database check, is what makes a tampered token detectable. This is the entire trust mechanism: possession of the secret to sign, versus the ability for anyone to verify without ever holding that secret themselves β€” because verification is just recomputing and comparing, no secret-holder round trip required.

6.5 β€” Hands-on: register and log in

Hands-on 3 Β· AuthController β€” hashing passwords, issuing tokens

Two small request records first:

src/main/java/com/library/api/auth/RegisterRequest.java & LoginRequest.javajava
public record RegisterRequest(
        @NotBlank(message = "username must not be blank") String username,
        @Size(min = 8, message = "password must be at least 8 characters") String password,
        @NotNull(message = "role is required") Role role) {}

public record LoginRequest(
        @NotBlank String username,
        @NotBlank String password) {}
src/main/java/com/library/api/auth/AuthController.javajava
@RestController
@RequestMapping("/api/auth")
public class AuthController {

    private final UserRepository users;
    private final PasswordEncoder passwordEncoder;
    private final JwtService jwtService;

    public AuthController(UserRepository users, PasswordEncoder passwordEncoder, JwtService jwtService) {
        this.users = users;
        this.passwordEncoder = passwordEncoder;
        this.jwtService = jwtService;
    }

    record TokenResponse(String token) {}

    @PostMapping("/register")
    public TokenResponse register(@Valid @RequestBody RegisterRequest body) {
        if (users.existsByUsername(body.username())) {
            throw new IllegalStateException("Username \"" + body.username() + "\" is already taken");
        }
        User user = new User(body.username(), passwordEncoder.encode(body.password()), body.role());
        users.save(user);
        return new TokenResponse(jwtService.generateToken(user));
    }

    @PostMapping("/login")
    public TokenResponse login(@Valid @RequestBody LoginRequest body) {
        User user = users.findByUsername(body.username())
                .orElseThrow(() -> new BadCredentialsException("Invalid username or password"));

        if (!passwordEncoder.matches(body.password(), user.getPasswordHash())) {
            throw new BadCredentialsException("Invalid username or password");
        }
        return new TokenResponse(jwtService.generateToken(user));
    }
}

PasswordEncoder is Spring Security's interface for one-way password hashing β€” you'll wire a BCryptPasswordEncoder bean in Β§6.6. Never store a password, ever; store passwordEncoder.encode(rawPassword), a salted hash that can verify a guess (matches(raw, hash)) but can't be reversed back into the original. Notice both failure branches in login β€” unknown username, and wrong password β€” throw the identical BadCredentialsException with the identical message. That's deliberate: if "unknown username" returned a different error than "wrong password," an attacker could enumerate valid usernames one guess at a time. Same error, same status, every time, regardless of which half was wrong.

One more piece: this hands the login logic to hand-written code instead of Spring Security's usual AuthenticationManager/UserDetailsService combo. That's a deliberate simplification for this lesson β€” the real mechanism (a UserDetailsService loading users, a DaoAuthenticationProvider checking passwords, an AuthenticationManager orchestrating both) does the same thing this method does by hand, just with more moving parts and more Spring magic between you and what's happening. Once this feels routine, swapping in the standard machinery is a natural next step β€” the field names below (UserDetailsService, AuthenticationManager) are exactly what to search for.

6.6 β€” Hands-on: the filter that reads every request's token

Hands-on 4 Β· JwtAuthenticationFilter
src/main/java/com/library/api/auth/JwtAuthenticationFilter.javajava
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtService jwtService;

    public JwtAuthenticationFilter(JwtService jwtService) {
        this.jwtService = jwtService;
    }

    @Override
    protected void doFilterInternal(
            HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {

        String header = request.getHeader("Authorization");
        if (header != null && header.startsWith("Bearer ")) {
            String token = header.substring("Bearer ".length());
            try {
                Claims claims = jwtService.parseClaims(token);
                String username = claims.getSubject();
                String role = claims.get("role", String.class);

                var authorities = List.of(new SimpleGrantedAuthority("ROLE_" + role));
                var authentication =
                        new UsernamePasswordAuthenticationToken(username, null, authorities);
                SecurityContextHolder.getContext().setAuthentication(authentication);
            } catch (JwtException ignored) {
                // Invalid or expired token: leave SecurityContext empty, request proceeds unauthenticated
            }
        }

        chain.doFilter(request, response);
    }
}

OncePerRequestFilter is a Spring helper guaranteeing this runs exactly once per request, regardless of internal forwards/includes. This filter does three things: read the Authorization: Bearer <token> header, ask JwtService to verify and decode it, and β€” only if that succeeds β€” populate the SecurityContextHolder with an Authentication built straight from the token's claims. No database call. The "ROLE_" prefix is a Spring Security convention: hasRole('LIBRARIAN') (used in Β§6.7) is sugar for hasAuthority('ROLE_LIBRARIAN'), so the prefix has to be there for the sugar to match. A missing header, a malformed token, or an expired one all fall into the same catch β€” the request simply proceeds with no authentication set, and it's the next stage (the security filter chain's own authorization rules) that decides whether an unauthenticated request is allowed to continue.

6.7 β€” Hands-on: wiring the filter chain

Hands-on 5 Β· SecurityConfig
src/main/java/com/library/api/auth/SecurityConfig.javajava
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
        this.jwtAuthenticationFilter = jwtAuthenticationFilter;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        AuthenticationEntryPoint unauthorizedEntryPoint =
                (request, response, authException) -> response.sendError(HttpStatus.UNAUTHORIZED.value());

        http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .exceptionHandling(ex -> ex.authenticationEntryPoint(unauthorizedEntryPoint))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**", "/actuator/health").permitAll()
                .anyRequest().authenticated())
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

Walk it top to bottom: csrf().disable() β€” CSRF protection defends session-cookie-based browser apps against forged form submissions; a stateless token API with no cookies isn't exposed to that attack, so the protection is pure friction here. STATELESS session policy tells Spring Security to never create or read an HttpSession β€” every request re-proves itself via the token, which is the whole "stateless" promise from Β§6.1 made literal. authorizeHttpRequests is the actual authorization table: auth endpoints and health checks are open, everything else needs some authenticated principal (not yet a specific role β€” that's Β§6.8). addFilterBefore splices JwtAuthenticationFilter into Spring Security's filter chain immediately before the filter that would normally handle username/password form logins β€” so by the time authorization rules are evaluated, the JWT (if any) has already been turned into an Authentication.

A second real gotcha, this one from Spring Security itself

Without the exceptionHandling(...) block above, the first curl test in Β§6.9 β€” an anonymous request to a protected endpoint β€” came back 403 Forbidden, not the 401 Unauthorized you'd expect for "you never proved who you are." The reason: Spring Security distinguishes authentication failures (no valid credentials at all β†’ 401) from authorization failures (valid credentials, insufficient permission β†’ 403), but without an explicit AuthenticationEntryPoint, some default configurations fall back to treating "not authenticated" the same as "not authorized." The unauthorizedEntryPoint above makes the distinction explicit again: no valid Authentication in the context β†’ 401, full stop. Β§6.9 shows both status codes side by side so you can see the difference land correctly.

6.8 β€” Hands-on: protecting the endpoints that matter

Hands-on 6 Β· @PreAuthorize on BookController

Only two methods change β€” the two that mutate state:

BookController.java β€” updatedjava
@PreAuthorize("hasRole('LIBRARIAN')")
@PostMapping
public Book create(@Valid @RequestBody NewBookRequest body) {
    return bookService.create(body);
}

@PreAuthorize("hasRole('LIBRARIAN')")
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable long id) {
    books.deleteById(id);
    return ResponseEntity.noContent().build();
}

all, byId, and search stay exactly as Stage 5 left them β€” no role check, just anyRequest().authenticated() from the filter chain, meaning any logged-in member or librarian can browse. @PreAuthorize evaluates its expression before the method body runs, using Spring's authorization expression language β€” hasRole('X') is the common case, but the same annotation accepts arbitrary boolean expressions, including ones referencing method arguments. This only works because @EnableMethodSecurity is present on SecurityConfig β€” without it, @PreAuthorize is inert, silently ignored, and every request would sail through, which is exactly the kind of "annotation present but not actually protecting anything" bug worth checking for deliberately the first time you wire this up.

One more piece completes the picture: a thrown AccessDeniedException from a failed @PreAuthorize check needs somewhere to land. Add it to GlobalExceptionHandler, alongside the handlers from Stage 4 and 5:

GlobalExceptionHandler.java β€” addjava
@ExceptionHandler(AccessDeniedException.class)
public ProblemDetail handleAccessDenied(AccessDeniedException ex) {
    ProblemDetail problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.FORBIDDEN, "You do not have permission to perform this action");
    problem.setTitle("Access denied");
    return problem;
}

This is worth pausing on, because it looks like it shouldn't work: GlobalExceptionHandler is a normal @RestControllerAdvice, and @PreAuthorize's check happens via a Spring AOP proxy wrapped around your controller method β€” nowhere near a servlet filter. But that proxy sits inside the same call stack DispatcherServlet invokes to run your controller, so when it throws, the exception propagates up through completely ordinary Spring MVC exception handling, and your @ExceptionHandler catches it exactly like any exception thrown from inside the method body itself. Contrast that with the plain-401 case in Β§6.7's gotcha: that failure happens inside the servlet filter chain, before DispatcherServlet ever runs, so no @RestControllerAdvice can reach it β€” which is why it needed its own AuthenticationEntryPoint instead.

6.9 β€” Hands-on: drive the whole thing

Hands-on 7 Β· register two roles, watch the rules apply
PowerShell
PS> curl.exe -i http://localhost:8080/api/books
HTTP/1.1 401                                          ← no token at all

PS> curl.exe -X POST http://localhost:8080/api/auth/register -H "Content-Type: application/json" -d '{\"username\":\"alice\",\"password\":\"password123\",\"role\":\"MEMBER\"}'
{"token":"eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJhbGljZSIs..."}

PS> curl.exe -X POST http://localhost:8080/api/auth/register -H "Content-Type: application/json" -d '{\"username\":\"bob\",\"password\":\"password123\",\"role\":\"LIBRARIAN\"}'
{"token":"eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJib2Ii..."}

# save each token to a variable, then:

PS> curl.exe -i -H "Authorization: Bearer $member" http://localhost:8080/api/books
HTTP/1.1 200                                          ← members can read

PS> curl.exe -i -X POST -H "Authorization: Bearer $member" -H "Content-Type: application/json" -d '{\"title\":\"x\",\"year\":2020,\"authorId\":1}' http://localhost:8080/api/books
HTTP/1.1 403
{"detail":"You do not have permission to perform this action","instance":"/api/books","status":403,"title":"Access denied"}

PS> curl.exe -X POST -H "Authorization: Bearer $librarian" -H "Content-Type: application/json" -d '{\"name\":\"Robert C. Martin\"}' http://localhost:8080/api/authors
{"name":"Robert C. Martin","id":1}

PS> curl.exe -i -X POST -H "Authorization: Bearer $librarian" -H "Content-Type: application/json" -d '{\"title\":\"Clean Code\",\"year\":2008,\"authorId\":1}' http://localhost:8080/api/books
HTTP/1.1 200                                          ← librarians can create

PS> curl.exe -X POST http://localhost:8080/api/auth/login -H "Content-Type: application/json" -d '{\"username\":\"alice\",\"password\":\"wrongpassword\"}'
{"detail":"Invalid username or password","instance":"/api/auth/login","status":401,"title":"Authentication failed"}

Four different failure/success shapes in one transcript, and each one means something different: 401 with an empty body (no token β€” the filter chain rejected it before DispatcherServlet ever ran), 403 with a ProblemDetail body (valid token, wrong role β€” @PreAuthorize rejected it from inside normal MVC exception handling), 401 from /api/auth/login (a business-logic rejection, thrown and caught exactly like Stage 4 and 5's exceptions), and 200 (every check passed). If you only remember one thing from this stage, make it that the two 401s above happen through completely different mechanisms despite an identical status code β€” one from the servlet filter layer, one from ordinary application code.

6.10 β€” A test that already existed just broke

Note Β· fixing Stage 5's BookControllerWebMvcTest

Re-running Stage 5's test suite after adding security produces a context-loading failure in BookControllerWebMvcTest, not a clean pass or a clean assertion failure. The cause: @WebMvcTest's slice scanning includes any Filter bean β€” and JwtAuthenticationFilter qualifies β€” but excludes plain @Service beans, so JwtService (its constructor dependency) isn't there to inject. The fix is to exclude the filter from this particular slice explicitly, since this test was never meant to exercise security in the first place:

BookControllerWebMvcTest.java β€” updated annotationjava
@WebMvcTest(
        controllers = BookController.class,
        excludeFilters = @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = JwtAuthenticationFilter.class))
@AutoConfigureMockMvc(addFilters = false)
@Import(GlobalExceptionHandler.class)
class BookControllerWebMvcTest { /* unchanged body */ }

addFilters = false alone wasn't enough β€” it stops MockMvc from applying filters, but Spring still tries to construct every filter bean in the context first, and that construction is what was failing. excludeFilters stops the bean from being created at all. This test now verifies JSON binding and validation exactly as it did in Stage 5, deliberately blind to security β€” real security behavior gets its own end-to-end test, next.

6.11 β€” Hands-on: testing security itself, end-to-end

Hands-on 8 Β· a full-stack test proving the three-tier policy
src/test/java/com/library/api/auth/LibraryApiSecurityIntegrationTest.javajava
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureTestRestTemplate
class LibraryApiSecurityIntegrationTest {

    @Autowired private TestRestTemplate rest;

    @Test
    void anonymousRequestToBooksIsRejected() {
        var response = rest.getForEntity("/api/books", String.class);
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
    }

    @Test
    void memberCanReadButNotCreateBooks() {
        // register a MEMBER, GET /api/books with its token β†’ 200,
        // POST /api/books with the same token β†’ 403
    }

    @Test
    void librarianCanCreateBooks() {
        // register a LIBRARIAN, POST an author, POST a book β†’ 200
    }
}

This is deliberately not a @WebMvcTest. Slice-testing security correctly means faking a valid SecurityContext, which β€” as Β§6.10 just demonstrated β€” is fiddly even to get building, let alone meaningfully asserting on. A full @SpringBootTest sidesteps all of it: real embedded server, real filter chain, real JWTs generated by hitting /api/auth/register over actual HTTP, then reused as real Authorization headers on the requests that follow. It's the slowest test category in the whole suite (Stage 5's testing pyramid again), but for a cross-cutting concern like security β€” spanning a filter, a config class, and per-endpoint annotations β€” it's also the one type of test that can't be fooled by getting any single layer's mock slightly wrong.

6.12 β€” Checkpoint

Checkpoint Β· can you answer these cold?
  • Where does a JWT's trustworthiness actually come from β€” what stops a client from editing the role claim to LIBRARIAN themselves? (the signature: any edit to the payload changes what the signature should be, and only the server holds the secret needed to produce a signature that verifies)
  • JwtAuthenticationFilter never queries the database. What earlier design decision made that possible, and what's the tradeoff? (all the info needed β€” username, role β€” is embedded in the token's claims at issue time; the tradeoff is that revoking a single compromised token before its natural expiry needs extra machinery, like a blocklist, that this lesson doesn't build)
  • Two different requests in Β§6.9 both came back 401. Structurally, how did they differ? (one β€” anonymous request to a protected route β€” was rejected by the servlet filter chain before DispatcherServlet ran, empty body; the other β€” bad login credentials β€” was thrown and caught as an ordinary exception inside application code, with a full ProblemDetail body)
  • What would happen right now if you removed @EnableMethodSecurity from SecurityConfig but left @PreAuthorize on BookController? (nothing enforces it β€” the annotation is inert without the enabling annotation, and a member could create books freely; this is why it's worth testing the negative case, not just the positive one)
  • Why does BookControllerWebMvcTest need excludeFilters now, when it built cleanly all through Stage 5? (@WebMvcTest's slice includes Filter-type beans like the new JwtAuthenticationFilter, but excludes the plain @Service it depends on, so without excluding it explicitly the context fails to construct before any test even runs)

Stretch exercise (no solution given): add a third role-gated rule β€” only the librarian who doesn't exist yet in this domain model, an ADMIN, can delete an Author (you'll need to add that endpoint too). Then write one more LibraryApiSecurityIntegrationTest case proving a LIBRARIAN token gets 403 on it.

πŸ”’Finish and mark Stage 5 complete to unlock this stage.
Done with Stage 6? Anonymous requests get 401, wrong-role requests get 403, correct-role requests succeed β€” and .\mvnw.cmd test is green across all 11+ tests.

Stage 7Production-ready

Goal: everything you've built so far assumes one machine, one database, one trusted operator reading logs. This stage removes those assumptions: environment-specific configuration, a config class that fails loudly instead of quietly, structured logs a machine can parse, actuator endpoints that respect the roles from Stage 6, a real deployable jar, and a container image.

7.1 β€” Hands-on: split configuration by profile

Everything in application.properties so far has been "development settings" β€” an in-memory H2 database, a hardcoded JWT secret. A profile is a named set of properties that only applies when active, letting the same jar run differently in different environments without a single code change.

Hands-on 1 Β· common vs. dev vs. prod

Split the one properties file into three. What's true everywhere stays in the base file:

src/main/resources/application.propertiesproperties
spring.application.name=library-api
spring.profiles.active=dev

management.endpoints.web.exposure.include=health,info,beans,mappings,metrics,env

What's dev-specific moves to a profile-suffixed file β€” Spring Boot loads application-{profile}.properties automatically whenever that profile is active:

src/main/resources/application-dev.propertiesproperties
spring.datasource.url=jdbc:h2:mem:librarydb
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true

library.jwt.secret=dev-only-secret-change-me-dev-only-secret-change-me
library.jwt.expiration-minutes=60

And a production profile that points at a real database via environment variables, never hardcoded values:

src/main/resources/application-prod.propertiesproperties
spring.datasource.url=${DB_URL}
spring.datasource.username=${DB_USERNAME}
spring.datasource.password=${DB_PASSWORD}
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.show-sql=false

library.jwt.secret=${JWT_SECRET}
library.jwt.expiration-minutes=60

logging.structured.format.console=ecs

spring.profiles.active=dev in the base file means running with no flags at all gives you dev β€” the same "just works" experience every earlier stage relied on. Production deployments always override it explicitly (SPRING_PROFILES_ACTIVE=prod, shown in Β§7.6) rather than trusting a default meant for your laptop. Notice ddl-auto flips from update (dev: Hibernate freely alters your schema) to validate (prod: Hibernate checks the schema matches your entities and refuses to start if it doesn't β€” schema changes in a real system go through a migration tool like Flyway, out of scope here but worth knowing the name before you need it).

7.2 β€” Hands-on: configuration that fails loudly, not quietly

Stage 6's JwtService pulled two settings in via @Value("${library.jwt.secret}") and @Value("${library.jwt.expiration-minutes}") β€” two separate injection points for what is really one cohesive setting. @ConfigurationProperties groups related settings into a single typed, validated object instead.

Hands-on 2 Β· LibraryProperties
src/main/java/com/library/api/config/LibraryProperties.javajava
package com.library.api.config;

import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;

@ConfigurationProperties(prefix = "library")
@Validated
public record LibraryProperties(@NotNull @Valid Jwt jwt) {
    public record Jwt(
            @Size(min = 32, message = "library.jwt.secret must be at least 32 characters "
                    + "(HMAC-SHA256 needs a >=256-bit key) - check JWT_SECRET is set and not an unresolved placeholder")
            String secret,
            @Positive long expirationMinutes) {}
}

Enable it with one annotation on the main class:

LibraryApiApplication.java β€” addjava
@SpringBootApplication
@ConfigurationPropertiesScan
public class LibraryApiApplication { /* unchanged body */ }

Then rewire JwtService to take the whole properties object instead of two loose values:

JwtService.java β€” constructorjava
public JwtService(LibraryProperties properties) {
    this.key = Keys.hmacShaKeyFor(properties.jwt().secret().getBytes());
    this.expirationMinutes = properties.jwt().expirationMinutes();
}

library.jwt.secret and library.jwt.expiration-minutes now bind into one LibraryProperties bean at startup β€” @ConfigurationPropertiesScan finds every @ConfigurationProperties class on the classpath and registers it, the same job component scan does for @Service/@RestController. Two real advantages over scattered @Value fields: as your app grows past one property, everything related lives in one typed record instead of one parameter per constructor per class; and β€” the more important one β€” Bean Validation now runs on your configuration itself, at startup, before a single HTTP request is served.

A production bug I actually shipped writing this lesson

Testing the prod profile with JWT_SECRET deliberately unset (simulating a deployment that forgot to configure it), the very first version of JwtService β€” still using two @Value fields β€” didn't fail at startup at all. It started successfully, then crashed at the first login attempt with WeakKeyException: The specified key byte array is 104 bits which is not secure enough. 104 bits is exactly 13 bytes β€” the length of the literal string "${JWT_SECRET}". Spring hadn't thrown a "missing property" error; it had silently left the placeholder unresolved as literal text, which passed right through as if it were a real secret, and only broke three layers away, inside a third-party library, with an error message that never mentions configuration at all.

Switching to @ConfigurationProperties changed the failure mode without changing which value was missing β€” this time, with an unresolvable library.jwt.secret, Spring's binder couldn't construct the Jwt record at all and bound the whole group as null. Without @NotNull on the outer jwt field, that surfaced as a NullPointerException inside JwtService's constructor β€” better (it's a config problem now, not a WeakKeyException three layers deep), but still not obviously a config problem to whoever reads it at 3am. Only with @NotNull @Valid Jwt jwt in place does misconfiguration produce this, at the moment the application tries to start, before it ever binds to a port:

startup output β€” missing JWT_SECRET, final version
***************************
APPLICATION FAILED TO START
***************************

Description:

Binding to target com.library.api.config.LibraryProperties failed:

    Property: library.jwt
    Value: "null"
    Reason: must not be null

Action:

Update your application's configuration

Three versions of the same missing value, three completely different failure experiences. The lesson isn't "add validation annotations" in the abstract β€” it's that where a missing setting fails, and how clearly, is itself something you design, not something that happens to you. A setting your app cannot run without deserves a check that runs before anything else does.

7.3 β€” Hands-on: logs a machine can read too

AuthController has run since Stage 6 without logging a single thing it did. Add real log lines β€” and notice the pattern: log the outcome, not the secret.

Hands-on 3 Β· SLF4J in AuthController
AuthController.java β€” addjava
private static final Logger log = LoggerFactory.getLogger(AuthController.class);

// inside register(), after users.save(user):
log.info("Registered new user username={} role={}", user.getUsername(), user.getRole());

// inside login(), on the wrong-password branch:
log.warn("Failed login attempt username={}", body.username());

// inside login(), on success:
log.info("Successful login username={}", user.getUsername());

Never body.password(), never the generated JWT, never user.getPasswordHash() β€” logs get shipped to aggregators, retained for months, and read by more people than your codebase. Log the fact that something happened and to whom, never the credential itself.

In dev, these print as ordinary readable text β€” that's application-dev.properties having no logging format override, so Boot's default console pattern applies. In prod, logging.structured.format.console=ecs (added in Β§7.1) changes every line to one JSON object per event, in the Elastic Common Schema β€” the format log aggregators like ELK or Loki expect, because grepping text logs across a fleet of containers doesn't scale the way querying structured fields does:

console output with logging.structured.format.console=ecs
{"@timestamp":"2026-08-08T11:43:25.640Z","log":{"level":"INFO","logger":"com.library.api.auth.AuthController"},
 "process":{"pid":16280,"thread":{"name":"http-nio-8080-exec-2"}},
 "service":{"name":"library-api","version":"0.0.1-SNAPSHOT"},
 "message":"Registered new user username=alice role=MEMBER","ecs":{"version":"8.11"}}

Same log.info(...) call, two completely different renderings β€” the format is entirely a property switch, zero code changes, one more reason logging through SLF4J instead of System.out.println pays for itself the moment you have more than one deployment target.

7.4 β€” Hands-on: actuator meets Stage 6's roles

Stage 1 exposed beans, mappings, and info to see the DI container; Stage 6's anyRequest().authenticated() quietly put all of them behind a valid JWT already. But "any logged-in member can read your environment variables" is still too broad β€” /actuator/env can leak configuration, /actuator/beans reveals your entire internal wiring. Neither belongs to ordinary members.

Hands-on 4 Β· role-gate the sensitive endpoints
SecurityConfig.java β€” authorizeHttpRequestsjava
.authorizeHttpRequests(auth -> auth
    .requestMatchers("/api/auth/**", "/actuator/health", "/error").permitAll()
    .requestMatchers("/actuator/beans", "/actuator/env", "/actuator/mappings").hasRole("LIBRARIAN")
    .anyRequest().authenticated())

Test it: a MEMBER token now gets 403 on /actuator/env, a LIBRARIAN token gets 200, /actuator/metrics stays open to both (it's operationally useful and not sensitive), and /actuator/health stays open to nobody-in-particular, same as Stage 6.

A second Spring Security gotcha, deep in the servlet container this time

The first version of this rule didn't include "/error" in permitAll(), and a MEMBER hitting the newly role-gated /actuator/env got 401, not the expected 403 β€” with an empty body, the signature of Stage 6's entry point, not the access-denied path. Running with logging.level.org.springframework.security=TRACE showed exactly what happened: Spring Security correctly identified the request as access-denied and handed it to the default AccessDeniedHandler β€” which responds with response.sendError(403). That call doesn't write a response body directly; it tells the servlet container "render the error page for this status," and Tomcat internally re-dispatches the request to /error β€” a real second pass through the entire filter chain, including your custom JwtAuthenticationFilter.

But JwtAuthenticationFilter extends OncePerRequestFilter, which by design skips re-running itself on error dispatches (to avoid exactly the kind of double-processing weirdness this is) β€” so on that second pass, no Authentication gets set at all. /error wasn't in the permit list, so anyRequest().authenticated() caught it unauthenticated, fired the Stage 6 entry point, and overwrote the original 403 with a 401 before the response ever reached the client. Adding /error to permitAll() lets that internal re-dispatch through untouched, so whatever status the first pass decided on is the one that actually ships. This is a standing trap for anyone combining a custom AuthenticationEntryPoint with Spring Security and a servlet container's default error handling β€” worth remembering the shape of it even outside this specific stack.

7.5 β€” Hands-on: a build machines can identify

Hands-on 5 Β· build-info for /actuator/info

One plugin execution, no new dependency:

pom.xml β€” build sectionxml
<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <executions>
        <execution>
            <goals>
                <goal>build-info</goal>
            </goals>
        </execution>
    </executions>
</plugin>

This writes META-INF/build-info.properties into the jar at build time β€” artifact name, version, and build timestamp β€” which Actuator's /actuator/info endpoint reads and serves automatically:

PowerShell (as a LIBRARIAN)
PS> curl.exe -H "Authorization: Bearer $lib" http://localhost:8080/actuator/info
{"build":{"artifact":"library-api","name":"library-api","time":"2026-08-08T11:42:01.812Z","version":"0.0.1-SNAPSHOT","group":"com.library"}}

Small, but this is exactly the question "which build is actually running in this environment right now?" β€” one your future self will ask during an incident, answered without SSHing anywhere.

7.6 β€” Hands-on: the artifact you actually deploy

Hands-on 6 Β· package and run the real jar
PowerShell
PS> .\mvnw.cmd clean package -DskipTests
BUILD SUCCESS

PS> java -jar target\library-api-0.0.1-SNAPSHOT.jar
Started LibraryApiApplication ... (dev profile, the default)

# in a second terminal, prove nothing else is needed:
PS> curl.exe http://localhost:8080/actuator/health
{"groups":["liveness","readiness"],"status":"UP"}

No IDE, no mvnw spring-boot:run, no source code required on the target machine at all β€” target/library-api-0.0.1-SNAPSHOT.jar is a single ~60 MB file containing your compiled classes, every dependency, and an embedded Tomcat. This is what spring-boot-maven-plugin has been building since Stage 1; you just never had a reason to run it directly until now. Try java -jar target\library-api-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod without setting DB_URL/JWT_SECRET first β€” you should see the exact clean failure from Β§7.2's note, on purpose.

7.7 β€” A container image (written, not run on this machine)

Note Β· Docker isn't installed here

The rest of this section is real, correct configuration β€” reviewed carefully rather than docker run-verified, since this machine doesn't have Docker installed. If you have Docker Desktop, these files work as shown; if not, understanding what each line does is still the actual goal.

Dockerfiledockerfile
# ---- Build stage ----
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN ./mvnw dependency:go-offline
COPY src ./src
RUN ./mvnw clean package -DskipTests

# ---- Run stage ----
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /app/target/library-api-*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

Two FROM lines make this a multi-stage build: the first stage has a full JDK and Maven's build tooling and produces the jar; the second starts completely fresh from a slim JRE-only image and copies in just the finished jar. The build stage's ~400 MB of compiler and build cache never ships β€” the final image is only your jar plus a minimal Java runtime. COPY .mvn/ mvnw pom.xml before COPY src is deliberate layer ordering: Docker caches each layer, so as long as pom.xml hasn't changed, dependency:go-offline (the slow network-heavy step) is reused from cache even when you've only edited a .java file.

docker-compose.ymlyaml
services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_DB: librarydb
      POSTGRES_USER: library
      POSTGRES_PASSWORD: library
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

  app:
    build: .
    depends_on:
      - postgres
    environment:
      SPRING_PROFILES_ACTIVE: prod
      DB_URL: jdbc:postgresql://postgres:5432/librarydb
      DB_USERNAME: library
      DB_PASSWORD: library
      JWT_SECRET: change-this-in-real-deployment-change-this-in-real-deployment
    ports:
      - "8080:8080"

volumes:
  pgdata:

This is Β§7.1's prod profile, made concrete: every ${DB_URL}-style placeholder from application-prod.properties gets its value from this file's environment: block. Notice DB_URL points at postgres, not localhost β€” inside a Compose network, each service is reachable by its service name as a hostname, resolved by Compose's internal DNS. depends_on only sequences container startup order, not "wait until Postgres can accept connections" (a genuinely common gotcha); production Compose setups typically add a healthcheck to postgres and a condition: service_healthy on depends_on for a real readiness wait, left out here to keep this file focused on the profile/config story.

If you install Docker Desktop later: docker compose up --build from this directory builds the image and starts both containers, and curl http://localhost:8080/actuator/health should behave exactly as it does when you run the jar directly β€” same code, same config mechanism, different infrastructure underneath.

7.8 β€” Checkpoint

Checkpoint Β· can you answer these cold?
  • You run the jar with no flags at all. Which profile is active, and what tells you that? (dev β€” spring.profiles.active=dev in the base application.properties is the default when nothing overrides it)
  • Three different versions of missing-JWT_SECRET failed three different ways in this stage. Put them in order from worst to best failure experience, and say what changed between each. (worst: silent pass-through as literal text, breaks later with a WeakKeyException three layers away; middle: NullPointerException at startup once switched to @ConfigurationProperties, at least fails immediately but unclear why; best: a named, clear "Property: library.jwt, Reason: must not be null" once @NotNull was added β€” same missing value, progressively clearer failure)
  • Why did adding a role-gated /actuator/env rule initially turn a 403 into a 401, and what fixed it? (Spring's default AccessDeniedHandler calls sendError(403), which Tomcat turns into an internal re-dispatch to /error; that second pass skips the custom filter (OncePerRequestFilter's error-dispatch exemption), finds no authentication, and the entry point overwrites the status with 401 β€” fixed by permitting /error so the re-dispatch doesn't get re-evaluated by anyRequest().authenticated())
  • In the Dockerfile, why does copying pom.xml and running dependency:go-offline happen before COPY src? (Docker layer caching β€” as long as pom.xml is unchanged, that slow dependency-download layer is reused from cache even when only application code changes)
  • In docker-compose.yml, why is the database host postgres and not localhost? (inside a Compose network, services reach each other by service name, resolved via Compose's internal DNS β€” localhost inside the app container would mean the app container itself, which has no database running in it)

Stretch exercise (no solution given): add a healthcheck to the postgres service in docker-compose.yml (Postgres ships pg_isready for exactly this) and a matching condition: service_healthy on app's depends_on, so the app container genuinely waits for a ready database instead of just a started container.

πŸ”’Finish and mark Stage 6 complete to unlock this stage.
Done with Stage 7? Dev profile runs standalone from the packaged jar; prod profile fails fast and clearly without its secrets; a MEMBER gets 403 (not 401) on /actuator/env; /actuator/info reports real build metadata.

Stage 8Microservices

Goal: split library-api into two independently deployable services β€” catalog-service owning books and authors, loan-service owning a brand-new "borrow a book" workflow that calls the catalog over HTTP. You'll feel exactly what a network boundary costs that an in-process method call doesn't: a call that can time out, a service that can be down, and errors that no longer come for free.

8.1 β€” Why split at all, and where

Nothing about library-api was broken β€” Stages 1–7 built a perfectly coherent monolith. Real systems split into services for reasons that are organizational and operational, not usually technical necessity: different teams owning different domains and deploying on their own schedule, one part of the system needing to scale independently of another, a failure in one area not being allowed to take down everything else. Splitting early, before you feel any of those pressures, mostly buys you the costs (network calls, duplicated infrastructure, harder local development) without the benefits β€” this stage exists so you've felt both sides once, not to suggest every project should start this way.

The split follows the domain boundary already visible in your code: Author and Book form one cohesive concept (the catalog); a new concept β€” a member borrowing a book β€” forms another. That second concept didn't exist in the monolith at all; you're adding it now specifically to have a real reason for one service to call another.

8.2 β€” Hands-on: extract catalog-service

Hands-on 1 Β· a second Spring Initializr project

Generate a new project the same way you generated library-api in Stage 1 β€” start.spring.io, Maven, Java 21, dependencies web, actuator, data-jpa, h2, validation β€” group com.library, artifact catalog-service, package com.library.catalog. Then move in almost exactly what Stage 3 and Stage 4 already taught you to build:

catalog-service/ β”œβ”€β”€ src/main/java/com/library/catalog/ β”‚ β”œβ”€β”€ author/  β† Author, AuthorRepository, AuthorController β€” unchanged from Stage 3 β”‚ β”œβ”€β”€ book/  β† Book, BookRepository, BookController β€” unchanged from Stage 3 β”‚ └── CatalogExceptionHandler.java  β† the missing-book β†’ 404 mapping from Stage 4, new file └── src/main/resources/application.properties
src/main/resources/application.propertiesproperties
spring.application.name=catalog-service
server.port=8081

spring.datasource.url=jdbc:h2:mem:catalogdb
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true

management.endpoints.web.exposure.include=health,info

server.port=8081 is the one property that matters most here: library-api ran on 8080 for seven stages because it was the only thing running. Two independent services can't share a port, so this is the first concrete thing "independently deployable" costs you β€” you now have to track which service lives where, something a monolith's single port made a non-question.

One genuinely new file: this project never went through Stage 4, so nothing yet converts a missing book's NoSuchElementException into a clean 404.

src/main/java/com/library/catalog/CatalogExceptionHandler.javajava
package com.library.catalog;

import java.util.NoSuchElementException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class CatalogExceptionHandler {

    @ExceptionHandler(NoSuchElementException.class)
    public ProblemDetail handleNotFound(NoSuchElementException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
        problem.setTitle("Resource not found");
        return problem;
    }
}

Run it (.\mvnw.cmd spring-boot:run from catalog-service/) and confirm it's really a standalone service now: curl.exe http://localhost:8081/actuator/health, then create an author and a book exactly as in Stage 3, entirely without library-api running at all.

8.3 β€” Hands-on: build loan-service, the new domain

Hands-on 2 Β· a project with no monolith history

Same Initializr, different dependencies: web, actuator, data-jpa, h2, validation, and β€” new β€” spring-restclient (the "HTTP Client" starter, which provides RestClient.Builder as an auto-configured bean). Group com.library, artifact loan-service, package com.library.loan.

src/main/resources/application.propertiesproperties
spring.application.name=loan-service
server.port=8082

spring.datasource.url=jdbc:h2:mem:loandb
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true

catalog.service.base-url=http://localhost:8081
catalog.service.connect-timeout-ms=1000
catalog.service.read-timeout-ms=2000

management.endpoints.web.exposure.include=health,info

The Loan entity is deliberately simple β€” this stage is about the service call, not about JPA relationships you've already practiced:

src/main/java/com/library/loan/Loan.javajava
@Entity
public class Loan {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private Long bookId;
    private String bookTitle;
    private String memberName;
    private LocalDate loanDate;
    // protected no-arg constructor, a real constructor, getters β€” same shape as every entity since Stage 3
}

Notice bookTitle is stored directly on the Loan, copied from the catalog at borrow time β€” loan-service has no book table of its own and never will. This is the other half of what a service split costs: no foreign key, no JPA @ManyToOne across the boundary, because there's no shared database to join across. If loan-service wants to display a title without calling catalog-service again on every read, it has to keep its own copy at the moment it learns it β€” a small, deliberate duplication that is the trade for independence, not an oversight.

LoanRepository is the one-line JpaRepository pattern you've written since Stage 3.

8.4 β€” Hands-on: calling another service with RestClient

Hands-on 3 Β· a typed, timeout-configured HTTP client

Three small files in a new catalog package inside loan-service β€” the client's own name for "the thing it talks to," independent of what the other service calls itself internally:

src/main/java/com/library/loan/catalog/BookSummary.javajava
public record BookSummary(Long id, String title, int year) {}
src/main/java/com/library/loan/catalog/CatalogClientConfig.javajava
@Configuration
public class CatalogClientConfig {

    @Bean
    public RestClient catalogRestClient(
            RestClient.Builder builder,
            @Value("${catalog.service.base-url}") String baseUrl,
            @Value("${catalog.service.connect-timeout-ms}") long connectTimeoutMs,
            @Value("${catalog.service.read-timeout-ms}") long readTimeoutMs) {

        var settings = HttpClientSettings.defaults()
                .withConnectTimeout(Duration.ofMillis(connectTimeoutMs))
                .withReadTimeout(Duration.ofMillis(readTimeoutMs));

        return builder
                .baseUrl(baseUrl)
                .requestFactory(ClientHttpRequestFactoryBuilder.detect().build(settings))
                .build();
    }
}

Two timeouts, two different failure modes they guard against: connect timeout bounds how long to wait for the TCP handshake itself β€” protects you when a host is unreachable or a network is dropping packets silently. Read timeout bounds how long to wait for a response after the connection succeeds β€” protects you when the other service accepted your request but is hung, deadlocked, or just very slow. Without either, a single struggling dependency can leave every request thread in your service blocked indefinitely, waiting on someone else's problem β€” this is one of the most common real outages in service-to-service systems, and it's prevented by two @Value-injected numbers.

src/main/java/com/library/loan/catalog/CatalogUnavailableException.javajava
public class CatalogUnavailableException extends RuntimeException {
    public CatalogUnavailableException(String message, Throwable cause) {
        super(message, cause);
    }
}
src/main/java/com/library/loan/catalog/CatalogClient.javajava
@Component
public class CatalogClient {

    private final RestClient restClient;

    public CatalogClient(RestClient catalogRestClient) {
        this.restClient = catalogRestClient;
    }

    public BookSummary findBook(Long bookId) {
        try {
            return restClient.get()
                    .uri("/api/books/{id}", bookId)
                    .retrieve()
                    .body(BookSummary.class);
        } catch (HttpClientErrorException.NotFound e) {
            throw new NoSuchElementException("No book with id " + bookId + " in the catalog");
        } catch (ResourceAccessException e) {
            throw new CatalogUnavailableException("catalog-service did not respond in time", e);
        }
    }
}

Two catch blocks, two categorically different kinds of failure, deliberately not merged into one: HttpClientErrorException.NotFound means catalog-service answered β€” clearly, correctly β€” "no such book." That's not really a failure at all; it's information, translated into NoSuchElementException so loan-service's own exception handling (next section) treats it exactly like Stage 3 and 4 taught you to treat any missing resource. ResourceAccessException means the request never got a real answer at all β€” connection refused, DNS failure, or one of Β§8.4's timeouts firing. That's a genuinely different situation (something is wrong, not just "not found"), so it becomes a different exception type, headed for a different HTTP status entirely.

8.5 β€” Hands-on: the endpoint and its two failure shapes

Hands-on 4 Β· LoanController and LoanExceptionHandler
src/main/java/com/library/loan/LoanController.javajava
@RestController
@RequestMapping("/api/loans")
public class LoanController {

    private final LoanRepository loans;
    private final CatalogClient catalogClient;

    public LoanController(LoanRepository loans, CatalogClient catalogClient) {
        this.loans = loans;
        this.catalogClient = catalogClient;
    }

    @GetMapping
    public List<Loan> all() {
        return loans.findAll();
    }

    record NewLoan(Long bookId, String memberName) {}

    @PostMapping
    public Loan create(@RequestBody NewLoan body) {
        BookSummary book = catalogClient.findBook(body.bookId());
        Loan loan = new Loan(book.id(), book.title(), body.memberName(), LocalDate.now());
        return loans.save(loan);
    }
}

Read create closely: every loan starts with a live call to another service β€” no book gets borrowed based on stale or assumed data. That's the correctness benefit of the split paying for its own cost: loan-service doesn't need its own copy of the whole catalog, doesn't need to keep it in sync, doesn't need to worry about it going stale β€” it just asks, every time, and only persists what it needs (the title, for display) at the moment of asking.

src/main/java/com/library/loan/LoanExceptionHandler.javajava
@RestControllerAdvice
public class LoanExceptionHandler {

    @ExceptionHandler(NoSuchElementException.class)
    public ProblemDetail handleNotFound(NoSuchElementException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
        problem.setTitle("Resource not found");
        return problem;
    }

    @ExceptionHandler(CatalogUnavailableException.class)
    public ProblemDetail handleCatalogUnavailable(CatalogUnavailableException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.SERVICE_UNAVAILABLE,
                "catalog-service is unreachable - could not verify the book, try again shortly");
        problem.setTitle("Upstream service unavailable");
        return problem;
    }
}

503 Service Unavailable is new in this course, and it means something specific: your request was fine, but something this service depends on isn't cooperating right now β€” try again later. That's a meaningfully different promise to the client than a 404 ("this doesn't exist," which retrying won't fix) or a 400 ("your request was wrong," which also won't fix itself on retry). A well-behaved client can look at 503 specifically and decide to retry with a backoff β€” information a generic 500 would have thrown away entirely.

8.6 β€” Hands-on: run both, then break one on purpose

Hands-on 5 Β· the full loop, then the failure
PowerShell β€” terminal 1powershell
PS> cd catalog-service; .\mvnw.cmd spring-boot:run
PowerShell β€” terminal 2powershell
PS> cd loan-service; .\mvnw.cmd spring-boot:run
PowerShell β€” terminal 3, once both are uppowershell
PS> curl.exe -X POST http://localhost:8081/api/authors -H "Content-Type: application/json" -d '{\"name\":\"Douglas Adams\"}'
{"name":"Douglas Adams","id":1}

PS> curl.exe -X POST http://localhost:8081/api/books -H "Content-Type: application/json" -d '{\"title\":\"The Hitchhikers Guide to the Galaxy\",\"year\":1979,\"authorId\":1}'
{"title":"The Hitchhikers Guide to the Galaxy","year":1979,"author":{...},"id":1}

PS> curl.exe -X POST http://localhost:8082/api/loans -H "Content-Type: application/json" -d '{\"bookId\":1,\"memberName\":\"Arthur Dent\"}'
{"bookId":1,"bookTitle":"The Hitchhikers Guide to the Galaxy","memberName":"Arthur Dent","loanDate":"2026-08-08","id":1}

PS> curl.exe -i -X POST http://localhost:8082/api/loans -H "Content-Type: application/json" -d '{\"bookId\":999,\"memberName\":\"Ford Prefect\"}'
HTTP/1.1 404
{"detail":"No book with id 999 in the catalog","instance":"/api/loans","status":404,"title":"Resource not found"}

Now go back to terminal 1 and stop catalog-service with Ctrl+C β€” leave loan-service running. Then, from terminal 3:

PowerShell β€” with catalog-service stopped
PS> curl.exe -i -X POST http://localhost:8082/api/loans -H "Content-Type: application/json" -d '{\"bookId\":1,\"memberName\":\"Trillian\"}'
HTTP/1.1 503
{"detail":"catalog-service is unreachable - could not verify the book, try again shortly",
 "instance":"/api/loans","status":503,"title":"Upstream service unavailable"}

Notice how fast that 503 comes back β€” well under your one-second connect timeout. That's because "connection refused" (nothing listening on 8081 at all) is a fast, immediate failure from the operating system, not a hang. The timeouts you configured in Β§8.4 exist for the other kind of unhealthy dependency β€” one that accepts the connection but then goes silent β€” where without a read timeout, loan-service would sit blocked for as long as the OS-level socket allows, easily minutes, for a single request. You've now seen the fast failure directly; the slow one is exactly what the read timeout is insurance against, even on the days you never trigger it.

8.7 β€” Checkpoint

Checkpoint Β· can you answer these cold?
  • Why does Loan store bookTitle directly instead of a foreign key to a Book row? (there is no shared database to join across anymore β€” each service owns its own data, so any information from another service's domain has to be copied in at the moment it's learned, not joined later)
  • CatalogClient.findBook has two catch blocks. What real-world difference justifies keeping them separate instead of one broad catch (Exception e)? (a 404 from catalog-service is a clear, correct answer β€” "no such book" β€” while a connection failure means no answer was received at all; conflating them would make "the book doesn't exist" indistinguishable from "we don't actually know if it exists," which a retrying client needs to treat very differently)
  • What specifically does a connect timeout protect against that a read timeout doesn't, and vice versa? (connect timeout bounds how long to wait for the TCP handshake β€” protects against unreachable hosts/dead networks; read timeout bounds how long to wait for a response after connecting β€” protects against a reachable-but-hung dependency)
  • Why is a 503 a more useful response to a client than a bare 500 when catalog-service is down? (503 specifically signals "try again later, the problem isn't your request" β€” a client can act on that distinction; a generic 500 gives no signal about whether retrying would ever help)
  • Name one concrete cost of this split that Stage 1–7's single monolith never had to pay. (any of: two ports to track, no cross-service foreign keys/joins, a network call that can time out or fail independently of your own code, duplicated infrastructure like two H2 databases)

Stretch exercise (no solution given): add a GET /api/loans/{id} to loan-service, then write a small script that borrows a book, stops catalog-service, and confirms that reading the loan you already created still works fine β€” proving that only the write path (which needs to re-verify the book) is coupled to catalog-service's availability, not every operation in the service.

πŸ”’Finish and mark Stage 7 complete to unlock this stage.
Done with Stage 8? Both services run independently on their own ports, a loan succeeds end-to-end, a missing book returns 404, and stopping catalog-service turns that same request into a clean 503 instead of a hang or a crash.

Stage 9Messaging

Goal: when a book is borrowed, someone should be notified β€” but loan-service calling a third service synchronously, the way it calls catalog-service, would mean a slow or broken notification system could delay or fail every loan. RabbitMQ lets loan-service publish "this happened" and move on, entirely unconcerned with who's listening, whether they're up, or when they get around to it.

No broker installed on this machine

Everything in this stage was written, and its failure behavior genuinely tested — including two real bugs caught along the way, described below — but the actual publish→queue→consume round trip couldn't run end to end without a RabbitMQ broker, which isn't installed here. If you want to run this stage yourself: RabbitMQ's Windows installer needs Erlang/OTP first, or far simpler if you have Docker Desktop from Stage 7: docker run -d --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:4-management gives you a broker plus a web console at http://localhost:15672 (guest/guest) in under a minute.

9.1 β€” Queues, exchanges, and why they're two different things

It's tempting to think of a message queue as one thing a producer pushes into and a consumer pulls from β€” and for the simplest cases it is, but RabbitMQ (an AMQP broker) deliberately splits the concept in two. A producer never sends to a queue directly; it sends to an exchange, with a routing key attached. The exchange's only job is deciding which queue(s), if any, that message gets copied into, based on bindings β€” rules that connect a queue to an exchange for messages matching a given key.

Why bother with the indirection? Because it decouples the producer from the number and identity of consumers, not just from their availability. loan-service publishes to an exchange named library.events with routing key loan.borrowed β€” and has no idea, and no way to know, whether zero, one, or five different services are listening. A future analytics-service could bind its own queue to that same exchange and key tomorrow, consuming the exact same events, without loan-service changing a single line. Compare that to Stage 8's RestClient: loan-service had to know catalog-service's exact base URL. A topic exchange (used here) matches routing keys with wildcard patterns like loan.*; a plainer direct exchange matches keys exactly; a fanout exchange ignores the key entirely and copies to every bound queue β€” topic is the most flexible default when you're not sure yet who else might want to listen.

9.2 β€” Hands-on: loan-service publishes an event

Hands-on 1 Β· add spring-boot-starter-amqp, define the event

One new dependency in loan-service/pom.xml, alongside everything from Stage 8:

pom.xml β€” addxml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
src/main/resources/application.properties β€” addproperties
library.events.exchange=library.events
library.events.loan-borrowed-routing-key=loan.borrowed

The event itself is a record, same shape as every DTO since Stage 2 β€” the only difference is it travels over a queue instead of an HTTP body:

src/main/java/com/library/loan/events/BookBorrowedEvent.javajava
public record BookBorrowedEvent(
        Long loanId, Long bookId, String bookTitle, String memberName, LocalDate loanDate) {}
src/main/java/com/library/loan/events/LoanEventsConfig.javajava
@Configuration
public class LoanEventsConfig {

    @Bean
    public TopicExchange libraryEventsExchange(@Value("${library.events.exchange}") String exchangeName) {
        return new TopicExchange(exchangeName);
    }

    @Bean
    public MessageConverter jsonMessageConverter() {
        return new JacksonJsonMessageConverter();
    }
}
A version-mismatch bug I hit writing this lesson

The obvious, every-tutorial-online choice here is Jackson2JsonMessageConverter. On this project it fails at startup with NoClassDefFoundError: com/fasterxml/jackson/databind/json/JsonMapper. The reason: Spring Boot 4.1 defaults to Jackson 3 (new Maven coordinates, tools.jackson:* instead of com.fasterxml.jackson:*) for its own web/JSON stack β€” but Spring AMQP 4.1's Jackson2JsonMessageConverter is still built against classic Jackson 2 APIs, which were never pulled onto the classpath because nothing else asked for them. Spring AMQP ships the fix already: JacksonJsonMessageConverter β€” no "2" β€” built against Jackson 3, sitting right next to the old one in the same package. Same pattern as every relocated class this course has hit since Stage 1: when a well-known class fails with a classpath error on this project's dependency versions, look for a sibling class in the same package before assuming you mistyped something.

And the publisher β€” the one file that actually talks to RabbitMQ:

src/main/java/com/library/loan/events/LoanEventPublisher.javajava
@Component
public class LoanEventPublisher {

    private static final Logger log = LoggerFactory.getLogger(LoanEventPublisher.class);

    private final RabbitTemplate rabbitTemplate;
    private final String exchange;
    private final String routingKey;

    public LoanEventPublisher(
            RabbitTemplate rabbitTemplate,
            @Value("${library.events.exchange}") String exchange,
            @Value("${library.events.loan-borrowed-routing-key}") String routingKey) {
        this.rabbitTemplate = rabbitTemplate;
        this.exchange = exchange;
        this.routingKey = routingKey;
    }

    public void publishBookBorrowed(BookBorrowedEvent event) {
        rabbitTemplate.convertAndSend(exchange, routingKey, event);
        log.info("Published BookBorrowedEvent loanId={} bookId={}", event.loanId(), event.bookId());
    }
}

RabbitTemplate is auto-configured the moment spring-boot-starter-amqp is on the classpath, the same way RestClient.Builder appeared in Stage 8 the moment you added the restclient starter. convertAndSend runs your registered MessageConverter (the JacksonJsonMessageConverter bean above) to turn BookBorrowedEvent into a JSON byte payload, attaches the routing key, and hands it to the exchange β€” from here, RabbitMQ owns getting it to whoever's listening.

9.3 β€” Hands-on: wire the publish into loan creation

Hands-on 2 Β· LoanController, updated
LoanController.java β€” createjava
@PostMapping
public Loan create(@RequestBody NewLoan body) {
    BookSummary book = catalogClient.findBook(body.bookId());
    Loan loan = loans.save(new Loan(book.id(), book.title(), body.memberName(), LocalDate.now()));

    eventPublisher.publishBookBorrowed(
            new BookBorrowedEvent(loan.getId(), loan.getBookId(), loan.getBookTitle(),
                    loan.getMemberName(), loan.getLoanDate()));

    return loan;
}

Notice the order: loans.save(...) happens, and only then does the event get published, using data read back off the saved loan (its generated id included). The loan existing is the fact being announced β€” publishing before the save succeeded would risk announcing something that might still fail to persist.

9.4 β€” A bug that only showed up with the broker turned off

A design bug I actually shipped, and caught by testing the unhappy path

The first version of Β§9.3 called eventPublisher.publishBookBorrowed(...) with no error handling at all. Testing it with RabbitMQ deliberately not running β€” the exact scenario this note is written under β€” POST /api/loans returned a bare 500, even though the log showed the loan had already been saved to the database before the crash. RabbitTemplate.convertAndSend throws synchronously when it can't reach a broker, and with nothing catching that exception, it propagated straight out of the controller method β€” turning "we couldn't send a notification" into "your loan failed," which was a lie: the loan was sitting in the database the whole time, and the client had no way to know that.

The fix belongs inside the publisher, not the controller β€” callers of publishBookBorrowed shouldn't need to know or care that it can fail:

LoanEventPublisher.java β€” the actual fixjava
public void publishBookBorrowed(BookBorrowedEvent event) {
    try {
        rabbitTemplate.convertAndSend(exchange, routingKey, event);
        log.info("Published BookBorrowedEvent loanId={} bookId={}", event.loanId(), event.bookId());
    } catch (AmqpException e) {
        // The loan itself already committed - a notification that never went out is not a reason
        // to tell the client their loan failed. Log it loudly so it's visible to whoever's on call,
        // but never let it surface as an error response for a write that already succeeded.
        log.error("Failed to publish BookBorrowedEvent loanId={} bookId={} - loan was still created",
                event.loanId(), event.bookId(), e);
    }
}

Retested with RabbitMQ still not running: POST /api/loans now returns a clean 200 with the created loan, and the log carries an ERROR-level line documenting exactly what failed to send and why β€” visible to monitoring, invisible to the client. This is the sharpest lesson in this stage, more than any RabbitMQ API detail: choosing async messaging doesn't automatically make a system resilient to messaging failures β€” you still have to decide, deliberately, what happens when the publish itself doesn't work, the same way Stage 8 made you decide what happens when catalog-service doesn't answer. The difference is what the right answer looks like: catalog-service being down should stop the loan (you can't lend a book you can't confirm exists), but a notification system being down should never stop a loan that's otherwise valid β€” the two failures deserved, and got, opposite treatments.

9.5 β€” Hands-on: a service that only knows how to listen

Hands-on 3 Β· notification-service, from Spring Initializr

A third project β€” web, actuator, amqp, port 8083, no database at all.

src/main/resources/application.propertiesproperties
spring.application.name=notification-service
server.port=8083

library.events.exchange=library.events
library.events.loan-borrowed-routing-key=loan.borrowed
library.events.loan-borrowed-queue=notification.loan-borrowed

management.endpoints.web.exposure.include=health,info

Where loan-service only had to declare the exchange (a producer doesn't need a queue β€” it doesn't consume anything), a consumer has to declare the queue it reads from, and the binding connecting that queue to the exchange and routing key it cares about:

src/main/java/com/library/notification/events/LoanEventsConfig.javajava
@Configuration
public class LoanEventsConfig {

    @Bean
    public TopicExchange libraryEventsExchange(@Value("${library.events.exchange}") String exchangeName) {
        return new TopicExchange(exchangeName);
    }

    @Bean
    public Queue loanBorrowedQueue(@Value("${library.events.loan-borrowed-queue}") String queueName) {
        return new Queue(queueName, true);  // true = durable, survives a broker restart
    }

    @Bean
    public Binding loanBorrowedBinding(
            Queue loanBorrowedQueue, TopicExchange libraryEventsExchange,
            @Value("${library.events.loan-borrowed-routing-key}") String routingKey) {
        return BindingBuilder.bind(loanBorrowedQueue).to(libraryEventsExchange).with(routingKey);
    }

    @Bean
    public MessageConverter jsonMessageConverter() {
        return new JacksonJsonMessageConverter();
    }
}

Declaring these as Spring beans matters beyond dependency injection convenience: Spring AMQP's RabbitAdmin notices every Exchange, Queue, and Binding bean in the context and creates them on the broker automatically at startup if they don't already exist. You never log into a RabbitMQ console and click "create queue" by hand β€” the topology is defined in code, in version control, deployed the same way as everything else.

The listener itself, plus a small in-memory list so you have something to inspect over HTTP once you do have a broker running:

src/main/java/com/library/notification/events/LoanBorrowedListener.javajava
@Component
public class LoanBorrowedListener {

    private static final Logger log = LoggerFactory.getLogger(LoanBorrowedListener.class);
    private final List<String> sentNotifications = new CopyOnWriteArrayList<>();

    @RabbitListener(queues = "${library.events.loan-borrowed-queue}")
    public void onBookBorrowed(BookBorrowedEvent event) {
        String message = "Notify %s: you borrowed \"%s\" on %s"
                .formatted(event.memberName(), event.bookTitle(), event.loanDate());
        log.info(message);
        sentNotifications.add(Instant.now() + " - " + message);
    }

    public List<String> getSentNotifications() {
        return Collections.unmodifiableList(sentNotifications);
    }
}

@RabbitListener(queues = "...") is doing the same conceptual job @RabbitMapping... no β€” think of it as Spring MVC's @GetMapping, but for a queue instead of a URL: Spring registers a background consumer thread that invokes this method automatically every time a message lands, deserializing the JSON payload back into a BookBorrowedEvent using the same JacksonJsonMessageConverter the producer used to serialize it. notification-service never calls loan-service; it never even knows loan-service exists by name. It knows one exchange, one routing key, and one event shape β€” the entire contract.

9.6 β€” What actually happens when the broker isn't there

Verified on this machine β€” the app starts, but not the way you'd guess

Running notification-service with no RabbitMQ listening on localhost:5672 produced a genuinely surprising, and genuinely important, result: the application starts successfully. The log shows CachingConnectionFactory: Attempting to connect to: [localhost:5672], followed by a connection-refused error, logged at ERROR β€” and then Started NotificationServiceApplication right after it. Spring AMQP's listener container retries the connection on its own background thread, indefinitely, without blocking startup or crashing the app. Unlike a missing database (which fails your app before it ever binds to a port) or an unresolved config placeholder (Stage 7), a missing message broker just... gets logged, and retried, forever, quietly.

Hit /actuator/health in that state and the split from Stage 1 pays off in a way it never has until now:

PowerShell β€” with RabbitMQ not running
PS> curl.exe http://localhost:8083/actuator/health
{"groups":["liveness","readiness"],"status":"DOWN"}          ← top-level: includes the RabbitMQ health check

PS> curl.exe http://localhost:8083/actuator/health/liveness
{"status":"UP"}                                              ← the process itself is fine

PS> curl.exe http://localhost:8083/actuator/health/readiness
{"status":"UP"}                                              ← Spring's own readiness state is fine too

Three different answers to what sounds like the same question, and each one is correct for what it's actually asking. The top-level /actuator/health aggregates every registered HealthIndicator, including a RabbitMQ-specific one contributed automatically by having spring-boot-starter-amqp on the classpath β€” so it correctly reports the service as unhealthy overall. But liveness and readiness are narrower, curated groups meant specifically for orchestrators like Kubernetes, and they report on "is this process alive and internally ready" β€” not "are all of this process's external dependencies healthy." That distinction is exactly why Kubernetes-style probes are configured against /actuator/health/liveness, never plain /actuator/health: a broker outage should page someone, but it should not make Kubernetes conclude notification-service itself is broken and start killing and restarting pods β€” restarting a healthy process fixes nothing about a broker that's down, and would just add churn on top of an existing outage.

9.7 β€” Sync vs. async, decided case by case

The actual trade-off, made concrete by two real decisions in this course

This course has now built both kinds of inter-service call, for two structurally different reasons β€” worth holding side by side rather than treating "use messaging" as a universal upgrade over HTTP:

  • loan-service β†’ catalog-service (Stage 8, synchronous RestClient): the loan cannot proceed at all without knowing the book exists β€” that's not an optional side effect, it's the core operation. A synchronous call that fails loudly (404, 503) when it can't be answered is exactly correct here; hiding that failure behind a queue would mean creating loans for books that don't exist.
  • loan-service β†’ notification-service (Stage 9, asynchronous messaging): the loan is completely valid whether or not a notification ever gets sent, or gets sent five minutes from now instead of five milliseconds. Coupling the loan's success to the notification system's uptime β€” as the bug in Β§9.4 briefly did by accident β€” actively made the system worse, not more correct.

The general version of that rule: reach for a synchronous call when the caller genuinely needs an answer before it can proceed, and reach for messaging when it doesn't β€” when the second service's job is a side effect, not a precondition. Messaging then buys you the things Β§9.1 and Β§9.6 demonstrated directly: the producer doesn't need to know who, or how many, or whether they're currently up; and a struggling or absent consumer degrades to "notifications are delayed" instead of "the whole system is down."

9.8 β€” Checkpoint

Checkpoint Β· can you answer these cold?
  • Why does a producer publish to an exchange with a routing key, instead of sending directly to a queue? (it decouples the producer from the number and identity of consumers β€” new consumers can bind new queues to the same exchange/key later without the producer ever changing)
  • What specifically went wrong in the first version of LoanEventPublisher, and why was it worse than it looked from the error message alone? (an unhandled AmqpException from a failed publish propagated out of the controller as a 500 β€” but the loan had already been saved to the database, so the client was told the operation failed when it had, in fact, succeeded)
  • Why do catalog-service being unreachable (Stage 8) and RabbitMQ being unreachable (Stage 9) deserve opposite handling β€” one fails the request, one doesn't? (catalog-service's answer is a precondition the loan cannot proceed without; notification-service's job is a side effect the loan's correctness never depended on)
  • /actuator/health reported DOWN while /actuator/health/liveness and /readiness both reported UP. What's the practical reason Kubernetes-style probes are pointed at the narrower endpoints? (the top-level health aggregates every dependency, including ones outside the app's control; restarting a process because an external broker is down fixes nothing and just adds churn on top of an existing outage)
  • Who declares the RabbitMQ queue that notification-service consumes from β€” and where does that declaration actually run? (the Queue and Binding beans in LoanEventsConfig; Spring AMQP's RabbitAdmin notices them and creates them on the broker automatically at startup, so the topology lives in version-controlled code, not a manually-clicked console)

Stretch exercise (no solution given, and this one needs a real broker): install RabbitMQ (or run the one-line Docker command from the note at the top of this stage), start all three services, borrow a book, and confirm GET http://localhost:8083/api/notifications shows the message. Then open the management console at http://localhost:15672 and watch the notification.loan-borrowed queue's message count in real time as you borrow more books β€” the closest this course gets to actually seeing the decoupling instead of just reading about it.

πŸ”’Finish and mark Stage 8 complete to unlock this stage.
Done with Stage 9? loan-service publishes without ever crashing on a broker outage, notification-service starts cleanly even without one, and you can explain precisely why /actuator/health and /actuator/health/liveness disagree. That's the whole course β€” nine stages, one system, built by hand.
Loading progress…