4. Comunicación entre microservicios

Una arquitectura de microservicios solo resulta efectiva si los servicios colaboran mediante canales robustos, predecibles y bien observados. La comunicación define la latencia percibida por los usuarios, la consistencia de los datos y la capacidad de los equipos para desplegar de manera independiente. En este tema revisamos los enfoques sincrónicos y asincrónicos, patrones de mensajería y técnicas de tolerancia a fallos que permiten construir plataformas resilientes.

4.1 Comunicación sincrónica y sus protocolos principales

La comunicación sincrónica implica que el consumidor espera la respuesta del proveedor en la misma interacción. Este modelo es apropiado cuando se requiere consistencia inmediata o una respuesta rápida para el usuario final. Los microservicios suelen apoyarse en HTTP para intercambiar mensajes basados en texto, con formatos como JSON, y en REST para definir contratos uniformes. Cuando se necesita mayor eficiencia binaria y contratos estrictos, frameworks como gRPC ofrecen stubs generados a partir de schemas con Protocol Buffers.

El diseño de APIs sincrónicas debe priorizar la estabilidad de contratos, la versionación y la observabilidad. Es recomendable publicar los esquemas mediante OpenAPI, documentar los códigos de estado y adoptar un gateway que unifique autenticación, control de tráfico y métricas.

4.1.1 Ejemplo de consumo HTTP/REST

En el siguiente fragmento un servicio de pagos invoca a un servicio de pedidos utilizando el cliente reactivo WebClient de Spring. Se gestionan los tiempos de espera y se propaga un identificador de correlación para facilitar la trazabilidad.

@Service
public class OrderGateway {

    private final WebClient webClient;

    public OrderGateway(WebClient.Builder builder) {
        this.webClient = builder
                .baseUrl("http://orders-service.internal/api")
                .build();
    }

    public Mono<OrderDto> getOrder(String orderId, String traceId) {
        return webClient.get()
                .uri("/orders/{id}", orderId)
                .header("X-Correlation-Id", traceId)
                .retrieve()
                .onStatus(HttpStatusCode::is4xxClientError, response ->
                        Mono.error(new OrderNotFoundException(orderId)))
                .bodyToMono(OrderDto.class)
                .timeout(Duration.ofSeconds(2));
    }
}

Este enfoque es adecuado cuando la lógica de negocio depende de datos inmediatos. Si el servicio remoto sufre latencia elevada o indisponibilidad, debemos establecer fallbacks y reintentos controlados para proteger la experiencia del usuario.

4.1.2 Ejemplo de contrato gRPC

También es posible utilizar un contrato binario compacto. En el archivo order.proto se define el servicio remoto y el siguiente código muestra cómo invocarlo desde un cliente basado en Protocol Buffers.

public class GrpcOrderClient {

    private final ManagedChannel channel;
    private final OrderServiceGrpc.OrderServiceBlockingStub stub;

    public GrpcOrderClient(String host, int port) {
        this.channel = ManagedChannelBuilder.forAddress(host, port)
                .usePlaintext()
                .build();
        this.stub = OrderServiceGrpc.newBlockingStub(channel)
                .withDeadlineAfter(1500, TimeUnit.MILLISECONDS);
    }

    public OrderReply fetchOrder(String id) {
        OrderRequest request = OrderRequest.newBuilder()
                .setOrderId(id)
                .build();
        return stub.getOrder(request);
    }

    public void shutdown() {
        channel.shutdown();
    }
}

Con gRPC se gana eficiencia en redes con alto volumen y se soportan canales streaming bidireccionales. La contrapartida es gestionar el balanceo de carga a través de service discovery y monitorear los tiempos de serialización.

4.2 Comunicación asincrónica y orientada a eventos

La comunicación asincrónica desacopla el ciclo de vida de productores y consumidores mediante colas o buses de eventos. El publicador emite un mensaje y continua con su flujo sin esperar respuesta inmediata, mientras que los consumidores procesan el evento cuando están disponibles. Este paradigma reduce la latencia percibida por el usuario y tolera mejor los picos de carga.

Para implementarlo es habitual adoptar RabbitMQ cuando se requieren colas clásicas, confirmaciones individuales y ruteo flexible. Cuando prima la distribución de eventos de dominio y el procesamiento en streaming, plataformas como Apache Kafka ofrecen particiones escalables y almacenamiento persistente.

