Comunicación entre servicios: ¿REST, gRPC o message queue?

1. Descripción general

Cuando divides un sistema monolítico en múltiples servicios pequeños, la primera pregunta siempre es: ¿cómo se comunican los servicios entre sí? Este artículo compara las tres opciones más populares — REST, gRPC y message queue (Kafka/RabbitMQ) — con ejemplos de código concretos. Al terminar de leerlo, sabrás cuándo usar cada una y evitarás varios errores clásicos al diseñar la comunicación entre servicios.

2. El problema planteado

Supongamos que tienes un sistema de e-commerce con 3 servicios: OrderService, PaymentService, InventoryService. Cuando OrderService recibe un pedido, necesita:

  • Llamar a PaymentService para procesar el pago (necesita el resultado inmediatamente para informar al usuario).
  • Llamar a InventoryService para reducir el inventario (puede procesarse de forma asincrónica).

Estas dos necesidades diferentes conducen a dos tipos de comunicación diferentes: síncrona (REST, gRPC) y asincrónica (message queue).

REST sobre HTTP/JSON es la opción predeterminada porque casi todos saben cómo usarla y las herramientas de depuración (Postman, curl) están disponibles en todas partes.

3.1. Ejemplo de llamada REST con 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 envía JSON a payment-service y recibe de vuelta un PaymentResponse ya deserializado. Si payment-service devuelve 4xx/5xx, retrieve() lanzará RestClientException — deberías capturar este error en la capa que realiza la llamada.

3.2. Ventajas y desventajas

  • Ventajas: fácil de aprender, fácil de probar con curl/Postman, soportado en todos los lenguajes, puedes aprovechar HTTP caching, load balancers, API gateway.
  • Desventajas: el payload JSON es más pesado que el binario, no hay un contrato estricto (es fácil que los campos diverjan entre cliente y servidor), latencia más alta que gRPC debido al análisis de texto y el overhead de HTTP/1.1.

Nota: REST es adecuado cuando el número de solicitudes no es demasiado grande y necesitas capacidad de depuración rápida a simple vista. Para sistemas internos que procesan decenas de miles de solicitudes por segundo, REST JSON puede convertirse en un cuello de botella.

4. gRPC — rendimiento alto, contrato estricto

gRPC usa Protocol Buffers (binario) y HTTP/2, es apropiado para la comunicación interna entre servicios que necesitan velocidad y un contrato claro.

4.1. Declaración 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 archivo .proto genera stubs Java tanto para servidor como para cliente — esto es el “contrato” estricto que REST no tiene por defecto.

4.2. Dependencia 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 librería grpc-spring-boot-starter ayuda a integrar gRPC server/cliente en Spring Boot sin necesidad de gestionar manualmente Server.

4.3. Implementación del 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 automáticamente este servidor en gRPC runtime. onNext envía la respuesta, onCompleted cierra el stream — es obligatorio llamarlo; si lo olvidas, el cliente se quedará esperando indefinidamente.

4.4. Cliente llamando a 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 hace una llamada síncrona, devuelve el resultado directamente — el código es más compacto que REST porque no necesita deserializar JSON manualmente.

4.5. Ventajas y desventajas

  • Ventajas: binario compacto, 2–5 veces más rápido que JSON; multiplexing de HTTP/2; el contrato .proto obliga a que cliente y servidor mantengan el esquema sincronizado; soporta streaming bidireccional.
  • Desventajas: difícil de depurar manualmente (no puedes leer el payload directamente), necesitas herramientas especiales (grpcurl, BloomRPC), no es adecuado para llamadas directas desde el navegador (necesitas grpc-web), el equipo necesita aprender proto además.

Nota: no uses gRPC para APIs públicas destinadas a frontend web si aún no tienes la infraestructura grpc-web. gRPC es más adecuado para la comunicación interna entre servicios.

5. Message queue — asincrónica, tolerancia a fallos robusta

Cuando OrderService no necesita esperar a que InventoryService responda inmediatamente, message queue (Kafka, RabbitMQ) es la opción más razonable: envía un mensaje y continúa procesando, el consumidor procesa cuando está listo.

5.1. Dependencia Maven

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

5.2. Productor — OrderService emite 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 envía el evento al tema stock-reserved-topic, usa orderId como clave para garantizar que todos los eventos del mismo pedido estén en la misma partición (manteniendo el orden de procesamiento).

5.3. Consumidor — InventoryService escucha

@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());
        // reducir inventario, procesar de forma idempotente según orderId
    }
}

