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.
Your toolchain (already checked)
| Tool | Status on your machine | Why it matters |
|---|---|---|
| JDK 22 | Installed β | Spring Boot needs JDK 17+. The project targets Java 21 bytecode; JDK 22 compiles and runs it fine. |
| Maven | Not installed β not needed | The 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. |
| Docker | Not installed | Needed 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. |
| IDE | Your choice | Recommended: IntelliJ IDEA Community (best Spring support) or VS Code + "Extension Pack for Java". Either is free. |
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-webmvcpulls 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:
// 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:
@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.
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/ βββ 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:
@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/@RestControllerclasses and register them as beans. This is why your code must live undercom.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
Open a terminal in the project folder and start the app:
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:
:: 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.
With the app running, in a second terminal:
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:
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.
Create a new file HelloController.java next to LibraryApiApplication.java (same package β remember component scan):
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")mapsGET /api/helloto 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 setContent-Type: application/json.
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:
Add these methods inside HelloController (new imports: RequestParam, PathVariable from the same package):
// 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:
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
- 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
HelloControllerto packagecom.other. What breaks, and why? (component scan only coverscom.library.apiand below β the bean is never registered β 404) - Why does
pom.xmllist no version for the webmvc starter? (the parent POM manages versions) @RestControllervs@Controllerβ what does theRestadd? (@ResponseBody: return values become the response body via Jackson)- Who turns your
Mapinto 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.
Delete HelloController.java β its job (demonstrating GET, params, path variables) is done, and it would otherwise sit oddly next to the real API.
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:
| Layer | Annotation | Responsibility |
|---|---|---|
| Controller | @RestController | HTTP only β routes, status codes, request/response shapes. No business logic. |
| Service | @Service | The actual logic and rules. Knows nothing about HTTP β could be called from a controller, a CLI, or a test with equal ease. |
| Repository | @Repository | Data 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
Create the package folder book under com.library.api, and inside it, Book.java:
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
Same package, BookService.java. This is where CRUD logic and the in-memory store live:
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:
@Serviceis 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, notHashMap. Your service bean is a singleton β one instance shared by every concurrent HTTP request. Two requests can callcreate()at the same instant; a plainHashMapwould 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 aNullPointerException. You'll unwrap it in the controller next.
2.4 β Hands-on: BookController
Same package, BookController.java:
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
@Autowiredanywhere β since Spring 4.3, a single constructor is auto-detected as the injection point. Spring finds the oneBookServicebean in the context and passes it in when it buildsBookController. Marking the fieldfinalis deliberate: the controller becomes impossible to construct in a broken half-wired state, and trivial to test later by just callingnew BookController(fakeService)yourself. @RequestMapping("/api/books")on the class prefixes every method's mapping β@GetMapping("/{id}")below actually meansGET /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 aLocationheader pointing at the new resource, and a successful delete returns 204 No Content β no body needed to say "gone."@RequestBodyis the inverse of Stage 1's return-value magic: Jackson now deserializes the incoming JSON into aBookbefore your method even runs.
2.5 β Hands-on: drive the whole API
Restart the app and run these in order β watch the status codes, not just the bodies:
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
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
- Why does
BookControllerhave no@Autowiredannotation anywhere, yet still receives a workingBookService? (single-constructor auto-detection since Spring 4.3) BookServiceis a singleton bean. What bug would appear under concurrent requests ifstorewere a plainHashMapinstead ofConcurrentHashMap? (race conditions / corrupted internal state from unsynchronized concurrent writes)- What three status codes does
BookControllerreturn besides 200, and which method produces each? (201 from create, 204 from delete, 404 from byId/update when absent) - Why is
Book.id()aLongand not a primitivelong? (needs to represent "no id yet" for incoming creation requests β primitives can't be null) - If you moved
BookControllerback out of thebookpackage 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.
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>:
<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:
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@Entityclasses 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
New package author under com.library.api, file Author.java:
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.
New package book, file Book.java:
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.
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
AuthorRepository.java and BookRepository.java, one per package:
package com.library.api.author; import org.springframework.data.jpa.repository.JpaRepository; public interface AuthorRepository extends JpaRepository<Author, Long> {}
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
AuthorController.java β minimal, just enough to create authors to attach books to:
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:
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
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
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
- Why can't
Bookbe arecordnow that it's a JPA entity, whenBookthe 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
yearwith no@Columnoverride, 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) BookRepositoryis 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_NameContainingIgnoreCaseinto English. (find books whose related author's name contains the given text, case-insensitively) - Why does
BookController.createaccept aNewBookrecord with anauthorIdinstead of accepting aBookdirectly? (the client shouldn't need to construct or know the internal shape of an Author β just reference one by id; also a raw incomingBookwould 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.
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
<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
Annotate the NewBook record inside BookController from Stage 3, and add @Valid to the create parameter:
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():
@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
New file, at the root package (not inside book/ β this handles exceptions from every feature, present and future):
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:
@RestControllerAdvicemakes this class apply globally, across every@RestControllerin the app βAuthorControllerandBookControllerboth, 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 throwsX(or a subclass) and doesn't catch it, run this method instead of letting it propagate to Spring's default handler."MethodArgumentNotValidExceptionis what@Validthrows automatically when a constraint fails β you never throw it yourself, Spring's validation machinery does, right before your controller method body would have run.ProblemDetailis 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 withsetProperty. Using it instead of a hand-rolled errorMapmeans any client library that understands RFC 9457 (a growing number do) parses your errors for free.
4.4 β Hands-on: prove both paths
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
- Why do the
@NotBlank/@Min/@NotNullannotations live onNewBook, not on theBookentity? (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
MethodArgumentNotValidExceptioncome from β do you throw it? (no β Spring's validation layer throws it automatically when@Validfinds a constraint violation, before your method body runs) GlobalExceptionHandleris never referenced by name inBookControllerorAuthorController. How does it end up handling their exceptions? (@RestControllerAdviceregisters it globally across every@RestControllerin 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 returnProblemDetail)
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.
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.
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
Pull the NewBook record out of the controller into its own file so both the controller and the service can reference it:
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:
boolean existsByTitleIgnoreCaseAndAuthor(String title, Author author);A tiny exception type:
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:
@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
@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
@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
@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
@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.
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
- Why did
BookServiceneed 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@DataJpaTestdoesn't already cover) @InjectMocksbuilds a realBookServicewith 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
@DataJpaTestpassed, provingexistsByTitleIgnoreCaseAndAuthorworks. YourBookServiceTestunit test also "proved" the duplicate-rejection logic works. What would aBookServiceTestalone not have caught, that only the@DataJpaTestcould? (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().
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
<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:
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
New package auth. A two-value role:
package com.library.api.auth; public enum Role { LIBRARIAN, MEMBER }
And the entity β note the @Table annotation before you even try leaving it off:
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; } }
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:
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
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
Two small request records first:
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) {}
@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
@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
@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.
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
Only two methods change β the two that mutate state:
@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:
@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
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
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:
@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
@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
- Where does a JWT's trustworthiness actually come from β what stops a client from editing the
roleclaim toLIBRARIANthemselves? (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) JwtAuthenticationFilternever 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 beforeDispatcherServletran, 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
@EnableMethodSecurityfromSecurityConfigbut left@PreAuthorizeonBookController? (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
BookControllerWebMvcTestneedexcludeFiltersnow, when it built cleanly all through Stage 5? (@WebMvcTest's slice includesFilter-type beans like the newJwtAuthenticationFilter, but excludes the plain@Serviceit 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.
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.
Split the one properties file into three. What's true everywhere stays in the base file:
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:
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:
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=ecsspring.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.
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:
@SpringBootApplication @ConfigurationPropertiesScan public class LibraryApiApplication { /* unchanged body */ }
Then rewire JwtService to take the whole properties object instead of two loose values:
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.
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:
***************************
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 configurationThree 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.
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:
{"@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.
.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.
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
One plugin execution, no new dependency:
<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:
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
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)
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.
# ---- 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.
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
- You run the jar with no flags at all. Which profile is active, and what tells you that? (
devβspring.profiles.active=devin the baseapplication.propertiesis the default when nothing overrides it) - Three different versions of missing-
JWT_SECRETfailed 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@NotNullwas added β same missing value, progressively clearer failure) - Why did adding a role-gated
/actuator/envrule initially turn a 403 into a 401, and what fixed it? (Spring's default AccessDeniedHandler callssendError(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/errorso the re-dispatch doesn't get re-evaluated byanyRequest().authenticated()) - In the Dockerfile, why does copying
pom.xmland runningdependency:go-offlinehappen beforeCOPY src? (Docker layer caching β as long aspom.xmlis unchanged, that slow dependency-download layer is reused from cache even when only application code changes) - In
docker-compose.yml, why is the database hostpostgresand notlocalhost? (inside a Compose network, services reach each other by service name, resolved via Compose's internal DNS βlocalhostinside theappcontainer 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.
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
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:
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.
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
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.
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:
@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
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:
public record BookSummary(Long id, String title, int year) {}
@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.
public class CatalogUnavailableException extends RuntimeException { public CatalogUnavailableException(String message, Throwable cause) { super(message, cause); } }
@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
@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.
@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
PS> cd catalog-service; .\mvnw.cmd spring-boot:runPS> cd loan-service; .\mvnw.cmd spring-boot:runPS> 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:
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
- Why does
LoanstorebookTitledirectly instead of a foreign key to aBookrow? (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.findBookhas twocatchblocks. What real-world difference justifies keeping them separate instead of one broadcatch (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.
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.
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
One new dependency in loan-service/pom.xml, alongside everything from Stage 8:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>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:
public record BookBorrowedEvent(
Long loanId, Long bookId, String bookTitle, String memberName, LocalDate loanDate) {}@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(); } }
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:
@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
@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
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:
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
A third project β web, actuator, amqp, port 8083, no database at all.
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:
@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:
@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
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:
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
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
- 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 unhandledAmqpExceptionfrom 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-servicebeing 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/healthreported DOWN while/actuator/health/livenessand/readinessboth 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-serviceconsumes from β and where does that declaration actually run? (theQueueandBindingbeans inLoanEventsConfig; Spring AMQP'sRabbitAdminnotices 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.