Skip to content
Published on

Spring Boot 3.x Complete Guide 2025: Virtual Threads, GraalVM Native, Spring AI

Authors

Introduction

Spring Boot 3.x represents the most significant evolution in the Java ecosystem. With the Jakarta EE 10 migration, Java 17+ baseline, and Java 21 Virtual Threads native integration, it sets a new standard for enterprise backend development.

This guide covers everything you need to build production-ready applications with Spring Boot 3.x in 2025 — from Virtual Threads and GraalVM Native Image to Spring AI, Security 6, Testcontainers, and full observability.


1. Spring Boot 3.x Big Picture

1.1 Jakarta EE Migration

The most breaking change in Spring Boot 3.x is the namespace migration from javax to jakarta.

ComponentBefore (2.x)After (3.x)
Servletjavax.servletjakarta.servlet
JPAjavax.persistencejakarta.persistence
Validationjavax.validationjakarta.validation
Mailjavax.mailjakarta.mail
Injectjavax.injectjakarta.inject
// Before: Spring Boot 2.x
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.validation.constraints.NotNull;

// After: Spring Boot 3.x
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.validation.constraints.NotNull;

Automated migration with OpenRewrite:

# build.gradle
plugins {
    id("org.openrewrite.rewrite") version "6.24.0"
}

rewrite {
    activeRecipe("org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_3")
}

# Execute migration
./gradlew rewriteRun

1.2 Java 21 Baseline

Spring Boot 3.x requires Java 17 minimum but is fully optimized for Java 21:

// Java 21 features you can use with Spring Boot 3.x

// 1. Record Patterns
public ResponseEntity<?> processPayment(Object payment) {
    return switch (payment) {
        case CreditCardPayment(var card, var amount)
            -> processCard(card, amount);
        case BankTransfer(var account, var amount)
            -> processTransfer(account, amount);
        default -> ResponseEntity.badRequest().build();
    };
}

// 2. Sealed Interfaces for domain modeling
public sealed interface PaymentResult
    permits PaymentSuccess, PaymentFailure, PaymentPending {
}
public record PaymentSuccess(String transactionId) implements PaymentResult {}
public record PaymentFailure(String reason) implements PaymentResult {}
public record PaymentPending(String retryAfter) implements PaymentResult {}
my-app/
  app-api/           # REST controllers, DTOs
  app-domain/        # Entities, repositories, services
  app-infra/         # External integrations, configs
  app-common/        # Shared utilities
  build.gradle       # Root build configuration

2. Java 21 Virtual Threads in Practice

2.1 Virtual Threads vs OS Threads

Virtual Threads are lightweight threads managed by the JVM, not the OS. They enable massive concurrency without the memory overhead of traditional platform threads.

// Platform Thread: 1:1 mapping to OS thread, ~1MB stack each
// 10,000 concurrent threads -> 10GB memory

// Virtual Thread: JVM-managed, ~1KB stack each
// 1,000,000 concurrent threads -> ~1GB memory

// Creating Virtual Threads
Thread.ofVirtual().start(() -> {
    System.out.println("Running on: " + Thread.currentThread());
});

// Executor for Virtual Threads
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 100_000).forEach(i ->
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1));
            return i;
        })
    );
}

2.2 Enabling Virtual Threads in Spring Boot

One line is all you need:

# application.yml
spring:
  threads:
    virtual:
      enabled: true

This replaces the Tomcat thread pool with Virtual Threads for all request handling.

2.3 Benchmark: Traditional vs Virtual Threads

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

    @Autowired
    TestRestTemplate restTemplate;

    @Test
    void benchmark10kConcurrentRequests() throws Exception {
        int concurrency = 10_000;
        CountDownLatch latch = new CountDownLatch(concurrency);
        AtomicInteger success = new AtomicInteger(0);

        long start = System.nanoTime();

        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < concurrency; i++) {
                executor.submit(() -> {
                    try {
                        ResponseEntity<String> response =
                            restTemplate.getForEntity("/api/slow-endpoint", String.class);
                        if (response.getStatusCode().is2xxSuccessful()) {
                            success.incrementAndGet();
                        }
                    } finally {
                        latch.countDown();
                    }
                });
            }
            latch.await(60, TimeUnit.SECONDS);
        }

        long elapsed = Duration.ofNanos(System.nanoTime() - start).toMillis();
        System.out.printf("10K requests: %dms, success: %d%n", elapsed, success.get());
        // Virtual Threads: ~2,500ms with 100ms DB calls
        // Platform Threads (200 pool): ~50,000ms
    }
}

