javamicroserviciosarquitecturabackendawskafka

Microservicios a Escala: El Patrón de 5 Repositorios

Cómo estructuramos microservicios en producción con 5 repositorios independientes, arquitectura hexagonal, programación reactiva con Kafka y un stack de observabilidad completo en AWS.

Microservicios a Escala: El Patrón de 5 Repositorios

Procesar miles de pagos por segundo implica algo más que escribir buen código Java. Implica decidir cómo se organiza ese código, cómo se despliega, cómo se monitoriza y cómo se recupera cuando algo falla — sin que un cambio en el dashboard de Grafana dispare la compilación del servicio.

Este post describe la arquitectura que hemos usado en BBVA para estructurar microservicios de pago: el patrón de 5 repositorios, junto con los patrones de código, infraestructura y observabilidad que lo hacen funcionar en producción.

Para que los conceptos no queden en el aire, todo gira alrededor de un microservicio ficticio llamado payment-bridge: un adaptador entre la plataforma interna de pagos y un proveedor externo llamado VerifyPay que valida transacciones. Recibe eventos cifrados por Kafka, los descifra, los transforma y los envía por HTTPS. La arquitectura que describe es idéntica a la de los servicios reales.


El Problema: Un Microservicio No Es Un Repositorio

La decisión más importante — y la que más sorprende a quien viene de proyectos anteriores — es que cada microservicio no vive en un único repositorio. Vive en cinco:

RepositorioResponsabilidadLenguaje
payment-bridgeLógica de negocioJava (Spring Boot)
payment-bridge-infrastructure-composerProvisión de infraestructura AWSPython
payment-bridge-alarms-composerAlarmas CloudWatchPython
payment-bridge-monitoring-composerDashboards GrafanaPython
payment-bridge-gocd-pipelinesPipelines CI/CDJSON (GoCD)

¿Por qué? Por el Principio de Responsabilidad Única aplicado a nivel de repositorio.

Cada repositorio tiene su propio ciclo de vida, sus propias pipelines y su propia cadencia de cambios. Puedes actualizar el umbral de una alarma sin redesplegar el servicio. Puedes refactorizar el servicio Java sin tocar la infraestructura. Puedes crear un dashboard nuevo sin que la build del servicio se entere.

Esto también tiene implicaciones prácticas durante incidentes: si hay una alerta de Kafka lag a las 3am, el equipo de guardia puede ajustar el umbral en alarms-composer y desplegarlo en minutos, sin necesidad de un release gate del servicio principal.


El Servicio: Arquitectura Hexagonal

El repositorio principal (payment-bridge) implementa el servicio con una arquitectura hexagonal (puertos y adaptadores). La idea es que la lógica de negocio no sabe de dónde vienen los datos ni cómo se transportan.

Los Puertos

  • Puertos de entrada: Un consumer de Kafka (PaymentRequestsConsumer) y un controller REST (PaymentVerificationResource). Ambos alimentan el mismo dominio.
  • Puertos de salida: VerifyPayClient (HTTPS hacia VerifyPay) y CipherClient (hacia el sidecar de descifrado).

El Dominio

Las clases *Flow son el corazón. Implementan Function<KafkaRecord<byte[]>, Mono<Void>> — una interfaz funcional simple que las hace testables, componibles y completamente desacopladas del transporte:

@Component
public class PaymentRequestsFlow implements Function<KafkaRecord<byte[]>, Mono<Void>> {

    @Override
    public Mono<Void> apply(KafkaRecord<byte[]> kafkaRecord) {
        return Mono.just(kafkaRecord)
            .flatMap(this::decryptKafkaRecord)               // 1. Descifrar
            .flatMap(this::translateToVerificationRequest)    // 2. Transformar
            .flatMap(this::notifyToVerifyPay)                 // 3. Enviar
            .then();
    }
}

Cada paso del pipeline transforma el tipo del payload:

byte[] (cifrado) → Map<String,Object> (descifrado) → Map<String,Object> (formato VerifyPay)

Esto lo hace posible el patrón de record inmutable como portador de contexto, que merece su propia sección.


KafkaRecord: El Portador Inmutable

En un pipeline reactivo donde el payload va cambiando de tipo, necesitas mantener la metadata original (headers Kafka, acknowledgment, transaction-id) a lo largo de toda la cadena. KafkaRecord resuelve esto de manera elegante:

public record KafkaRecord<T>(
    ConsumerRecord<String, byte[]> consumerRecord,
    T payload,
    Acknowledgment acknowledgment
) {
    public <T> KafkaRecord<T> withPayload(T newPayload) {
        return new KafkaRecord<>(consumerRecord, newPayload, acknowledgment);
    }
}

withPayload() crea un nuevo record con el payload transformado, pero conservando el consumerRecord original (que contiene los headers de trazabilidad) y el acknowledgment. El resultado es que el transaction-id viaja sin esfuerzo desde que llega el mensaje de Kafka hasta que se hace el ACK, sin necesidad de pasarlo explícitamente en cada método.

Es un uso perfecto de Java Records: inmutabilidad, cero código boilerplate y semántica clara.


El Puente Reactivo: ¿Por Qué block()?

El código de Spring Boot es reactivo (Project Reactor), pero Spring Kafka espera un listener síncrono. El CommonKafkaConsumer resuelve esta tensión con un patrón de puente:

public void listen(KafkaRecord<byte[]> kafkaRecord, Acknowledgment acknowledgment) {
    var firstAttemptTimestamp = firstTimestampKafkaMessageStore
        .getFirstTimestamp(getPartition(kafkaRecord), getOffset(kafkaRecord), topicName.get());

    Mono.just(kafkaRecord)
        .filter(this::shouldConsumeFrom)              // Feature toggle check
        .doOnNext(record -> kafkaRecordMetrics
            .emitMessageTripMetric(getTopic(record), getPartition(record), getProducerTimeHeader(record)))
        .flatMap(kafkaFlow)                           // Flujo de negocio (inyectado)
        .doOnSuccess(unused -> acknowledgment.acknowledge())
        .doOnError(not(shouldDiscard(firstAttemptTimestamp)), this::logRetryError)
        .doOnError(shouldDiscard(firstAttemptTimestamp), this::logDiscardError)
        .doOnError(shouldDiscard(firstAttemptTimestamp), unused -> acknowledgment.acknowledge())
        .onErrorComplete(shouldDiscard(firstAttemptTimestamp))
        .block();                                     // Puente: Reactor → Spring Kafka
}

El block() al final es el único punto donde se “rompe” la reactividad. No es un antipatrón aquí — es una decisión deliberada para hacer de puente entre dos modelos. Todo lo que ocurre dentro del Mono es no bloqueante; solo el boundary con Spring Kafka es síncrono.

Esto también implementa una lógica de descarte sofisticada: si un mensaje lleva más tiempo del máximo configurado en retry, se descarta (se hace ACK y se logea como discard). Si es un mensaje reciente, se loguea el error para que Spring Kafka lo reintente según su configuración.


Kafka: Configuración y Naming

La configuración de Kafka está diseñada para exactamente-una-vez semántica con ACK manual:

ConfiguraciónValorPor qué
ACK modeMANUAL_IMMEDIATESolo ACK cuando el procesamiento completa con éxito
DeserializerByteArrayDeserializerLos mensajes llegan cifrados; primero bytes, luego se descifran
Auto-commitfalseComplementa el ACK manual
Auto offset resetearliestProcesa mensajes pendientes al conectar un nuevo consumidor
ConcurrenciaNUMBER_OF_PARTITIONSUn thread por partición para máximo paralelismo

Los nombres de topic incluyen la versión del servicio, lo que permite migraciones sin downtime:

public class TopicName implements Supplier<String> {
    @Override
    public String get() {
        // "payment-requests" + "0.51.99+BUILD" → "payment-requests-0-51-99-BUILD"
        return getKafkaCompliantName(getCompleteName());
    }
}

Esto significa que puedes desplegar una nueva versión del servicio que consuma un topic diferente mientras la versión antigua sigue procesando, y migrar el tráfico gradualmente.


Cifrado: El Patrón Sidecar

Los mensajes Kafka viajan cifrados con KMS (AWS Key Management Service). El descifrado no lo hace el servicio directamente — lo delega a un sidecar (localhost:8082) que tiene acceso a las claves KMS:

public class CipherService {

    public Mono<Map<String, Object>> decrypt(byte[] message) {
        return Mono.just(message)
            .map(Hex::encodeHexString)                           // bytes → hex
            .map(hex -> Map.<String, Object>of("input-data", hex))
            .flatMap(request -> cipherClient.decrypt(keyLabel, request))  // HTTP al sidecar
            .map(response -> response.get("output-data").toString())
            .map(this::convertToMap);                            // hex → JSON → Map
    }
}

Esta decisión tiene implicaciones importantes: el servicio principal no necesita credenciales de KMS. Si el sidecar no está disponible, el descifrado falla y el mensaje se reintenta. La gestión de secretos queda completamente fuera del código de la aplicación.


