Communication entre services: REST, gRPC ou message queue?

1. Vue d’ensemble

Quand on casse un système monolithique en plusieurs petits services, la première question est toujours : comment ces services communiquent-ils entre eux ? Cet article compare trois choix populaires — REST, gRPC et message queue (Kafka/RabbitMQ) — à travers des exemples de code concrets. Après la lecture, tu sauras quand choisir lequel, et tu éviteras quelques pièges classiques lors de la conception de la communication inter-services.

2. Le problème posé

Supposons que tu as un système e-commerce avec 3 services : OrderService, PaymentService, InventoryService. Quand OrderService reçoit une commande, il doit :

  • Appeler PaymentService pour débiter l’argent (besoin du résultat immédiatement pour informer l’utilisateur).
  • Appeler InventoryService pour réduire le stock (peut être traité de manière asynchrone).

Ces deux besoins différents mènent à deux types de communication différents : synchrone (REST, gRPC) et asynchrone (message queue).

3. REST — simple, populaire, facile à déboguer

REST via HTTP/JSON est le choix par défaut parce que presque tout le monde sait l’utiliser, les outils de débogage (Postman, curl) sont partout.

3.1. Exemple d’appel REST avec RestClient

public record PaymentRequest(String orderId, BigDecimal amount) {}
public record PaymentResponse(String transactionId, String status) {}

@Service
public class PaymentClient {

    private final RestClient restClient;

    public PaymentClient(RestClient.Builder builder) {
        this.restClient = builder.baseUrl("http://payment-service:8081").build();
    }

    public PaymentResponse charge(PaymentRequest request) {
        return restClient.post()
                .uri("/api/payments")
                .body(request)
                .retrieve()
                .body(PaymentResponse.class);
    }
}

RestClient (Spring 6.1+) envoie du JSON à payment-service, reçoit PaymentResponse déjà désérialisé. Si payment-service retourne 4xx/5xx, retrieve() lèvera une RestClientException — tu dois attraper cette erreur dans la couche appelante.

3.2. Avantages et inconvénients

  • Avantages : facile à apprendre, facile à tester avec curl/Postman, supporté nativement dans tous les langages, possibilité de tirer parti du caching HTTP, load balancer, API gateway.
  • Inconvénients : payload JSON plus lourd que du binaire, pas de contrat strict (facile de diverger sur les champs entre client/serveur), latence plus élevée que gRPC à cause du parsing texte et de l’overhead HTTP/1.1.

À noter : REST convient quand le nombre de requêtes n’est pas trop élevé et que tu as besoin de la possibilité de déboguer rapidement à l’œil nu. Avec un système interne qui traite des dizaines de milliers de requêtes par seconde, REST JSON peut devenir un goulot d’étranglement.

4. gRPC — haute performance, contrat strict

gRPC utilise Protocol Buffers (binaire) et HTTP/2, idéal pour la communication interne entre services nécessitant de la vitesse et un contrat clair.

4.1. Déclaration proto

syntax = "proto3";

package payment;

option java_package = "dev.nonla.payment.grpc";
option java_multiple_files = true;

service PaymentGrpcService {
  rpc Charge (ChargeRequest) returns (ChargeResponse);
}

message ChargeRequest {
  string order_id = 1;
  double amount = 2;
}

message ChargeResponse {
  string transaction_id = 1;
  string status = 2;
}

Ce fichier .proto génère des stubs Java pour le serveur et le client — c’est le “contrat” strict que REST n’offre pas nativement.

4.2. Dépendance Maven

<dependency>
    <groupId>net.devh</groupId>
    <artifactId>grpc-server-spring-boot-starter</artifactId>
    <version>3.1.0.RELEASE</version>
</dependency>
<dependency>
    <groupId>net.devh</groupId>
    <artifactId>grpc-client-spring-boot-starter</artifactId>
    <version>3.1.0.RELEASE</version>
</dependency>

La bibliothèque grpc-spring-boot-starter facilite l’intégration du serveur/client gRPC dans Spring Boot sans avoir à gérer manuellement Server.

4.3. Implémentation du serveur

@GrpcService
public class PaymentGrpcServer extends PaymentGrpcServiceGrpc.PaymentGrpcServiceImplBase {

    @Override
    public void charge(ChargeRequest request, StreamObserver<ChargeResponse> responseObserver) {
        ChargeResponse response = ChargeResponse.newBuilder()
                .setTransactionId(UUID.randomUUID().toString())
                .setStatus("SUCCESS")
                .build();
        responseObserver.onNext(response);
        responseObserver.onCompleted();
    }
}

@GrpcService enregistre automatiquement ce serveur dans le runtime gRPC. onNext envoie la réponse, onCompleted ferme le flux — tu dois obligatoirement appeler les deux, sinon le client restera bloqué indéfiniment.

