← Retour au blog

Migration vers Java 21 et Spring Boot 3.2 : ce que j'ai vraiment gagné

Retour d'expérience sur la migration d'un backend de paiements vers Java 21 et Spring Boot 3.2. Virtual Threads, performance, et pièges à éviter.

Pourquoi j’ai migré (et pourquoi maintenant)

En décembre 2025, j’ai démarré un nouveau projet : standup.myticketing.ch, un backend de paiement pour une plateforme de billetterie. Au lieu de rester sur Java 17 et Spring Boot 2.7 (ma stack habituelle), j’ai décidé de partir directement sur Java 21 et Spring Boot 3.2.

Motivations

Risques

Spoiler : ça en valait largement la peine.

Java 21 : ce qui change vraiment

Virtual Threads (Project Loom)

Le problème classique

Avec des threads plateforme classiques, chaque requête HTTP = 1 thread OS :

// Avant : Thread pool limité
@Configuration
public class TomcatConfig {
    @Bean
    public TomcatServletWebServerFactory tomcatFactory() {
        TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory();
        factory.addConnectorCustomizers(connector -> {
            connector.setProperty("maxThreads", "200");  // Limite stricte
        });
        return factory;
    }
}

Problème : avec 200 threads max, la 201ème requête attend qu’un thread se libère.

Avec Virtual Threads (Java 21)

// Application.java
public static void main(String[] args) {
    System.setProperty("spring.threads.virtual.enabled", "true");
    SpringApplication.run(Application.class, args);
}
# application.yml
spring:
  threads:
    virtual:
      enabled: true

Résultat : des millions de threads virtuels possibles. Chaque requête a son propre thread virtuel ultra-léger (quelques Ko vs 1-2 Mo pour un thread plateforme).

Test de charge : avant/après

Scénario : endpoint /api/stats qui fait 3 appels base de données séquentiels (simule une charge I/O).

@RestController
@RequestMapping("/api")
public class StatsController {
    
    @GetMapping("/stats")
    public Map<String, Object> getStats() throws InterruptedException {
        // Simule 3 requêtes DB (100ms chacune)
        var users = fetchUsers();          // 100ms
        var orders = fetchOrders();        // 100ms
        var revenue = calculateRevenue();  // 100ms
        
        return Map.of(
            "users", users,
            "orders", orders,
            "revenue", revenue
        );
    }
    
    private List<User> fetchUsers() throws InterruptedException {
        Thread.sleep(100);  // Simule latence DB
        return userRepository.findAll();
    }
}

Résultats avec Apache Bench

# Java 17 + Spring Boot 2.7 (threads plateforme)
ab -n 10000 -c 500 http://localhost:8080/api/stats