2.4 Virtual Threads vs WebFlux

// === Reactive approach (WebFlux) ===
@GetMapping("/users-reactive")
public Mono<List<User>> getUsersReactive() {
    return webClient.get()
        .uri("/api/users")
        .retrieve()
        .bodyToFlux(User.class)
        .collectList()
        .flatMap(users ->
            externalService.enrich(users)
                .map(enriched -> enriched)
        );
}

// === Virtual Thread approach (synchronous code, async performance) ===
@GetMapping("/users-virtual")
public List<User> getUsersVirtual() {
    List<User> users = restClient.get()
        .uri("/api/users")
        .retrieve()
        .body(new ParameterizedTypeReference<>() {});

    return externalService.enrich(users); // Blocking is fine!
}

Key insight: Virtual Threads let you write synchronous code while achieving throughput comparable to Reactive programming. Debugging is dramatically simpler.

2.5 Virtual Thread Pitfalls

// 1. NEVER use synchronized -> use ReentrantLock
// Bad: Virtual Thread gets pinned to carrier thread
public synchronized void updateCounter() {
    counter++;
}

// Good: ReentrantLock allows yielding
private final ReentrantLock lock = new ReentrantLock();
public void updateCounter() {
    lock.lock();
    try {
        counter++;
    } finally {
        lock.unlock();
    }
}

// 2. Minimize ThreadLocal usage
// Virtual Threads can number in millions
// ThreadLocal abuse causes memory explosion
// -> Use ScopedValue (Preview) instead

// 3. CPU-bound tasks still benefit from Platform Threads
@Bean
TaskExecutor cpuIntensiveExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(Runtime.getRuntime().availableProcessors());
    executor.setMaxPoolSize(Runtime.getRuntime().availableProcessors() * 2);
    return executor;
}

// 4. Detect pinning with JVM flag
// -Djdk.tracePinnedThreads=short

3. GraalVM Native Image

3.1 Why Native Image?

GraalVM Native Image compiles Spring Boot apps to native binaries via AOT (Ahead-of-Time) compilation. The results are remarkable:

MetricJVM ModeNative Image
Startup time2-5s0.05-0.2s
Memory (RSS)200-500MB30-80MB
Peak throughputHigherModerate-High
Build timeFast3-10min
ReflectionUnrestrictedRequires hints

3.2 Native Image Build Configuration

// build.gradle
plugins {
    id 'org.graalvm.buildtools.native' version '0.10.3'
}

graalvmNative {
    binaries {
        main {
            imageName = 'my-app'
            mainClass = 'com.example.MyApplication'
            buildArgs.addAll(
                '--no-fallback',
                '-H:+ReportExceptionStackTraces',
                '--enable-url-protocols=http,https'
            )
        }
    }
}
# Build native image
./gradlew nativeCompile

# Run
./build/native/nativeCompile/my-app
# Started in 0.087 seconds!

3.3 Reflection Hints

@Configuration
@ImportRuntimeHints(AppRuntimeHints.class)
public class NativeImageConfig {
}

public class AppRuntimeHints implements RuntimeHintsRegistrar {

    @Override
    public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
        // Register types for reflection
        hints.reflection()
            .registerType(UserDto.class, MemberCategory.values())
            .registerType(OrderDto.class, MemberCategory.values());

        // Register resources
        hints.resources()
            .registerPattern("templates/*")
            .registerPattern("static/**")
            .registerPattern("messages/*.properties");

        // Register JDK proxies
        hints.proxies()
            .registerJdkProxy(MyRepository.class);

        // Register serialization
        hints.serialization()
            .registerType(MyEvent.class);
    }
}

// Simpler alternative: annotation-based
@RegisterReflectionForBinding({UserDto.class, OrderDto.class})
@Configuration
public class ReflectionConfig {
}

3.4 Docker Multi-Stage Native Build

