Learn Spring Boot 4

A two-day course on Spring Boot 4, built on Spring Framework 7. No Spring experience needed. Java basics are assumed — see Learn Java 25 if you need them first.

Everything here runs with a JDK and the Maven wrapper — no application server, no XML.

Why Spring Boot 4?

Spring Boot 4.0 (November 2025) sits on Spring Framework 7: a fully modularized code base, null safety with JSpecify, API versioning, declarative HTTP service clients and first-class Java 25 support — while keeping Java 17 as the baseline.

Course outline

IntroductionSetup · First app
Day 1beans, configuration, REST, validation, data, HTTP clients, tests
Day 2API versioning, null safety, security, actuator, resilience, threads, messaging, deployment
AppendixCheat sheet · Resources

Each chapter ends with ★ Exercises. Type the samples instead of pasting them — reading stack traces is how you learn Spring fastest.

What you need

  • A JDK 25 build (Temurin, Corretto, Oracle OpenJDK) — Java 17 also works
  • IntelliJ IDEA Community, or VS Code with the Spring Boot extension pack
  • A terminal — every sample runs with ./mvnw

Start with the setup →

Subsections of Learn Spring Boot 4

Course Introduction

Get a JDK and a generated project running, then meet the parts every Spring Boot app is made of.

Subsections of Course Introduction

Setup: JDK & Project

Install the JDK

Spring Boot 4 needs Java 17 or newer and supports Java 25. Take the newest LTS:

# macOS
brew install --cask temurin@25

# Linux
sudo apt install openjdk-25-jdk

# Windows
winget install EclipseAdoptium.Temurin.25.JDK

# any platform, several versions side by side
sdk install java 25-tem      # sdkman.io
java --version     # openjdk 25 …

No Maven or Gradle install needed — generated projects ship a wrapper.

Generate a project

Use start.spring.io in the browser, or the API:

curl https://start.spring.io/starter.zip \
  -d bootVersion=4.0.0 -d javaVersion=25 -d type=maven-project \
  -d groupId=com.example -d artifactId=demo -d name=demo \
  -d dependencies=web,data-jpa,h2,actuator,validation \
  -o demo.zip && unzip demo.zip -d demo

What you get:

demo/
  pom.xml
  mvnw  mvnw.cmd                     Maven wrapper — no local Maven needed
  src/main/java/com/example/demo/DemoApplication.java
  src/main/resources/application.properties
  src/test/java/com/example/demo/DemoApplicationTests.java

The build file

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>4.0.0</version>
</parent>

<properties>
    <java.version>25</java.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
</dependencies>

A starter is a dependency that pulls in a whole topic. The parent pins every version, so your dependencies carry no <version> tag.

New in Spring Boot 4

The code base is fully modularized: auto-configuration now lives in many small, focused jars instead of one big spring-boot-autoconfigure. Starters keep working unchanged — you just ship fewer classes you never use.

Run it

cd demo
./mvnw spring-boot:run       # http://localhost:8080
./mvnw test
./mvnw package               # target/demo-0.0.1-SNAPSHOT.jar
java -jar target/demo-0.0.1-SNAPSHOT.jar

Editor

  • IntelliJ IDEA Community — open pom.xml, set the project SDK to 25
  • VS Code — install Extension Pack for Java and Spring Boot Extension Pack
Port already in use?

Another process holds 8080. Start with ./mvnw spring-boot:run -Dspring-boot.run.arguments=--server.port=8081.

★ Exercises

  1. Install a JDK and print java --version.
  2. Generate a project with the web and actuator dependencies.
  3. Start it and open http://localhost:8080/actuator/health.
  4. Run ./mvnw dependency:tree and find out what spring-boot-starter-web pulled in.

Your First Application

The entry point

package com.example.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class DemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}

@SpringBootApplication is three annotations in one:

AnnotationJob
@SpringBootConfigurationthis class may declare beans
@ComponentScanfind components in this package and below
@EnableAutoConfigurationconfigure what the classpath suggests
Warning

Component scanning starts at the application class’s package. Keep it at the root — classes in a sibling package are invisible.

A first endpoint

@RestController
public class HelloController {

    @GetMapping("/hello")
    public String hello(@RequestParam(defaultValue = "world") String name) {
        return "Hello, " + name + "!";
    }
}
curl "localhost:8080/hello?name=Ann"     # Hello, Ann!

No web.xml, no servlet registration: spring-boot-starter-web puts Tomcat on the classpath, and auto-configuration starts it.

Returning JSON

Return an object and Jackson serializes it.

public record Greeting(String message, Instant at) {}

@GetMapping("/greeting")
public Greeting greeting() {
    return new Greeting("Hello", Instant.now());
}
{"message":"Hello","at":"2026-03-01T10:15:30.00Z"}

Configuration

src/main/resources/application.properties:

server.port=8081
spring.application.name=demo
logging.level.com.example=DEBUG

Or YAML, in application.yaml:

server:
  port: 8081
logging:
  level:
    com.example: DEBUG

What happens on startup

  1. SpringApplication.run creates an application context
  2. component scan collects your @Component/@Service/@RestController classes
  3. auto-configuration adds what the classpath implies (web server, DataSource, …)
  4. beans are created and wired
  5. the embedded Tomcat starts and binds a port
./mvnw spring-boot:run -Ddebug           # prints the auto-configuration report

★ Exercises

  1. Add a /time endpoint returning the current time as JSON.
  2. Change the port to 9000 in application.properties.
  3. Move HelloController into com.example.other — what happens, and why?
  4. Run with -Ddebug and find three auto-configurations that matched.

Day 1: Spring Boot Basics

The core ideas: beans, configuration, REST controllers, validation, database access, calling other services and testing all of it.

Subsections of Day 1: Spring Boot Basics

Why Spring Boot?

The problem it solves

Plain Java gives you a language, not an application. A service needs an HTTP server, JSON mapping, a connection pool, transactions, config per environment, health checks, metrics and tests. Spring Boot wires all of that from a dependency list and sensible defaults.

@RestController
class Api {
    @GetMapping("/ping")
    String ping() { return "pong"; }
}

One dependency and four lines is a running HTTP service.

The three ideas

IdeaWhat it means
Inversion of controlyou declare components, the container creates and connects them
Auto-configurationclasspath contents decide what gets configured
Startersone dependency per topic, versions pinned by the parent

Every default is replaceable: define your own bean, and the auto-configured one backs off.

The ecosystem

ProjectFor
Spring Framework 7core container, MVC/WebFlux, transactions
Spring DataJPA, MongoDB, Redis repositories
Spring Securityauthentication and authorization
Spring Boot Actuatorhealth, metrics, observability
Spring Batch / Integration / Kafkajobs, pipelines, messaging

What is new in Spring Boot 4

  • Modularization — many small jars instead of one spring-boot-autoconfigure
  • Null safety — the whole portfolio is annotated with JSpecify
  • API versioning — first-class support in Spring MVC and WebFlux
  • HTTP service clients — declare an interface, get an implementation
  • Java 25 support — with Java 17 still the baseline
  • Resilience@Retryable and @ConcurrencyLimit moved into the core framework
  • RestTestClient — one test client for mock and live servers

Built on Spring Framework 7, Jakarta EE 11, Jackson 3 and Kotlin 2.2.

