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.