# Stage 1: Build native image
FROM ghcr.io/graalvm/native-image-community:21 AS builder
WORKDIR /app
COPY . .
RUN ./gradlew nativeCompile --no-daemon

# Stage 2: Minimal runtime
FROM debian:bookworm-slim
RUN addgroup --system spring && adduser --system --ingroup spring spring
USER spring:spring
COPY --from=builder /app/build/native/nativeCompile/my-app /app
EXPOSE 8080
ENTRYPOINT ["/app"]
docker build -t my-app:native .
docker run -p 8080:8080 my-app:native
# Started in 0.071 seconds, 42MB RSS

3.5 Native Image Limitations and Workarounds

// Issue: Dynamic class loading won't work
// Solution: Register at build time

// Issue: Reflection-heavy libraries (Jackson, Hibernate)
// Solution: Spring AOT auto-generates most hints

// Issue: Build time is long (5-10 minutes)
// Solution: Use JVM mode for dev, Native for production
// Use Cloud Native Buildpacks for CI/CD:
// ./gradlew bootBuildImage --imageName=my-app:native

4. Spring AI — LLM Integration

4.1 Spring AI Overview

Spring AI provides a consistent abstraction over multiple LLM providers (OpenAI, Anthropic, Ollama, etc.) with familiar Spring patterns.

dependencies {
    // Choose your provider
    implementation 'org.springframework.ai:spring-ai-openai-spring-boot-starter'
    // implementation 'org.springframework.ai:spring-ai-anthropic-spring-boot-starter'
    // implementation 'org.springframework.ai:spring-ai-ollama-spring-boot-starter'

    // Vector Store for RAG
    implementation 'org.springframework.ai:spring-ai-pgvector-store-spring-boot-starter'
}
# application.yml
spring:
  ai:
    openai:
      api-key: your-api-key-here
      chat:
        options:
          model: gpt-4o
          temperature: 0.7

4.2 ChatClient Usage

@RestController
@RequiredArgsConstructor
public class AiController {

    private final ChatClient.Builder chatClientBuilder;

    // Simple chat
    @GetMapping("/ai/chat")
    public String chat(@RequestParam String message) {
        return chatClientBuilder.build()
            .prompt()
            .user(message)
            .call()
            .content();
    }

    // Streaming response
    @GetMapping(value = "/ai/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> chatStream(@RequestParam String message) {
        return chatClientBuilder.build()
            .prompt()
            .user(message)
            .stream()
            .content();
    }

    // Structured output
    @GetMapping("/ai/analyze")
    public SentimentResult analyzeSentiment(@RequestParam String text) {
        return chatClientBuilder.build()
            .prompt()
            .user("Analyze the sentiment of this text: " + text)
            .call()
            .entity(SentimentResult.class);
    }
}

record SentimentResult(String sentiment, double confidence, String explanation) {}

4.3 RAG (Retrieval-Augmented Generation)

@Service
@RequiredArgsConstructor
public class RagService {

    private final ChatClient.Builder chatClientBuilder;
    private final VectorStore vectorStore;

    // Ingest documents into vector store
    public void ingestDocuments(List<Document> documents) {
        vectorStore.add(documents);
    }

    // Query with RAG context
    public String queryWithContext(String question) {
        // 1. Similarity search for relevant documents
        List<Document> relevantDocs = vectorStore.similaritySearch(
            SearchRequest.query(question).withTopK(5)
        );

        // 2. Build context from results
        String context = relevantDocs.stream()
            .map(Document::getContent)
            .collect(Collectors.joining("\n\n"));

        // 3. Query LLM with context
        return chatClientBuilder.build()
            .prompt()
            .system(s -> s.text("""
                You are a helpful assistant. Answer based on the provided context.
                If the answer is not in the context, say you don't know.

                Context:
                {context}
                """).param("context", context))
            .user(question)
            .call()
            .content();
    }
}

4.4 Function Calling (Tool Calling)

@Configuration
public class AiFunctionConfig {

    @Bean
    @Description("Get current weather for a given city")
    public Function<WeatherRequest, WeatherResponse> currentWeather() {
        return request -> {
            // Call actual weather API
            return new WeatherResponse(request.city(), 25.0, "Sunny");
        };
    }