4.4. Client qui appelle gRPC

@Service
public class PaymentGrpcClient {

    @GrpcClient("payment-service")
    private PaymentGrpcServiceGrpc.PaymentGrpcServiceBlockingStub stub;

    public ChargeResponse charge(String orderId, double amount) {
        ChargeRequest request = ChargeRequest.newBuilder()
                .setOrderId(orderId)
                .setAmount(amount)
                .build();
        return stub.charge(request);
    }
}

BlockingStub appelle de manière synchrone, retourne le résultat directement — le code est plus concis que REST puisque tu n’as pas besoin de désérialiser JSON toi-même.

4.5. Avantages et inconvénients

  • Avantages : binaire plus petit, 2-5 fois plus rapide que JSON ; multiplexing HTTP/2 ; contrat .proto force la synchronisation du schéma entre client et serveur ; support du streaming bidirectionnel.
  • Inconvénients : difficile à déboguer manuellement (impossible de lire le payload directement), a besoin d’outils spécifiques (grpcurl, BloomRPC), ne convient pas pour appeler depuis un navigateur directement (nécessite grpc-web), l’équipe doit apprendre proto en plus.

À noter : n’utilise pas gRPC pour une API publique destinée au frontend web si tu n’as pas d’infrastructure grpc-web. gRPC convient mieux pour la communication interne entre services.

5. Message queue — asynchrone, résilience élevée

Quand OrderService n’a pas besoin d’une réponse immédiate de InventoryService, une message queue (Kafka, RabbitMQ) est un choix raisonnable : envoyer un message puis continuer le traitement, le consommateur traite quand il est prêt.

5.1. Dépendance Maven

<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>

5.2. Producer — OrderService émet l’événement

public record StockReservedEvent(String orderId, String sku, int quantity) {}

@Service
public class InventoryEventPublisher {

    private final KafkaTemplate<String, StockReservedEvent> kafkaTemplate;

    public InventoryEventPublisher(KafkaTemplate<String, StockReservedEvent> kafkaTemplate) {
        this.kafkaTemplate = kafkaTemplate;
    }

    public void publishStockReserved(StockReservedEvent event) {
        kafkaTemplate.send("stock-reserved-topic", event.orderId(), event);
    }
}

kafkaTemplate.send pousse l’événement vers le topic stock-reserved-topic, utilise orderId comme clé pour s’assurer que tous les événements d’une même commande se retrouvent sur la même partition (préserve l’ordre de traitement).

5.3. Consumer — InventoryService écoute

@Component
public class StockReservedListener {

    @KafkaListener(topics = "stock-reserved-topic", groupId = "inventory-service")
    public void onStockReserved(StockReservedEvent event) {
        log.info("Reserving {} units of {} for order {}",
                event.quantity(), event.sku(), event.orderId());
        // réduire le stock, traiter de manière idempotente selon orderId
    }
}

@KafkaListener poll automatiquement les nouveaux messages du topic. Si InventoryService plante temporairement, Kafka garde le message — aucune perte de données, le consommateur traite quand il se rétablit.

5.4. Avantages et inconvénients

  • Avantages : découplage puissant (le producteur n’a pas besoin de savoir si le consommateur est vivant), résilience élevée (les messages sont sauvegardés), facile à scaler selon les partitions, support de l’architecture event-driven.
  • Inconvénients : pas de réponse immédiate (faut concevoir du callback/polling supplémentaire si tu as besoin du résultat), déboguer les flux de données est plus complexe (faut tracer à travers plusieurs topics), faut une infrastructure broker à part (Kafka/RabbitMQ) à opérer et surveiller.

À noter : n’utilise pas message queue pour les cas où tu dois répondre immédiatement à l’utilisateur (par exemple confirmer si un paiement a réussi ou échoué) — la latence asynchrone dégraderas l’UX.

6. Comparaison rapide des trois approches

Critère REST gRPC Message Queue
Type de communication Synchrone Synchrone Asynchrone
Format JSON (texte) Protobuf (binaire) Généralement JSON/Avro
Vitesse Moyenne Élevée Ne s’applique pas directement (débit élevé)
Contrat Lâche (OpenAPI optionnel) Strict (.proto) Lâche (schema registry si disponible)
Débogage Facile (curl, Postman) Plus difficile Plus difficile (faut tracer plusieurs consumers)
Résilience quand service down Échoue immédiatement Échoue immédiatement Le message attend, pas de perte
Adapté pour API public, CRUD simple Communication interne, haute performance Event-driven, traitements en arrière-plan

7. Choisir la communication appropriée pour chaque situation

Revenons à l’exemple avec OrderService :

@Service
public class OrderProcessingService {

