javamicroserveisarquitecturabackendawskafka

Microserveis a Escala: El Patró de 5 Repositoris

Com estructurem microserveis en producció amb 5 repositoris independents, arquitectura hexagonal, programació reactiva amb Kafka i un stack d'observabilitat complet a AWS.

Microserveis a Escala: El Patró de 5 Repositoris

Processar milers de pagaments per segon implica molt més que escriure bon codi Java. Implica decidir com s’organitza aquell codi, com es desplega, com es monitoritza i com es recupera quan alguna cosa falla — sense que un canvi al dashboard de Grafana dispari la compilació del servei.

Aquest post descriu l’arquitectura que fem servir a BBVA per estructurar microserveis de pagament: el patró de 5 repositoris, juntament amb els patrons de codi, infraestructura i observabilitat que el fan funcionar en producció.

Per aterrar els conceptes, tot gira al voltant d’un microservei fictici anomenat payment-bridge: un adaptador entre la plataforma interna de pagaments i un proveïdor extern anomenat VerifyPay que valida transaccions. Rep esdeveniments xifrats per Kafka, els desxifra, els transforma i els envia per HTTPS. L’arquitectura que descriu és idèntica a la dels serveis reals.


El Problema: Un Microservei No És Un Repositori

La decisió més important — i la que més sorprèn qui ve d’altres projectes — és que cada microservei no viu en un únic repositori. Viu en cinc:

RepositoriResponsabilitatLlenguatge
payment-bridgeLògica de negociJava (Spring Boot)
payment-bridge-infrastructure-composerProvisió d’infraestructura AWSPython
payment-bridge-alarms-composerAlarmes CloudWatchPython
payment-bridge-monitoring-composerDashboards GrafanaPython
payment-bridge-gocd-pipelinesPipelines CI/CDJSON (GoCD)

Per què? El Principi de Responsabilitat Única aplicat a nivell de repositori.

Cada repositori té el seu propi cicle de vida, les seves pròpies pipelines i la seva pròpia cadència de canvis. Pots actualitzar el llindar d’una alarma sense redesplegar el servei. Pots refactoritzar el servei Java sense tocar la infraestructura. Pots crear un dashboard nou sense que la build del servei s’assabenti.

Això també té implicacions pràctiques durant incidents: si hi ha una alerta de Kafka lag a les 3am, l’equip de guàrdia pot ajustar el llindar a alarms-composer i desplegar-lo en minuts, sense necessitat d’un release gate del servei principal.


El Servei: Arquitectura Hexagonal

El repositori principal (payment-bridge) implementa el servei amb arquitectura hexagonal (ports i adaptadors). La idea és que la lògica de negoci no sap d’on vénen les dades ni com es transporten.

Els Ports

  • Ports d’entrada: Un consumer de Kafka (PaymentRequestsConsumer) i un controller REST (PaymentVerificationResource). Tots dos alimenten el mateix domini.
  • Ports de sortida: VerifyPayClient (HTTPS cap a VerifyPay) i CipherClient (cap al sidecar de desxifrat).

El Domini

Les classes *Flow són el cor. Implementen Function<KafkaRecord<byte[]>, Mono<Void>> — una interfície funcional simple que les fa testables, composables i completament desacoblades del transport:

@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. Desxifrar
            .flatMap(this::translateToVerificationRequest)    // 2. Transformar
            .flatMap(this::notifyToVerifyPay)                 // 3. Enviar
            .then();
    }
}

Cada pas del pipeline transforma el tipus del payload:

byte[] (xifrat) → Map<String,Object> (desxifrat) → Map<String,Object> (format VerifyPay)

Això ho fa possible el patró del record immutable com a portador de context, que mereix la seva pròpia secció.


KafkaRecord: El Portador Immutable

En un pipeline reactiu on el tipus del payload va canviant a cada pas, cal mantenir la metadata original de Kafka (headers, acknowledgment, transaction-id) al llarg de tota la cadena. KafkaRecord resol això de manera elegant:

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 nou record amb el payload transformat, però conservant el consumerRecord original (que conté els headers de traçabilitat) i l’acknowledgment. El resultat és que el transaction-id viatja sense esforç des que arriba el missatge de Kafka fins que es fa l’ACK, sense necessitat de passar-lo explícitament a cada mètode.

És un ús perfecte dels Java Records: immutabilitat, zero codi boilerplate i semàntica clara.


El Pont Reactiu: Per Què block()?

El codi de Spring Boot és reactiu (Project Reactor), però Spring Kafka espera un listener síncron. CommonKafkaConsumer resol aquesta tensió amb un patró de pont:

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)                           // Flux de negoci (injectat)
        .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();                                     // Pont: Reactor → Spring Kafka
}