    @Bean
    @Description("Search products in the catalog")
    public Function<ProductSearchRequest, List<Product>> searchProducts(
            ProductRepository productRepository) {
        return request -> productRepository
            .findByNameContaining(request.query());
    }
}

record WeatherRequest(String city) {}
record WeatherResponse(String city, double temperature, String condition) {}
record ProductSearchRequest(String query) {}

// Usage: LLM automatically calls registered functions
@GetMapping("/ai/assistant")
public String assistant(@RequestParam String question) {
    return chatClientBuilder.build()
        .prompt()
        .user(question) // "What's the weather in Seoul?"
        .functions("currentWeather", "searchProducts")
        .call()
        .content();
}

5. Spring Security 6

5.1 SecurityFilterChain Configuration

Spring Security 6 uses Lambda DSL exclusively and removes WebSecurityConfigurerAdapter.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(csrf -> csrf
                .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
            )
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll()
                .requestMatchers(HttpMethod.POST, "/api/posts/**").authenticated()
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt.jwtDecoder(jwtDecoder()))
            )
            .exceptionHandling(ex -> ex
                .authenticationEntryPoint(
                    new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
            )
            .build();
    }

    @Bean
    public JwtDecoder jwtDecoder() {
        return NimbusJwtDecoder
            .withJwkSetUri("https://auth.example.com/.well-known/jwks.json")
            .build();
    }
}

5.2 Method Security

@Configuration
@EnableMethodSecurity // replaces @EnableGlobalMethodSecurity
public class MethodSecurityConfig {}

@Service
public class PostService {

    @PreAuthorize("hasRole('ADMIN') or #authorId == authentication.principal.id")
    public void deletePost(Long postId, Long authorId) {
        // Only admin or the author can delete
    }

    @PostAuthorize("returnObject.author.id == authentication.principal.id")
    public Post getMyPost(Long postId) {
        return postRepository.findById(postId).orElseThrow();
    }
}

5.3 Security Headers for Production

@Bean
@Profile("prod")
public SecurityFilterChain productionSecurity(HttpSecurity http) throws Exception {
    return http
        .headers(headers -> headers
            .contentSecurityPolicy(csp -> csp
                .policyDirectives("default-src 'self'; script-src 'self'"))
            .frameOptions(frame -> frame.deny())
            .httpStrictTransportSecurity(hsts -> hsts
                .includeSubDomains(true)
                .maxAgeInSeconds(31536000))
            .permissionsPolicy(pp -> pp
                .policy("camera=(), microphone=(), geolocation=()"))
        )
        .build();
}

6. Data Access: JPA, R2DBC, QueryDSL

6.1 Spring Data JPA with Hibernate 6

@Entity
@Table(name = "posts")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Post {

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

    @Column(nullable = false, length = 200)
    private String title;

    @Column(columnDefinition = "TEXT")
    private String content;

    @Enumerated(EnumType.STRING)
    private PostStatus status;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "author_id")
    private User author;

    @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Comment> comments = new ArrayList<>();

    @CreatedDate
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime updatedAt;

    // Business method
    public void publish() {
        if (this.status != PostStatus.DRAFT) {
            throw new IllegalStateException("Only draft posts can be published");
        }
        this.status = PostStatus.PUBLISHED;
    }
}

6.2 Solving the N+1 Problem

public interface PostRepository extends JpaRepository<Post, Long> {

    // Solution 1: EntityGraph
    @EntityGraph(attributePaths = {"author", "comments"})
    List<Post> findByStatus(PostStatus status);

    // Solution 2: Fetch Join
    @Query("SELECT DISTINCT p FROM Post p JOIN FETCH p.author WHERE p.status = :status")
    List<Post> findByStatusWithAuthor(@Param("status") PostStatus status);

    // Solution 3: Batch Size (global in application.yml)
    // spring.jpa.properties.hibernate.default_batch_fetch_size: 100
}

6.3 QueryDSL Integration

@Repository
@RequiredArgsConstructor
public class PostQueryRepository {

    private final JPAQueryFactory queryFactory;

