Spring Boot Course · Reference
The choices this course makes — tools, code conventions, HTTP rules — each with the reason behind it. In the lessons, a coin marks the spot where each one is introduced; the coin next to a decision here takes you there.
Install and manage JDKs with SDKMAN!, which installs them and switches versions per project without ever editing PATH or JAVA_HOME by hand, since real projects are pinned to different Java versions.
The course builds on Eclipse Temurin 21 (21-tem), the current long-term-support release; anything 17+ works.
Never hand-write a Spring Boot project: generate it with spring init, which calls the same generator as start.spring.io from the command line and is faster once you know what you want.
.sdkmanrc A .sdkmanrc file in the project root records exactly which JDK the project expects, so sdk env (or SDKMAN's auto-env) makes the whole project always build against the same Java.
Run Maven through ./mvnw, which downloads and runs the exact Maven version the project expects, so you never have to install Maven yourself and every machine builds identically.
Everything for one feature lives in one package under the application's root package, which keeps everything you touch for one change side by side, scales better as the API grows, and stays within component scanning's reach.
Simple, immutable data carriers are Java records: one line gives a constructor, accessors, equals/hashCode/toString, and Jackson reads the accessors to produce JSON out of the box.
Ids are UUIDs, not auto-incremented Longs: the app can generate them without a database round-trip, clients can't enumerate them by counting, and they stay unique when data from several sources is merged, at the cost of a larger, less human-friendly id.
/api, with the base path on the class Each controller declares its resource path once with a class-level @RequestMapping (for example /api/books), so the prefix isn't repeated on every method. The /api prefix keeps data endpoints distinct from any pages or static files.
Split the app into a thin stack where each layer has one job and only talks to the layer below it, so web work (HTTP in, JSON out) and business work stop living in the same class, and a layer such as the data store can be swapped later without the service having to change much.
The controller routes and the service supplies: the controller asks for its collaborators and delegates to them, and it doesn't know or care where the data comes from. It only turns the service's answers into HTTP, for example turning an empty Optional into a 404, with no business logic and no try/catch.
Service methods read like questions you ask a data source (findAll(), findById()), not like HTTP handlers, because the service speaks the language of the domain, not the web, and stays free of web concerns.
A class takes its collaborators as constructor parameters, never through an @Autowired field: the fields can be final, the constructor lists everything the class needs, and a test can pass in a fake without starting Spring.
@Service, @Repository, @Controller and @RestController all register a bean the same way as @Component. Use the one that matches the layer, because the name documents the class's role in the architecture, and some add behaviour of their own (@Repository translates database exceptions).
The noun (the resource) stays in the URL and the verb is the HTTP method: create is POST, read is GET, replace is PUT and delete is DELETE on /api/books and /api/books/{id}. Paths like /createBook never appear, because the method already says that.
POST with 201 Created and a Location header Creating a resource answers 201 Created with a Location header pointing at the new resource's URL, as the HTTP specification says. POST is the one operation where the server picks the id, so this header is the client's only way to learn where the resource lives.
The server generates every new record's id and ignores any id in the request body, because identity is the server's job and a client must not get to pick (or overwrite) a record's id.
PUT replaces the whole resource With PUT the client sends the whole resource and it is stored as sent, so an omitted field is blanked out, not merged. The method is named replace to match, and the name update is kept for PATCH, which sends only the changed fields, so each method stays honest about its HTTP contract.
Talk to Spring Data JPA and let it drive Hibernate and JPA underneath: you declare a repository interface and Spring Data generates the implementation at runtime, which removes the boilerplate.
Use PostgreSQL, the robust, open-source SQL server that companies run in production. It runs as a separate server process that writes to disk, so data survives restarts.
Describe the database once in a compose.yaml and let spring-boot-docker-compose start the container when the app boots and stop it when the app exits, instead of installing and managing the server by hand. The compose file pins the exact database the app needs.
Building the schema is Liquibase's job, not Hibernate's. Each change is a changeset applied in order, exactly once, and recorded, so the schema becomes versioned, reviewable code that can be replayed on any fresh database.
Once a changeset has run anywhere it is history, so a schema change always goes into a new changeset. This append-only discipline lets a teammate or a fresh production database replay the changelog from empty and arrive at exactly the right schema.
Set spring.jpa.hibernate.ddl-auto=validate: Liquibase owns the schema, so Hibernate only checks that the entities match the tables and changes nothing. If an entity drifts from the schema, the app refuses to start before a single request runs.
Write Liquibase changelogs in YAML for its readable, database-agnostic change types (createTable, addColumn, …), which Liquibase translates into the right SQL for each database.
@Data on an entity Use Lombok to generate getters, constructors and equals/hashCode so an entity reads as just its fields, but never put @Data on an entity. It bundles an all-fields equals/hashCode and an unguarded toString, and both are dangerous on a JPA entity.
The entity assigns itself a time-ordered UUIDv7 the moment it is constructed, so every entity has a stable, non-null id before it touches the database. Because the ids are time-ordered, new rows insert near the end of the primary-key index instead of scattering across it.
equals/hashCode use the id and nothing else, because two objects that share a primary key are the same thing. Mutable fields in hashCode break hash-based collections, and an app-assigned id that is never null and never changes makes id-based equality work even before the entity is saved.
Call the entity Book, not BookEntity: it is the domain model, and Entity is a persistence detail that the @Entity annotation already states.
DTOs take role-based names such as BookResponse and CreateBookRequest, because those suffixes tell you purpose and direction. A suffix like Entity would only repeat what the annotation says.
Express a query as a repository method name like findByAuthor, which Spring Data parses into the SQL for you. Drop to @Query with explicit JPQL or SQL only when the query gets too complex to express as a name.
Use a path segment (@PathVariable, /api/books/{id}) for "which resource" and the query string (@RequestParam, ?author=…) for filtering, sorting or paging a collection.
Watch Hibernate's SQL by setting org.hibernate.SQL and the bind logger to debug/trace levels, not with spring.jpa.show-sql. That setting prints straight to System.out, unformatted and without the bound parameter values. The one exception is a @DataJpaTest, which turns show-sql on by itself: the SQL in a repository test's output needs no configuration.
Set the default time zone to UTC as the first line of main, so timestamps and logs mean the same thing wherever the machine is and host-timezone problems with the JDBC connection are avoided. Setting it in code covers every way of launching the app, not just one launcher. Tests don't run main, so the test database configuration sets the same default (Lesson 12).
Endpoints accept and send their own immutable record shapes, never the entity, because an entity on the wire makes the API hostage to persistence settings (lazy collections fire queries or throw LazyInitializationException), leaks internal fields, and lets a client smuggle an id.
Each response record has a static from(entity) factory, and the controller does the mapping (entities to responses on the way out, fields off requests on the way in). That keeps the mapping next to the shape it produces and lets the service and repository work in entities only.
The controller unpacks the request and passes plain values such as title, year, authorIds, because a request DTO is a web type and the service should speak the domain, not HTTP. That way a bulk importer, a scheduled job or a message consumer can call it without building an HTTP-request object.
An unordered collection gets its order in the response mapping (from(...) sorts authors by name), not in the entity. The database stores no order, but a JSON array is ordered, and a client that sees the order change on every request can't tell "reordered" from "changed".
Only the side the API asks about declares the association (Book carries @JoinTable, and Author has no collection at all). A unidirectional mapping gives one place to look and one place to change, with no two collections to keep in sync by hand.
Set for to-many associations A to-many association is a Set, because the join table's primary key already forbids duplicate links, and Hibernate treats an unordered List as a bag. A bag rewrites every join row when one changes and can't be fetched alongside another bag.
To-many associations stay lazy, and fetch = FetchType.LAZY is written out even though it is the default. That way the decision is visible where it matters: loading the collection is a deliberate act with a cost attached.
Entities with independent lifecycles get no cascade, because in a many-to-many CascadeType.REMOVE would delete a book's authors, including co-authors still attached to other books. The price is one rule followed by hand: save the referenced entities before the entity that references them.
A schema change that touches existing data runs in three steps: create the new structures, copy the data into them, and only then drop the old ones. Dropping first would lose the data, which on a production database is an incident.
Requests and filters point at a related resource by its id, not by a text field such as a name. Two authors can share a name, an exact string match breaks on spelling variants, and a client that got the resource from the API is already holding the id.
When a request references several ids, all of them must resolve or nothing is written. findAllById silently drops unknown ids, so the sizes are compared and the missing ids are reported, because a partial write is worse than a rejected one.
Validation is a property of the request you accept, so constraints go on the request DTO at the boundary, not on the entity. On the entity they would scatter input rules into the persistence model, fire on save instead of on receipt, and tie accepted input to storage.
When a well-formed request still has to be refused, the service throws a small domain exception for that case: NotFoundException or ConflictException. Controllers then need no error branches, and the service is the only place that decides something is missing.
ProblemDetail A single @RestControllerAdvice that extends ResponseEntityExceptionHandler turns every failure, whether the framework's own or the API's, into an RFC 9457 ProblemDetail body. Error formatting lives in one place, and the whole API speaks one error contract.
The Exception.class safety net records the real exception server-side but returns only a generic message. Echoing ex.getMessage() or a stack trace to clients exposes internals.
find… may return nothing, get… must return something A find… method signals "might not be there" and returns an Optional. A get… method signals "should be there" and returns the value or throws. When a method's contract changes, its name changes too, so callers are never misled.
404 says the thing you named isn't there. 409 Conflict says the request is fine but the data's current state won't allow it. Picking the right one tells the client whether to give up or to fix something and retry.
A request body that references an entity that doesn't exist, such as an unknown entry in authorIds, gets a 404 naming the missing ids. It uses the same NotFoundException as a missing path to keep the model simple. What matters is a deliberate 4xx with a clear message, not an accidental 500.
The service checks first so the common case gets a polite 409, but the foreign key stays in the database as the real guarantee. A race between the check and the write is still refused.
Pageable and Page List endpoints take a Pageable, which Spring builds from the page, size and sort query parameters, and return a Page instead of a List. No collection is ever returned unbounded.
PagedModel shape spring.data.web.pageable.serialization-mode=via_dto renders every Page as the guaranteed-stable { content, page } PagedModel shape. The raw PageImpl JSON comes with no promise that it stays the same across versions. The property, unlike the annotation, keeps Boot's auto-configuration and the page-size cap working.
@PageableDefault gives a request without parameters a sensible page size and a default sort, so results never come back in arbitrary order. max-page-size caps how many rows one request can pull, so a client can't escape pagination by asking for everything.
If the sort key isn't unique, ties can be ordered differently between queries, so rows repeat or vanish across pages. The server therefore always adds id to the ORDER BY for a total order, rather than relying on clients to add &sort=id.
In package-by-feature, a stateless utility used by every feature, like Pageables, is defined once in its own package. It is not inlined or copy-pasted into each controller.
Set spring.jpa.open-in-view=false so the session closes with the transaction that loaded the data: a lazy load that would have silently fired a hidden query per row now throws LazyInitializationException. Every association you need then has to be fetched on purpose.
@EntityGraph, never EAGER Put @EntityGraph on the repository methods whose results get mapped, so the associations come back in the same query. FetchType.EAGER still runs one extra select per row for queries, which hides the N+1 instead of fixing it, and it makes every load of the entity pay for the association; an entity graph is per query, while EAGER is forever.
logging.level.org.hibernate.SQL=DEBUG goes into application.properties while you look at what Hibernate runs, and comes out when you're done. Left in, it logs every statement of every request wherever the app runs, production included.
Build the service with new, passing Mockito test doubles in place of the repositories, with no Spring context and no database. A service's behaviour is pure Java over its dependencies, so booting the application would be overhead, and constructor injection is what lets the class be built in isolation.
Every test follows three visibly separate beats: arrange the data and script the fakes, act by calling the one method under test, and assert the outcome. That gives one action per test and one behaviour verified.
unitOfWork_scenario Test methods get a short, greppable unitOfWork_scenario name such as getById_found or create_missingAuthor, used consistently. The full human-readable sentence goes in @DisplayName, which carries it into the test report and the IDE.
Assert that an exception message contains the meaningful fact, such as the missing id, instead of pinning the exact string. The wording can then change without breaking the test, while a wrong id is still caught. Where the exact wording really is a contract, assert it at the web boundary where the client sees it.
When an operation must fail, also check with verify(..., never()) that the write was never made. That proves the service threw before touching save and no half-finished write slipped through.
@WebMvcTest slice with the service mocked Each controller gets a @WebMvcTest for that controller alone, with its service as a @MockitoBean. Routing, conversion, @Valid, JSON and the ProblemDetail advice are Spring MVC's work, so calling the controller method directly would test none of it, and booting the whole application would drag in the database.
MockMvcTester Controller tests send requests and assert on responses with MockMvcTester, Spring's AssertJ API over MockMvc. It reads like the service tests (assertThat(...) and a fluent chain), and @WebMvcTest configures it whenever AssertJ is on the classpath.
Every controller test writes out the full response body, including the fields Spring fills in such as a problem's instance, and compares it with isStrictlyEqualTo. Reading the test then shows exactly what the client receives, and any change a client would notice, such as an added field or a reordered array, fails the test. Lenient comparison and single-path checks are kept for values a test can't know in advance.
A test's request body is the JSON a client would send, written out, not a serialized request record. The JSON is the contract, so renaming a record field must break the test the way it breaks clients.
Stub the service with any() and check the exact arguments afterwards, with verify or, for an argument the controller builds such as a Pageable, a captor compared to a named expected value. A stub that doesn't match returns null and surfaces as an unrelated 500; a failed check shows the expected and the actual argument side by side.
@DataJpaTest slice against real PostgreSQL Repository tests start only the persistence layer, with @DataJpaTest, on the same PostgreSQL the app runs on, not an in-memory database. The derived queries, the entity graphs and the Liquibase changelog (which calls the PostgreSQL 18 function uuidv7()) only prove something on the real database.
A @TestConfiguration declares one PostgreSQLContainer bean with @ServiceConnection, and each test that needs a database imports it. Spring starts the container before the beans that use it and stops it after them, Spring Boot wires the DataSource to it with no properties, and test classes with the same configuration share one container.
The test container uses postgres:18, the tag in compose.yaml, not latest. The tests should run on the database the app runs on and move to a new major version only together with the app.
Every repository read with an @EntityGraph has a test that reads the associations the graph fetches and asserts Hibernate's prepared-statement count. The annotation's whole job is the number of statements: inside the test's transaction a missing graph throws nothing, so only the count notices.
Repository tests flush and clear the persistence context after saving their fixtures. Otherwise the code under test gets the objects the test left in memory, with their associations already loaded, and a missing entity graph passes unnoticed.
JdbcTemplate A test of a save or a delete flushes, then reads the book and book_author rows back with JdbcTemplate and plain SQL. Reading back through JPA would show what Hibernate believes; the mapping's job is what PostgreSQL ends up holding. @DataJpaTest provides the JdbcTemplate, on the test's own transaction, so no new library is needed.
Integration tests start the whole application with @SpringBootTest(webEnvironment = RANDOM_PORT) on the Testcontainers PostgreSQL and call it with RestTestClient. The request then runs on a server thread and the transactions open and close as in production, so a lazy load outside the session fails here as it fails for a client; a mock environment inside a test transaction would hide it.
@BeforeEach deletes all books, then all authors, through the repositories, and each test creates what it needs through the API. Nothing rolls back with a real server, the cached context shares one database across tests, and DataSeeder fills it at startup, so without the cleanup no test could assert on exact lists and totals.
The OpenAPI spec comes from springdoc-openapi, which reads the Spring MVC mappings, the request and response records and their Bean Validation constraints. A hand-written spec has to be kept in step with the code by hand; a generated one changes when the code does.
The docs page is Scalar, from springdoc's springdoc-openapi-starter-webmvc-scalar module, moved from /scalar to /docs and titled "Library API" (scalar.path, scalar.page-title), so its address and name are the API's, not the tool's. It reads as a reference and gives every operation a request to copy and send; Swagger UI, the widespread alternative, renders the same spec and is a one-dependency swap.
Every operation gets an @Operation summary and an @ApiResponse for each status it can return, errors included (also the 400 for an id that isn't a UUID), every parameter a description and example, and every record field a @Schema description and example. The code supplies the API's shape but not its meaning, and the generator gets some of the shape wrong (a 201 set at runtime, Pageable as one object, exception-handler statuses).
ProblemResponses registers a Problem schema of the fields the API's problem bodies have, and one named response per error with an example copied from a real response; operations refer to them with @ApiResponse(ref = ...). Pointing at Spring's ProblemDetail would document the Java class (a type and a properties object the JSON never has) with placeholder examples, and shared errors described once stay consistent. The examples are kept in step with the error messages by hand.
Each controller implements an interface (BookApi, AuthorApi) that holds only the Swagger annotations, while the controller keeps only the Spring MVC code. springdoc reads the annotations from the interface method a handler implements, so the spec is the same, the controller stays readable, and @Override makes the compiler keep the two signatures in step. The records keep their @Schema: they are the schema.
src/main/resources/api-guide.md covers what the endpoints share — getting started, ids, paging and sorting, errors — and OpenApiConfig loads it as the spec's description, which Scalar renders with each heading in the sidebar. The generated reference describes endpoints one at a time; the guide is the part a person has to write, and keeping it in the project means it changes in the same commit as the code.
Scalar's AI chat is off (scalar.agent.disabled=true), its developer toolbar and its separate API client hidden (scalar.show-developer-tools=never, scalar.hide-client-button=true; each operation's "Test Request" stays), and its fonts, Inter and JetBrains Mono, are served by the app from static/fonts with their licenses, through scalar.custom-css, instead of Scalar's font server. Out of the box the page contacts Scalar's servers and its chat would send the spec there; this way nothing about the API leaves the app, and the page looks right without anyone else's servers.