Kommunikation zwischen Services: REST, gRPC oder Message Queue?

1. Übersicht

Wenn Sie ein Monolith-System in mehrere kleine Services aufteilen, ist die erste Frage immer: Wie kommunizieren die Services miteinander? Dieser Artikel vergleicht die drei beliebtesten Optionen – REST, gRPC und Message Queue (Kafka/RabbitMQ) – anhand konkreter Code-Beispiele. Nach dem Lesen werden Sie wissen, wann Sie welche Option wählen sollten, und Sie vermeiden ein paar klassische Fehler beim Design der Kommunikation zwischen Services.

2. Problemstellung

Angenommen, Sie haben ein E-Commerce-System mit 3 Services: OrderService, PaymentService, InventoryService. Wenn OrderService eine Bestellung erhält, muss es:

  • PaymentService aufrufen, um Geld abzubuchen (braucht sofort eine Antwort, um dem Benutzer Bescheid zu geben).
  • InventoryService aufrufen, um den Bestand zu reduzieren (kann asynchron verarbeitet werden).

Diese zwei unterschiedlichen Anforderungen führen zu zwei verschiedenen Kommunikationstypen: synchron (REST, gRPC) und asynchron (Message Queue).

3. REST — einfach, weit verbreitet, einfach zu debuggen

REST über HTTP/JSON ist die Standardwahl, weil fast jeder es kennt und Debug-Tools (Postman, curl) überall verfügbar sind.

3.1. Beispiel: REST-Aufruf mit 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+) sendet JSON an payment-service und deserialisiert PaymentResponse automatisch. Wenn payment-service 4xx/5xx zurückgibt, wirft retrieve() eine RestClientException – Sie sollten diesen Fehler in der aufrufenden Schicht abfangen.

3.2. Vor- und Nachteile

  • Vorteile: einfach zu lernen, einfach mit curl/Postman zu testen, in allen Programmiersprachen vorhanden, nutzt HTTP-Caching, Load Balancer, API Gateway.
  • Nachteile: JSON-Payload ist schwerer als binär, kein strikter Vertrag (Felder können zwischen Client und Server divergieren), höhere Latenz als gRPC wegen Text-Parsing und HTTP/1.1-Overhead.

Hinweis: REST ist geeignet, wenn die Anzahl der Anfragen nicht zu groß ist und Sie schnell mit bloßem Auge debuggen können. Für interne Systeme mit Dutzenden von Tausenden von Anfragen pro Sekunde kann REST JSON zum Engpass werden.

4. gRPC — hohe Leistung, strikter Vertrag

gRPC verwendet Protocol Buffers (binär) und HTTP/2, geeignet für interne Kommunikation zwischen Services, die Geschwindigkeit und einen klaren Vertrag benötigen.

4.1. Proto-Deklaration

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;
}

Diese .proto-Datei generiert Java Stubs für Client und Server – das ist der „Vertrag”, den REST nicht standardmäßig hat.

4.2. Maven-Abhängigkeit

<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>

Die grpc-spring-boot-starter-Bibliothek integriert gRPC Server/Client in Spring Boot, ohne dass Sie Server manuell verwalten müssen.

4.3. Server-Implementierung

@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 registriert diesen Server automatisch in der gRPC-Runtime. onNext sendet die Antwort, onCompleted schließt den Stream – müssen aufgerufen werden, sonst bleibt der Client auf Antwort warten.

4.4. gRPC-Client-Aufruf

@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 ruft synchron auf und gibt das Ergebnis direkt zurück – Code ist kompakter als REST, weil keine manuelle JSON-Deserialisierung nötig ist.

4.5. Vor- und Nachteile

  • Vorteile: kleine Binärdatei, 2–5x schneller als JSON; HTTP/2-Multiplexing; .proto-Vertrag zwingt Client/Server, das Schema zu synchronisieren; unterstützt bidirektionales Streaming.
  • Nachteile: schwer manuell zu debuggen (Payload ist nicht lesbar), benötigt spezielle Tools (grpcurl, BloomRPC), nicht geeignet für direkte Browser-Aufrufe (benötigt grpc-web), Team muss Proto lernen.

Hinweis: Verwenden Sie gRPC nicht für öffentliche APIs gegenüber dem Web-Frontend, es sei denn, Sie haben eine grpc-web-Infrastruktur. gRPC ist am besten für die interne Kommunikation zwischen Services geeignet.

5. Message Queue — asynchron, gute Fehlertoleranz

Wenn OrderService nicht auf eine Antwort von InventoryService warten muss, ist Message Queue (Kafka, RabbitMQ) die beste Wahl: Message senden und dann weitermachen, Consumer verarbeitet, wenn er bereit ist.

5.1. Maven-Abhängigkeit

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

5.2. Producer – OrderService sendet Event

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 pusht Event ins Topic stock-reserved-topic, verwendet orderId als Schlüssel, um sicherzustellen, dass alle Events derselben Bestellung auf derselben Partition liegen (Verarbeitungsreihenfolge beibehalten).

5.3. Consumer – InventoryService horcht

@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());
        // Bestand reduzieren, idempotente Verarbeitung nach orderId
    }
}