    public Page<PostDto> searchPosts(PostSearchCondition condition, Pageable pageable) {
        QPost post = QPost.post;
        QUser user = QUser.user;

        List<PostDto> content = queryFactory
            .select(Projections.constructor(PostDto.class,
                post.id, post.title, post.status,
                user.name, post.createdAt))
            .from(post)
            .join(post.author, user)
            .where(
                titleContains(condition.title()),
                statusEquals(condition.status()),
                createdAfter(condition.fromDate())
            )
            .orderBy(post.createdAt.desc())
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();

        Long total = queryFactory
            .select(post.count())
            .from(post)
            .where(
                titleContains(condition.title()),
                statusEquals(condition.status()),
                createdAfter(condition.fromDate())
            )
            .fetchOne();

        return new PageImpl<>(content, pageable, total != null ? total : 0);
    }

    private BooleanExpression titleContains(String title) {
        return title != null ? QPost.post.title.contains(title) : null;
    }

    private BooleanExpression statusEquals(PostStatus status) {
        return status != null ? QPost.post.status.eq(status) : null;
    }

    private BooleanExpression createdAfter(LocalDateTime from) {
        return from != null ? QPost.post.createdAt.after(from) : null;
    }
}

6.4 R2DBC (Reactive Data Access)

public interface PostR2dbcRepository extends ReactiveCrudRepository<Post, Long> {

    Flux<Post> findByStatus(PostStatus status);

    @Query("SELECT * FROM posts WHERE title LIKE :keyword ORDER BY created_at DESC LIMIT :limit")
    Flux<Post> searchByTitle(String keyword, int limit);
}

7. Testcontainers Integration

7.1 ServiceConnection Auto-Configuration

@SpringBootTest
@Testcontainers
class PostServiceIntegrationTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:16-alpine");

    @Container
    @ServiceConnection
    static GenericContainer<?> redis =
        new GenericContainer<>("redis:7-alpine")
            .withExposedPorts(6379);

    @Autowired
    private PostService postService;

    @Test
    void shouldCreateAndRetrievePost() {
        CreatePostRequest request = new CreatePostRequest("Title", "Content");
        Post created = postService.createPost(request);

        Post found = postService.getPost(created.getId());
        assertThat(found.getTitle()).isEqualTo("Title");
        assertThat(found.getStatus()).isEqualTo(PostStatus.DRAFT);
    }
}

7.2 Reusable Test Containers

@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfig {

    @Bean
    @ServiceConnection
    PostgreSQLContainer<?> postgresContainer() {
        return new PostgreSQLContainer<>("postgres:16-alpine")
            .withReuse(true);
    }

    @Bean
    @ServiceConnection(name = "redis")
    GenericContainer<?> redisContainer() {
        return new GenericContainer<>("redis:7-alpine")
            .withExposedPorts(6379)
            .withReuse(true);
    }
}

// Run as development server with test containers
public class TestMyApplication {
    public static void main(String[] args) {
        SpringApplication.from(MyApplication::main)
            .with(TestcontainersConfig.class)
            .run(args);
    }
}

7.3 Kafka Testcontainer

@SpringBootTest
@Testcontainers
class KafkaIntegrationTest {

    @Container
    @ServiceConnection
    static KafkaContainer kafka = new KafkaContainer(
        DockerImageName.parse("confluentinc/cp-kafka:7.6.0")
    );

    @Autowired
    private KafkaTemplate<String, OrderEvent> kafkaTemplate;

    @Test
    void shouldProcessOrderEvent() throws Exception {
        OrderEvent event = new OrderEvent("order-123", "CREATED", BigDecimal.valueOf(99.99));

        kafkaTemplate.send("orders", event).get();

        await().atMost(Duration.ofSeconds(10))
            .untilAsserted(() ->
                assertThat(consumer.getProcessedOrders())
                    .hasSize(1)
                    .first()
                    .extracting(OrderEvent::orderId)
                    .isEqualTo("order-123")
            );
    }
}

8. Observability: Micrometer + OpenTelemetry

8.1 OTLP Configuration