4.2.1 Productores y consumidores

Un servicio de inventario podría publicar eventos de cambio de stock y otros servicios, como el carrito de compras, reaccionar a la información para validar reservas. El siguiente ejemplo muestra cómo publicar un evento utilizando Spring Cloud Stream y manejarlo desde otro servicio.

@Component
public class InventoryPublisher {

    private final StreamBridge bridge;

    public InventoryPublisher(StreamBridge bridge) {
        this.bridge = bridge;
    }

    public void publishStockChanged(StockChanged event) {
        bridge.send("stockChanged-out-0", MessageBuilder
                .withPayload(event)
                .setHeader("eventType", "StockChanged")
                .build());
    }
}
@Component
public class CartEventHandler {

    @Bean
    public Consumer<StockChanged> stockChangedListener() {
        return event -> {
            if (event.availableQuantity() == 0) {
                cartService.markItemsAsUnavailable(event.sku());
            }
        };
    }
}

Los eventos permiten notificar cambios de estado sin bloquear el flujo principal. Es importante garantizar que los mensajes se serialicen de forma compatible y contar con dead-letter queues para mensajes que no puedan ser procesados.

4.3 Patrones de mensajería

Los patrones de mensajería brindan vocabulario común para diseñar sistemas distribuidos:

  • Event Bus (4.3.1): canal centralizado donde todos los servicios publican y escuchan eventos. Favorece la extensión de la plataforma, pero requiere gobernanza para evitar dependencias implícitas.
  • Event Sourcing (4.3.2): el estado de una entidad se reconstruye a partir de la secuencia de eventos que ha experimentado. Los microservicios almacenan las mutaciones en un log y proyectan vistas para consultas. Requiere manejar versiones de eventos y proyecciones resilientes.
  • Pub/Sub (4.3.3): los productores publican mensajes sin conocer a los consumidores, que se suscriben a tópicos de interés. Esta estrategia facilita la difusión de eventos de negocio a múltiples servicios.

Independientemente del patrón, debemos definir contratos para los mensajes, emplear esquemas versionados y controlar el volumen de eventos para prevenir backpressure.

4.4 Tolerancia a fallos y reintentos

El acoplamiento temporal entre servicios impone el riesgo de fallas en cadena. Para mitigarlo es vital aplicar estrategias como reintentos exponenciales, circuit breakers, timeouts ajustados y patrones idempotentes. Herramientas como Resilience4j simplifican la configuración de estas políticas.

Los reintentos deben aplicarse con moderación: repetir un comando de pago sin idempotencia puede propagar cobros duplicados. Los comandos deben incluir identificadores únicos y verificaciones de estado. Cuando una operación involucra varios servicios, las Sagas coordinan pasos con compensaciones para revertir acciones parciales.

Para la mensajería asincrónica, conviene habilitar colas de mensajes fallidos y mecanismos de retry diferenciados. En Kafka se acostumbra usar tópicos de retry con retardo controlado, mientras que en RabbitMQ las dead-letter exchanges permiten reenviar mensajes tras uno o más intentos.

4.4.1 Ejemplo de reintento con Resilience4j

El siguiente código envuelve una llamada remota en una configuración de reintentos y circuit breaker. La configuración se declara mediante anotaciones, dejando la política parametrizable.

@Service
public class PaymentGateway {

    private final OrderGateway orderGateway;

    public PaymentGateway(OrderGateway orderGateway) {
        this.orderGateway = orderGateway;
    }

    @Retry(name = "orders")
    @CircuitBreaker(name = "orders", fallbackMethod = "fallbackOrder")
    public OrderDto fetchOrder(String orderId, String traceId) {
        return orderGateway.getOrder(orderId, traceId)
                .blockOptional()
                .orElseThrow(() -> new IllegalStateException("Order unavailable"));
    }

    private OrderDto fallbackOrder(String orderId, String traceId, Throwable throwable) {
        return new OrderDto(orderId, OrderStatus.UNKNOWN, traceId);
    }
}

En la práctica se complementa la protección con alertas automatizadas, trazabilidad distribuida y tableros de métricas que permitan detectar anomalías antes de que impacten al negocio.