febrero de 2026
Proyectos

Event-Driven Fraud Detection

Arquitectura antifraude event-driven con microservicios, Kafka, idempotencia, SLOs de negocio y observabilidad operativa.

Java Spring Boot Kafka PostgreSQL Grafana Prometheus Loki Tempo

TL;DR

Este proyecto nació como una iniciativa práctica de arquitectura event-driven y se consolidó como una plataforma para aprender de verdad cómo diseñar y operar un pipeline asíncrono que no se rompa en silencio.

La arquitectura se apoyó en tres microservicios transaction-service, fraud-detection-service, alert-service conectados por Kafka, con idempotencia en consumidores, manejo explícito de DLQ y validación bajo carga con k6.

El resultado no fue una promesa abstracta de microservicios, sino un sistema con trade-offs claros, límites conocidos y un camino técnico concreto para evolucionar.


Contexto y motivación

Mi punto de partida fue una inquietud operativa: en un flujo síncrono clásico, la validación antifraude termina compitiendo por tiempo de respuesta con la creación de la transacción.

Eso funciona mientras el volumen es bajo y las reglas son simples. En cuanto aumenta la carga, aparecen reintentos, integraciones, errores parciales y más presión sobre latencia.

La decisión fue separar la persistencia del acto transaccional del análisis antifraude, sin perder trazabilidad del proceso completo.


Objetivo técnico

Definí este objetivo como guía de diseño:

  • Registrar transacciones rápido y de forma consistente.
  • Evaluar fraude de manera asíncrona con reglas explícitas.
  • Generar alertas auditables cuando hay riesgo real.
  • Poder detectar degradación antes de que se convierta en incidente.

Ese foco evitó que el proyecto se dispersara en funcionalidades accesorias.

También me dejó una definición útil de éxito técnico para esta etapa: no buscaba complejidad por sí misma, buscaba un flujo antifraude que soportara errores reales sin perder control operativo.


Arquitectura implementada

La arquitectura quedó dividida por contexto de dominio:

  • transaction-service: recibe REST/webhook, persiste transacción y publica TransactionCreatedEvent.
  • fraud-detection-service: consume eventos de transacción, evalúa reglas de riesgo y publica FraudDetectedEvent si aplica.
  • alert-service: consume fraude detectado, persiste alertas y dispara notificaciones.

Cada servicio tuvo su PostgreSQL para mantener independencia de modelo y evolución.

Diagrama de arquitectura completo con servicios, topics y bases de datos

La comunicación se apoyó en Kafka con tópicos principales y sus correspondientes DLQ:

  • transactions.created
  • fraud.detected
  • transactions.created.dlq
  • fraud.detected.dlq

Decisiones de particionado y escalabilidad

La plataforma se apoyó en tópicos particionados para poder escalar consumidores por grupo y sostener volumen sin convertir cada servicio en un cuello de botella único.

La decisión práctica fue diseñar desde el inicio pensando en procesamiento paralelo, pero manteniendo un modelo de idempotencia que tolerara redelivery y reproceso.

Límite de consistencia asumido

Acepté consistencia eventual entre transacción y alerta. La garantía no fue “todo al mismo tiempo”, sino “todo trazable, recuperable y medible”.

Ese límite fue importante porque evitó falsas promesas y obligó a definir métricas de salud de pipeline, no solo métricas HTTP.


Flujo operativo

  1. El cliente crea una transacción por REST o webhook.
  2. transaction-service valida el payload, persiste y publica evento.
  3. fraud-detection-service consume, consulta historial y calcula riskScore.
  4. Si el score supera umbral, publica FraudDetectedEvent.
  5. alert-service persiste alerta y ejecuta notificación.
  6. Si falla el procesamiento, el evento va a DLQ para análisis y reproceso.

Este flujo separó bien latencia de escritura y latencia de análisis, que era el principal conflicto del enfoque síncrono.

Punto de control por etapa

Para que el flujo no quedara como una caja negra, definí señales claras por etapa:

  • Entrada: volumen recibido por canal (REST/webhook) y validaciones fallidas.
  • Publicación: éxito/fallo al enviar eventos a Kafka.
  • Evaluación: decisiones clean/fraud y latencia de reglas.
  • Alertado: alertas creadas y resultado de notificaciones por canal.
  • Recuperación: tráfico DLQ, reprocesos exitosos y reprocesos fallidos.

Esa separación hizo mucho más simple localizar degradaciones sin saltar directamente a revisar todo el sistema.


Resiliencia y consistencia

Donde más valor aportó el diseño fue en control de efectos bajo semántica at-least-once.