Cliente HTTP: Resiliencia en Capas

La comunicación con VerifyPay tiene resiliencia en tres capas:

Capa 1 — WebClient: Connection pool agresivo y timeouts estrictos para alta carga:

var connectionProvider = ConnectionProvider.builder("verifyPayConnectionPool")
    .maxConnections(1000)
    .pendingAcquireMaxCount(2000)
    .build();

HttpClient.create(connectionProvider)
    .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 500)
    .responseTimeout(Duration.ofMillis(400));

Capa 2 — Error mapping: Errores de red tipados para poder distinguir entre ellos:

.onErrorMap(this::isConnectionRefusedError, this::createConnectionRefusedException)
.onErrorMap(this::isRequestTimeoutError, this::createRequestTimeoutException)

Capa 3 — Retry con discriminación de error:

.retryWhen(retryServerError)          // 3 reintentos para 5xx (300ms delay)
.retryWhen(retryUnrecognizableError)  // 3 reintentos para errores desconocidos
Tipo de errorAcción
4xxDescarta el mensaje (MessageDiscardedException)
5xxReintenta 3 veces (300ms)
Connection Refused / TimeoutMapea a 503, reintentable
Error desconocidoReintenta 3 veces → excepción definitiva

Para errores 4xx hay un patrón de fallback configurable: ante un error del proveedor externo, se devuelve una respuesta por defecto en lugar de bloquear la operación. Esto es una decisión de negocio — preferimos procesar la transacción con la respuesta por defecto que rechazarla por un fallo técnico del proveedor.


Feature Toggles: Kill Switches Operacionales

FF4j (in-memory) proporciona feature toggles que permiten actuar sobre el comportamiento del servicio sin redesplegar:

@Configuration
public class FF4jConfig {
    @Bean
    public FF4j getFF4j() {
        var ff4j = new FF4j();
        ff4j.setFeatureStore(new InMemoryFeatureStore());
        ff4j.autoCreate(false);
        return ff4j;
    }
}

Ejemplos de toggles en producción:

TogglePropósito
consume-from-payment-requestsKill switch: detener consumo del topic de pagos
consume-from-settlementsKill switch: detener consumo de liquidaciones
use-release-candidate-apiProbar versión RC del API de VerifyPay
enable-multi-currencyActivar soporte multi-divisa gradualmente

Y se gestionan en caliente vía REST:

GET  /admin/features            → Lista todos los toggles
PUT  /admin/features/{feature}  → Activa/desactiva sin redesplegar

En la práctica, los toggles más valiosos son los kill switches: cuando un topic tiene un spike de mensajes corruptos, puedes desactivar el consumo en segundos, sin necesidad de un hotfix ni un despliegue de emergencia.


Infraestructura: Composers en Lugar de Terraform

El repositorio de infraestructura provisiona todos los recursos AWS usando composers propios — librerías Python que envuelven Boto3. En lugar de Terraform o CloudFormation, se usa código Python que controla explícitamente el orden de creación y las dependencias:

cad_payment_bridge_infrastructure_composer create \
  --environment play \
  --cde-version 0.51.99 \
  --region eu-west-1

El orden de operaciones importa:

  1. Parameter Store con conexión de Kafka brokers
  2. ECS Fargate + API Gateway + networking
  3. Claves KMS multi-región (Irlanda + Frankfurt)
  4. Permisos IAM para que el task role acceda a KMS
  5. Private CA y acceso a Secrets Manager

Multi-región desde el principio: cada entorno se despliega en dos regiones (Irlanda eu-west-1 y Frankfurt eu-central-1). Las claves KMS son multi-región, permitiendo que consumidores en cualquier región descifren mensajes producidos en la otra.


Observabilidad: Las Tres Capas

Alarmas CloudWatch

El alarms-composer define cinco tipos de alarmas que cubren la salud completa del servicio:

AlarmaQué monitorizaUmbral
AvailabilitySi el proceso está corriendoprocess_uptime = 0
ErrorsErrores en logs> 4 errores en el periodo
DiscardsMensajes descartados tras reintentos≥ 350 discards / 15 min
Kafka LagsLag de los consumer groups≥ 1000 mensajes
Certificate ExpiryDías hasta expiración de certificados≤ 15 días

La alarma de Kafka Lags merece atención especial: las métricas nativas de Kafka en CloudWatch están dispersas en el namespace AWS/Kafka. Una Lambda de agregación (parte del alarms-composer, ejecutándose cada 2 minutos) las agrega y publica en el namespace custom del servicio:

AWS/Kafka (SumOffsetLag por consumer group)
    ↓ Lambda (EventBridge, cada 2 min)
AWS/ECS/{env}/payment-bridge (total_kafka_lags)

CloudWatch Alarm (kafka-lags ≥ 1000)

Dashboards Grafana

El monitoring-composer crea tres dashboards parametrizados por entorno:

  • Status: métricas de negocio (requests/s, latencia, errores, mensajes procesados)
  • JVM: heap memory, GC pauses, threads activos
  • Envoy: métricas del sidecar proxy

Las variables de template permiten reutilizar los dashboards entre entornos:

${selectedEnvironment} = live / play
${selectedDatasource} = CloudWatch (LIVE) / CloudWatch (WORK)

El Namespace Compartido

Todos los componentes convergen en un namespace de CloudWatch unificado:

AWS/ECS/{environment}/payment-bridge

El servicio publica métricas aquí (via Micrometer → StatsD). Las alarmas consultan aquí. La Lambda de Kafka agrega aquí. Los dashboards visualizan desde aquí. Es un contrato claro: quien produce, quien consume y donde.


CI/CD: La Cadena de Entornos

El gocd-pipelines define toda la orquestación. El flujo del servicio principal sigue una cadena progresiva con aprobaciones manuales:

Build (auto) → Acceptance Tests → Security Checks → Release Gate
    → Play → Work → Performance → Live

Detalles clave:

El Build ejecuta en cada commit: compilación, unit tests, JaCoCo (cobertura), PMD (análisis estático).

Security corre diariamente a las 8:00 y en cada cambio: Semgrep (bugs/vulnerabilidades en código), OWASP Dependency-Check (CVEs en dependencias), Trivy (vulnerabilidades en imagen Docker).

El Release Gate requiere aprobación manual y que hayan pasado acceptance tests y el stage trivy. Solo entonces se publican las imágenes Docker al registry.

Cada despliegue requiere aprobación manual. Para live, se requiere adicionalmente el rol release-manager.

Cada entorno despliega en dos regiones (Frankfurt + Irlanda) con pipelines separadas. En total son ~31 pipelines en dev/test y 6 en producción — toda la complejidad encapsulada en el quinto repositorio.


Lecciones Aprendidas

1. El SRP a nivel de repositorio tiene valor real. La independencia de ciclos de vida no es teórica — en la práctica, los cambios en alarmas y dashboards son 10x más frecuentes que los cambios en el servicio. Mezclarlos generaría ruido constante en el histórico de commits y en las pipelines.

2. La reactividad no tiene que ser todo-o-nada. El block() en CommonKafkaConsumer es un patrón legítimo. Forzar reactividad pura en los boundaries con sistemas síncronos genera complejidad innecesaria. El pragmatismo importa.

3. Los feature toggles son infraestructura, no una feature. Tener kill switches antes de desplegar a producción — no después — marca la diferencia entre un incidente de 5 minutos y uno de 2 horas.

4. El namespace compartido de métricas es un contrato de equipo. Cuando alarmas, Lambda, dashboards y el servicio convergen en AWS/ECS/{env}/payment-bridge, cualquier miembro del equipo sabe exactamente dónde buscar. No hay “¿en qué namespace están las métricas de Kafka?”.

5. Las claves KMS multi-región desde el principio. Añadir multi-región retroactivamente en un servicio de pagos cifrado es doloroso. Diseñarlo desde el día uno es gratuito.


Resumen

  • 5 repos = SRP a nivel de repositorio. Ciclos de vida independientes, pipelines aisladas, rollbacks granulares.
  • Arquitectura hexagonal: las clases *Flow son la lógica pura; los consumers y controllers son puertos de entrada; los clients son puertos de salida.
  • KafkaRecord<T>: record inmutable como portador de contexto a través del pipeline de transformación.
  • block() en el consumer: patrón de puente legítimo entre Reactor y Spring Kafka. No es un antipatrón — es pragmatismo.
  • Cifrado sidecar: el servicio principal no gestiona credenciales KMS; las delega a un sidecar local.
  • Feature toggles = kill switches: capacidad de desactivar consumo de topics sin redesplegar.
  • Observabilidad como código: alarmas, Lambda de agregación y dashboards Grafana viven en repos versionados y se despliegan con la misma disciplina que el servicio.
  • Cadena de entornos: play → work → performance → live, con aprobaciones manuales y release-manager para producción.