Architecture microservices Java : patterns essentiels et pièges à éviter
Circuit breaker, API Gateway, service discovery, communication synchrone vs asynchrone — les patterns fondamentaux d'une architecture microservices Java robuste en production.
Les microservices ne sont pas une solution universelle. Mais quand le contexte le justifie — équipes multiples, scaling indépendant, déploiements fréquents — certains patterns sont incontournables pour que l’architecture tienne en production. Voici les essentiels.
Quand ne pas faire de microservices
Avant les patterns, la question de fond : les microservices ajoutent de la complexité opérationnelle. Plusieurs services à déployer, monitorer, faire communiquer. Un monolithe bien structuré livre souvent plus vite et coûte moins cher à maintenir pour une équipe de moins de 10 développeurs.
Passez aux microservices quand : les équipes doivent déployer indépendamment, certains composants ont des exigences de scaling radicalement différentes, ou la base de code est trop grande pour qu’une équipe la maîtrise entièrement.
Pattern 1 : API Gateway
Un seul point d’entrée externe vers vos services. L’API Gateway gère l’authentification, le routage, le rate limiting et le CORS.
// Spring Cloud Gateway
@Bean
public RouteLocator routes(RouteLocatorBuilder builder) {
return builder.routes()
.route("service-commandes", r -> r
.path("/api/commandes/**")
.filters(f -> f
.stripPrefix(1)
.addRequestHeader("X-Service-Origin", "gateway")
.circuitBreaker(config -> config
.setName("commandesCB")
.setFallbackUri("forward:/fallback/commandes")))
.uri("lb://SERVICE-COMMANDES"))
.route("service-produits", r -> r
.path("/api/produits/**")
.filters(f -> f.stripPrefix(1))
.uri("lb://SERVICE-PRODUITS"))
.build();
}
lb://SERVICE-COMMANDES utilise Ribbon/LoadBalancer pour répartir la charge entre les instances enregistrées dans Eureka.
Pattern 2 : Circuit Breaker avec Resilience4j
Sans circuit breaker, si un service aval répond lentement, vos threads d’attente s’accumulent jusqu’à saturation de la pool — et tout tombe. Le circuit breaker détecte les défaillances et coupe les appels pendant un délai de récupération.
@Service
public class CommandeService {
private final ProduitClient produitClient;
@CircuitBreaker(name = "produitService", fallbackMethod = "getProduitFallback")
@Retry(name = "produitService")
@TimeLimiter(name = "produitService")
public CompletableFuture<ProduitDto> getProduit(Long id) {
return CompletableFuture.supplyAsync(() -> produitClient.findById(id));
}
private CompletableFuture<ProduitDto> getProduitFallback(Long id, Exception ex) {
log.warn("Fallback produit {} : {}", id, ex.getMessage());
return CompletableFuture.completedFuture(ProduitDto.inconnu(id));
}
}
Configuration Resilience4j :
resilience4j:
circuitbreaker:
instances:
produitService:
slidingWindowSize: 10
failureRateThreshold: 50
waitDurationInOpenState: 30s
permittedNumberOfCallsInHalfOpenState: 3
retry:
instances:
produitService:
maxAttempts: 3
waitDuration: 500ms
retryExceptions:
- java.net.ConnectException
- java.net.SocketTimeoutException
Pattern 3 : Communication asynchrone avec Kafka
La communication synchrone (REST) crée un couplage fort : si le service B est lent, le service A attend. La communication asynchrone via un broker de messages (Kafka, RabbitMQ) découple les services.
// Producteur : service-commandes publie un événement
@Service
public class CommandeEventPublisher {
private final KafkaTemplate<String, CommandeCreeeEvent> kafkaTemplate;
public void publierCommandeCreee(Commande commande) {
CommandeCreeeEvent event = new CommandeCreeeEvent(
commande.getId(),
commande.getClientId(),
commande.getTotal(),
Instant.now()
);
kafkaTemplate.send("commandes.creees", commande.getId().toString(), event)
.whenComplete((result, ex) -> {
if (ex != null) {
log.error("Échec publication événement commande {}", commande.getId(), ex);
// Gérer : dead letter queue, retry, alerte
}
});
}
}
// Consommateur : service-facturation réagit à l'événement
@KafkaListener(topics = "commandes.creees", groupId = "facturation-group")
public void onCommandeCreee(CommandeCreeeEvent event) {
log.info("Génération facture pour commande {}", event.commandeId());
facturationService.genererFacture(event);
}
Pattern 4 : Saga pour les transactions distribuées
Les transactions ACID n’existent pas entre microservices. La Saga Pattern gère les transactions longues via une séquence d’étapes avec compensation en cas d’échec.
Commande créée
→ Réserver stock ✓ succès
→ Débiter paiement ✓ succès
→ Créer livraison ✗ échec
→ Annuler paiement ← compensation
→ Libérer stock ← compensation
→ Annuler commande ← compensation finale
Chaque étape publie un événement de succès ou d’échec. L’orchestrateur (ou les chorégraphes) réagissent et déclenchent l’étape suivante ou les compensations.
Pattern 5 : Outbox Pattern
Le problème : vous sauvegardez en base ET publiez un événement Kafka. Si la base réussit mais Kafka échoue, vous avez un état incohérent.
La solution : écrivez l’événement dans une table outbox dans la même transaction que la donnée. Un processus séparé lit la table outbox et publie sur Kafka.
@Transactional
public Commande creerCommande(CommandeRequest request) {
Commande commande = commandeRepository.save(mapper.toEntity(request));
// Même transaction : si l'une échoue, les deux échouent
OutboxEvent event = new OutboxEvent(
"commandes.creees",
commande.getId().toString(),
objectMapper.writeValueAsString(new CommandeCreeeEvent(commande))
);
outboxRepository.save(event);
return commande;
}
Debezium peut surveiller la table outbox via CDC (Change Data Capture) et publier automatiquement sur Kafka.
Observabilité : le prérequis non négociable
Sans observabilité, déboguer un problème dans une architecture microservices est un enfer. Les trois piliers :
Traces distribuées (OpenTelemetry + Jaeger/Zipkin) : suivre une requête à travers tous les services.
Métriques (Micrometer + Prometheus + Grafana) : CPU, mémoire, latence, taux d’erreur par service.
Logs corrélés : chaque log doit contenir le traceId et spanId pour relier les logs d’une même requête distribuée.
// Spring Boot 3 + Micrometer Tracing (Brave/OpenTelemetry)
// Le traceId est automatiquement propagé entre services via les headers HTTP
@GetMapping("/commandes/{id}")
public CommandeDto getCommande(@PathVariable Long id) {
// Les logs incluent automatiquement [traceId=abc123, spanId=def456]
log.info("Récupération commande {}", id);
return commandeService.findById(id);
}
Conclusion
Les microservices bien architecturés reposent sur quelques patterns clés : Gateway pour l’entrée unique, Circuit Breaker pour la résilience, Kafka pour le découplage asynchrone, Saga/Outbox pour la cohérence distribuée. Et surtout : une observabilité complète dès le départ, pas ajoutée après coup.
Amine MEGDICHE
Développeur AEM & Java Full Stack — Freelance depuis 2013