management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus
  metrics:
    distribution:
      percentiles-histogram:
        http.server.requests: true
    tags:
      application: my-spring-app
  tracing:
    sampling:
      probability: 1.0
  otlp:
    metrics:
      export:
        url: http://otel-collector:4318/v1/metrics
    tracing:
      endpoint: http://otel-collector:4318/v1/traces
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
    implementation 'io.micrometer:micrometer-tracing-bridge-otel'
    implementation 'io.opentelemetry:opentelemetry-exporter-otlp'
    runtimeOnly 'io.micrometer:micrometer-registry-prometheus'
}

8.2 Custom Metrics

@Service
public class OrderService {

    private final Counter orderCounter;
    private final Timer orderProcessingTimer;
    private final MeterRegistry meterRegistry;

    public OrderService(MeterRegistry meterRegistry) {
        this.meterRegistry = meterRegistry;
        this.orderCounter = Counter.builder("orders.created")
            .description("Total orders created")
            .register(meterRegistry);
        this.orderProcessingTimer = Timer.builder("orders.processing.time")
            .description("Order processing duration")
            .register(meterRegistry);
    }

    public Order createOrder(CreateOrderRequest request) {
        return orderProcessingTimer.record(() -> {
            Order order = processOrder(request);
            orderCounter.increment();
            meterRegistry.gauge("orders.pending",
                orderRepository.countByStatus(OrderStatus.PENDING));
            return order;
        });
    }
}

8.3 Distributed Tracing

@RestController
@RequiredArgsConstructor
public class OrderController {

    private final OrderService orderService;
    private final ObservationRegistry observationRegistry;

    @PostMapping("/api/orders")
    public ResponseEntity<Order> createOrder(@RequestBody CreateOrderRequest request) {
        return Observation.createNotStarted("order.creation", observationRegistry)
            .lowCardinalityKeyValue("order.type", request.type())
            .observe(() -> {
                Order order = orderService.createOrder(request);
                return ResponseEntity.created(
                    URI.create("/api/orders/" + order.getId())
                ).body(order);
            });
    }
}

8.4 Grafana Stack (Docker Compose)

# docker-compose-observability.yml
services:
  otel-collector:
    image: otel/opentelemetry-collector-contrib:latest
    ports:
      - "4317:4317"
      - "4318:4318"
    volumes:
      - ./otel-config.yaml:/etc/otelcol-contrib/config.yaml

  prometheus:
    image: prom/prometheus:latest
    ports:
      - "9090:9090"
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml

  grafana:
    image: grafana/grafana:latest
    ports:
      - "3000:3000"
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=admin

  tempo:
    image: grafana/tempo:latest
    ports:
      - "3200:3200"
    command: [ "-config.file=/etc/tempo.yaml" ]

9. Docker and Kubernetes Deployment

9.1 Optimized Layered Dockerfile

# Use layered JAR for optimal Docker caching
FROM eclipse-temurin:21-jre-alpine AS builder
WORKDIR /app
COPY build/libs/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract

FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
RUN addgroup -S spring && adduser -S spring -G spring
USER spring:spring

COPY --from=builder /app/dependencies/ ./
COPY --from=builder /app/spring-boot-loader/ ./
COPY --from=builder /app/snapshot-dependencies/ ./
COPY --from=builder /app/application/ ./

EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

9.2 Cloud Native Buildpacks

# No Dockerfile needed!
./gradlew bootBuildImage --imageName=myregistry/my-app:latest

# For native image
./gradlew bootBuildImage \
  --imageName=myregistry/my-app:native \
  --builder=paketobuildpacks/builder-jammy-tiny

9.3 Kubernetes Manifests

apiVersion: apps/v1
kind: Deployment
metadata:
  name: spring-app
spec:
  replicas: 3
  selector:
    matchLabels:
      app: spring-app
  template:
    metadata:
      labels:
        app: spring-app
      annotations:
        prometheus.io/scrape: "true"
        prometheus.io/port: "8080"
        prometheus.io/path: "/actuator/prometheus"
    spec:
      containers:
        - name: spring-app
          image: myregistry/spring-app:latest
          ports:
            - containerPort: 8080
          env:
            - name: SPRING_PROFILES_ACTIVE
              value: "prod"
            - name: JAVA_OPTS
              value: "-XX:MaxRAMPercentage=75.0 -XX:+UseZGC"
          resources:
            requests:
              memory: "256Mi"
              cpu: "250m"
            limits:
              memory: "512Mi"
              cpu: "1000m"
          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: 8080
            initialDelaySeconds: 10
            periodSeconds: 5
          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: 8080
            initialDelaySeconds: 30
            periodSeconds: 10
          startupProbe:
            httpGet:
              path: /actuator/health
              port: 8080
            failureThreshold: 30
            periodSeconds: 2
