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
- Java 21 = LTS (support jusqu’en 2031)
- Virtual Threads promettaient des gains de performance massifs
- Spring Boot 3.2 avec AOT et GraalVM natif
- Pattern matching et records matures
Risques
- Écosystème de dépendances potentiellement cassé
- APIs Spring changées (Jakarta EE vs javax)
- Documentation encore en cours de mise à jour
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 :
- Springdoc OpenAPI : 1.7.0 → 2.3.0
- Lombok : 1.18.24 → 1.18.30
Étape 4 : Activer Virtual Threads
spring:
threads:
virtual:
enabled: true
Étape 5 : Tests
mvn clean test
Erreurs rencontrées :
@WebMvcTestnécessitejakarta.servletdans classpath- Mockito updated :
1.5.x→5.x
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
- Démarrage : 8.2s
- Mémoire : 480 MB (heap + non-heap)
- Throughput : 1,200 req/s (endpoint simple)
- Latency p95 : 425ms
Java 21 + Spring Boot 3.2 + Virtual Threads
- Démarrage : 6.8s (-17%)
- Mémoire : 420 MB (-12%)
- Throughput : 3,800 req/s (+217%)
- Latency p95 : 145ms (-66%)
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
- Temps de démarrage : 0.08s (vs 6.8s en JVM)
- Mémoire : 70 MB (vs 420 MB)
- Taille binaire : 82 MB
Limitations
- Build time long : 4min30
- Pas de reflection dynamique
- Incompatible avec certaines libs (Hibernate parfois capricieux)
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 :
- Nouveau projet (comme moi)
- App I/O-intensive (DB, APIs externes)
- Besoin de scalabilité (virtual threads = game changer)
- Support long terme requis (Java 21 LTS)
❌ Attendez si :
- App legacy avec dépendances anciennes
- Équipe pas formée Java moderne
- Deadline serrée (prévoir 2-3 semaines migration)
- CPU-bound app (virtual threads n’aident pas)
Conclusion
Gains concrets de ma migration :
- +217% de throughput grâce aux Virtual Threads
- -66% de latency sur endpoints DB-intensifs
- Code plus propre (records, pattern matching)
- Observability native (Micrometer)
Coûts :
- ~2 semaines de migration (mais nouveau projet)
- Formation équipe Java 21 features
- Quelques libs incompatibles à remplacer
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 !