Idempotencia de consumidores

Implementé idempotencia por eventId en una tabla processed_events con inserción atómica (saveAndFlush) y captura de DataIntegrityViolationException para tratar duplicados bajo concurrencia. Así, un mensaje duplicado no volvió a crear efectos de negocio.

Retries y DLQ

Configuré retries con backoff fijo y desvío a DLQ cuando el evento no pudo procesarse.

Reproceso explícito

No dejé la DLQ como almacén pasivo: la traté como parte del flujo operativo, con métricas y lógica de reproceso.

Qué problema evité con esta estrategia

Sin idempotencia + DLQ operada, un incidente de consumidor podía terminar en dos daños simultáneos: pérdida de visibilidad y duplicación de efectos.

Con la estrategia actual, cuando algo falla:

  1. El evento no desaparece, queda identificado.
  2. El fallo se vuelve visible por métricas y alertas.
  3. Existe una vía explícita de recuperación.

No eliminé los fallos, pero sí reduje su impacto y su tiempo de diagnóstico.

Trade-off que quedó pendiente

El punto más sensible siguió siendo el dual-write DB/Kafka en transaction-service.

Elegí ese camino para avanzar en flujo y observabilidad, sabiendo que el cierre robusto de consistencia debía llegar con Transactional Outbox.


Motor de reglas de fraude

El motor se mantuvo heurístico, con reglas acumulativas de score y umbral configurable.

Ese enfoque me permitió dos ventajas prácticas:

  • Explicabilidad inmediata de por qué se disparó una alerta.
  • Iteración rápida sobre reglas sin depender de un pipeline de entrenamiento.

Las señales principales que utilicé fueron monto, velocidad transaccional, cambio de país y merchants de riesgo.

Por qué no introduje ML en esta fase

No fue por desinterés técnico. Fue una decisión de secuencia.

Sin una base sólida de contratos, calidad de datos, observabilidad y operación de incidentes, incorporar ML habría aumentado complejidad sin mejorar realmente la confiabilidad del sistema.


Contratos de eventos

El intercambio entre servicios se apoyó en eventos JSON versionables por diseño, aunque todavía sin gobernanza formal de esquema.

El aprendizaje fue claro: en arquitecturas event-driven, cambiar un payload “pequeño” puede romper consumidores aguas abajo de forma silenciosa si no hay disciplina de compatibilidad.

Por eso dejé como siguiente paso formalizar:

  • Versionado explícito de eventos.
  • Validación de contrato en CI.
  • Reglas de backward compatibility para productores y consumidores.

Fragmento de código representativo

Este método resume el orden que seguí para evitar inconsistencias: deduplicar, evaluar, persistir trazabilidad y publicar.

