Comunicação entre serviços: REST, gRPC ou message queue?

1. Visão geral

Quando você separa um sistema monolítico em vários serviços pequenos, a primeira pergunta é sempre: como esses serviços se comunicam? Este artigo compara as três opções mais populares — REST, gRPC e message queue (Kafka/RabbitMQ) — com exemplos de código específicos. Ao final, você saberá quando escolher cada uma delas e evitará alguns erros clássicos ao projetar a comunicação entre serviços.

2. O problema proposto

Suponha que você tenha um sistema de e-commerce com 3 serviços: OrderService, PaymentService, InventoryService. Quando OrderService recebe um pedido, ele precisa:

  • Chamar PaymentService para cobrar (precisa do resultado imediatamente para informar o usuário).
  • Chamar InventoryService para debitar do estoque (pode ser processado de forma assíncrona).

Essas duas necessidades diferentes levam a dois tipos de comunicação diferentes: síncrona (REST, gRPC) e assíncrona (message queue).

REST via HTTP/JSON é a escolha padrão porque praticamente todos sabem usar, e as ferramentas de debug (Postman, curl) estão disponíveis em todo lugar.

3.1. Exemplo de chamada REST com 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+) envia JSON para payment-service e recebe de volta PaymentResponse já desserializado. Se payment-service retornar 4xx/5xx, retrieve() lançará RestClientException — você deve capturar esse erro na classe chamadora.

3.2. Vantagens e desvantagens

  • Vantagens: fácil de aprender, fácil de testar com curl/Postman, suportado em todas as linguagens, permite aproveitar HTTP caching, load balancer, API gateway.
  • Desvantagens: payload JSON é mais pesado que binário, não há contrato rigoroso (fácil divergir fields entre cliente/servidor), latência maior que gRPC por causa de parsing de texto e overhead HTTP/1.1.

Observação: REST é adequado quando o número de requisições não é muito grande e você precisa da capacidade de debugar rapidamente a olho nu. Para sistemas internos que fazem dezenas de milhares de requisições/segundo, REST JSON pode ser um gargalo.

4. gRPC — alto desempenho, contrato rigoroso

gRPC usa Protocol Buffers (binário) e HTTP/2, adequado para comunicação interna entre serviços que precisam de velocidade e contrato claro.

4.1. Declaração do 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;
}

Este arquivo .proto gera um stub Java para servidor e cliente — este é o “contrato” rigoroso que REST não tem por padrão.

4.2. Dependência 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>

A biblioteca grpc-spring-boot-starter ajuda a integrar o servidor/cliente gRPC no Spring Boot sem precisar gerenciar manualmente o Server.

4.3. Implementação do servidor

@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 registra automaticamente o servidor no runtime gRPC. onNext envia a resposta, onCompleted fecha o stream — é obrigatório chamar, se esquecer o cliente ficará esperando para sempre.

4.4. Cliente chamando 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 faz uma chamada síncrona, retornando o resultado diretamente — código mais conciso que REST porque não precisa fazer desserialização JSON manualmente.

4.5. Vantagens e desvantagens

  • Vantagens: binário pequeno, 2–5 vezes mais rápido que JSON; multiplexing HTTP/2; contrato .proto força cliente/servidor a sincronizar schema; suporta streaming bidirecional.
  • Desvantagens: difícil debugar manualmente (não pode ler o payload diretamente), requer ferramentas específicas (grpcurl, BloomRPC), não é adequado chamar diretamente de um navegador (precisa de grpc-web), o time precisa aprender proto.

Observação: não use gRPC para uma API pública voltada ao frontend web sem infraestrutura de grpc-web. gRPC é mais adequado para comunicação interna entre serviços.

5. Message queue — assíncrona, boa tolerância a falhas

Quando OrderService não precisa esperar InventoryService responder imediatamente, message queue (Kafka, RabbitMQ) é a escolha razoável: envia uma mensagem e continua processando, o consumer processa quando estiver pronto.

5.1. Dependência Maven

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

5.2. Producer — OrderService publicando evento

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 envia o evento para o tópico stock-reserved-topic, usando orderId como chave para garantir que todos os eventos do mesmo pedido fiquem na mesma partição (mantendo a ordem de processamento).

5.3. Consumer — InventoryService escutando

@Component
public class StockReservedListener {

    @KafkaListener(topics = "stock-reserved-topic", groupId = "inventory-service")
    public void onStockReserved(StockReservedEvent event) {
        log.info("Reservando {} unidades de {} para o pedido {}",
                event.quantity(), event.sku(), event.orderId());
        // debitar do estoque, processar de forma idempotente por orderId
    }
}