@KafkaListener ruft neue Nachrichten aus dem Topic ab. Wenn InventoryService ausfällt, behält Kafka die Nachricht – keine Datenverluste, Consumer verarbeitet weiter, wenn er sich erholt.

5.4. Vor- und Nachteile

  • Vorteile: starke Entkopplung (Producer muss nicht wissen, ob Consumer noch läuft), gute Fehlertoleranz (Nachrichten werden gespeichert), einfach über Partitionen zu skalieren, unterstützt Event-Driven-Architektur.
  • Nachteile: keine sofortige Antwort (muss zusätzliche Callbacks/Polling entwerfen, wenn Ergebnisse benötigt werden), Debugging von Datenflüssen ist komplexer (muss über mehrere Topics verfolgt werden), benötigt separate Broker-Infrastruktur (Kafka/RabbitMQ) für Betrieb und Überwachung.

Hinweis: Verwenden Sie Message Queue nicht für Fälle, in denen Sie sofortige Rückmeldung für den Benutzer benötigen (z. B. Bestätigung der Zahlungs-erfolg/-fehler) – die asynchrone Latenz wird die UX verschlechtern.

6. Schneller Vergleich der drei Optionen

Kriterium REST gRPC Message Queue
Kommunikationstyp Synchron Synchron Asynchron
Format JSON (Text) Protobuf (binär) Normalerweise JSON/Avro
Geschwindigkeit Mittel Hoch Nicht direkt anwendbar (hoher Durchsatz)
Vertrag Locker (OpenAPI optional) Streng (.proto) Locker (Schema Registry falls vorhanden)
Debuggen Einfach (curl, Postman) Schwieriger Am schwierigsten (muss über mehrere Consumer verfolgt werden)
Fehlertoleranz, wenn Service ausfällt Sofort Fehler Sofort Fehler Message wartet, nichts geht verloren
Geeignet für Öffentliche API, einfaches CRUD Interne Kommunikation, hohe Leistung Event-Driven, Background-Verarbeitung

7. Welche Kommunikation für welche Situation

Zurück zum OrderService-Beispiel:

@Service
public class OrderProcessingService {

    private final PaymentGrpcClient paymentClient;      // braucht Ergebnis sofort, intern, hohe Leistung
    private final InventoryEventPublisher inventoryPublisher; // nicht warten müssen, Background-Verarbeitung

    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 ruft gRPC auf, um sofort zu wissen, ob die Zahlung erfolgreich ist (muss synchron sein), sendet dann nur Event, um den Bestand zu reduzieren – weil der Benutzer nicht auf diesen Schritt warten muss, und wenn InventoryService ausfällt, behält Kafka die Nachricht sicher.

Allgemeine Regeln zum Wählen:

  • Benötigen Sie sofortige Ergebnisse, interne Kommunikation, hohe Häufigkeit → gRPC.
  • Benötigen Sie sofortige Ergebnisse, Kommunikation mit außen oder Team ist noch nicht mit gRPC vertraut → REST.
  • Benötigen Sie keine sofortige Ergebnisse, brauchen Sie Fehlertoleranz und Entkopplung → Message Queue.

8. Test für die Auswahl-Logik des Verarbeitungsflusses schreiben

@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);
    }
}

Der erste Test prüft, wenn Zahlung erfolgreich ist, muss InventoryEventPublisher.publishStockReserved mit richtigen Parametern aufgerufen werden. Der zweite Test prüft, wenn Zahlung fehlschlägt, sendet das System keine Event zum Bestandsabbau – verifyNoInteractions stellt sicher, dass keine unerwünschten Nebenwirkungen auftreten.

9. Häufige Fehler beim Kombinieren aller drei

  • Missbrauch von synchronem REST für alles: führt zu „verteiltem Monolith” – Service A ruft B auf, B ruft C auf, ein langsamer Service lässt die ganze Kette timeout.
  • gRPC verwenden, aber nicht versionieren: .proto-Felder ändern, ohne abwärtskompatibilität zu gewährleisten (alte Felder löschen statt zu deprecaten) lässt alte Clients abstürzen.
  • Nachrichten senden, aber nicht idempotent verarbeiten: Kafka kann Nachrichten neu senden, wenn Consumer Retry durchführt – wenn InventoryService zweimal den Bestand für dieselbe orderId reduziert, ist das ein Bug.
  • Synchron/Asynchron durcheinander mischen ohne Klarheit: Damit der Code leicht zu warten ist, sollten Methoden-/Klassennamen deutlich den Kommunikationstyp zeigen, z.B. publishXxxEvent (asynchron) anders als fetchXxx/chargeXxx (synchron).

10. Fazit

Es gibt keine „beste” Lösung für jeden Fall – REST ist geeignet, wenn Sie Einfachheit und einfaches Debugging brauchen, gRPC ist geeignet, wenn Sie Geschwindigkeit und einen strikten Vertrag für die interne Kommunikation brauchen, Message Queue ist geeignet, wenn Sie Entkopplung und Fehlertoleranz für Flüsse brauchen, die keine sofortige Rückmeldung brauchen. In der Praxis verwendet ein ausgereiftes Microservices-System normalerweise alle drei gleichzeitig, jede Lösung für genau die Art von Kommunikation, für die sie am besten geeignet ist – wie das obige OrderService-Beispiel zeigt.

microservicesRESTgRPCmessage-queuespring-boot