Coming from Spring Boot 3?

Most code compiles unchanged. Watch for: Jackson 2 support deprecated, a few renamed properties (spring.dao.exceptiontranslation.enabledspring.persistence.exceptiontranslation.enabled, management.tracing.enabledmanagement.tracing.export.enabled), and @MockBean replaced by @MockitoBean.

When not to use it

  • a 200-line CLI tool — plain Java is lighter
  • hard real-time or tiny memory budgets — the container costs startup and RAM
  • a library that others embed — keep it framework-free

★ Exercises

  1. Name three things auto-configuration does for spring-boot-starter-web.
  2. Remove the web starter from a project. What still runs?
  3. Look up two more starters at start.spring.io and say what each adds.
  4. Which of the Boot 4 features above would change code you already have?

Beans & Dependency Injection

What a bean is

A bean is an object the container creates, configures and hands out. You never call new on it yourself.

@Service
public class GreetingService {
    public String greet(String name) {
        return "Hello, " + name + "!";
    }
}
StereotypeMeaning
@Componentany bean
@Servicebusiness logic
@Repositorydata access (adds exception translation)
@RestControllerweb endpoint returning data
@Configurationclass that declares @Bean methods

All four of the first are @Component underneath — the name documents intent.

Constructor injection

@RestController
public class GreetingController {

    private final GreetingService service;

    public GreetingController(GreetingService service) {   // no @Autowired needed
        this.service = service;
    }

    @GetMapping("/greet/{name}")
    String greet(@PathVariable String name) {
        return service.greet(name);
    }
}

One constructor, final fields, no framework annotation in sight — that class is testable with plain new.

Avoid field injection

@Autowired private Foo foo; hides dependencies, breaks final, and forces reflection in tests. Use the constructor.

Beans from a configuration class

Use @Bean for types you do not own:

@Configuration
public class ClientConfig {

    @Bean
    RestClient restClient(RestClient.Builder builder) {
        return builder.baseUrl("https://api.example.com").build();
    }
}

The method name is the bean name; parameters are injected.

Choosing between candidates

public interface Notifier { void send(String msg); }

@Component @Primary  class EmailNotifier implements Notifier {  }
@Component("sms")    class SmsNotifier   implements Notifier {  }
Alerts(Notifier defaultOne,                       // EmailNotifier, it is @Primary
       @Qualifier("sms") Notifier urgent) {  }

Inject List<Notifier> or Map<String, Notifier> to get all of them.

Conditional beans

@Bean
@ConditionalOnMissingBean          // only if the user did not define one
@ConditionalOnProperty(name = "features.cache", havingValue = "true")
CacheManager cacheManager() {  }

This is exactly how auto-configuration backs off in favour of your own beans.

Scope and lifecycle

Beans are singletons by default — one instance per context, shared by every thread. Keep them stateless.

@Component
@Scope("prototype")        // new instance per injection point
class Ticket { }
@Component
class Warmup {
    @PostConstruct  void start() {  }   // after injection
    @PreDestroy     void stop()  {  }   // on shutdown
}

For startup work with access to arguments:

@Bean
ApplicationRunner seed(BookRepository repo) {
    return args -> repo.save(new Book("Dune"));
}

★ Exercises

  1. Write a RandomQuoteService and inject it into a controller.
  2. Add a second Notifier implementation and select one with @Qualifier.
  3. Inject List<Notifier> and call every implementation.
  4. Add a @PostConstruct log line and watch the order of construction on startup.
  5. Why does a singleton bean holding a mutable field cause bugs?

Configuration & Profiles

Where values come from

Spring reads many sources and lets later ones win:

  1. command line — --server.port=9000
  2. environment variables — SERVER_PORT=9000
  3. application-{profile}.properties
  4. application.properties
  5. defaults in code
java -jar app.jar --server.port=9000
SERVER_PORT=9000 java -jar app.jar

Relaxed binding maps server.port, SERVER_PORT and server-port to the same property.

Reading single values

@Component
class Mailer {
    private final String from;

    Mailer(@Value("${app.mail.from:noreply@example.com}") String from) {
        this.from = from;
    }
}

The part after : is the default.

Typed configuration

Better for anything with more than one key:

@ConfigurationProperties("app.mail")
public record MailProperties(String from, String host, int port, boolean tls) {}
@SpringBootApplication
@EnableConfigurationProperties(MailProperties.class)
public class DemoApplication {  }
app.mail.from=noreply@example.com
app.mail.host=smtp.example.com
app.mail.port=587
app.mail.tls=true

Inject MailProperties like any other bean. Records give you immutability and constructor binding for free.

Tip

Add spring-boot-configuration-processor as an optional dependency and your IDE autocompletes your own properties.

Validating configuration

@Validated
@ConfigurationProperties("app.mail")
public record MailProperties(@Email String from, @NotBlank String host,
                             @Min(1) @Max(65535) int port) {}

A bad value now fails at startup, not at the first send.

Profiles

A profile is a named set of configuration.

application.properties            always applied
application-dev.properties        only with the dev profile
application-prod.properties       only with the prod profile
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev
java -jar app.jar --spring.profiles.active=prod
SPRING_PROFILES_ACTIVE=prod java -jar app.jar

Beans can be profile-specific:

@Bean
@Profile("!prod")
Notifier consoleNotifier() { return msg -> System.out.println(msg); }

Secrets

Never commit passwords. Pass them as environment variables and reference them:

spring.datasource.password=${DB_PASSWORD}
Warning

application.properties ends up inside the jar. Anything environment-specific or secret belongs outside it.

Useful built-in properties

PropertyEffect
server.portHTTP port, 0 = random
spring.application.namename in logs, metrics, traces
logging.level.<package>log level per package
logging.console.enablednew in Boot 4: turn console logging off
spring.threads.virtual.enabledvirtual threads for request handling
spring.jpa.hibernate.ddl-autoschema handling, none in production

★ Exercises

  1. Move a hard-coded string into application.properties and inject it with @Value.
  2. Convert it to a @ConfigurationProperties record with three keys.
  3. Add @Validated and make startup fail on an empty value.
  4. Create application-dev.properties with logging.level.com.example=DEBUG and run with that profile.
  5. Override the port three ways: file, environment variable, command line. Which wins?

REST Controllers

Mapping requests

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

    @GetMapping                 List<Book> all()                  {  }
    @GetMapping("/{id}")        Book one(@PathVariable long id)    {  }
    @PostMapping                Book create(@RequestBody Book b)   {  }
    @PutMapping("/{id}")        Book replace(@PathVariable long id, @RequestBody Book b) {  }
    @DeleteMapping("/{id}")     void delete(@PathVariable long id) {  }
}

@RestController = @Controller + @ResponseBody: return values are serialized, not resolved as view names.

Reading the request

AnnotationSourceExample
@PathVariableURL segment/books/7
@RequestParamquery string?page=2&size=20
@RequestBodyJSON bodyPOST payload
@RequestHeaderheaderAuthorization
@CookieValuecookiesession id
@GetMapping("/search")
List<Book> search(@RequestParam String q,
                  @RequestParam(defaultValue = "0") int page,
                  @RequestParam Optional<String> author) {  }

Records as DTOs

public record BookRequest(String title, String author, int year) {}
public record BookResponse(long id, String title, String author) {}