El block() al final és l’únic punt on es “trenca” la reactivitat. No és un antipatró aquí — és una decisió deliberada per fer de pont entre dos models. Tot el que passa dins del Mono és no bloquejant; només el boundary amb Spring Kafka és síncron.

Això també implementa una lògica de descart sofisticada: si un missatge porta més temps del màxim configurat en retry, es descarta (es fa ACK i es registra com a descart). Si és un missatge recent, es registra l’error perquè Spring Kafka el reintenti.


Kafka: Configuració i Naming

La configuració de Kafka està dissenyada per a semàntica exactament-una-vegada amb ACK manual:

ConfiguracióValorPer què
ACK modeMANUAL_IMMEDIATENomés ACK quan el processament completa amb èxit
DeserializerByteArrayDeserializerEls missatges arriben xifrats; primer bytes, llavors es desxifren
Auto-commitfalseComplementa l’ACK manual
Auto offset resetearliestProcessa missatges pendents en connectar un nou consumer
ConcurrènciaNUMBER_OF_PARTITIONSUn thread per partició per màxim paral·lelisme

Els noms de topic inclouen la versió del servei, permetent migracions sense 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());
    }
}

Això significa que pots desplegar una nova versió del servei que consumeixi un topic diferent mentre la versió antiga segueix processant, i migrar el tràfic gradualment.


Xifratge: El Patró Sidecar

Els missatges Kafka viatgen xifrats amb KMS (AWS Key Management Service). El desxifrat no el fa el servei directament — el delega a un sidecar (localhost:8082) que té accés a les claus 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
    }
}

Aquesta decisió té implicacions importants: el servei principal no necessita credencials de KMS. Si el sidecar no està disponible, el desxifrat falla i el missatge es reintenta. La gestió de secrets queda completament fora del codi de l’aplicació.


Client HTTP: Resiliència per Capes

La comunicació amb VerifyPay té resiliència en tres capes:

Capa 1 — WebClient: Connection pool agressiu i timeouts estrictes per a alta càrrega:

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: Errors de xarxa tipats per discriminar-los amb precisió:

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

Capa 3 — Retry amb discriminació d’error:

.retryWhen(retryServerError)          // 3 reintents per a 5xx (300ms delay)
.retryWhen(retryUnrecognizableError)  // 3 reintents per a errors desconeguts
Tipus d’errorAcció
4xxDescarta el missatge (MessageDiscardedException)
5xxReintenta 3 vegades (300ms)
Connection Refused / TimeoutMapeja a 503, reintentable
Error desconegutReintenta 3 vegades → excepció definitiva

Per a errors 4xx hi ha un patró de fallback configurable: en lloc de bloquejar l’operació, es retorna una resposta per defecte. Aquesta és una decisió de negoci — preferim processar la transacció amb la resposta per defecte que rebutjar-la per un error tècnic del proveïdor.


Feature Toggles: Kill Switches Operacionals

FF4j (in-memory) proporciona feature toggles que permeten canviar el comportament del servei sense redesplegar:

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

Toggles típics en producció:

TogglePropòsit
consume-from-payment-requestsKill switch: aturar el consum del topic de pagaments
consume-from-settlementsKill switch: aturar el consum de liquidacions
use-release-candidate-apiProvar la versió RC de l’API de VerifyPay
enable-multi-currencyActivar suport multi-divisa gradualment

Gestionats en calent via REST:

GET  /admin/features            → Llista tots els toggles
PUT  /admin/features/{feature}  → Activa/desactiva sense redesplegar

A la pràctica, els toggles més valuosos són els kill switches: quan un topic rep un spike de missatges corruptes, pots desactivar el consum en segons — sense hotfix ni desplegament d’emergència.


Infraestructura: Composers en Lloc de Terraform

El repositori d’infraestructura provisiona tots els recursos AWS usant composers propis — llibreries Python que embolcallen Boto3. En lloc de Terraform o CloudFormation, codi Python explícit controla l’ordre de creació i les dependències:

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

L’ordre d’operacions importa:

  1. Parameter Store amb la cadena de connexió dels brokers Kafka
  2. ECS Fargate + API Gateway + networking
  3. Claus KMS multi-regió (Irlanda + Frankfurt)
  4. Permisos IAM perquè el task role pugui accedir a KMS
  5. Private CA i accés a Secrets Manager

Multi-regió des del primer dia: cada entorn es desplega en dues regions AWS (Irlanda eu-west-1 i Frankfurt eu-central-1). Les claus KMS són multi-regió, permetent que els consumers de qualsevol regió desxifrin missatges produïts a l’altra.


Observabilitat: Tres Capes

Alarmes CloudWatch

