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