Keep web types separate from database entities — otherwise every column change is an API change.

Status codes and headers

@PostMapping
ResponseEntity<BookResponse> create(@RequestBody BookRequest req) {
    var saved = service.create(req);
    return ResponseEntity
            .created(URI.create("/api/books/" + saved.id()))   // 201 + Location
            .body(saved);
}

Or declare it:

@PostMapping
@ResponseStatus(HttpStatus.CREATED)
BookResponse create(@RequestBody BookRequest req) {  }
SituationStatus
read ok200
created201
deleted, no body204
invalid input400
not authenticated / not allowed401 / 403
unknown id404
conflict, e.g. duplicate409

Content negotiation

@GetMapping(value = "/{id}", produces = MediaType.APPLICATION_JSON_VALUE)

Jackson 3 handles JSON. Java time types serialize as ISO-8601 by default.

CORS

@CrossOrigin(origins = "https://app.example.com")
@RestController
class BookController {  }

Globally:

@Bean
WebMvcConfigurer cors() {
    return new WebMvcConfigurer() {
        @Override public void addCorsMappings(CorsRegistry reg) {
            reg.addMapping("/api/**").allowedOrigins("https://app.example.com");
        }
    };
}

MVC or WebFlux?

Spring MVCSpring WebFlux
modelone thread per requestevent loop, reactive
returnBook, List<Book>Mono<Book>, Flux<Book>
use whenthe normal casestreaming, very high concurrency

With virtual threads (spring.threads.virtual.enabled=true) blocking MVC code scales far enough for most services — start there.

Tip

Return ResponseEntity only when you need to control status or headers. Otherwise return the payload and keep the signature readable.

★ Exercises

  1. Build /api/books with an in-memory List and the five CRUD methods.
  2. Return 201 with a Location header on create.
  3. Add ?author= filtering with @RequestParam.
  4. Return 404 for an unknown id (ResponseEntity.notFound().build()).
  5. Add a second endpoint returning the same book as plain text.

Validation & Error Handling

Bean validation

Add spring-boot-starter-validation, annotate the DTO, and mark the parameter @Valid.

public record BookRequest(
        @NotBlank String title,
        @NotBlank @Size(max = 100) String author,
        @Min(1450) @Max(2100) int year,
        @Email String contact) {}
@PostMapping
BookResponse create(@Valid @RequestBody BookRequest req) {  }

A violation throws MethodArgumentNotValidException → 400 before your method runs.

AnnotationChecks
@NotNull / @NotBlank / @NotEmptypresent / non-blank text / non-empty collection
@Size(min, max)length or size
@Min / @Max / @Positivenumbers
@Email / @Patternformat
@Past / @Futuredates

Validate parameters too:

@Validated                                       // on the class
@GetMapping("/search")
List<Book> search(@RequestParam @NotBlank String q) {  }

Your own exceptions

public class BookNotFoundException extends RuntimeException {
    public BookNotFoundException(long id) {
        super("No book with id " + id);
    }
}

One place for error responses

@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(BookNotFoundException.class)
    ProblemDetail notFound(BookNotFoundException ex) {
        var pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
        pd.setTitle("Book not found");
        return pd;
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    ProblemDetail invalid(MethodArgumentNotValidException ex) {
        var pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
        pd.setTitle("Validation failed");
        pd.setProperty("errors", ex.getBindingResult().getFieldErrors().stream()
                .collect(toMap(FieldError::getField, FieldError::getDefaultMessage)));
        return pd;
    }
}

@RestControllerAdvice applies to every controller.

RFC 9457 problem details

ProblemDetail is the standard error format:

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

Turn it on for the framework’s own errors too:

spring.mvc.problemdetails.enabled=true

Shortcut for simple cases

throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No book with id " + id);

Fine in small apps; a typed exception plus advice scales better.

Warning

Never return the stack trace to clients. Log it with the request id and send the client a short message.

Logging the failure

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

@ExceptionHandler(Exception.class)
ProblemDetail unexpected(Exception ex) {
    log.error("Unhandled exception", ex);
    return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "Internal error");
}

★ Exercises

  1. Add validation to BookRequest and post an invalid body — read the response.
  2. Write BookNotFoundException and map it to 404 with ProblemDetail.
  3. Return field-level messages for validation errors.
  4. Add a catch-all handler that logs and returns 500 without details.
  5. Which of your endpoints should answer 409 instead of 400?

Data Access with JPA

Setup

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

With H2 on the classpath and no spring.datasource.url, Boot configures an in-memory database. For PostgreSQL:

spring.datasource.url=jdbc:postgresql://localhost:5432/demo
spring.datasource.username=demo
spring.datasource.password=${DB_PASSWORD}
spring.jpa.hibernate.ddl-auto=validate

An entity

@Entity
public class Book {

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

    @Column(nullable = false)
    private String title;

    private String author;
    private int year;

    protected Book() {}                       // required by JPA

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

    // getters, and setters only where they make sense
}
Warning

Entities are mutable objects tied to a persistence context — not DTOs. Records cannot be entities. Map to a record before returning data from a controller.

Repositories

public interface BookRepository extends JpaRepository<Book, Long> {

    List<Book> findByAuthor(String author);
    List<Book> findByYearGreaterThanOrderByYearDesc(int year);
    Optional<Book> findByTitleIgnoreCase(String title);
    boolean existsByTitle(String title);
    long countByAuthor(String author);
}

You write no implementation — Spring Data derives the query from the method name.

Inherited for free: save, saveAll, findById, findAll, delete, count, findAll(Pageable).

Own queries

@Query("select b from Book b where b.year between :from and :to")
List<Book> inRange(int from, int to);

@Query(value = "select * from book order by random() limit 1", nativeQuery = true)
Book random();

Projections keep result sets small:

public interface TitleOnly { String getTitle(); }

List<TitleOnly> findByAuthor(String author);

Paging and sorting

Page<Book> page = repo.findAll(PageRequest.of(0, 20, Sort.by("title")));
page.getContent(); page.getTotalElements(); page.getTotalPages();

Controllers can take a Pageable parameter directly: ?page=1&size=20&sort=title,asc.

Transactions

@Service
public class BookService {

    private final BookRepository repo;

    BookService(BookRepository repo) { this.repo = repo; }

    @Transactional
    public Book rename(long id, String title) {
        var book = repo.findById(id).orElseThrow(() -> new BookNotFoundException(id));
        book.setTitle(title);                 // dirty checking writes on commit
        return book;
    }

    @Transactional(readOnly = true)
    public List<Book> all() { return repo.findAll(); }
}

Rules: @Transactional belongs on the service, rolls back on unchecked exceptions, and only works when the call comes in from outside the bean (it is a proxy).

Schema management

SettingUse
spring.jpa.hibernate.ddl-auto=create-droptests, demos
…=updatelocal development, never production
…=validateproduction, with Flyway or Liquibase for migrations
<dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-core</artifactId>
</dependency>

Migrations live in src/main/resources/db/migration/V1__init.sql.

The N+1 trap

@Query("select b from Book b join fetch b.reviews where b.author = :author")
List<Book> withReviews(String author);

Turn on spring.jpa.show-sql=true once and count the statements.