---
apiVersion: v1
kind: Service
metadata:
  name: spring-app-service
spec:
  selector:
    app: spring-app
  ports:
    - port: 80
      targetPort: 8080
  type: ClusterIP
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: spring-app-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: spring-app
  minReplicas: 2
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70

9.4 Graceful Shutdown

# application-prod.yml
server:
  shutdown: graceful

spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s
@Component
@Slf4j
public class GracefulShutdownHandler {

    @PreDestroy
    public void onShutdown() {
        log.info("Shutting down gracefully...");
        // Finish in-flight requests
        // Close connection pools
        // Flush metrics
    }
}

10. Production Checklist

Security

  • CSRF protection for browser-based APIs
  • CORS restricted to allowed origins
  • Security headers (CSP, HSTS, X-Frame-Options)
  • Secrets externalized via environment variables or vault
  • Actuator endpoints secured and restricted

Performance

  • Virtual Threads enabled for I/O-heavy workloads
  • HikariCP pool size tuned (CPU cores x 2 + disk spindles)
  • JPA open-in-view set to false
  • Batch fetch size configured (100)
  • Connection timeouts configured (3s)

Reliability

  • Graceful shutdown enabled
  • Health probes configured (readiness + liveness)
  • Circuit breaker with Resilience4j
  • Retry with exponential backoff
  • DB migrations managed with Flyway

Operations

  • Structured JSON logging
  • Distributed tracing with OTLP
  • Prometheus metrics exposed
  • Alerting rules configured
  • Regular dependency updates with Renovate/Dependabot

Production application.yml

spring:
  datasource:
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5
      connection-timeout: 3000
      idle-timeout: 600000
      max-lifetime: 1800000
  jpa:
    open-in-view: false
    hibernate:
      ddl-auto: none
    properties:
      hibernate:
        default_batch_fetch_size: 100
        jdbc.batch_size: 50
        order_inserts: true
        order_updates: true
  threads:
    virtual:
      enabled: true

server:
  shutdown: graceful
  tomcat:
    max-connections: 10000
    accept-count: 200
    connection-timeout: 5s

logging:
  level:
    root: WARN
    com.myapp: INFO
  pattern:
    console: "%d [%thread] %-5level %logger - traceId=%X{traceId} - %msg%n"

11. Interview Questions (15)

Fundamentals (1-5)

Q1. What are the major breaking changes in Spring Boot 3.x?

Jakarta EE migration (javax to jakarta), Java 17 minimum requirement, removal of WebSecurityConfigurerAdapter, Spring Security 6 Lambda DSL, and requestMatchers replacing antMatchers.

Q2. How do Virtual Threads differ from Reactive Programming?

Virtual Threads maintain a synchronous programming model while achieving high concurrency through JVM-managed lightweight threads. Reactive uses an asynchronous non-blocking model with operators. Virtual Threads excel at I/O-bound work; Reactive excels at streaming scenarios with backpressure.

Q3. What are the limitations of GraalVM Native Image?

Reflection, dynamic proxies, and JNI require explicit configuration via RuntimeHints. Build times are long (5-10 min). Peak throughput may be lower than JVM due to lack of JIT optimization. Some libraries may not be compatible.

Q4. Why was WebSecurityConfigurerAdapter removed?

To enable component-based security configuration using SecurityFilterChain beans, which is more flexible, testable, and allows multiple security filter chains for different URL patterns.

Q5. How does ServiceConnection work in Testcontainers?

It automatically detects the container type and maps its dynamic port and host information to Spring Boot auto-configuration properties, eliminating the need for manual DynamicPropertySource configuration.

Intermediate (6-10)

Q6. What is thread pinning in Virtual Threads?

When a Virtual Thread enters a synchronized block or performs a native call, it becomes pinned to its carrier platform thread, preventing other Virtual Threads from using that carrier. Use ReentrantLock instead of synchronized.

