Spring Boot Course · Reference

Architecture Decisions

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.

Lesson 1 · Your First Running Endpoint

Install and switch JDKs with SDKMAN!

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.

Use Temurin 21, the current LTS

The course builds on Eclipse Temurin 21 (21-tem), the current long-term-support release; anything 17+ works.

Generate the project with the Spring Boot CLI

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.

Pin the project's JDK in .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.

Build with Maven, always through the wrapper

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.

Lesson 2 · Returning JSON & Modelling a Book

Organise packages by feature

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.

Use records for immutable data carriers

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.

Use UUIDs for ids

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.

Serve the API under /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.

Lesson 3 · Layered Architecture & Dependency Injection

Use a layered architecture

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.

Keep controllers thin: the controller translates, the service decides

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.

Name service methods in domain terms, not HTTP terms

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.

Use constructor injection

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.

Use the stereotype annotation that matches the layer

@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).

Lesson 4 · Full CRUD: POST, PUT, DELETE & Status Codes

Map CRUD to HTTP methods on a noun resource

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.

Answer 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 assigns ids

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.

Lesson 5 · Persistence with Spring Data JPA

Persist with Spring Data JPA

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

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.

Run the dev database with Docker Compose, started by Spring Boot

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.

Manage the schema with Liquibase migrations

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.

Never edit an applied changeset

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.

Hibernate validates the schema, never changes it

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 changelogs in YAML

Write Liquibase changelogs in YAML for its readable, database-agnostic change types (createTable, addColumn, …), which Liquibase translates into the right SQL for each database.

Use Lombok, never @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.

Generate time-ordered UUIDv7 ids in the app

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.

Base an entity's equality on its id only

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.

Name entities after the plain domain noun

Call the entity Book, not BookEntity: it is the domain model, and Entity is a persistence detail that the @Entity annotation already states.

Name DTOs by their role

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.

Prefer derived query methods

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.

The path identifies the resource, query parameters filter the list

Use a path segment (@PathVariable, /api/books/{id}) for "which resource" and the query string (@RequestParam, ?author=…) for filtering, sorting or paging a collection.

Log SQL through the logger

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.

Run the backend in UTC

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).

Lesson 6 · Relationships & DTOs

Never return entities as JSON

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.

Map between entities and DTOs at the edge

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.

Services take plain values, not request DTOs

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.

Sort collections in the response

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".

Map relationships one way

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.

Use a 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.

Keep to-many associations lazy, and say so

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.

No cascade between independent entities

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.

Change a live schema as expand, migrate, contract

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.

Resolve references all or nothing

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.

Lesson 7 · Validation & Error Handling

Validate request DTOs, not entities

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.

Services throw domain exceptions

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.

One global handler returns 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 catch-all logs the cause and leaks nothing

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 for something missing, 409 for a state conflict

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 missing referenced id is a 404

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.

Check in the service, keep the constraint in the database

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.

Lesson 8 · Pagination & Sorting

Paginate collections with 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.

Serialize pages in the stable 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.

Default the page size and sort, and cap the size

@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.

Always sort by a unique tie-breaker

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.

Cross-cutting code gets its own package

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.

Lesson 9 · The N+1 Query Problem

Turn off Open Session in View

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.

Fetch with @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.

Switch the SQL log on to investigate, then off again

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.

Lesson 10 · Unit-Testing the Service Layer

Unit-test services in plain Java with mocks

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.

Structure every test as Arrange, Act, Assert

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.

Name tests 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 on the stable fact, not the wording

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.

Verify that nothing was written on failure

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.

Lesson 11 · Testing the Web Layer

Test controllers in a @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.

Assert web responses with 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.

Compare the whole response body strictly

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.

Write request bodies in tests as JSON text

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 loosely, verify exactly

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.

Lesson 12 · Testing the Data Layer

Test repositories in a @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.

Declare the test database once, as a container bean

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.

Test against the production PostgreSQL version

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.

Guard each fetch plan with a statement count

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.

Flush and clear the fixtures before the code under test runs

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.

Check writes by reading the tables with 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.

Lesson 13 · Integration Tests with @SpringBootTest

Test the whole application over real HTTP on a random port

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.

Start each integration test from empty tables

@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.

Lesson 14 · Documenting the API with OpenAPI

Generate the API docs from the code with springdoc-openapi

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.

Serve the docs with Scalar

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.

Document every operation, response and field

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).

Describe each error response once, with the body the API really sends

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.

Keep the API documentation in an interface the controller implements

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.

Write a short API guide by hand, in Markdown next to the code

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.

Keep the docs page self-contained

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.