fraud-detection-service/src/main/java/com/fraud/detection/service/FraudDetectionService.java
@Transactional
public void process(TransactionCreatedEvent event) {
fraudDetectionMetrics.recordEventConsumed();
String traceId = resolveTraceId(event.traceId());
long lagMs = event.occurredAt() == null ? 0 : Math.max(0, Instant.now().toEpochMilli() - event.occurredAt().toEpochMilli());
log.info("fraud_event_consumed",
kv("event", "fraud_event_consumed"),
kv("outcome", "success"),
kv("eventId", event.eventId()),
kv("transactionId", event.transactionId()),
kv("lag_ms", lagMs)
);
Instant occurredAt = event.occurredAt() != null ? event.occurredAt() : Instant.now();
if (!tryMarkAsProcessed(event.eventId(), occurredAt)) {
log.info("fraud_event_duplicate",
kv("event", "fraud_event_duplicate"),
kv("outcome", "duplicate"),
kv("eventId", event.eventId()),
kv("transactionId", event.transactionId())
);
return;
}
long recentTransactionsCount = historyRepository.countByUserIdAndOccurredAtAfter(
event.userId(),
occurredAt.minus(rules.getVelocityWindow())
);
Optional<UserTransactionHistory> lastTransaction = historyRepository.findTopByUserIdOrderByOccurredAtDesc(event.userId());
long evaluationStartNanos = System.nanoTime();
FraudEvaluation evaluation = fraudRulesEngine.evaluate(event, lastTransaction, recentTransactionsCount, occurredAt);
long evaluationDurationNanos = System.nanoTime() - evaluationStartNanos;
fraudDetectionMetrics.recordEvaluationNanos(evaluationDurationNanos);
double evaluationDurationMs = evaluationDurationNanos / 1_000_000.0;
log.info("fraud_rules_evaluated",
kv("event", "fraud_rules_evaluated"),
kv("outcome", "success"),
kv("eventId", event.eventId()),
kv("transactionId", event.transactionId()),
kv("risk_score", evaluation.riskScore()),
kv("rules_triggered_count", evaluation.reasons().size()),
kv("reasons", evaluation.reasons()),
kv("duration_ms", evaluationDurationMs)
);
for (String rule : evaluation.reasons()) {
fraudDetectionMetrics.recordRuleHit(rule);
log.info("fraud_rule_hit",
kv("event", "fraud_rule_hit"),
kv("outcome", "success"),
kv("eventId", event.eventId()),
kv("transactionId", event.transactionId()),
kv("rule", rule)
);
}
historyRepository.save(userTransactionHistoryMapper.toHistory(event, occurredAt));
if (!evaluation.fraudulent()) {
fraudDetectionMetrics.recordDecision("clean");
log.info("fraud_decision_made",
kv("event", "fraud_decision_made"),
kv("outcome", "clean"),
kv("eventId", event.eventId()),
kv("transactionId", event.transactionId()),
kv("decision", "clean"),
kv("risk_score", evaluation.riskScore()),
kv("rule_version", RULE_VERSION)
);
return;
}
fraudDetectionMetrics.recordDecision("fraud");
log.warn("fraud_decision_made",
kv("event", "fraud_decision_made"),
kv("outcome", "fraud"),
kv("eventId", event.eventId()),
kv("transactionId", event.transactionId()),
kv("decision", "fraud"),
kv("risk_score", evaluation.riskScore()),
kv("reasons", evaluation.reasons()),
kv("rule_version", RULE_VERSION)
);
FraudDetectedEvent fraudDetectedEvent = fraudDetectedEventMapper.toFraudDetectedEvent(
event,
evaluation,
UUID.randomUUID().toString(),
Instant.now(),
traceId,
RULE_VERSION
);
try {
fraudEventPublisher.publish(fraudDetectedEvent);
fraudDetectionMetrics.recordFraudEventPublished("success");
log.info("fraud_event_published",
kv("event", "fraud_event_published"),
kv("outcome", "success"),
kv("eventId", fraudDetectedEvent.eventId()),
kv("transactionId", fraudDetectedEvent.transactionId()),
kv("risk_score", fraudDetectedEvent.riskScore())
);
} catch (IllegalStateException ex) {
fraudDetectionMetrics.recordFraudEventPublished("failed");
log.error("fraud_event_publish_failed",
kv("event", "fraud_event_publish_failed"),
kv("outcome", "failed"),
kv("eventId", fraudDetectedEvent.eventId()),
kv("transactionId", fraudDetectedEvent.transactionId()),
kv("error_code", "KAFKA_PUBLISH_FAILED"),
kv("error_class", ex.getClass().getSimpleName()),
kv("error_message", ex.getMessage())
);
throw ex;
}
}

Ese orden redujo efectos indeseados en escenarios de redelivery y dejó más claro dónde instrumentar cuando aparecieron errores intermitentes.


Observabilidad operativa

La observabilidad no entró al final: la traté como requisito estructural.

  • Métricas con Prometheus.
  • Logs estructurados con Loki.
  • Trazas distribuidas con Tempo.
  • Dashboards de operación y triage en Grafana.

Dashboards destacados:

  • fraud-observability
  • fraud-alerting-live
  • fraud-tracing
  • fraud-alert-triage-db

También incorporé reglas de alertado con enfoque de negocio (coverage, conversión fraude->alerta, latencias y fallos de notificación).

Métricas que más valor aportaron

Más allá de CPU/memoria, las métricas que marcaron diferencias fueron las de comportamiento del negocio:

  • transactions_received_total
  • fraud_decisions_total{decision}
  • fraud_alerts_total
  • fraud_alert_notifications_total{channel,outcome}
  • kafka_dlq_events_received_total
  • kafka_dlq_events_reprocessed_total

Con ese conjunto pude responder preguntas concretas durante pruebas e incidentes: cuántas transacciones llegaron, cuántas se evaluaron, cuántas terminaron en alerta y dónde se degradó el flujo.

Logs y trazas con contexto útil

Los logs estructurados con traceId y spanId permitieron pasar de una alerta en panel a una traza específica sin navegación manual excesiva.

Ese puente entre métricas, logs y trazas fue lo que convirtió la observabilidad en herramienta operativa real, no en un tablero “bonito”.


Validación de comportamiento bajo carga