★ Exercises

  1. Make Book an entity and add a BookRepository.
  2. Seed three books with an ApplicationRunner.
  3. Add findByAuthor and expose it as /api/books?author=.
  4. Add paging to the list endpoint.
  5. Write a @Transactional rename method and check that a thrown exception rolls it back.
  6. Enable show-sql and look at what a findAll really executes.

Calling Other Services

RestClient

The synchronous client since Spring 6.1 — fluent, blocking, the default choice in MVC apps.

@Configuration
class ClientConfig {
    @Bean
    RestClient booksClient(RestClient.Builder builder) {
        return builder.baseUrl("https://api.example.com").build();
    }
}
Book book = client.get()
        .uri("/books/{id}", id)
        .retrieve()
        .body(Book.class);

List<Book> all = client.get()
        .uri("/books?author={a}", author)
        .retrieve()
        .body(new ParameterizedTypeReference<>() {});

Book created = client.post()
        .uri("/books")
        .contentType(MediaType.APPLICATION_JSON)
        .body(new BookRequest("Dune", "Herbert", 1965))
        .retrieve()
        .body(Book.class);

Handling failures:

var book = client.get().uri("/books/{id}", id)
        .retrieve()
        .onStatus(HttpStatusCode::is4xxClientError,
                  (req, res) -> { throw new BookNotFoundException(id); })
        .body(Book.class);
Note

RestTemplate still works but is in maintenance mode. New code uses RestClient (blocking) or WebClient (reactive).

HTTP service clients

New in Spring Boot 4: describe the remote API as an interface, let Spring implement it.

package com.example.clients;

@HttpExchange("/books")
public interface BookApi {

    @GetExchange("/{id}")
    Book byId(@PathVariable long id);

    @GetExchange
    List<Book> search(@RequestParam String author);

    @PostExchange
    Book create(@RequestBody BookRequest request);
}

Register the package and give the group a name:

@SpringBootApplication
@ImportHttpServices(group = "books", basePackages = "com.example.clients")
public class DemoApplication {  }

Configure the group instead of hard-coding URLs:

spring.http.clients.connect-timeout=1s
spring.http.serviceclient.books.base-url=https://api.example.com
spring.http.serviceclient.books.connect-timeout=2s
spring.http.serviceclient.books.read-timeout=2s

Then inject BookApi anywhere — no implementation class in your code base.

@Service
class Catalog {
    private final BookApi api;
    Catalog(BookApi api) { this.api = api; }

    Book fetch(long id) { return api.byId(id); }
}

Customize a group programmatically when properties are not enough:

@Bean
RestClientHttpServiceGroupConfigurer headers() {
    return groups -> groups.forEachClient((group, builder) ->
            builder.defaultHeader("X-Client", group.name()));
}

Timeouts

Always set them. A client without a read timeout turns a slow dependency into an outage.

spring.http.clients.connect-timeout=1s
spring.http.clients.read-timeout=5s

WebClient

Reactive and non-blocking; needs spring-boot-starter-webflux.

Mono<Book> book = webClient.get()
        .uri("/books/{id}", id)
        .retrieve()
        .bodyToMono(Book.class);

Use it for streaming responses or when you are already reactive.

Testing a client

@RestClientTest(Catalog.class)
class CatalogTest {

    @Autowired Catalog catalog;
    @Autowired MockRestServiceServer server;

    @Test
    void fetchesBook() {
        server.expect(requestTo("/books/1"))
              .andRespond(withSuccess("""
                  {"id":1,"title":"Dune"}""", MediaType.APPLICATION_JSON));

        assertThat(catalog.fetch(1).title()).isEqualTo("Dune");
    }
}

★ Exercises

  1. Call https://api.github.com/users/spring-projects with RestClient and print the name.
  2. Turn that call into an @HttpExchange interface and register it with @ImportHttpServices.
  3. Move the base URL into spring.http.serviceclient.<group>.base-url.
  4. Set a 2 s read timeout and point the client at a slow endpoint — what exception do you get?
  5. Map a 404 from the remote API to your own exception.

Testing

The starter

spring-boot-starter-test is in every generated project and brings JUnit 5, AssertJ, Mockito, JSONassert and the Spring test support.

./mvnw test

Plain unit tests

Constructor injection means most classes need no Spring at all.

class GreetingServiceTest {

    private final GreetingService service = new GreetingService();

    @Test
    void greetsByName() {
        assertThat(service.greet("Ann")).isEqualTo("Hello, Ann!");
    }
}

Fast, and where the bulk of your tests should live.

Slice tests

Start only the part of the context you need.

AnnotationLoads
@WebMvcTestcontrollers, JSON mapping, no services
@DataJpaTestJPA, repositories, in-memory database
@RestClientTestHTTP clients with a mock server
@JsonTestserialization only
@WebMvcTest(BookController.class)
class BookControllerTest {

    @Autowired RestTestClient client;
    @MockitoBean BookService service;             // replaces the bean in the context

    @Test
    void returnsBook() {
        given(service.byId(1)).willReturn(new Book(1, "Dune"));

        client.get().uri("/api/books/1")
              .exchange()
              .expectStatus().isOk()
              .expectBody().jsonPath("$.title").isEqualTo("Dune");
    }
}
New in Spring Boot 4

RestTestClient is the one fluent test client for both mock and running servers. @MockBean is gone — use @MockitoBean (and @MockitoSpyBean).

Repository tests

@DataJpaTest
class BookRepositoryTest {

    @Autowired BookRepository repo;

    @Test
    void findsByAuthor() {
        repo.save(new Book("Dune", "Herbert", 1965));

        assertThat(repo.findByAuthor("Herbert")).hasSize(1);
    }
}

Each test runs in a transaction that is rolled back afterwards.

Full integration tests

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class BookApiIT {

    @Autowired RestTestClient client;

    @Test
    void createsAndReadsBack() {
        client.post().uri("/api/books")
              .body(new BookRequest("Dune", "Herbert", 1965))
              .exchange()
              .expectStatus().isCreated();

        client.get().uri("/api/books")
              .exchange()
              .expectBody().jsonPath("$.length()").isEqualTo(1);
    }
}

Slow — a handful of these is enough.

Test configuration

src/test/resources/application.properties overrides settings during tests:

spring.jpa.hibernate.ddl-auto=create-drop
logging.level.org.springframework.web=DEBUG

Or use a profile: @ActiveProfiles("test").

A real database with Testcontainers

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
class PostgresIT {

    @Container @ServiceConnection
    static PostgreSQLContainer<?> db = new PostgreSQLContainer<>("postgres:17");
}

@ServiceConnection wires the container’s URL, user and password into the context — no property files.

What to test

  • services: business rules, plain unit tests
  • controllers: status codes, validation, JSON shape — @WebMvcTest
  • repositories: custom queries only — @DataJpaTest
  • end to end: one or two happy paths — @SpringBootTest
Warning

A test that starts the whole context to check a string comparison costs seconds every run. Push tests down the pyramid.

★ Exercises

  1. Unit-test a service with no Spring annotations.
  2. Write a @WebMvcTest with @MockitoBean for the service.
  3. Assert that an invalid POST body returns 400 and names the bad field.
  4. Write a @DataJpaTest for a derived query method.
  5. Add one @SpringBootTest covering create-then-read.

Day 2: Intermediate Spring

