Inter-service Communication: REST, gRPC, or Message Queue?

1. Overview

When breaking a monolithic system into multiple smaller services, the first question is always: how do these services communicate with each other? This article compares three popular choices — REST, gRPC, and message queues (Kafka/RabbitMQ) — through concrete code examples. After reading, you’ll know when to choose which one and avoid several classic mistakes when designing inter-service communication.

2. Problem Statement

Suppose you have an e-commerce system with three services: OrderService, PaymentService, and InventoryService. When OrderService receives an order, it needs to:

  • Call PaymentService to charge the payment (needs the result immediately to report back to the user).
  • Call InventoryService to reduce inventory (can be processed asynchronously).

These two different needs lead to two types of communication: synchronous (REST, gRPC) and asynchronous (message queue).

REST over HTTP/JSON is the default choice because almost everyone knows how to use it, and debugging tools (Postman, curl) are readily available everywhere.

3.1. Example of Calling REST with 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+) sends JSON to payment-service and receives a deserialized PaymentResponse. If payment-service returns 4xx/5xx, retrieve() throws RestClientException — you should catch this exception in the calling layer.

3.2. Advantages and Disadvantages

  • Advantages: easy to learn, easy to test with curl/Postman, supported natively on all platforms, leverages HTTP caching, load balancers, and API gateways.
  • Disadvantages: JSON payload is heavier than binary, no strict contract (fields can drift between client/server), higher latency than gRPC due to text parsing and HTTP/1.1 overhead.

Note: REST is suitable when the request volume is not too high and you need the ability to quickly debug by reading with your eyes. For systems making tens of thousands of requests per second internally, JSON REST can become a bottleneck.

4. gRPC — High Performance, Strict Contract

gRPC uses Protocol Buffers (binary) and HTTP/2, suitable for internal service communication requiring speed and clear contracts.

4.1. Proto Declaration

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

This .proto file generates Java stubs for both server and client — this is the “strict contract” that REST doesn’t have built-in.

4.2. Maven Dependency

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

The grpc-spring-boot-starter library helps integrate gRPC server/client into Spring Boot without manually managing the Server.

4.3. Server Implementation

@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 automatically registers this server into the gRPC runtime. onNext sends the response, onCompleted closes the stream — you must call both, otherwise the client will hang waiting forever.

4.4. Client Calling 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 calls synchronously and returns the result directly — more concise code than REST since you don’t need to manually deserialize JSON.

4.5. Advantages and Disadvantages

  • Advantages: smaller binary payload, 2–5× faster than JSON; HTTP/2 multiplexing; .proto contract forces client/server schema synchronization; supports bidirectional streaming.
  • Disadvantages: hard to debug manually (payload not human-readable), requires special tools (grpcurl, BloomRPC), not suitable for direct browser calls (needs grpc-web), team must learn proto.

Note: don’t use gRPC for public APIs aimed at web frontends unless you have grpc-web infrastructure in place. gRPC is best for internal communication between services.

5. Message Queue — Asynchronous, Better Fault Tolerance

When OrderService doesn’t need to wait for InventoryService to respond immediately, message queues (Kafka, RabbitMQ) are a sensible choice: send a message and continue processing, the consumer handles it when ready.

5.1. Maven Dependency

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

5.2. Producer — OrderService Publishes 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 pushes the event to the stock-reserved-topic topic, using orderId as the key to ensure events for the same order stay on the same partition (preserves processing order).

5.3. Consumer — InventoryService Listens

@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());
        // deduct inventory, handle idempotently by orderId
    }
}

@KafkaListener automatically polls new messages from the topic. If InventoryService temporarily crashes, Kafka retains the message — no data loss; the consumer processes it once recovered.

5.4. Advantages and Disadvantages

  • Advantages: strong decoupling (producer doesn’t need to know if consumer is alive), good fault tolerance (messages are persisted), easy to scale by partition, supports event-driven architecture.
  • Disadvantages: no immediate response (must design callbacks/polling if you need the result), debugging data flow is more complex (must trace through multiple topics), requires separate broker infrastructure (Kafka/RabbitMQ) to operate and monitor.

Note: don’t use message queues for cases requiring immediate user feedback (e.g., confirming payment success/failure) — the async delay will degrade UX.

6. Quick Comparison of Three Approaches

Criterion REST gRPC Message Queue
Communication Type Synchronous Synchronous Asynchronous
Format JSON (text) Protobuf (binary) Usually JSON/Avro
Speed Average High N/A (high throughput)
Contract Loose (OpenAPI optional) Strict (.proto) Loose (schema registry if available)
Debug Easy (curl, Postman) Harder Hardest (must trace through multiple consumers)
Fault Tolerance (service down) Fail immediately Fail immediately Message waits, no data loss
Best For Public API, simple CRUD Internal communication, high performance Event-driven, background processing

7. Choosing Which Communication for Which Scenario

Back to the OrderService example:

@Service
public class OrderProcessingService {

    private final PaymentGrpcClient paymentClient;      // need result immediately, internal, high performance
    private final InventoryEventPublisher inventoryPublisher; // don't need to wait, background processing

    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 calls gRPC to know immediately if payment succeeded (must be synchronous), then only publishes an event to deduct inventory — because the user doesn’t need to wait for this step, and if InventoryService temporarily crashes, Kafka still keeps the message safe.

General rules for choosing:

  • Need result immediately, internal communication, high frequency → gRPC.
  • Need result immediately, communication with outside or teams unfamiliar with gRPC → REST.
  • Don’t need result immediately, need fault tolerance and decoupling → message queue.

8. Writing Tests for the Processing Logic

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

The first test checks that when payment succeeds, InventoryEventPublisher.publishStockReserved is called with the correct parameters. The second test checks that when payment fails, the system does not publish the inventory deduction event — verifyNoInteractions ensures no unwanted side-effects.

9. Common Mistakes When Combining All Three

  • Overusing synchronous REST for everything: leads to a “distributed monolith” — service A calls B, B calls C, one slow service times out the entire chain.
  • Using gRPC without versioning proto: changing fields in .proto without backward compatibility (deleting old fields instead of deprecating) breaks old clients.
  • Publishing messages but not handling idempotently: Kafka can resend messages when consumers retry — if InventoryService deducts inventory twice for the same orderId, that’s a bug.
  • Mixing sync/async communication unclearly: to keep code maintainable, method/class names should reflect the communication type, e.g., publishXxxEvent (async) vs fetchXxx/chargeXxx (sync).

10. Conclusion

There’s no “best” approach for every situation — REST is good when you need simplicity and easy debugging, gRPC is good when you need speed and strict contracts for internal communication, message queues are good when you need decoupling and fault tolerance for flows that don’t need immediate responses. In practice, a mature microservices system typically uses all three concurrently, each for the type of communication it’s best suited for — as the OrderService example above demonstrates.

microservicesRESTgRPCmessage-queuespring-boot