Para validar comportamiento real, pasé de scripts ad-hoc a una estrategia reproducible con k6.

  • Modos de prueba: stress, spike, soak, smoke.
  • Perfiles de payload: balanced, mostly-normal, fraud-focus, validation, chaos-5xx.
  • Ejecución interactiva o no interactiva, local o vía Docker.

Esto permitió observar no solo throughput, sino también degradación de latencia, resiliencia ante errores y estabilidad del pipeline de eventos.

Qué busqué en las pruebas

No busqué únicamente “más RPS”. Busqué validar cuatro preguntas concretas:

  1. Si aumentaba el tráfico, ¿la API seguía respondiendo dentro de umbrales razonables?
  2. Si subía el porcentaje de payload inválido, ¿el sistema degradaba de forma controlada?
  3. Si forzaba escenarios de error, ¿la DLQ absorbía correctamente y quedaba trazabilidad?
  4. Si el tráfico era mixto, ¿el ratio de decisiones y alertas seguía siendo coherente?

Esa perspectiva hizo que la carga se usara como validación de arquitectura y no solo como benchmark superficial.


Estrategia de pruebas

Combiné tres niveles para cubrir desde lógica hasta flujo completo:

  • Unitarias para reglas de fraude y componentes de dominio.
  • Integración con Testcontainers (Kafka + PostgreSQL) para validar interacción real entre infraestructura y código.
  • E2E para comprobar el recorrido completo desde creación de transacción hasta materialización de alerta.

Esta combinación redujo huecos entre lo que “parecía correcto” en unit tests y lo que realmente ocurría cuando intervenían brokers, commits y reintentos.


Operación diaria y triage

Una parte importante del trabajo estuvo en definir cómo investigar rápido cuando algo salía mal.

Flujo de triage que seguí:

  1. Verificar señales globales en dashboards de negocio.
  2. Confirmar si el problema estaba en publicación, consumo o notificación.
  3. Correlacionar con logs estructurados por traceId.
  4. Abrir traza distribuida para entender la secuencia exacta.
  5. Revisar métricas de DLQ para decidir reproceso o intervención.

Este enfoque evitó diagnósticos por intuición y mejoró la velocidad de respuesta ante incidencias.


Lo que quedó fuera (de forma consciente)

Esta versión dejó fuera piezas importantes para no perder foco de arquitectura base:

  • Seguridad completa en endpoints (authN/authZ y rate limiting por actor).
  • Transactional Outbox para cerrar dual-write DB/Kafka.
  • Gobernanza formal de contratos de eventos (versionado y validación de esquema).
  • Motor de fraude avanzado (segmentación y estrategias adaptativas).

No fue omisión accidental: fue priorización para estabilizar primero la columna vertebral del sistema.

Esa priorización también ayudó a no mezclar capas: primero robustecí flujo, señales y recuperación; después planifiqué endurecimiento de seguridad, contratos y evolución del motor.


Deudas técnicas principales

ÁreaSituación actualRiesgoPróximo paso
DB + KafkaDual-write en transaction-serviceDesalineación en fallos rarosOutbox + relay
Esquema de BDUso parcial de ddl-autoDrift de esquemaMigraciones versionadas (Flyway/Liquibase)
Contratos de eventoJSON sin governance formalBreaking changes silenciososVersionado + validación de contrato
SeguridadEndpoints abiertosExposición operativaJWT/OAuth2 + rate limiting

Además, dejé identificado un frente adicional: homogeneizar naming de métricas para evitar paneles ambiguos cuando la plataforma siga creciendo.


Aprendizajes clave

  1. En distribuido, el reto principal no fue enviar mensajes, sino controlar consecuencias de fallos parciales.
  2. La idempotencia dejó de ser recomendación y pasó a ser requisito mínimo.
  3. Una DLQ sin proceso operativo fue deuda escondida.
  4. La observabilidad temprana acortó muchísimo el tiempo de diagnóstico.
  5. Definir alcance técnico con honestidad aceleró más que intentar cubrir todo a la vez.

Próximos pasos

  1. Implementar Transactional Outbox para consistencia fuerte entre DB y publicación de eventos.
  2. Endurecer seguridad de APIs y notificaciones.
  3. Formalizar contratos de eventos con compatibilidad hacia atrás.
  4. Evolucionar reglas antifraude con experimentación controlada por segmentos.

Cierre

Este proyecto me dejó una conclusión práctica: en arquitecturas orientadas a eventos, construir el flujo funcional es solo la mitad del trabajo; la otra mitad es hacerlo observable, recuperable y operable bajo presión.

Eso cambió mi criterio de diseño: ahora priorizo garantías, señales operativas y estrategias de recuperación desde el inicio.