El alarms-composer defineix cinc tipus d’alarmes que cobreixen la salut completa del servei:

AlarmaQuè monitoritzaLlindar
AvailabilitySi el procés s’executaprocess_uptime = 0
ErrorsErrors als logs (nivell ERROR)> 4 errors en el període
DiscardsMissatges descartats després de reintents≥ 350 discards / 15 min
Kafka LagsLag dels consumer groups≥ 1000 missatges
Certificate ExpiryDies fins a expiració de certificats≤ 15 dies

L’alarma de Kafka Lags mereix atenció especial: les mètriques natives de Kafka a CloudWatch estan disperses al namespace AWS/Kafka. Una Lambda d’agregació (part del alarms-composer, executant-se cada 2 minuts via EventBridge) les agrega i publica al namespace custom del servei:

AWS/Kafka (SumOffsetLag per 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 parametritzats per entorn:

  • Status: mètriques de negoci (requests/s, latència, errors, missatges processats)
  • JVM: heap memory, GC pauses, threads actius
  • Envoy: mètriques del sidecar proxy

Les variables de template permeten reutilitzar els dashboards entre entorns:

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

El Namespace Compartit

Tots els components convergeixen en un namespace de CloudWatch unificat:

AWS/ECS/{environment}/payment-bridge

El servei publica mètriques aquí (via Micrometer → StatsD). Les alarmes consulten aquí. La Lambda de Kafka agrega aquí. Els dashboards visualitzen des d’aquí. És un contracte clar: qui produeix, qui consumeix, on.


CI/CD: La Cadena d’Entorns

El gocd-pipelines defineix tota l’orquestració. El flux del servei principal segueix una cadena progressiva amb aprovacions manuals:

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

Detalls clau:

El Build s’executa en cada commit: compilació, unit tests, JaCoCo (cobertura), PMD (anàlisi estàtica).

Security s’executa diàriament a les 8:00 i en cada canvi: Semgrep (bugs/vulnerabilitats al codi), OWASP Dependency-Check (CVEs en dependències), Trivy (vulnerabilitats a la imatge Docker).

El Release Gate requereix aprovació manual i que hagin passat els acceptance tests i l’stage trivy. Només llavors es publiquen les imatges Docker al registry.

Cada desplegament requereix aprovació manual. Per a live, s’exigeix addicionalment el rol release-manager.

Cada entorn es desplega en dues regions (Frankfurt + Irlanda) amb pipelines separades. En total: ~31 pipelines en dev/test, 6 en producció — tota la complexitat encapsulada al cinquè repositori.


Lliçons Apreses

1. El SRP a nivell de repositori té valor real. La independència dels cicles de vida no és teòrica — a la pràctica, els canvis en alarmes i dashboards succeeixen 10x més sovint que els canvis al servei. Barrejar-los generaria soroll constant a l’historial de commits i a les pipelines.

2. La reactivitat no ha de ser tot o res. El block() a CommonKafkaConsumer és un patró legítim. Forçar reactivitat pura als boundaries amb sistemes síncrons genera complexitat innecessària. El pragmatisme importa.

3. Els feature toggles són infraestructura, no una feature. Tenir kill switches abans de desplegar a producció — no després — marca la diferència entre un incident de 5 minuts i un de 2 hores.

4. El namespace compartit de mètriques és un contracte d’equip. Quan alarmes, Lambda, dashboards i el servei convergeixen a AWS/ECS/{env}/payment-bridge, qualsevol membre de l’equip sap exactament on buscar. Sense “a quin namespace estan les mètriques de Kafka?”.

5. Les claus KMS multi-regió des del primer dia. Afegir suport multi-regió retroactivament en un servei de pagaments xifrat és dolorós. Dissenyar-ho des del dia u no té cap cost.


Resum

  • 5 repos = SRP a nivell de repositori. Cicles de vida independents, pipelines aïllades, rollbacks granulars.
  • Arquitectura hexagonal: les classes *Flow són la lògica pura; consumers i controllers són ports d’entrada; clients són ports de sortida.
  • KafkaRecord<T>: record immutable com a portador de context a través del pipeline de transformació.
  • block() al consumer: patró de pont legítim entre Reactor i Spring Kafka. No és un antipatró — és pragmatisme.
  • Xifratge sidecar: el servei principal no gestiona credencials KMS; les delega a un sidecar local.
  • Feature toggles = kill switches: capacitat de desactivar el consum de topics sense redesplegar.
  • Observabilitat com a codi: alarmes, Lambda d’agregació i dashboards Grafana viuen en repos versionats i es despleguen amb la mateixa disciplina que el servei.
  • Cadena d’entorns: play → work → performance → live, amb aprovacions manuals i release-manager per a producció.