    private final PaymentGrpcClient paymentClient;      // besoin du résultat immédiatement, interne, haute perf
    private final InventoryEventPublisher inventoryPublisher; // pas besoin d'attendre, traitement en arrière-plan

    public OrderProcessingService(PaymentGrpcClient paymentClient,
                                   InventoryEventPublisher inventoryPublisher) {
        this.paymentClient = paymentClient;
        this.inventoryPublisher = inventoryPublisher;
    }

    public OrderResult processOrder(Order order) {
        ChargeResponse charge = paymentClient.charge(order.id(), order.amount());
        if (!"SUCCESS".equals(charge.getStatus())) {
            return OrderResult.failed(order.id());
        }
        inventoryPublisher.publishStockReserved(
                new StockReservedEvent(order.id(), order.sku(), order.quantity()));
        return OrderResult.accepted(order.id(), charge.getTransactionId());
    }
}

processOrder appelle gRPC pour savoir immédiatement si le paiement a réussi ou non (obligatoirement synchrone), puis émet un événement pour réduire le stock — parce que l’utilisateur n’a pas besoin d’attendre cette étape, et si InventoryService plante temporairement, Kafka garde le message en toute sécurité.

Règles générales pour choisir :

  • Besoin du résultat immédiatement, communication interne, fréquence élevée → gRPC.
  • Besoin du résultat immédiatement, communication avec l’extérieur ou équipe qui ne connaît pas encore gRPC → REST.
  • Pas besoin du résultat immédiatement, besoin de résilience et découplage → message queue.

8. Écrire des tests pour la logique de choix du flux

@ExtendWith(MockitoExtension.class)
class OrderProcessingServiceTest {

    @Mock PaymentGrpcClient paymentClient;
    @Mock InventoryEventPublisher inventoryPublisher;
    @InjectMocks OrderProcessingService orderProcessingService;

    @Test
    void shouldPublishStockEvent_whenPaymentSucceeds() {
        Order order = new Order("order-1", "SKU-01", 2, new BigDecimal("100.00"));
        ChargeResponse success = ChargeResponse.newBuilder()
                .setTransactionId("tx-1")
                .setStatus("SUCCESS")
                .build();
        given(paymentClient.charge(order.id(), order.amount())).willReturn(success);

        OrderResult result = orderProcessingService.processOrder(order);

        assertThat(result.status()).isEqualTo("ACCEPTED");
        verify(inventoryPublisher).publishStockReserved(
                new StockReservedEvent("order-1", "SKU-01", 2));
    }

    @Test
    void shouldNotPublishStockEvent_whenPaymentFails() {
        Order order = new Order("order-2", "SKU-02", 1, new BigDecimal("50.00"));
        ChargeResponse failed = ChargeResponse.newBuilder().setStatus("FAILED").build();
        given(paymentClient.charge(order.id(), order.amount())).willReturn(failed);

        OrderResult result = orderProcessingService.processOrder(order);

        assertThat(result.status()).isEqualTo("FAILED");
        verifyNoInteractions(inventoryPublisher);
    }
}

Le premier test vérifie que quand le paiement réussit, InventoryEventPublisher.publishStockReserved est appelé avec les bons paramètres. Le second vérifie que quand le paiement échoue, le système n’émet pas l’événement de réduction du stock — verifyNoInteractions garantit l’absence d’effet de bord indésirable.

9. Erreurs courantes en combinant les trois

  • Abuser de REST synchrone pour tout : aboutit à un “distributed monolith” — le service A appelle B, B appelle C, un service lent bloque toute la chaîne.
  • Utiliser gRPC sans versioner le proto : modifier un champ dans .proto sans garder la compatibilité descendante (supprimer un champ au lieu de le marquer comme deprecated) plante les vieux clients.
  • Émettre un message mais ne pas traiter l’idempotence : Kafka peut renvoyer un message quand le consumer retry — si InventoryService réduit le stock deux fois pour la même orderId, c’est un bug.
  • Mélanger synchrone/asynchrone sans clarté : pour maintenir le code, les noms de method/classe doivent montrer clairement le type de communication, par exemple publishXxxEvent (asynchrone) se distingue de fetchXxx/chargeXxx (synchrone).

10. Conclusion

Il n’existe pas de solution “la meilleure” pour tous les cas — REST convient quand il faut de la simplicité et du débogage facile, gRPC convient quand tu as besoin de vitesse et d’un contrat strict pour la communication interne, message queue convient quand tu as besoin du découplage et de la résilience pour les flux sans réponse immédiate. En pratique, un système microservices mature utilise les trois en même temps, chaque approche pour le type de communication qui lui convient le mieux — comme l’exemple avec OrderService ci-dessus l’a montré.

microservicesRESTgRPCmessage-queuespring-boot