@KafkaListener automáticamente hace polling de nuevos mensajes del tema. Si InventoryService se cae temporalmente, Kafka conserva el mensaje — sin pérdida de datos, el consumidor continúa procesando cuando se recupera.

5.4. Ventajas y desventajas

  • Ventajas: desacoplamiento fuerte (el productor no necesita saber si el consumidor sigue vivo), buena tolerancia a fallos (los mensajes se guardan), fácil de escalar con particiones, soporta arquitectura orientada a eventos.
  • Desventajas: no hay respuesta inmediata (tienes que diseñar callback/polling adicionales si necesitas un resultado), depuración del flujo de datos más compleja (tienes que rastrear a través de múltiples temas), necesitas infraestructura broker separada (Kafka/RabbitMQ) para operar y supervisar.

Nota: no uses message queue para casos que requieren una respuesta inmediata al usuario (por ejemplo, confirmación de pago exitoso/fallido) — la latencia asincrónica deteriorará la experiencia de usuario.

6. Comparación rápida de las tres opciones

Criterio REST gRPC Message Queue
Tipo de comunicación Síncrona Síncrona Asincrónica
Formato JSON (texto) Protobuf (binario) Generalmente JSON/Avro
Velocidad Media Alta No aplica directamente (alto throughput)
Contrato Flexible (OpenAPI opcional) Estricta (.proto) Flexible (schema registry si está disponible)
Depuración Fácil (curl, Postman) Más difícil Más difícil (requiere rastreo a través de múltiples consumidores)
Tolerancia a fallos cuando el servicio está caído Falla inmediatamente Falla inmediatamente El mensaje espera, no se pierde
Apropiado para API pública, CRUD simple Comunicación interna, rendimiento alto Orientado a eventos, procesamiento en segundo plano

7. Qué tipo de comunicación elegir para cada situación

Volviendo al ejemplo de OrderService:

@Service
public class OrderProcessingService {

    private final PaymentGrpcClient paymentClient;      // necesita el resultado inmediatamente, interno, rendimiento alto
    private final InventoryEventPublisher inventoryPublisher; // no necesita esperar, procesamiento en segundo plano

    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 llama a gRPC para saber inmediatamente si el pago fue exitoso (debe ser síncrono), luego simplemente emite un evento para reducir el inventario — porque el usuario no necesita esperar este paso, y si InventoryService se cae, Kafka mantiene el mensaje seguro.

Reglas generales para elegir:

  • Necesitas resultado inmediato, comunicación interna, alta frecuencia → gRPC.
  • Necesitas resultado inmediato, comunicación con terceros u otros equipos que no están familiarizados con gRPC → REST.
  • No necesitas resultado inmediato, necesitas tolerancia a fallos y desacoplamiento → message queue.

8. Escribir pruebas para la lógica de selección de flujo

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

La primera prueba verifica que cuando el pago es exitoso, InventoryEventPublisher.publishStockReserved debe ser llamado con los parámetros correctos. La segunda prueba verifica que cuando el pago falla, el sistema no emite el evento de reducción de inventario — verifyNoInteractions asegura que no hay efectos secundarios no deseados.

9. Errores comunes al combinar los tres

  • Abusar de REST síncrono para todo: conduce a un “monolítico distribuido” — el servicio A llama a B, B llama a C, un servicio lento hace que toda la cadena expire.
  • Usar gRPC sin versionar proto: cambiar campos en .proto sin mantener compatibilidad hacia atrás (eliminar campos antiguos en lugar de deprecarlos) hace que los clientes antiguos fallen.
  • Emitir mensajes pero no manejar idempotencia: Kafka puede reenviar mensajes cuando el consumidor intenta de nuevo — si InventoryService reduce el inventario dos veces para el mismo orderId, hay un error.
  • Mezclar síncrono/asincrónico de forma poco clara: para que el código sea fácil de mantener, los nombres de métodos/clases deben indicar claramente el tipo de comunicación, por ejemplo publishXxxEvent (asincrónico) es diferente de fetchXxx/chargeXxx (síncrono).

10. Conclusión

No existe una solución “mejor” para todos los casos — REST es apropiado cuando necesitas simplicidad y facilidad de depuración, gRPC es apropiado cuando necesitas velocidad y un contrato estricto para la comunicación interna, message queue es apropiado cuando necesitas desacoplamiento y tolerancia a fallos para flujos que no requieren una respuesta inmediata. En la práctica, un sistema de microservicios maduro generalmente utiliza los tres simultáneamente, cada opción para el tipo correcto de comunicación en el que es más apropiada — como ha ilustrado el ejemplo de OrderService anterior.

microservicesRESTgRPCmessage-queuespring-boot