Aller au contenu
Java 8 min de lecture

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.

JavaMicroservicesSpring BootArchitectureBackend

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

Amine MEGDICHE

Développeur AEM & Java Full Stack — Freelance depuis 2013

Vous avez un projet sur ces sujets ?

Envoyez-moi un message pour qualifier votre besoin, vos contraintes techniques et le bon format d'intervention.

Cadrer mon besoin