Requests per second:    1,247 [#/sec]
Time per request:       401 ms [mean]
Failed requests:        2847 (timeouts!)
# Java 21 + Spring Boot 3.2 (virtual threads)
ab -n 10000 -c 500 http://localhost:8080/api/stats

Requests per second:    3,892 [#/sec]  (+212%)
Time per request:       128 ms [mean]  (-68%)
Failed requests:        0

Gain réel : +212% de throughput sans changer une seule ligne de code métier.

Pattern Matching pour switch

Avant (Java 17)

public String processPaymentEvent(Object event) {
    if (event instanceof PaymentSucceeded) {
        PaymentSucceeded p = (PaymentSucceeded) event;
        return "Payment of " + p.amount() + " succeeded";
    } else if (event instanceof PaymentFailed) {
        PaymentFailed p = (PaymentFailed) event;
        return "Payment failed: " + p.reason();
    } else if (event instanceof PaymentPending) {
        return "Payment pending";
    } else {
        return "Unknown event";
    }
}

Après (Java 21)

public String processPaymentEvent(Object event) {
    return switch (event) {
        case PaymentSucceeded p -> 
            "Payment of " + p.amount() + " succeeded";
        case PaymentFailed p -> 
            "Payment failed: " + p.reason();
        case PaymentPending p -> 
            "Payment pending";
        case null, default -> 
            "Unknown event";
    };
}

Plus concis, plus sûr (exhaustivité vérifiée à la compilation).

Records : DTOs sans boilerplate

Avant (Java 17 + Lombok)

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PaymentIntentRequest {
    private UUID eventId;
    private Double amount;
    private String customerEmail;
}

Après (Java 21 records)

public record PaymentIntentRequest(
    UUID eventId,
    Double amount,
    String customerEmail
) {}

Bonus : immutabilité par défaut, equals()/hashCode()/toString() générés.

Limitation : pas d’annotations JPA (records immuables). Pour les entités, j’utilise toujours @Entity + Lombok.

Sequenced Collections

Avant

List<Order> orders = orderRepository.findAll();
Order firstOrder = orders.get(0);
Order lastOrder = orders.get(orders.size() - 1);

Après

List<Order> orders = orderRepository.findAll();
Order firstOrder = orders.getFirst();  // Plus lisible
Order lastOrder = orders.getLast();

Petit détail, mais améliore la lisibilité.

Spring Boot 3.2 : les changements structurels

Jakarta EE vs javax

Le gros breaking change : tous les packages javax.* deviennent jakarta.*.

// ❌ Java 17 + Spring Boot 2.7
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.validation.constraints.NotNull;

// ✅ Java 21 + Spring Boot 3.2
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.validation.constraints.NotNull;

Migration : search & replace dans tout le codebase. IntelliJ a un outil dédié : Refactor → Migrate Packages and Classes → Java EE to Jakarta EE

Observability native avec Micrometer

Spring Boot 3.2 inclut Micrometer Tracing + OpenTelemetry par défaut.

<!-- pom.xml -->
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-tracing-bridge-brave</artifactId>
</dependency>
<dependency>
    <groupId>io.zipkin.reporter2</groupId>
    <artifactId>zipkin-reporter-brave</artifactId>
</dependency>
# application.yml
management:
  tracing:
    sampling:
      probability: 1.0  # 100% des traces en dev
  zipkin:
    tracing:
      endpoint: http://localhost:9411/api/v2/spans

Résultat : toutes mes requêtes sont tracées automatiquement avec un ID unique.

// Logs automatiquement enrichis
2026-03-19 10:23:45 INFO [standup,a8f3c4d2,b5e9f1a3] 
    o.s.web.servlet.DispatcherServlet : 
    Completed 200 OK in 142ms

Format : [app-name,trace-id,span-id]

ProblemDetail : gestion d’erreur standardisée

Avant

@ExceptionHandler(NotFoundException.class)
public ResponseEntity<Map<String, String>> handleNotFound(NotFoundException e) {
    return ResponseEntity.status(404).body(Map.of(
        "error", "Not Found",
        "message", e.getMessage()
    ));
}

Après (RFC 7807 ProblemDetail)

@ExceptionHandler(NotFoundException.class)
public ProblemDetail handleNotFound(NotFoundException e) {
    ProblemDetail problem = ProblemDetail.forStatusAndDetail(
        HttpStatus.NOT_FOUND, 
        e.getMessage()
    );
    problem.setTitle("Resource Not Found");
    problem.setProperty("timestamp", Instant.now());
    return problem;
}

Réponse JSON standardisée

{
  "type": "about:blank",
  "title": "Resource Not Found",
  "status": 404,
  "detail": "Event with ID 123 not found",
  "timestamp": "2026-03-19T10:23:45.123Z"
}

RestClient : remplacement de RestTemplate

Avant (RestTemplate)

@Service
public class StripeService {
    private final RestTemplate restTemplate;
    
    public Account getAccount(String accountId) {
        String url = "https://api.stripe.com/v1/accounts/" + accountId;
        HttpHeaders headers = new HttpHeaders();
        headers.setBearerAuth(stripeSecretKey);
        HttpEntity<String> entity = new HttpEntity<>(headers);
        
        ResponseEntity<Account> response = restTemplate.exchange(
            url, HttpMethod.GET, entity, Account.class
        );
        
        return response.getBody();
    }
}

Après (RestClient)

@Service
public class StripeService {
    private final RestClient restClient;
    
    public StripeService(RestClient.Builder builder) {
        this.restClient = builder
            .baseUrl("https://api.stripe.com/v1")
            .defaultHeader("Authorization", "Bearer " + stripeSecretKey)
            .build();
    }
    
    public Account getAccount(String accountId) {
        return restClient
            .get()
            .uri("/accounts/{id}", accountId)
            .retrieve()
            .body(Account.class);
    }
}

Plus fluide, plus moderne, moins verbeux.

Migration : étapes concrètes

Étape 1 : Maven dependencies

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.2.3</version>  <!-- Avant : 2.7.x -->
</parent>

<properties>
    <java.version>21</java.version>  <!-- Avant : 17 -->
</properties>

Étape 2 : javax → jakarta

# Search & replace global (avec sed sur Linux/Mac)
find src -name "*.java" -type f -exec sed -i '' 's/javax.persistence/jakarta.persistence/g' {} +
find src -name "*.java" -type f -exec sed -i '' 's/javax.validation/jakarta.validation/g' {} +
find src -name "*.java" -type f -exec sed -i '' 's/javax.servlet/jakarta.servlet/g' {} +

Ou via IntelliJ : Refactor → Migrate Packages

Étape 3 : Vérifier les dépendances

Certaines libs ne sont pas compatibles Spring Boot 3.

mvn dependency:tree

J’ai dû upgrader :

Étape 4 : Activer Virtual Threads

spring:
  threads:
    virtual:
      enabled: true

Étape 5 : Tests

mvn clean test

Erreurs rencontrées :

Les pièges que j’ai rencontrés

Piège #1 : Hibernate 6 plus strict

Erreur

org.hibernate.query.SemanticException: 
    Query specified join fetching, but the owner was not present in the select list

Code problématique

@Query("SELECT o FROM Order o JOIN FETCH o.event")
List<Order> findAllWithEvent();

Fix

@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.event")
List<Order> findAllWithEvent();

Hibernate 6 force l’explicitation du DISTINCT pour éviter les duplicates.

Piège #2 : Spring Security 6 changes

Si vous utilisez Spring Security, l’API a changé :

// ❌ Avant (Spring Security 5)
http
    .authorizeRequests()
    .antMatchers("/api/admin/**").hasRole("ADMIN")
    .anyRequest().authenticated();

// ✅ Après (Spring Security 6)
http
    .authorizeHttpRequests(auth -> auth
        .requestMatchers("/api/admin/**").hasRole("ADMIN")
        .anyRequest().authenticated()
    );

Piège #3 : PostgreSQL et Hibernate types

// Erreur avec UUID
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;  // ❌ Ne compile pas en Java 17

Fix : Java 21 supporte GenerationType.UUID nativement.

En Java 17, il fallait :

@Id
@GeneratedValue(generator = "UUID")
@GenericGenerator(name = "UUID", strategy = "org.hibernate.id.UUIDGenerator")
private UUID id;

Piège #4 : Docker image Java 21

# ❌ Avant
FROM openjdk:17-alpine

# ✅ Après
FROM eclipse-temurin:21-jre-alpine

OpenJDK 17 n’inclut pas Java 21. Utiliser Eclipse Temurin (ex-AdoptOpenJDK).

Performance réelle en production

Métriques avant/après

Configuration matériel : AWS EC2 t3.medium (2 vCPU, 4GB RAM)

Java 17 + Spring Boot 2.7

Java 21 + Spring Boot 3.2 + Virtual Threads

Gain global : je peux diviser ma flotte EC2 par 3 pour le même trafic.

Load test avec k6

// load-test.js
import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  stages: [
    { duration: '1m', target: 100 },   // Ramp up
    { duration: '3m', target: 1000 },  // Peak
    { duration: '1m', target: 0 },     // Ramp down
  ],
};

export default function () {
  const res = http.get('http://localhost:8080/api/orders');
  check(res, { 'status is 200': (r) => r.status === 200 });
  sleep(1);
}
k6 run load-test.js

Résultat Java 21 : 1000 utilisateurs concurrents sans échec.

GraalVM Native Image (bonus)

Spring Boot 3.2 supporte GraalVM nativement pour compiler en binaire natif.

<plugin>
    <groupId>org.graalvm.buildtools</groupId>
    <artifactId>native-maven-plugin</artifactId>
</plugin>
mvn -Pnative native:compile

Résultats

Limitations

Je n’utilise pas GraalVM en prod pour ce projet (complexité vs gain marginal), mais c’est parfait pour des lambdas AWS ou Kubernetes avec autoscaling rapide.

Checklist de migration

Préparation
[ ] Upgrade Maven/Gradle vers latest
[ ] Vérifier compatibilité des dépendances
[ ] Lire les release notes Spring Boot 3.0-3.2

Migration
[ ] Changer parent POM : 2.7.x → 3.2.x
[ ] Changer java.version : 17 → 21
[ ] javax.* → jakarta.* (refactor global)
[ ] Mettre à jour Spring Security config si utilisé
[ ] Activer virtual threads dans application.yml

Tests
[ ] mvn clean test
[ ] Tests d'intégration (Testcontainers)
[ ] Load tests (k6, JMeter, ou Gatling)

Production
[ ] Dockerfile avec eclipse-temurin:21
[ ] Monitoring (métriques, logs, traces)
[ ] Rollback plan si problème

Quand migrer (ou pas)

✅ Migrez si :

❌ Attendez si :

Conclusion

Gains concrets de ma migration :

Coûts :

Verdict : Java 21 + Spring Boot 3.2 est production-ready et offre des gains mesurables. Je ne reviendrai pas en arrière.

Le code complet est sur mon GitHub @fabiomeyer.

Questions sur la migration Java 21 ? Contactez-moi sur Twitter !