Versioned APIs, null safety, security, observability, resilience, concurrency and deployment.

Subsections of Day 2: Intermediate Spring

API Versioning

Spring Boot 4 versions endpoints for you — no /v1/ copies of every controller, no manual header parsing.

Enable it

spring.mvc.apiversion.default=1.0
spring.mvc.apiversion.use.header=X-API-Version

Other strategies:

PropertyClient sends
spring.mvc.apiversion.use.header=X-API-Versiona header
spring.mvc.apiversion.use.query-parameter=version?version=1.1
spring.mvc.apiversion.use.path-segment=1/api/1.1/books
spring.mvc.apiversion.use.media-type-parameter=…Accept: application/json;version=1.1

Reactive apps use spring.webflux.apiversion.*.

Version a mapping

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

    @GetMapping                      // any version — lowest priority
    Book get(@PathVariable long id) {  }

    @GetMapping(version = "1.1")     // exactly 1.1
    BookV1_1 get1_1(@PathVariable long id) {  }

    @GetMapping(version = "1.2+")    // 1.2 and everything above
    BookV1_2 get1_2(@PathVariable long id) {  }
}
curl -H "X-API-Version: 1.2" localhost:8080/api/books/1

Matching picks the highest version at or below the requested one. A request above every declared version fails with NotAcceptableApiVersionException → 400.

Tip

1.2+ is the useful default for new endpoints: you write the method once, and it keeps serving every later version until something actually changes.

Programmatic configuration

For several resolvers at once:

@Bean
WebMvcConfigurer apiVersioning() {
    return new WebMvcConfigurer() {
        @Override public void configureApiVersioning(ApiVersioningConfigurer c) {
            c.defaultVersion("1.0")
             .withHeaderResolver("X-API-Version")
             .withQueryParameterResolver("version");
        }
    };
}

Custom beans take over the details: ApiVersionResolver, ApiVersionParser, ApiVersionDeprecationHandler (for Deprecation / Sunset headers).

On the client side

RestClient and WebClient can send the version:

client.get().uri("/books/{id}", id)
      .apiVersion("1.2")
      .retrieve()
      .body(Book.class);

Versioning strategy

  • version the representation, not every internal change
  • additive changes (a new optional field) need no new version
  • removing or renaming a field does
  • announce deprecation before removal, and delete old versions on a schedule

★ Exercises

  1. Enable header-based versioning with a default of 1.0.
  2. Serve /api/books/{id} at 1.0 and a renamed field at 1.1+.
  3. Call both versions with curl and compare the JSON.
  4. Request version 9.9 — what status and body come back?
  5. Switch to query-parameter versioning without touching the controllers.

Null Safety with JSpecify

Spring Framework 7 and Spring Boot 4 annotate the whole portfolio with JSpecify. Your IDE and build can now tell you where a null can appear — before it appears at runtime.

The four annotations

AnnotationMeaning
@NullMarkedin this scope everything is non-null unless marked otherwise
@Nullablethis type may be null
@NonNullthis type is not null (rarely needed inside @NullMarked)
@NullUnmarkedopt a scope back out
<dependency>
    <groupId>org.jspecify</groupId>
    <artifactId>jspecify</artifactId>
</dependency>

Mark your packages

src/main/java/com/example/demo/package-info.java:

@NullMarked
package com.example.demo;

import org.jspecify.annotations.NullMarked;

Everything in the package is now non-null by default — one file per package, no annotations sprinkled over the code.

Then annotate the exceptions

@Service
public class BookService {

    public Book byId(long id) {  }                    // never null

    public @Nullable Book findByTitle(String title) {  }   // may be null

    public List<Book> byAuthor(@Nullable String author) {  }  // parameter may be null
}

@Nullable goes on the type, so generics work as expected:

List<@Nullable String> listOfNullableStrings;   // list is non-null, elements may be null
@Nullable List<String> nullableList;            // list may be null, elements are not

What you get

  • IDE warnings where a nullable value is dereferenced
  • Kotlin sees Spring APIs as proper platform-free types
  • static analysis — NullAway can fail the build on a violation
<!-- NullAway via Error Prone, shortened -->
<compilerArgs>
    <arg>-XepOpt:NullAway:AnnotatedPackages=com.example</arg>
</compilerArgs>
Note

These annotations are compile-time only. Nothing is checked at runtime — the payoff is in the tools.

Optional or @Nullable?

UseWhere
Optional<T>return values of query-style methods, stream chains
@Nullable Tfields, parameters, hot paths, overrides

Never use Optional as a parameter type or a field.

Practical rules

  • prefer designs where null never appears: empty collections, default objects, Optional
  • validate at the edge (@Valid), so the inside of the app can assume non-null
  • Objects.requireNonNull(x, "x") for constructor arguments that must be present
  • do not annotate @Nullable on something you then dereference unchecked

★ Exercises

  1. Add a package-info.java with @NullMarked to your service package.
  2. Mark one repository lookup @Nullable and see what the IDE says at the call site.
  3. Rewrite that method to return Optional<Book> — which reads better here?
  4. Explain the difference between List<@Nullable String> and @Nullable List<String>.
  5. Add Objects.requireNonNull to a constructor and write a test for it.

Security

Add the starter

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

Every endpoint is now protected, and a generated password is printed at startup. That default is a reminder to configure it, not a setup.