@KafkaListener automaticamente sonda por novas mensagens do tópico. Se InventoryService ficar temporariamente inativo, Kafka mantém a mensagem — nenhum dado é perdido, o consumer continua processando quando se recupera.

5.4. Vantagens e desvantagens

  • Vantagens: desacoplamento forte (o produtor não precisa saber se o consumidor está vivo ou não), boa tolerância a falhas (as mensagens são retidas), fácil de escalar por partição, suporta arquitetura orientada a eventos.
  • Desvantagens: sem resposta imediata (precisa projetar callback/polling se precisar do resultado), debug do fluxo de dados é mais complexo (precisa rastrear vários tópicos), requer infraestrutura de broker separada (Kafka/RabbitMQ) para operar e monitorar.

Observação: não use message queue quando precisar de resposta imediata para o usuário (por exemplo, confirmação de pagamento bem-sucedido/falhado) — a latência assíncrona piorará a experiência do usuário.

6. Comparação rápida das três abordagens

Critério REST gRPC Message Queue
Tipo de comunicação Síncrona Síncrona Assíncrona
Formato JSON (texto) Protobuf (binário) Geralmente JSON/Avro
Velocidade Média Alta Não se aplica diretamente (throughput alto)
Contrato Flexível (OpenAPI opcional) Rigoroso (.proto) Flexível (schema registry se houver)
Debug Fácil (curl, Postman) Mais difícil Mais difícil (precisa rastrear vários consumers)
Tolerância a falhas quando o serviço está offline Falha imediatamente Falha imediatamente Mensagem aguarda, não é perdida
Adequado para API pública, CRUD simples Comunicação interna, alto desempenho Event-driven, processamento em background

7. Escolher qual comunicação para qual situação

Voltando ao exemplo OrderService:

@Service
public class OrderProcessingService {

    private final PaymentGrpcClient paymentClient;      // precisa do resultado imediatamente, interno, alto desempenho
    private final InventoryEventPublisher inventoryPublisher; // não precisa esperar, processamento em background

    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 chama gRPC para saber imediatamente se o pagamento foi bem-sucedido ou não (obrigatoriamente síncrono), depois apenas publica o evento para debitar do estoque — porque o usuário não precisa esperar por essa etapa, e se InventoryService ficar temporariamente fora, o Kafka ainda mantém a mensagem segura.

Regras gerais para escolher:

  • Precisa de resultado imediatamente, comunicação interna, frequência alta → gRPC.
  • Precisa de resultado imediatamente, comunicação com terceiros ou time não familiarizado com gRPC → REST.
  • Não precisa de resultado imediatamente, precisa de tolerância a falhas e desacoplamento → message queue.

8. Escrever testes para a lógica de escolha do fluxo de processamento

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

O primeiro teste verifica se, quando o pagamento for bem-sucedido, InventoryEventPublisher.publishStockReserved é chamado com os parâmetros corretos. O segundo teste verifica se, quando o pagamento falha, o sistema não publica o evento de débito — verifyNoInteractions garante que não haja efeitos colaterais indesejados.

9. Erros comuns ao combinar as três

  • Abuso de REST síncrono para tudo: leva a um “monólito distribuído” — serviço A chama B, B chama C, um serviço lento causa timeout em toda a cadeia.
  • Usar gRPC mas não versionar o proto: mudar fields no .proto sem manter compatibilidade reversa (deletar field antigo em vez de deprecar) faz clients antigos quebrar.
  • Publicar mensagens mas não processar de forma idempotente: Kafka pode reenviar mensagens quando o consumer tenta novamente — se InventoryService debita do estoque duas vezes para o mesmo orderId é um bug.
  • Misturar síncrono/assíncrono sem clareza: para facilitar a manutenção do código, nomes de método/classe devem deixar claro o tipo de comunicação, por exemplo publishXxxEvent (assíncrono) diferente de fetchXxx/chargeXxx (síncrono).

10. Conclusão

Não há uma abordagem “melhor” para todos os casos — REST é adequado quando você precisa de simplicidade e debug fácil, gRPC é adequado quando você precisa de velocidade e contrato rigoroso para comunicação interna, message queue é adequado quando você precisa de desacoplamento e tolerância a falhas para fluxos que não precisam de resposta imediata. Na prática, um sistema maduro de microservices geralmente usa os três simultaneamente, cada abordagem para exatamente o tipo de comunicação para o qual é mais adequada — como o exemplo OrderService acima demonstrou.

microservicesRESTgRPCmessage-queuespring-boot