Q7. Explain the RAG pattern in Spring AI.

Documents are embedded into vectors and stored in a VectorStore. On query, similar documents are retrieved via similarity search, then passed as context to the LLM prompt along with the user question, grounding the response in factual data.

Q8. Three ways to solve the N+1 problem in JPA?

EntityGraph (declarative), Fetch Join (JPQL control), and Batch Size (global setting that groups lazy loading into IN clauses). Each has trade-offs regarding flexibility and query complexity.

Q9. What is the relationship between Micrometer and OpenTelemetry?

Micrometer is a metrics abstraction layer. OpenTelemetry is an observability standard (traces, metrics, logs). Spring Boot 3.x bridges them via Micrometer Tracing, exporting to OTLP-compatible backends.

Q10. Why set open-in-view to false?

When true (default), the persistence context stays open through the view layer, causing lazy-loaded queries during rendering and prolonged database connection usage. For API servers, this should always be false.

Advanced (11-15)

Q11. How does AOT processing work in Spring Boot?

At build time, Spring analyzes bean definitions, generates proxy classes, collects reflection metadata, and creates optimized bean factory code. This is a prerequisite for GraalVM Native Image and also speeds up JVM mode startup.

Q12. What is the difference between readiness and liveness probes?

Readiness probe (actuator/health/readiness) checks if the app can receive traffic. Failure removes it from the Service. Liveness probe (actuator/health/liveness) checks if the app is alive. Failure triggers a pod restart.

Q13. How to optimize HikariCP connection pool?

Set maximum-pool-size to CPU cores x 2 + disk spindles. Keep connection-timeout under 3 seconds. Set idle-timeout to 10 minutes and max-lifetime shorter than the database wait_timeout.

Q14. Explain Graceful Shutdown in Kubernetes context.

On SIGTERM, Spring stops accepting new requests while completing in-flight ones within the configured timeout. Combined with preStop hooks and readiness probe failure, this ensures zero-downtime rolling updates.

Q15. When to choose Virtual Threads over WebFlux?

Choose Virtual Threads for: existing synchronous codebases, simpler debugging needs, I/O-bound workloads. Choose WebFlux for: streaming use cases, backpressure requirements, existing reactive infrastructure, or when using non-blocking drivers exclusively.


12. Quiz

Q1. What is the one-line config to enable Virtual Threads in Spring Boot?

spring.threads.virtual.enabled=true in application.yml. This replaces the Tomcat thread pool with Virtual Threads for all request handling. Requires Spring Boot 3.2+ and Java 21+.

Q2. Why does reflection fail in GraalVM Native Image by default?

Native Image performs AOT compilation, which means class metadata is not available at runtime for dynamic reflection. You must register reflection targets via RuntimeHintsRegistrar or reflect-config.json at build time.

Q3. What replaced antMatchers() in Spring Security 6?

requestMatchers() replaced antMatchers(). This unified API works across both Servlet and Reactive environments, removing the tight coupling to the Servlet API that antMatchers had.

Q4. What does Testcontainers withReuse(true) do?

It reuses Docker containers between test runs instead of creating new ones each time, dramatically speeding up test execution. Requires testcontainers.reuse.enable=true in HOME/.testcontainers.properties.

Q5. How does Spring AI Function Calling work?

The LLM analyzes the user question and selects appropriate registered functions, extracting parameters. Spring AI executes the corresponding Java method and feeds the result back to the LLM, which generates the final response incorporating the function output.


References

  1. Spring Boot 3.3 Release Notes
  2. Spring Framework 6 Documentation
  3. JEP 444: Virtual Threads
  4. GraalVM Native Image Guide
  5. Spring AI Reference
  6. Spring Security 6 Migration Guide
  7. Testcontainers Spring Boot
  8. Micrometer Documentation
  9. OpenTelemetry Java SDK
  10. Spring Data JPA Reference
  11. HikariCP Configuration
  12. Spring Boot Docker Guide
  13. Kubernetes Spring Boot Best Practices
  14. OpenRewrite Spring Boot 3 Migration
  15. Baeldung Spring Boot 3 Tutorials
  16. Spring Boot Actuator Guide