A filter chain

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain api(HttpSecurity http) throws Exception {
        return http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/actuator/health", "/public/**").permitAll()
                .requestMatchers(HttpMethod.GET, "/api/books/**").hasRole("USER")
                .requestMatchers("/api/**").hasRole("ADMIN")
                .anyRequest().authenticated())
            .csrf(csrf -> csrf.disable())        // stateless API with tokens only
            .httpBasic(Customizer.withDefaults())
            .build();
    }
}

Rules are matched top to bottom — put the specific ones first.

Do not disable CSRF blindly

Turn it off only for stateless APIs authenticated by a token or basic auth. A session-cookie app needs CSRF protection.

Users

For a demo:

@Bean
UserDetailsService users(PasswordEncoder encoder) {
    return new InMemoryUserDetailsManager(
        User.withUsername("ann").password(encoder.encode("secret")).roles("ADMIN").build());
}

@Bean
PasswordEncoder passwordEncoder() {
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}

In production, back UserDetailsService with your database — or do not manage passwords at all and use OAuth2/OIDC.

JWT resource server

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
spring.security.oauth2.resourceserver.jwt.issuer-uri=https://auth.example.com/realms/demo
http.oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()));

Spring validates signature, issuer and expiry, and populates the Authentication.

Method security

@Configuration
@EnableMethodSecurity
class MethodSecurityConfig {}
@PreAuthorize("hasRole('ADMIN')")
public void delete(long id) {  }

@PreAuthorize("#username == authentication.name")
public Profile profile(String username) {  }

@PostAuthorize("returnObject.owner == authentication.name")
public Document load(long id) {  }

The current user

@GetMapping("/me")
String me(Authentication auth) {
    return auth.getName();
}

@GetMapping("/claims")
Map<String, Object> claims(@AuthenticationPrincipal Jwt jwt) {
    return jwt.getClaims();
}

Checklist

  • HTTPS everywhere; server.ssl.* or TLS at the proxy
  • passwords hashed with bcrypt/argon2 — never encrypted, never plain
  • secrets from the environment, not from application.properties
  • deny by default: anyRequest().authenticated() as the last rule
  • keep error responses vague: no “unknown user” vs “wrong password”
  • dependencies patched — check with ./mvnw versions:display-dependency-updates

★ Exercises

  1. Add the security starter and log in with the generated password.
  2. Configure two users with roles USER and ADMIN.
  3. Allow anonymous GET /api/books, require ADMIN for DELETE.
  4. Protect a service method with @PreAuthorize and test the denial.
  5. Add /actuator/health to the public matchers — why is that one usually fine?

Actuator & Observability

Actuator

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

Only /actuator/health is exposed over HTTP by default. Open more explicitly:

management.endpoints.web.exposure.include=health,info,metrics,env,loggers
management.endpoint.health.show-details=when-authorized
EndpointShows
/actuator/healthup/down, plus per-component checks
/actuator/infobuild and git info
/actuator/metricscounters, gauges, timers
/actuator/loggerslog levels, changeable at runtime
/actuator/envresolved configuration
/actuator/prometheusmetrics in Prometheus format
curl localhost:8080/actuator/health
curl localhost:8080/actuator/metrics/http.server.requests
Warning

env, heapdump and threaddump leak internals. Expose them only behind authentication, or move the whole actuator to its own port: management.server.port=9001.

Health for Kubernetes

management.endpoint.health.probes.enabled=true
  • /actuator/health/liveness — is the process healthy? restart if not
  • /actuator/health/readiness — can it take traffic? remove from the load balancer if not

Custom check:

@Component
class QueueHealthIndicator implements HealthIndicator {
    @Override public Health health() {
        int depth = queue.depth();
        return depth < 1000
            ? Health.up().withDetail("depth", depth).build()
            : Health.down().withDetail("depth", depth).build();
    }
}

Metrics

Micrometer is the API; the backend is a dependency choice.

@Service
class BookService {

    private final Counter created;

    BookService(MeterRegistry registry) {
        this.created = registry.counter("books.created");
    }

    Book create(BookRequest r) {
        created.increment();
        return ;
    }
}
@Timed("books.search")            // needs @EnableAspectJAutoProxy + aop starter
List<Book> search(String q) {  }

Export to Prometheus:

<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-registry-prometheus</artifactId>
    <scope>runtime</scope>
</dependency>

Tracing

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-opentelemetry</artifactId>
</dependency>

New in Spring Boot 4: one starter for OpenTelemetry metrics and traces.

management.tracing.export.enabled=true
management.tracing.sampling.probability=0.1
spring.application.name=demo

Trace and span ids land in the log pattern automatically, so a log line can be matched to a trace.

Logging

logging.level.root=INFO
logging.level.com.example=DEBUG
logging.file.name=logs/app.log
logging.console.enabled=true          # new in Spring Boot 4: set false in container setups
logging.structured.format.console=ecs # JSON logs: ecs, gelf, logstash
private static final Logger log = LoggerFactory.getLogger(BookService.class);

log.info("Created book id={} title={}", id, title);   // placeholders, not concatenation

Build info in /actuator/info

<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <executions><execution><goals><goal>build-info</goal></goals></execution></executions>
</plugin>

★ Exercises

  1. Expose health, info and metrics and call all three.
  2. Add build-info and check /actuator/info.
  3. Write a HealthIndicator that reports down when a property is set.
  4. Count something with a Micrometer Counter and read it from /actuator/metrics.
  5. Change a log level at runtime through /actuator/loggers.

Resilience

Retries and concurrency limits moved into the core framework in Spring 7 — no extra project, no Resilience4j needed for the common cases.

Enable it

@Configuration
@EnableResilientMethods
class ResilienceConfig {}

@Retryable

import org.springframework.resilience.annotation.Retryable;

@Retryable
public void sendNotification() {  }             // 3 retries, 1 s apart
@Retryable(
    includes = RemoteServiceException.class,
    maxRetries = 4,
    delay = 100,          // ms
    jitter = 10,          // random spread, avoids thundering herds
    multiplier = 2,       // exponential backoff: 100, 200, 400, 800
    maxDelay = 1000)
public Book fetch(long id) {  }
AttributeDefaultMeaning
maxRetries3attempts after the first one
delay1000milliseconds before retrying
jitter0random addition to the delay
multiplier1backoff factor
maxDelaycap for the growing delay
includes / excludesall / nonewhich exceptions to retry

Reactive return types are retried too:

@Retryable(maxRetries = 4, delay = 100)
public Mono<Void> publish() {  }
Warning

Only retry idempotent operations. A retried POST /payments can charge twice — send an idempotency key, or do not retry.

Programmatic retries

When the annotation does not fit — a lambda, a loop body, a non-bean call:

var policy = RetryPolicy.builder()
        .includes(RemoteServiceException.class)
        .maxRetries(4)
        .delay(Duration.ofMillis(100))
        .multiplier(2)
        .maxDelay(Duration.ofSeconds(1))
        .build();

var template = new RetryTemplate(policy);
var book = template.invoke(() -> api.byId(id));

@ConcurrencyLimit

Caps how many threads may be inside a method at once — useful in front of a fragile dependency or a bounded pool.

import org.springframework.resilience.annotation.ConcurrencyLimit;

@ConcurrencyLimit(10)
public void callLegacySystem() {  }

@ConcurrencyLimit(1)                     // lock-like: one at a time
public void rebuildIndex() {  }

@ConcurrencyLimit(limitString = "${app.concurrency.limit:10}")
public void export() {  }

Callers beyond the limit wait — this is backpressure, not rejection.

Timeouts first

Retries multiply load. Before adding one, make sure the call can fail fast:

spring.http.clients.connect-timeout=1s
spring.http.clients.read-timeout=2s

A 30 s timeout with 4 retries means a client waiting two minutes for an error.

When you need more

Circuit breakers, bulkheads and rate limiters still live in Resilience4j (spring-cloud-starter-circuitbreaker-resilience4j). Reach for it when a failing dependency must be cut off entirely, not just retried.

★ Exercises

  1. Enable resilient methods and add @Retryable to a flaky call.
  2. Log every attempt and confirm the exponential backoff in the timestamps.
  3. Restrict retries to one exception type with includes.
  4. Put @ConcurrencyLimit(2) on a slow method and fire 10 parallel requests.
  5. Name two operations in your app that must never be retried.

Async, Scheduling & Virtual Threads

Virtual threads

One property, and every request plus every @Async task runs on a virtual thread:

spring.threads.virtual.enabled=true

Blocking code stops being expensive: a thread parked on I/O costs almost nothing, so an MVC app handles thousands of concurrent requests without going reactive.

Two rules

Do not pool virtual threads — create one per task. And avoid synchronized around blocking calls; use ReentrantLock instead.

@Async

@Configuration
@EnableAsync
class AsyncConfig {}
@Service
class ReportService {

    @Async
    public CompletableFuture<Report> build(long id) {
        
        return CompletableFuture.completedFuture(report);
    }
}
  • return CompletableFuture<T> (or void for fire-and-forget)
  • the call must come from another bean — self-invocation bypasses the proxy
  • exceptions in void methods disappear unless you set an AsyncUncaughtExceptionHandler
var f1 = reports.build(1);
var f2 = reports.build(2);
CompletableFuture.allOf(f1, f2).join();

Scheduling

@Configuration
@EnableScheduling
class SchedulingConfig {}
@Component
class Jobs {

    @Scheduled(fixedRate = 60_000)                 // every minute, from start to start
    void poll() {  }

    @Scheduled(fixedDelay = 5_000, initialDelay = 10_000)   // 5 s after the last one ended
    void drainQueue() {  }

    @Scheduled(cron = "0 0 3 * * *", zone = "Europe/Berlin")   // 03:00 daily
    void nightlyCleanup() {  }
}

Cron fields: second minute hour day-of-month month day-of-week.

app.cleanup.cron=0 0 3 * * *
app.cleanup.cron=-              # "-" disables the job
@Scheduled(cron = "${app.cleanup.cron}")

By default all scheduled tasks share one thread — a slow job delays the others:

spring.task.scheduling.pool.size=4
Warning

With several instances running, every instance runs the job. Use a leader election or a database lock (ShedLock) for “exactly once”.

Application events

Decouple side effects from the main flow.

public record BookCreated(long id, String title) {}
@Service
class BookService {
    private final ApplicationEventPublisher events;

    BookService(ApplicationEventPublisher events) { this.events = events; }

    @Transactional
    public Book create(BookRequest r) {
        var saved = repo.save();
        events.publishEvent(new BookCreated(saved.getId(), saved.getTitle()));
        return saved;
    }
}
@Component
class SearchIndexer {

    @EventListener
    void on(BookCreated event) {  }                    // synchronous, same transaction

    @Async
    @TransactionalEventListener                         // only after a successful commit
    void index(BookCreated event) {  }
}

Task executors

spring.task.execution.pool.core-size=8
spring.task.execution.pool.max-size=32
spring.task.execution.pool.queue-capacity=1000

Ignored when virtual threads are enabled — there is no pool to size.

★ Exercises

  1. Turn on virtual threads and log Thread.currentThread() in a controller.
  2. Make a slow service method @Async and call it twice in parallel.
  3. Add a @Scheduled(fixedDelay = …) job and watch the timing in the logs.
  4. Publish an event on create and index it with @TransactionalEventListener.
  5. Why does calling an @Async method from within the same class do nothing?

Caching & Messaging

Caching

@Configuration
@EnableCaching
class CacheConfig {}
@Service
public class BookService {

    @Cacheable("books")
    public Book byId(long id) {  }                 // called once per id

    @CachePut(value = "books", key = "#book.id")
    public Book update(Book book) {  }             // always runs, refreshes the entry

    @CacheEvict(value = "books", key = "#id")
    public void delete(long id) {  }

    @CacheEvict(value = "books", allEntries = true)
    public void reload() {  }
}

Keys default to the method arguments; key = "#id" or a SpEL expression overrides that.

Without a cache library you get a simple ConcurrentHashMap. For anything real, pick one:

<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>
spring.cache.cache-names=books
spring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=10m

Shared across instances? Use Redis:

spring.cache.type=redis
spring.data.redis.host=localhost
spring.data.redis.time-to-live=10m
Warning

Cache only what is expensive and stable. Every cache adds a staleness window and a new class of bug: wrong data that looks right.

Messaging

Messages decouple producer and consumer, absorb load spikes, and survive a restart of the receiver.

Kafka

<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>
spring.kafka.bootstrap-servers=localhost:9092
spring.kafka.consumer.group-id=demo
spring.kafka.consumer.auto-offset-reset=earliest
@Service
class BookEvents {
    private final KafkaTemplate<String, BookCreated> template;

    BookEvents(KafkaTemplate<String, BookCreated> template) { this.template = template; }

    void publish(BookCreated event) {
        template.send("books", String.valueOf(event.id()), event);
    }
}

@Component
class BookConsumer {
    @KafkaListener(topics = "books", groupId = "demo")
    void on(BookCreated event) {  }
}

RabbitMQ

spring.rabbitmq.host=localhost
rabbitTemplate.convertAndSend("books.exchange", "book.created", event);

@RabbitListener(queues = "books")
void on(BookCreated event) {  }

JmsClient

Spring 7 adds a fluent JMS client next to JmsTemplate:

jmsClient.destination("notifications").send(event);

Consumer rules

  • idempotent handlers: the same message can arrive twice
  • acknowledge after the work succeeded, not before
  • a dead letter topic for messages that keep failing
  • log the message key with every error, or debugging is guesswork

Local infrastructure

compose.yaml next to pom.xml plus:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-docker-compose</artifactId>
    <optional>true</optional>
</dependency>

./mvnw spring-boot:run now starts the containers and wires the connection details in.

★ Exercises

  1. Add @Cacheable to a slow lookup and measure the second call.
  2. Configure Caffeine with a 1-minute expiry and prove entries disappear.
  3. Evict the cache on update and show a stale read without it.
  4. Publish a BookCreated event to Kafka or RabbitMQ and consume it in the same app.
  5. Make the consumer idempotent — how do you detect a duplicate?

Packaging & Deployment

The executable jar

./mvnw clean package
java -jar target/demo-0.0.1-SNAPSHOT.jar

One file with your classes, the dependencies and a launcher. No application server.

java -jar app.jar --server.port=9000 --spring.profiles.active=prod
java -Dspring.profiles.active=prod -jar app.jar
SPRING_PROFILES_ACTIVE=prod java -jar app.jar

Container image, no Dockerfile

./mvnw spring-boot:build-image -Dspring-boot.build-image.imageName=demo:1.0
docker run -p 8080:8080 demo:1.0

Cloud Native Buildpacks pick a JDK, layer the image and set sane defaults.

Container image with a Dockerfile

Layered jars keep dependencies in their own layer, so a code change rebuilds only the last one:

FROM eclipse-temurin:25-jre AS builder
WORKDIR /app
COPY target/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --launcher

FROM eclipse-temurin:25-jre
WORKDIR /app
COPY --from=builder /app/app/dependencies/ ./
COPY --from=builder /app/app/spring-boot-loader/ ./
COPY --from=builder /app/app/snapshot-dependencies/ ./
COPY --from=builder /app/app/application/ ./
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

Native images

./mvnw -Pnative native:compile          # needs GraalVM
./target/demo
JVMNative
startup~1 s~50 ms
memoryhighermuch lower
build timesecondsminutes
runtime reflectionfreeneeds hints

Worth it for functions and scale-to-zero workloads; usually not for a long-running service.

Configuration in production

  • profiles per environment, secrets from the environment or a secret manager
  • spring.jpa.hibernate.ddl-auto=validate plus Flyway migrations
  • actuator on a separate port: management.server.port=9001
  • structured logs: logging.structured.format.console=ecs
  • graceful shutdown so in-flight requests finish:
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=30s

Kubernetes essentials

readinessProbe:
  httpGet: { path: /actuator/health/readiness, port: 9001 }
livenessProbe:
  httpGet: { path: /actuator/health/liveness, port: 9001 }
resources:
  requests: { memory: 512Mi, cpu: "0.5" }
  limits:   { memory: 1Gi }

The JVM reads container limits by default — set the memory limit, not -Xmx, unless you have a reason.

Tip

management.endpoint.health.probes.enabled=true gives you the two probe endpoints; combined with graceful shutdown you get rolling deploys without dropped requests.

Release checklist

  • ./mvnw verify green, including integration tests
  • migrations tested against a copy of production data
  • no secrets in the image or the repository
  • health probes, metrics and logs reaching your platform
  • rollback path: previous image tag still deployable
  • dependency and base image versions patched

★ Exercises

  1. Build the jar and run it with the prod profile.
  2. Build an image with spring-boot:build-image and run it.
  3. Write a layered Dockerfile and compare the rebuild time after a one-line change.
  4. Enable graceful shutdown and watch a long request finish during SIGTERM.
  5. Add readiness and liveness probes, then make readiness fail on purpose.

Spring Boot 4 Cheat Sheet

Commands

./mvnw spring-boot:run          ./mvnw test          ./mvnw package
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev
./mvnw spring-boot:build-image  ./mvnw dependency:tree
java -jar target/app.jar --server.port=9000 --spring.profiles.active=prod

Application class

@SpringBootApplication
@ImportHttpServices(group = "books", basePackages = "com.example.clients")
public class DemoApplication {
    public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); }
}

Stereotypes and wiring

@Component  @Service  @Repository  @RestController  @Configuration  @Bean
@Primary    @Qualifier("name")     @Scope("prototype")
@PostConstruct  @PreDestroy        @Profile("!prod")
@ConditionalOnProperty  @ConditionalOnMissingBean
@Service
class S { private final Repo repo; S(Repo repo) { this.repo = repo; } }   // constructor injection

Web

@RestController @RequestMapping("/api/books")
@GetMapping @PostMapping @PutMapping @PatchMapping @DeleteMapping
@PathVariable  @RequestParam  @RequestBody  @RequestHeader  @CookieValue
@ResponseStatus(HttpStatus.CREATED)   @CrossOrigin   @GetMapping(version = "1.2+")
ResponseEntity.ok(b)  .created(uri).body(b)  .noContent().build()  .notFound().build()
StatusWhen
200 / 201 / 204ok / created / no content
400 / 401 / 403 / 404 / 409invalid / unauthenticated / forbidden / missing / conflict
500unhandled

Validation & errors

@Valid @RequestBody Req r
@NotNull @NotBlank @Size(max=100) @Min @Max @Email @Pattern @Past @Future
@RestControllerAdvice  @ExceptionHandler(X.class)
ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, "…")

Configuration

server.port=8080
spring.application.name=demo
spring.profiles.active=dev
logging.level.com.example=DEBUG
logging.console.enabled=true
spring.threads.virtual.enabled=true
spring.jpa.hibernate.ddl-auto=validate
management.endpoints.web.exposure.include=health,info,metrics
management.endpoint.health.probes.enabled=true
server.shutdown=graceful
@Value("${app.key:default}") String key;

@ConfigurationProperties("app.mail")
record MailProperties(String from, String host, int port) {}

Precedence: command line → environment → application-{profile}application → code.

Data

@Entity @Id @GeneratedValue @Column(nullable = false) @ManyToOne @OneToMany
interface BookRepo extends JpaRepository<Book, Long> {
    List<Book> findByAuthor(String a);
    Optional<Book> findByTitleIgnoreCase(String t);
    @Query("select b from Book b where b.year > :y") List<Book> after(int y);
}
repo.save(b)  repo.findById(id)  repo.findAll(PageRequest.of(0, 20, Sort.by("title")))
@Transactional  @Transactional(readOnly = true)

HTTP clients

client.get().uri("/books/{id}", id).retrieve().body(Book.class);
client.post().uri("/books").body(req).retrieve().body(Book.class);
@HttpExchange("/books")
interface BookApi {
    @GetExchange("/{id}") Book byId(@PathVariable long id);
    @PostExchange Book create(@RequestBody BookRequest r);
}
spring.http.serviceclient.books.base-url=https://api.example.com
spring.http.clients.read-timeout=2s

Resilience

@EnableResilientMethods
@Retryable(includes = X.class, maxRetries = 4, delay = 100, multiplier = 2, maxDelay = 1000)
@ConcurrencyLimit(10)

Async, scheduling, events

@EnableAsync @Async CompletableFuture<T> 
@EnableScheduling
@Scheduled(fixedRate = 60_000) @Scheduled(fixedDelay = 5_000) @Scheduled(cron = "0 0 3 * * *")
events.publishEvent(new BookCreated(id));  @EventListener  @TransactionalEventListener

Caching

@EnableCaching  @Cacheable("books")  @CachePut(value="books", key="#b.id")  @CacheEvict("books")

Security

@EnableWebSecurity  @EnableMethodSecurity  @PreAuthorize("hasRole('ADMIN')")
http.authorizeHttpRequests(a -> a.requestMatchers("/public/**").permitAll()
                                 .anyRequest().authenticated());

Null safety

@NullMarked          // package-info.java
@Nullable Book find(String title);
List<@Nullable String>   vs   @Nullable List<String>

Testing

@SpringBootTest(webEnvironment = RANDOM_PORT)   @WebMvcTest(C.class)   @DataJpaTest
@RestClientTest   @JsonTest   @ActiveProfiles("test")
@MockitoBean  @MockitoSpyBean
client.get().uri("/api/books/1").exchange().expectStatus().isOk()
      .expectBody().jsonPath("$.title").isEqualTo("Dune");

Actuator

/actuator/health   /health/liveness   /health/readiness
/actuator/info     /actuator/metrics  /actuator/loggers   /actuator/prometheus

Spring Boot 3 → Spring Boot 4

OldNew
@MockBean@MockitoBean
RestTemplateRestClient
spring.dao.exceptiontranslation.enabledspring.persistence.exceptiontranslation.enabled
management.tracing.enabledmanagement.tracing.export.enabled
hand-written client classes@HttpExchange + @ImportHttpServices
/v1/ controller copies@GetMapping(version = "…")

Further Resources

Official

What is new in Spring Boot 4

TopicWhere
Modularization, JSpecify null safety, Java 25Spring Boot 4.0 announcement
API versioningFramework 7 GA
HTTP service clientsSpring Boot reference: REST clients
@Retryable, @ConcurrencyLimitFramework reference: resilience
RestTestClient, @MockitoBeanSpring Boot reference: testing

Ecosystem

Tools

Practice

  • Rebuild the course project without looking: entity, repository, service, controller, tests
  • Put a real API behind an @HttpExchange interface
  • Take an existing Spring Boot 3 app and upgrade it to Spring Boot 4
  • Deploy something small — a container plus a health probe teaches more than a chapter

Books

  • Spring Start Here (Laurentiu Spilca) — gentle, container-first
  • Spring in Action (Craig Walls) — broad tour of the ecosystem
  • Spring Boot: Up and Running (Mark Heckler) — practical, production-minded
  • Effective Java (Joshua Bloch) — not Spring, but it makes your Spring code better

Community

  • Stack Overflow — check the date on every answer
  • GitHub issues — the maintainers answer here
  • Devoxx, Spring I/O and SpringOne talks are on YouTube
Tip

Check the version of anything you read. A lot of “how to do it in Spring” describes XML configuration or Spring Boot 1 — if a snippet has applicationContext.xml in it, keep scrolling.

About this course

All code samples are public domain (CC0). Written against Spring Boot 4.0 on JDK 25.