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.
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.
SpringApplication.run creates an application context
component scan collects your @Component/@Service/@RestController classes
auto-configuration adds what the classpath implies (web server, DataSource, …)
beans are created and wired
the embedded Tomcat starts and binds a port
./mvnw spring-boot:run -Ddebug # prints the auto-configuration report
★ Exercises
Add a /time endpoint returning the current time as JSON.
Change the port to 9000 in application.properties.
Move HelloController into com.example.other — what happens, and why?
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.
One dependency and four lines is a running HTTP service.
The three ideas
Idea
What it means
Inversion of control
you declare components, the container creates and connects them
Auto-configuration
classpath contents decide what gets configured
Starters
one 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
Project
For
Spring Framework 7
core container, MVC/WebFlux, transactions
Spring Data
JPA, MongoDB, Redis repositories
Spring Security
authentication and authorization
Spring Boot Actuator
health, metrics, observability
Spring Batch / Integration / Kafka
jobs, 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.enabled → spring.persistence.exceptiontranslation.enabled,
management.tracing.enabled → management.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
Name three things auto-configuration does for spring-boot-starter-web.
Remove the web starter from a project. What still runs?
Look up two more starters at start.spring.io and say what each adds.
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.
All four of the first are @Component underneath — the name documents intent.
Constructor injection
@RestControllerpublicclassGreetingController{privatefinalGreetingServiceservice;publicGreetingController(GreetingServiceservice){// no @Autowired neededthis.service=service;}@GetMapping("/greet/{name}")Stringgreet(@PathVariableStringname){returnservice.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.
Alerts(NotifierdefaultOne,// EmailNotifier, it is @Primary@Qualifier("sms")Notifierurgent){…}
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")CacheManagercacheManager(){…}
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 pointclassTicket{}
@ComponentclassWarmup{@PostConstructvoidstart(){…}// after injection@PreDestroyvoidstop(){…}// on shutdown}
A violation throws MethodArgumentNotValidException → 400 before your method runs.
Annotation
Checks
@NotNull / @NotBlank / @NotEmpty
present / non-blank text / non-empty collection
@Size(min, max)
length or size
@Min / @Max / @Positive
numbers
@Email / @Pattern
format
@Past / @Future
dates
Validate parameters too:
@Validated// on the class@GetMapping("/search")List<Book>search(@RequestParam@NotBlankStringq){…}
Your own exceptions
publicclassBookNotFoundExceptionextendsRuntimeException{publicBookNotFoundException(longid){super("No book with id "+id);}}
One place for error responses
@RestControllerAdvicepublicclassApiExceptionHandler{@ExceptionHandler(BookNotFoundException.class)ProblemDetailnotFound(BookNotFoundExceptionex){varpd=ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND,ex.getMessage());pd.setTitle("Book not found");returnpd;}@ExceptionHandler(MethodArgumentNotValidException.class)ProblemDetailinvalid(MethodArgumentNotValidExceptionex){varpd=ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);pd.setTitle("Validation failed");pd.setProperty("errors",ex.getBindingResult().getFieldErrors().stream().collect(toMap(FieldError::getField,FieldError::getDefaultMessage)));returnpd;}}
@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
thrownewResponseStatusException(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.
@EntitypublicclassBook{@Id@GeneratedValue(strategy=GenerationType.IDENTITY)privateLongid;@Column(nullable=false)privateStringtitle;privateStringauthor;privateintyear;protectedBook(){}// required by JPApublicBook(Stringtitle,Stringauthor,intyear){…}// 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.
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(intfrom,intto);@Query(value="select * from book order by random() limit 1",nativeQuery=true)Bookrandom();
Controllers can take a Pageable parameter directly: ?page=1&size=20&sort=title,asc.
Transactions
@ServicepublicclassBookService{privatefinalBookRepositoryrepo;BookService(BookRepositoryrepo){this.repo=repo;}@TransactionalpublicBookrename(longid,Stringtitle){varbook=repo.findById(id).orElseThrow(()->newBookNotFoundException(id));book.setTitle(title);// dirty checking writes on commitreturnbook;}@Transactional(readOnly=true)publicList<Book>all(){returnrepo.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
Setting
Use
spring.jpa.hibernate.ddl-auto=create-drop
tests, demos
…=update
local development, never production
…=validate
production, with Flyway or Liquibase for migrations
Fast, and where the bulk of your tests should live.
Slice tests
Start only the part of the context you need.
Annotation
Loads
@WebMvcTest
controllers, JSON mapping, no services
@DataJpaTest
JPA, repositories, in-memory database
@RestClientTest
HTTP clients with a mock server
@JsonTest
serialization only
@WebMvcTest(BookController.class)classBookControllerTest{@AutowiredRestTestClientclient;@MockitoBeanBookServiceservice;// replaces the bean in the context@TestvoidreturnsBook(){given(service.byId(1)).willReturn(newBook(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).
@RestController@RequestMapping("/api/books/{id}")publicclassBookController{@GetMapping// any version — lowest priorityBookget(@PathVariablelongid){…}@GetMapping(version="1.1")// exactly 1.1BookV1_1get1_1(@PathVariablelongid){…}@GetMapping(version="1.2+")// 1.2 and everything aboveBookV1_2get1_2(@PathVariablelongid){…}}
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.
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
Enable header-based versioning with a default of 1.0.
Serve /api/books/{id} at 1.0 and a renamed field at 1.1+.
Call both versions with curl and compare the JSON.
Request version 9.9 — what status and body come back?
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
Annotation
Meaning
@NullMarked
in this scope everything is non-null unless marked otherwise
@Nullable
this type may be null
@NonNull
this type is not null (rarely needed inside @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
@ServicepublicclassBookService{publicBookbyId(longid){…}// never nullpublic@NullableBookfindByTitle(Stringtitle){…}// may be nullpublicList<Book>byAuthor(@NullableStringauthor){…}// parameter may be null}
@Nullable goes on the type, so generics work as expected:
List<@NullableString>listOfNullableStrings;// list is non-null, elements may be null@NullableList<String>nullableList;//listmaybenull,elementsarenot
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?
Use
Where
Optional<T>
return values of query-style methods, stream chains
@Nullable T
fields, 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
Add a package-info.java with @NullMarked to your service package.
Mark one repository lookup @Nullable and see what the IDE says at the call site.
Rewrite that method to return Optional<Book> — which reads better here?
Explain the difference between List<@Nullable String> and @Nullable List<String>.
Add Objects.requireNonNull to a constructor and write a test for it.
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@EnableWebSecuritypublicclassSecurityConfig{@BeanSecurityFilterChainapi(HttpSecurityhttp)throwsException{returnhttp.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.
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
Trace and span ids land in the log pattern automatically, so a log line can be matched to a
trace.
Logging
logging.level.root=INFOlogging.level.com.example=DEBUGlogging.file.name=logs/app.loglogging.console.enabled=true # new in Spring Boot 4: set false in container setupslogging.structured.format.console=ecs # JSON logs: ecs, gelf, logstash
privatestaticfinalLoggerlog=LoggerFactory.getLogger(BookService.class);log.info("Created book id={} title={}",id,title);//placeholders,notconcatenation
Caps how many threads may be inside a method at once — useful in front of a fragile
dependency or a bounded pool.
importorg.springframework.resilience.annotation.ConcurrencyLimit;@ConcurrencyLimit(10)publicvoidcallLegacySystem(){…}@ConcurrencyLimit(1)// lock-like: one at a timepublicvoidrebuildIndex(){…}@ConcurrencyLimit(limitString="${app.concurrency.limit:10}")publicvoidexport(){…}
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:
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
Enable resilient methods and add @Retryable to a flaky call.
Log every attempt and confirm the exponential backoff in the timestamps.
Restrict retries to one exception type with includes.
Put @ConcurrencyLimit(2) on a slow method and fire 10 parallel requests.
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.
@ComponentclassJobs{@Scheduled(fixedRate=60_000)// every minute, from start to startvoidpoll(){…}@Scheduled(fixedDelay=5_000,initialDelay=10_000)// 5 s after the last one endedvoiddrainQueue(){…}@Scheduled(cron="0 0 3 * * *",zone="Europe/Berlin")// 03:00 dailyvoidnightlyCleanup(){…}}
Cron fields: second minute hour day-of-month month day-of-week.
@ComponentclassSearchIndexer{@EventListenervoidon(BookCreatedevent){…}// synchronous, same transaction@Async@TransactionalEventListener// only after a successful commitvoidindex(BookCreatedevent){…}}
Ignored when virtual threads are enabled — there is no pool to size.
★ Exercises
Turn on virtual threads and log Thread.currentThread() in a controller.
Make a slow service method @Async and call it twice in parallel.
Add a @Scheduled(fixedDelay = …) job and watch the timing in the logs.
Publish an event on create and index it with @TransactionalEventListener.
Why does calling an @Async method from within the same class do nothing?
Caching & Messaging
Caching
@Configuration@EnableCachingclassCacheConfig{}
@ServicepublicclassBookService{@Cacheable("books")publicBookbyId(longid){…}// called once per id@CachePut(value="books",key="#book.id")publicBookupdate(Bookbook){…}// always runs, refreshes the entry@CacheEvict(value="books",key="#id")publicvoiddelete(longid){…}@CacheEvict(value="books",allEntries=true)publicvoidreload(){…}}
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:
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
Build the jar and run it with the prod profile.
Build an image with spring-boot:build-image and run it.
Write a layered Dockerfile and compare the rebuild time after a one-line change.
Enable graceful shutdown and watch a long request finish during SIGTERM.
Add readiness and liveness probes, then make readiness fail on purpose.
@Entity@Id@GeneratedValue@Column(nullable=false)@ManyToOne@OneToManyinterfaceBookRepoextendsJpaRepository<Book,Long>{List<Book>findByAuthor(Stringa);Optional<Book>findByTitleIgnoreCase(Stringt);@Query("select b from Book b where b.year > :y")List<Book>after(inty);}repo.save(b)repo.findById(id)repo.findAll(PageRequest.of(0,20,Sort.by("title")))@Transactional@Transactional(readOnly=true)
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.