Arquitectura
29 artículos
Observabilidad sin Ruido: Diseñando un Sistema de Logs con AOP en Arquitecturas DDD — Parte III
- Mauricio ECR
- Arquitectura
- 07 Jun, 2026
Las dos primeras partes de esta serie resolvieron un problema bien delimitado: construir un sistema de logging centralizado que operara de forma transversal sobre una arquitectura DDD sin contaminar l
Observabilidad sin Ruido: Diseñando un Sistema de Logs con AOP en Arquitecturas DDD — Parte III
- Mauricio ECR
- Arquitectura
- 07 Jun, 2026
Las dos primeras partes de esta serie resolvieron un problema bien delimitado: construir un sistema de logging centralizado que operara de forma transversal sobre una arquitectura DDD sin contaminar la lógica de negocio. Al final de ese recorrido, el sistema producía registros estructurados, consistentes y con métricas de tiempo precisas en cada capa, todo sin una sola línea de log escrita manualmente en ninguna clase del dominio o la infraestructura.
Pero quedaba una deuda pendiente, y era visible precisamente porque el sistema funcionaba tan bien. Al serializar los argumentos y resultados de cada método, el aspecto exponía los datos tal como viajan por el sistema: nombres completos, direcciones de correo, identificadores, cualquier dato que el método recibiera o retornara aparecía en texto plano en el log. En un entorno de desarrollo o en una demostración técnica eso es aceptable. En producción, con herramientas de observabilidad accesibles a equipos de soporte, operaciones o incluso a proveedores externos, es un problema real.
La respuesta convencional a este problema es la convención: no logueen datos personales. Ya se exploró en la primera parte por qué las convenciones fallan, y el argumento aplica aquí con la misma fuerza. Una convención requiere que cada desarrollador, en cada momento, recuerde aplicarla. El día que alguien olvida, o que un nuevo integrante del equipo no la conoce, la protección desaparece sin dejar rastro. La única solución que escala es que la privacidad deje de ser responsabilidad de quien escribe el log y pase a ser una propiedad declarada en el modelo. Esta tercera parte documenta cómo se implementa exactamente eso.
El punto de partida: qué tenía el sistema y qué faltaba
Al concluir la segunda parte, el sistema de logs contaba con cinco artefactos: LoggingAopProperties para la configuración de patrones de interceptación, JacksonConfig para el ObjectMapper del aspecto, MethodLoggingAspect como motor de interceptación, la clase principal de la aplicación con @EnableConfigurationProperties, y el archivo application.properties. Cinco piezas que funcionaban como una unidad cohesionada.
El problema que esta iteración viene a resolver surgió de una decisión de diseño inicial que parecía razonable en ese momento: el ObjectMapper que usaba el aspecto para serializar argumentos y resultados era el mismo que Spring MVC usaba para las respuestas HTTP. Esto implicaba que cualquier cambio en la serialización para los logs afectaría también al contrato público de la API. Si se añadía un introspector que enmascarara emails, los emails llegarían enmascarados no solo al log, sino también al cliente que consumía la API. Es exactamente el tipo de acoplamiento involuntario que un diseño cuidadoso debe evitar: dos preocupaciones distintas compartiendo la misma pieza de infraestructura, sin que ninguna de las dos pueda evolucionar independientemente.
La primera tarea, entonces, era separar los dos mappers: uno para las respuestas HTTP, sin ninguna modificación, y otro exclusivo para los logs, que sería el que recibiría toda la lógica de enmascaramiento. Esta separación no es un detalle técnico menor. Es la decisión arquitectónica que hace posible todo lo que viene después.
La separación de los ObjectMapper
La solución es directa. Se crean dos configuraciones de Jackson independientes, cada una produciendo su propio bean con un calificador distinto.
SpringJacksonConfig, en el paquete applications/config, produce el ObjectMapper principal de la aplicación anotado con @Primary. Este mapper no tiene ningún introspector especial ni ninguna lógica de enmascaramiento. Es el que Spring MVC usa por defecto para serializar las respuestas HTTP y para deserializar los cuerpos de los requests, exactamente igual que antes:
@Configuration
public class SpringJacksonConfig {
@Bean
@Primary
public ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
return mapper;
}
}
JacksonConfig, en el paquete applications/shared/serialization/config, produce un segundo ObjectMapper identificado con el calificador "loggingObjectMapper". Este es el que el aspecto recibe por inyección y el único que conoce la existencia del sistema de enmascaramiento:
@Configuration
public class JacksonConfig {
@Bean("loggingObjectMapper")
public ObjectMapper objectMapper(MaskingStrategyRegistry registry, MaskingProperties maskingProperties) {
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
mapper.setAnnotationIntrospector(new DomainAnnotationIntrospectorConfig(registry, maskingProperties));
return mapper;
}
}
La línea que marca la diferencia es mapper.setAnnotationIntrospector(...). Un introspector en Jackson es el componente que decide, campo por campo, cómo debe serializarse cada propiedad de un objeto. Al inyectar un introspector personalizado, se puede interceptar el proceso de serialización en el momento exacto en que Jackson va a escribir un campo y aplicar la lógica de enmascaramiento antes de que el valor llegue al log. El aspecto, por su parte, pasa a inyectar el mapper correcto usando el calificador:
private final @Qualifier("loggingObjectMapper") ObjectMapper objectMapper;
A partir de este punto, los dos mappers evolucionan de forma completamente independiente. Añadir una nueva estrategia de enmascaramiento, modificar el comportamiento de una existente, o cambiar cómo se resuelven las reglas por nombre de campo son operaciones que ocurren en el sistema de logs sin afectar en absoluto las respuestas HTTP de la aplicación.
Las anotaciones del dominio
Con la infraestructura de serialización dividida, el siguiente paso es definir el vocabulario que el modelo de dominio usará para declarar la sensibilidad de sus campos. Ese vocabulario son tres anotaciones que viven en el paquete del dominio, completamente aisladas de cualquier dependencia de infraestructura.
La primera es @Hidden. Cuando un campo está anotado con ella, el introspector le indica a Jackson que lo omita completamente durante la serialización. No aparece como null, no aparece enmascarado: directamente no existe en el JSON producido para el log. El caso de uso más claro es el de campos cuyo tamaño o naturaleza los hace inadecuados para cualquier registro: imágenes en Base64, documentos adjuntos, objetos anidados muy grandes. En el proyecto de ejemplo se aplica sobre el campo fechaRegistro del modelo Usuario, que es un dato técnico interno sin valor para el diagnóstico operacional:
@Hidden
private LocalDateTime fechaRegistro;
La segunda es @Masked. Esta anotación indica que el campo contiene información sensible y que su valor debe transformarse antes de escribirse en el log. A diferencia de @Hidden, el campo sigue apareciendo en el registro, pero con su contenido protegido. La anotación acepta cuatro parámetros que controlan cómo se aplica la transformación: type define la estrategia de enmascaramiento, visibleStart y visibleEnd especifican cuántos caracteres se preservan al inicio y al final del valor original, y maskChar define el carácter de relleno. Cuando no se especifica ningún parámetro, el comportamiento por defecto es enmascaramiento total con asteriscos:
@Masked(type = MaskType.EMAIL)
private String email;
@Masked
private Integer edad;
@Masked(type = MaskType.CUSTOM, visibleStart = 2, visibleEnd = 2, maskChar = '*')
private String username;
La tercera anotación, @NoMask, sirve como escape explícito del sistema. Si un campo pertenece a una clase que tiene reglas globales por nombre aplicadas desde application.properties, pero ese campo en particular no debe enmascararse aunque su nombre coincida con alguna regla, @NoMask garantiza que el introspector lo serialice sin ninguna transformación. Es la forma de decir explícitamente que este campo, en este contexto, es seguro para el log.
Las tres se definen con retención RUNTIME para que estén disponibles mediante reflexión en el momento de la serialización, y con target FIELD porque se aplican sobre los campos del modelo:
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Hidden { }
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Masked {
MaskType type() default MaskType.FULL;
int visibleStart() default -1;
int visibleEnd() default -1;
char maskChar() default '*';
}
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NoMask { }
Junto a las anotaciones, el enumerado MaskType define el catálogo de estrategias disponibles. En lugar de pasar strings o constantes al sistema, cada campo declara su tipo de máscara usando un valor tipado:
public enum MaskType {
EMAIL,
PHONE,
CREDIT_CARD,
DOCUMENT,
PASSWORD,
TOKEN,
IBAN,
FULL,
CUSTOM
}
El catálogo incluye tanto tipos genéricos —FULL para enmascaramiento total y CUSTOM para transformaciones configuradas con los parámetros de la anotación— como tipos específicos por categoría de dato. El tipo CUSTOM merece una mención especial: es el que habilita las máscaras de desplazamiento, donde el desarrollador controla exactamente cuántos caracteres quedan visibles y desde dónde, sin necesidad de crear una estrategia nueva para cada variante.
Vale la pena detenerse un momento en dónde viven estas definiciones. Las anotaciones y el enumerado están en el paquete domain/shared/serialization/masking, dentro del dominio. No en infraestructura, no en la capa de aplicación: en el dominio. Esto es deliberado y tiene una implicación directa en el modelo de propiedad: quien define qué es sensible es el modelo de dominio mismo, en el mismo lugar donde se define la estructura del dato. Cuando un desarrollador abre Usuario.java y ve @Masked(type = MaskType.EMAIL) sobre el campo email, la intención es inmediata y no requiere buscar configuración en ningún otro archivo.
Las estrategias de enmascaramiento
Con el vocabulario de declaración definido, se necesita el mecanismo de ejecución: las clases que saben cómo transformar un valor según cada tipo de máscara. El diseño usa el patrón Strategy, con una interfaz común que todas las implementaciones respetan:
public interface MaskingStrategy {
String mask(String value, Masked annotation);
}
El parámetro annotation no es ceremonial. Algunas estrategias, como CUSTOM, necesitan leer los valores de visibleStart, visibleEnd y maskChar de la anotación para saber cómo operar. Pasarla como argumento en lugar de extraerla en cada implementación hace que la interfaz sea suficientemente expresiva para todos los casos sin requerir que las estrategias simples la utilicen.
Cada implementación se anota con @MaskTypeHandler, una anotación personalizada que actúa como metadato de registro:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Component
public @interface MaskTypeHandler {
MaskType value();
}
La anotación también incluye @Component, lo que hace que cada estrategia sea automáticamente un bean de Spring. Esto permite que MaskingStrategyRegistry, el componente que centraliza el acceso a las estrategias, las reciba todas por inyección de lista y construya un mapa indexado por tipo en su constructor:
@Component
public class MaskingStrategyRegistry {
private final Map<MaskType, MaskingStrategy> strategies = new EnumMap<>(MaskType.class);
public MaskingStrategyRegistry(List<MaskingStrategy> strategiesList) {
for (MaskingStrategy strategy : strategiesList) {
MaskTypeHandler annotation = strategy.getClass().getAnnotation(MaskTypeHandler.class);
if (annotation != null) {
strategies.put(annotation.value(), strategy);
}
}
}
public MaskingStrategy get(MaskType type) {
return strategies.get(type);
}
}
Este diseño tiene una propiedad muy conveniente: añadir una nueva estrategia de enmascaramiento al sistema se reduce a crear una clase que implemente MaskingStrategy y anotarla con @MaskTypeHandler indicando el tipo. El registry la descubre automáticamente en el siguiente arranque de la aplicación, sin ningún lugar central que modificar.
Las tres estrategias que el proyecto implementa ilustran el rango de transformaciones posibles. FullMaskingStrategy es la más simple: reemplaza cualquier valor con "****" independientemente de su contenido, cuando el dato no debe revelar ninguna información ni siquiera estructural:
@MaskTypeHandler(MaskType.FULL)
public class FullMaskingStrategy implements MaskingStrategy {
@Override
public String mask(String value, Masked annotation) {
if (value == null) return null;
return "****";
}
}
EmailMaskingStrategy preserva la estructura del correo electrónico, manteniendo el dominio visible y enmascarando la parte local excepto los primeros dos caracteres. Un correo como [email protected] se convierte en ju***@empresa.com. Esta transformación comunica que el valor era un email y a qué dominio pertenecía, sin revelar la identidad del destinatario:
@MaskTypeHandler(MaskType.EMAIL)
public class EmailMaskingStrategy implements MaskingStrategy {
@Override
public String mask(String value, Masked annotation) {
if (value == null || !value.contains("@")) {
return value;
}
String[] parts = value.split("@", 2);
String local = parts[0];
String domain = parts[1];
if (local.length() <= 2) {
return "*@" + domain;
}
return local.substring(0, 2) + "***@" + domain;
}
}
CustomMaskingStrategy delega en OffsetMasker, un componente que implementa la lógica de máscara por desplazamiento. Recibe los parámetros visibleStart, visibleEnd y maskChar directamente de la anotación y preserva exactamente esa cantidad de caracteres en cada extremo del valor, reemplazando el centro con el carácter de máscara configurado. Si la suma de los caracteres visibles es mayor o igual a la longitud total del valor, la cadena se retorna sin modificación, evitando transformaciones que no aportarían ningún tipo de protección real:
@RequiredArgsConstructor
@MaskTypeHandler(MaskType.CUSTOM)
public class CustomMaskingStrategy implements MaskingStrategy {
private final OffsetMasker offsetMasker;
@Override
public String mask(String value, Masked annotation) {
return offsetMasker.mask(value, annotation);
}
}
@Component
public class OffsetMasker {
public String mask(String value, Masked annotation) {
return Optional.ofNullable(value)
.filter(v -> !v.isBlank())
.filter(v -> annotation != null)
.map(v -> {
int length = v.length();
int visibleStart = annotation.visibleStart();
int visibleEnd = annotation.visibleEnd();
if (visibleStart + visibleEnd >= length) {
return v;
}
String start = v.substring(0, visibleStart);
String end = v.substring(length - visibleEnd);
String fixedMask = String.valueOf(annotation.maskChar()).repeat(4);
return start + fixedMask + end;
})
.orElse(value);
}
}
Aplicado sobre el campo username con la declaración @Masked(type = MaskType.CUSTOM, visibleStart = 2, visibleEnd = 2, maskChar = '*'), un valor como juanperez se transforma en ju****ez. Los dos primeros y los dos últimos caracteres permanecen visibles; el centro queda reemplazado por cuatro asteriscos, independientemente de cuántos caracteres haya entre los extremos. Esta consistencia en la longitud del bloque de máscara es deliberada: evita que la longitud del valor enmascarado revele indirectamente la longitud del valor original.
El introspector: donde todo se conecta
Las estrategias saben cómo transformar valores, las anotaciones declaran qué campos son sensibles, y el registry mapea tipos a estrategias. La pieza que conecta todos estos elementos durante la serialización es DomainAnnotationIntrospectorConfig, la implementación personalizada del introspector de Jackson.
Esta clase extiende JacksonAnnotationIntrospector, que es el introspector estándar de Jackson. Al extender en lugar de reemplazar, se hereda todo el comportamiento normal de serialización y solo se sobreescriben los dos métodos relevantes para el enmascaramiento: hasIgnoreMarker, que controla si un campo debe omitirse, y findSerializer, que controla qué serializador se aplica sobre un campo.
La lógica de hasIgnoreMarker implementa la precedencia entre @NoMask y @Hidden. Si un campo tiene @NoMask, devuelve false independientemente de cualquier otra condición. Si tiene @Hidden, devuelve true para que Jackson lo excluya del JSON resultante. En cualquier otro caso delega al comportamiento estándar del padre:
@Override
public boolean hasIgnoreMarker(AnnotatedMember m) {
if (m.hasAnnotation(NoMask.class)) {
return false;
}
return m.hasAnnotation(Hidden.class) || super.hasIgnoreMarker(m);
}
La lógica de findSerializer implementa tres niveles de prioridad. El primer nivel es @NoMask: si el campo tiene esta anotación, el método devuelve el serializador estándar sin ninguna modificación. El segundo nivel es @Masked: si el campo tiene esta anotación, se construye un MaskedSerializer con el tipo y la anotación completa. El tercer nivel son las reglas por nombre de campo definidas en application.properties: si el nombre del campo coincide con alguna de esas reglas, se construye una instancia sintética de @Masked con el tipo resuelto y se aplica el mismo MaskedSerializer:
@Override
public Object findSerializer(Annotated am) {
if (am.hasAnnotation(NoMask.class)) {
return super.findSerializer(am);
}
Masked masked = am.getAnnotation(Masked.class);
if (masked != null) {
return new MaskedSerializer(registry, masked.type(), masked);
}
if (!resolvedRules.isEmpty()
&& am instanceof AnnotatedMethod
&& am.getName() != null) {
String fieldName = am.getName();
MaskType resolvedType = resolveByFieldName(fieldName);
if (resolvedType != null) {
Masked syntheticMasked = buildSyntheticMasked(resolvedType);
return new MaskedSerializer(registry, syntheticMasked.type(), syntheticMasked);
}
}
return super.findSerializer(am);
}
Este sistema de prioridades tiene una consecuencia operacional importante: las reglas por nombre de campo desde application.properties actúan como una red de seguridad para los datos que todavía no tienen anotación en el modelo. Si el equipo decide que todo campo cuyo nombre contenga email debe enmascararse como EMAIL aunque ningún campo del dominio tenga @Masked, basta con agregar la regla en el archivo de configuración.
La resolución de las reglas por nombre usa coincidencia parcial insensible a mayúsculas. Si más de una regla coincide con el mismo campo, el sistema aplica FULL como estrategia por defecto, eligiendo siempre la opción más conservadora ante la ambigüedad:
private MaskType resolveByFieldName(String fieldName) {
String fieldNameLower = fieldName.toLowerCase();
List<MaskType> matches = resolvedRules.entrySet().stream()
.filter(entry -> fieldNameLower.contains(entry.getKey()))
.map(Map.Entry::getValue)
.collect(Collectors.toList());
if (matches.isEmpty()) return null;
if (matches.size() > 1) return MaskType.FULL;
return matches.get(0);
}
Las reglas se pre-procesan en el constructor del introspector, convirtiendo los strings del mapa de propiedades a valores tipados de MaskType una sola vez en el momento de creación del bean. Esto garantiza que la comparación durante la serialización sea siempre una operación de bajo costo:
private Map<String, MaskType> buildResolvedRules(Map<String, String> rawRules) {
if (rawRules == null || rawRules.isEmpty()) {
return Map.of();
}
return rawRules.entrySet().stream()
.collect(Collectors.toMap(
entry -> entry.getKey().toLowerCase().trim(),
entry -> resolveMaskType(entry.getValue())));
}
Si el string del valor en las propiedades no corresponde a ningún valor del enumerado MaskType, el método resolveMaskType devuelve FULL como fallback. Ante una configuración incorrecta o ambigua, el sistema protege más de lo necesario en lugar de exponer datos que deberían estar protegidos.
El serializador contextual
MaskedSerializer es el componente que Jackson invoca directamente cuando necesita escribir el valor de un campo que el introspector ha marcado para enmascarar. Implementa dos interfaces: JsonSerializer<Object>, que es el contrato estándar de serialización, y ContextualSerializer, que permite a Jackson pasar información adicional sobre el contexto del campo en el momento de la serialización.
La implementación de ContextualSerializer a través del método createContextual resuelve un problema sutil. Jackson no siempre invoca directamente el serializador registrado para un campo: a veces lo crea primero y luego le pasa el contexto del campo a través de createContextual. Sin esta interfaz, el serializador puede perder acceso a la anotación @Masked del campo concreto y por tanto a sus parámetros de configuración:
@Override
public JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty property) {
if (property != null) {
Masked masked = property.getAnnotation(Masked.class);
if (masked != null) {
return new MaskedSerializer(registry, masked.type(), masked);
}
}
if (maskType != null && maskedAnnotation != null) {
return this;
}
return new MaskedSerializer(registry);
}
La serialización del valor en sí es directa: si el valor es nulo se escribe null, de lo contrario se convierte a string, se consulta la estrategia correspondiente en el registry y se escribe el resultado transformado. Si por alguna razón no hay estrategia disponible para el tipo indicado, el campo se escribe como "****", garantizando que ningún dato sensible llegue al log incluso en casos de configuración incompleta:
@Override
public void serialize(Object value, JsonGenerator gen, SerializerProvider serializers)
throws IOException {
if (value == null) {
gen.writeNull();
return;
}
String strValue = value.toString();
if (maskType != null && maskedAnnotation != null) {
MaskingStrategy strategy = registry.get(maskType);
if (strategy != null) {
gen.writeString(strategy.mask(strValue, maskedAnnotation));
return;
}
}
gen.writeString("****");
}
El enmascaramiento de tipos simples en los parámetros de entrada
Las anotaciones sobre los campos del modelo de dominio cubren la serialización de los objetos que el aspecto captura como argumentos o resultados. Pero hay una categoría de casos que ese mecanismo no alcanza: los métodos que reciben tipos simples como parámetros directos, sin que haya un objeto con campos anotados de por medio.
Un ejemplo concreto está en el propio proyecto de ejemplo. El método existeEmail del adapter recibe un String como argumento:
public boolean existeEmail(String email)
Cuando el aspecto intercepta esa llamada y loguea el INPUT, el argumento es directamente el string con el correo electrónico. No hay ningún objeto Usuario que serializar, no hay ninguna anotación @Masked sobre el parámetro —Java no permite aplicar las anotaciones de campo sobre parámetros de método con la misma semántica—, y el ObjectMapper con el introspector no tiene forma de saber que ese string en particular es un email que debe enmascararse.
Para cubrir este caso, el aspecto implementa un mecanismo complementario de enmascaramiento a nivel de parámetro, basado en el nombre del parámetro en lugar de en una anotación sobre el campo. La clase MaskingProperties expone un mapa de reglas configurables desde application.properties:
logging.masking.field-name-rules.email=EMAIL
logging.masking.field-name-rules.phone=PHONE
El aspecto aplica esta lógica en el método maskIfSimpleType, que se invoca sobre cada argumento antes de que llegue a formatArg para la serialización:
private Object maskIfSimpleType(String paramName, Object value) {
if (value == null) return null;
if (!LoggingUtils.isSimpleType(value)) return value;
Map<String, String> rules = maskingProperties.getFieldNameRules();
if (rules == null || rules.isEmpty()) return value;
String paramNameLower = paramName.toLowerCase();
List<MaskType> matches = rules.entrySet().stream()
.filter(entry -> paramNameLower.contains(
entry.getKey().toLowerCase().trim()))
.map(entry -> LoggingUtils.resolveMaskType(entry.getValue()))
.collect(Collectors.toList());
if (matches.isEmpty()) return value;
MaskType maskType = matches.size() > 1 ? MaskType.FULL : matches.get(0);
MaskingStrategy strategy = maskingStrategyRegistry.get(maskType);
if (strategy == null) return "****";
return strategy.mask(value.toString(), LoggingUtils.buildSyntheticMasked(maskType));
}
El método solo actúa sobre tipos simples: String, Number, Boolean y Character. Para cualquier otro tipo, devuelve el valor sin modificación y deja que el ObjectMapper con el introspector maneje el enmascaramiento a través de las anotaciones del modelo. Esta separación evita la duplicación: los objetos complejos se enmascaran por vía del introspector, los tipos simples por vía del nombre del parámetro.
Cuando la estrategia necesita aplicarse sobre un tipo simple que no tiene una anotación real, se construye una instancia sintética de @Masked con los valores por defecto del tipo correspondiente. LoggingUtils.buildSyntheticMasked centraliza esa construcción:
public static Masked buildSyntheticMasked(MaskType maskType) {
return new Masked() {
@Override
public Class<? extends java.lang.annotation.Annotation> annotationType() {
return Masked.class;
}
@Override
public MaskType type() {
return maskType;
}
@Override
public int visibleStart() {
return -1;
}
@Override
public int visibleEnd() {
return -1;
}
@Override
public char maskChar() {
return '*';
}
};
}
Los valores negativos en visibleStart y visibleEnd son intencionales. Las estrategias que no usan esos parámetros, como FULL o EMAIL, simplemente los ignoran. La estrategia CUSTOM, que sí los usa, los interpreta como ausencia de configuración y aplica su lógica de fallback. De esta forma, la instancia sintética es válida para cualquier estrategia sin necesidad de crear variantes distintas según el tipo.
El registro de las propiedades en el bootstrap
Con todos los componentes del sistema de enmascaramiento en su lugar, la clase principal de la aplicación necesita registrar tanto LoggingAopProperties como la nueva MaskingProperties para que Spring Boot las enlace con el prefijo correspondiente del archivo de configuración:
@SpringBootApplication
@EnableConfigurationProperties({
LoggingAopProperties.class,
MaskingProperties.class
})
public class Id202603212000artApplication {
public static void main(String[] args) {
SpringApplication.run(Id202603212000artApplication.class, args);
}
}
Sin MaskingProperties en esta lista, Spring Boot crea el bean pero no lo enlaza con el prefijo logging.masking. El mapa de reglas permanece vacío, el introspector construye un resolvedRules vacío, y el aspecto devuelve todos los tipos simples sin enmascarar. Es el mismo error silencioso que ocurriría si LoggingAopProperties no estuviera registrada: el sistema arranca sin errores, pero el comportamiento es diferente al esperado y sin ninguna advertencia visible.
La nueva estructura del sistema de logs
Con estas incorporaciones, el sistema de logs pasa de cinco artefactos a diecisiete. La división entre applications/shared/serialization y domain/shared/serialization refleja con precisión el límite de responsabilidades:
src/main/java/com/app_247/blog/id202603212000art/
│
├── Id202603212000artApplication.java
│
├── applications/
│ ├── config/
│ │ └── SpringJacksonConfig.java ← ObjectMapper principal (@Primary)
│ │
│ └── shared/
│ ├── log/
│ │ ├── aspect/
│ │ │ └── MethodLoggingAspect.java ← Motor de interceptación
│ │ ├── config/
│ │ │ └── LoggingAopProperties.java ← Patrones de interceptación
│ │ └── tool/
│ │ ├── LoggingUtils.java ← Utilidades de formato
│ │ └── PatternMatcher.java ← Evaluación y cache de patrones
│ │
│ └── serialization/
│ ├── config/
│ │ ├── DomainAnnotationIntrospectorConfig.java ← Introspector de máscaras
│ │ ├── JacksonConfig.java ← ObjectMapper de logs
│ │ └── MaskingProperties.java ← Reglas por nombre de campo
│ ├── strategy/
│ │ ├── MaskedSerializer.java ← Serializador contextual
│ │ ├── MaskingStrategy.java ← Interfaz de estrategias
│ │ ├── MaskingStrategyRegistry.java ← Registro de estrategias
│ │ ├── MaskTypeHandler.java ← Anotación de registro
│ │ └── strategies/
│ │ ├── CustomMaskingStrategy.java
│ │ ├── EmailMaskingStrategy.java
│ │ └── FullMaskingStrategy.java
│ └── util/
│ └── OffsetMasker.java ← Lógica de máscara por offset
│
└── domain/
└── shared/
└── serialization/
└── masking/
├── annotation/
│ ├── Hidden.java
│ ├── Masked.java
│ └── NoMask.java
└── vo/
└── MaskType.java
El dominio declara la intención: este campo es un email sensible, este campo no debe aparecer en ningún registro. La capa de aplicación ejecuta esa intención: sabe cómo transformar un email, sabe cómo invocar a Jackson con el introspector correcto, sabe cómo resolver los nombres de parámetro contra las reglas de configuración. El dominio no sabe nada de Jackson. La capa de aplicación no necesita saber qué datos son sensibles porque el dominio ya se lo comunicó a través de las anotaciones.
El flujo completo con enmascaramiento activo
Con todos los componentes integrados, vale la pena recorrer la salida real del sistema para el mismo flujo de registro de usuario que se documentó en la segunda parte, esta vez con el enmascaramiento activo.
La solicitud llega al Controller con nombre Juan Perez, email [email protected] y edad 25. El INPUT del Controller serializa el objeto RegistrarUsuarioRequest completo. Si la regla logging.masking.field-name-rules.email=EMAIL está activa, el campo email del request aparece transformado:
INFO : c.a.b.i.i.e.a.registrarusuario.RegistrarUsuarioController#registrar
>>> [INPUT] | args: {request={"nombre":"Juan Perez","email":"ju***@empresa.com","edad":25}}
El UseCase recibe el RegistrarUsuarioIn y el aspecto loguea su INPUT. Los campos de este DTO tampoco tienen anotaciones directas, pero el email sigue siendo detectado por la regla de nombre de campo:
INFO : c.a.b.i.d.u.registrarusuario.RegistrarUsuarioUseCase#ejecutar
>>> [INPUT] | args: {command={"nombre":"Juan Perez","email":"ju***@empresa.com","edad":25}}
El adapter consulta si el email existe. Aquí el parámetro es un String directo, y el mecanismo de enmascaramiento de tipos simples entra en acción. El nombre del parámetro es email, coincide con la regla configurada, y la estrategia EMAIL se aplica sobre el string antes de que llegue al log:
DEBUG : c.a.b.i.i.d.j.u.adapter.UsuarioPersistenciaAdapter#existeEmail
>>> [INPUT] | args: {email="ju***@empresa.com"}
DEBUG : c.a.b.i.i.d.j.u.adapter.UsuarioPersistenciaAdapter#existeEmail
<<< [OUTPUT] | return: false
WARN : c.a.b.i.i.d.j.u.adapter.UsuarioPersistenciaAdapter#existeEmail
*** [TIMING] | start: 15:47:05.750 | end: 15:47:05.870 | elapsed: 120ms ⚠️ superó umbral de 100ms
El UseCase construye el objeto Usuario y llama a gateway.guardar(). Aquí es donde el enmascaramiento basado en anotaciones del modelo muestra su efecto más visible. El objeto Usuario tiene cuatro campos con comportamiento especial: email con @Masked(type = MaskType.EMAIL), edad con @Masked por defecto que aplica FULL, username con @Masked(type = MaskType.CUSTOM, visibleStart = 2, visibleEnd = 2, maskChar = '*'), y fechaRegistro con @Hidden. El introspector lee esas anotaciones en el momento de la serialización y produce un JSON donde cada campo respeta exactamente la declaración del modelo:
DEBUG : c.a.b.i.i.d.j.u.adapter.UsuarioPersistenciaAdapter#guardar
>>> [INPUT] | args: {usuario={"id":null,"nombre":"Juan Perez",
"email":"ju***@empresa.com","edad":"****",
"username":"ju****ez"}}
Tres aspectos de este registro merecen atención. Primero: fechaRegistro no aparece en absoluto, ni como null, ni como "****". La anotación @Hidden le indica al introspector que omita el campo completamente, y Jackson lo excluye sin dejar ninguna huella de su existencia. Segundo: edad es un entero, pero en el log aparece como "****", una cadena; el MaskedSerializer convierte cualquier valor a string antes de aplicar la máscara, porque la representación en el log es siempre texto. Tercero: username muestra "ju****ez", preservando exactamente dos caracteres al inicio y dos al final, con cuatro asteriscos en el centro independientemente de cuántos caracteres tenga el username real.
La persistencia ocurre y el adapter retorna el objeto Usuario con el id ya asignado. El OUTPUT aplica exactamente el mismo enmascaramiento sobre el objeto de retorno:
DEBUG : c.a.b.i.i.d.j.u.adapter.UsuarioPersistenciaAdapter#guardar
<<< [OUTPUT] | return: {"id":1,"nombre":"Juan Perez",
"email":"ju***@empresa.com","edad":"****",
"username":"ju****ez"}
DEBUG : c.a.b.i.i.d.j.u.adapter.UsuarioPersistenciaAdapter#guardar
*** [TIMING] | start: 15:47:05.877 | end: 15:47:05.939 | elapsed: 62ms
Finalmente, el Controller retorna el response HTTP. El RegistrarUsuarioResponse tampoco tiene anotaciones de enmascaramiento, pero las reglas por nombre de campo del introspector siguen activas:
INFO : c.a.b.i.i.e.a.registrarusuario.RegistrarUsuarioController#registrar
<<< [OUTPUT] | return: {"id":1,"nombre":"Juan Perez",
"email":"ju***@empresa.com","username":"ju****ez",
"fechaRegistro":"2026-05-18T15:47:05.8756894",
"mensaje":"Usuario registrado exitosamente"}
INFO : c.a.b.i.i.e.a.registrarusuario.RegistrarUsuarioController#registrar
*** [TIMING] | start: 15:47:05.747 | end: 15:47:05.944 | elapsed: 197ms
La respuesta HTTP que el cliente recibe es completamente diferente. El SpringJacksonConfig produce un ObjectMapper sin ningún introspector de enmascaramiento, así que Spring MVC serializa el RegistrarUsuarioResponse tal como está, con todos sus campos en texto plano. El cliente ve el email completo, el username completo y la fecha de registro. El enmascaramiento existe exclusivamente en los logs y no tiene ningún efecto sobre el contrato público de la API.
Dos canales, dos contratos
Recorrer el flujo completo con enmascaramiento activo hace visible algo que conviene nombrar explícitamente porque es la garantía central de todo el diseño: los datos sensibles tienen dos representaciones distintas que nunca se contaminan entre sí.
En el canal de respuesta HTTP, los datos viajan en su forma original. El ObjectMapper principal, marcado con @Primary, es el que Spring Boot usa por defecto para cualquier serialización no calificada. No tiene introspector de enmascaramiento, no conoce la existencia de @Masked ni de @Hidden, y serializa exactamente lo que recibe.
En el canal de logs, los datos viajan en su forma protegida. El ObjectMapper de logs, identificado con el calificador "loggingObjectMapper", es el único que conoce el sistema de enmascaramiento. Solo lo recibe el aspecto, explícitamente a través de @Qualifier. Ningún otro componente del sistema puede confundirlo con el mapper principal porque @Primary garantiza que Spring resuelva cualquier inyección sin calificador hacia el mapper de HTTP.
Esta separación tiene una consecuencia que vale la pena señalar para los equipos que trabajan con múltiples desarrolladores en paralelo: es físicamente imposible introducir un bug donde el enmascaramiento afecte la respuesta HTTP, o donde la ausencia de enmascaramiento exponga datos en el log, siempre que se respeten dos reglas. Primera: cualquier inyección del ObjectMapper que no sea en el aspecto debe hacerse sin calificador, recibiendo siempre el bean @Primary. Segunda: cualquier nueva estrategia de enmascaramiento se registra en el sistema de logs a través de @MaskTypeHandler, nunca modificando el SpringJacksonConfig.
Si alguna vez aparece en una revisión de código un @Qualifier("loggingObjectMapper") en una clase que no sea MethodLoggingAspect, es una señal de alerta clara. La arquitectura hace visible la anomalía antes de que llegue a producción.
Privacidad por configuración, privacidad por declaración
El sistema implementa dos mecanismos de enmascaramiento que son complementarios pero que sirven propósitos distintos, y entender cuándo usar cada uno evita duplicaciones innecesarias y configuraciones contradictorias.
Las reglas por nombre de campo en application.properties son el mecanismo de cobertura amplia. Funcionan sobre cualquier objeto que el aspecto serialice, incluso si ese objeto pertenece a una librería externa o a una capa de la aplicación que no tiene acceso al paquete del dominio para añadir anotaciones. Son también el mecanismo de transición: mientras el equipo va añadiendo las anotaciones correctas al modelo, las reglas por nombre garantizan que los campos sensibles no queden expuestos en el proceso de migración. Su limitación es la precisión: una regla que aplica a cualquier campo cuyo nombre contenga email puede afectar campos que no son correos electrónicos pero que tienen esa cadena en su nombre por coincidencia.
Las anotaciones @Masked y @Hidden en el modelo son el mecanismo de precisión quirúrgica. Se aplican campo a campo, con el tipo exacto de transformación que corresponde a cada dato, y viven donde tienen semántica: en la definición del modelo. Su limitación es que requieren acceso al código fuente del modelo para añadirlas, lo cual no siempre es posible para tipos de terceros.
La convivencia de ambos mecanismos está gestionada por el orden de prioridades del introspector: @NoMask gana sobre todo, @Masked gana sobre las reglas por nombre, y las reglas por nombre actúan cuando no hay ninguna anotación. Un patrón de adopción razonable sería comenzar con reglas por nombre para tener cobertura inmediata en las categorías más sensibles —email, password, token, phone, document—, e ir añadiendo anotaciones al modelo progresivamente. Cuando todos los campos sensibles tienen su anotación, las reglas por nombre pasan a ser una segunda línea de defensa para los casos que se hayan podido olvidar.
Añadir una nueva estrategia de enmascaramiento
Uno de los beneficios del diseño basado en @MaskTypeHandler y MaskingStrategyRegistry es que extender el catálogo de estrategias es un proceso completamente autocontenido. Para ilustrarlo, los pasos para añadir una estrategia de enmascaramiento de números de teléfono que preserve los últimos cuatro dígitos son exactamente tres.
Primero, añadir el valor al enumerado si no existe ya:
public enum MaskType {
EMAIL,
PHONE, // ya existe en el catálogo
// ...
}
Segundo, crear la clase de estrategia con la anotación @MaskTypeHandler:
@MaskTypeHandler(MaskType.PHONE)
public class PhoneMaskingStrategy implements MaskingStrategy {
@Override
public String mask(String value, Masked annotation) {
if (value == null || value.length() < 4) {
return "****";
}
String lastFour = value.substring(value.length() - 4);
return "****" + lastFour;
}
}
Tercero, activar la regla en las propiedades si se quiere que aplique por nombre de campo sin necesidad de anotar cada campo individualmente:
logging.masking.field-name-rules.phone=PHONE
En el siguiente arranque de la aplicación, MaskingStrategyRegistry descubre la nueva clase a través de la inyección de lista y la registra bajo MaskType.PHONE. Ninguna otra clase del sistema necesita ser modificada. El mismo patrón aplica para cualquier necesidad especial: números de identificación nacional, IBANs bancarios, tokens de autenticación con un formato particular. El sistema crece con el proyecto sin acumular complejidad en ningún componente central.
Lo que el sistema no puede hacer solo
Con todo lo documentado hasta aquí, es tentador concluir que el sistema de enmascaramiento cubre la privacidad de los logs de forma completa y automática. Eso sería inexacto, y vale la pena ser preciso sobre los límites.
El sistema protege los datos que el modelo declara como sensibles y los que las reglas por nombre cubren. No puede proteger los datos que nadie declaró como sensibles. Si un desarrollador añade un campo numeroCuentaBancaria al modelo de dominio sin ninguna anotación y sin que ninguna regla por nombre lo cubra, ese campo llegará al log en texto plano.
Tampoco puede actuar sobre el contenido de los mensajes de excepción. Cuando el sistema loguea exception: BusinessException - El email [email protected] ya está registrado, el mensaje de la excepción llega al log tal como fue construido en el código que la lanzó. La única solución es no incluir datos sensibles en el mensaje de la excepción desde el inicio, lo cual es una decisión que ocurre en el momento de escribir el throw, no en el sistema de logs.
El mismo límite aplica para los stacktraces completos. Si en algún punto del sistema se loguea un stacktrace fuera del aspecto, y ese stacktrace incluye representaciones de objetos con datos sensibles en sus métodos toString(), esos datos quedarán expuestos. La regla práctica que complementa al sistema automatizado es la misma que aplica a cualquier mecanismo de privacidad: los datos sensibles no deben aparecer en los mensajes de error, en los métodos toString() de los modelos, ni en ningún otro lugar desde el que puedan filtrarse hacia un log sin pasar por el introspector.
Estas limitaciones no invalidan el diseño, lo contextualizan. El sistema automatizado elimina la categoría más grande y frecuente de exposición accidental. La categoría residual requiere disciplina en el código que construye los mensajes, y esa disciplina es significativamente más fácil de aplicar cuando el desarrollador sabe que el resto del sistema ya está cubierto.
Mirando hacia adelante
El sistema de observabilidad que esta serie ha construido a lo largo de sus tres partes es funcional, extensible y listo para producción. Pero como cualquier sistema bien diseñado, establece una base desde la que hay líneas naturales de evolución.
La integración con OpenTelemetry es la más inmediata. Los registros estructurados que produce el aspecto, con sus campos de capa, duración y firma de método, son compatibles con el modelo de spans de OpenTelemetry. Los mismos puntos de interceptación que hoy emiten registros de texto podrían emitir spans instrumentados que plataformas como Jaeger o Zipkin renderizan como árboles de llamadas con tiempos y metadatos visuales. La transición no requeriría cambios en ninguna clase de negocio: solo en el aspecto, que ya tiene toda la información necesaria para construir esos spans.
La generación de métricas con Micrometer desde los mismos puntos de interceptación eliminaría la duplicación entre el sistema de logs y el sistema de métricas. Hoy, para calcular la latencia promedio de un adapter externo es necesario parsear los registros TIMING. Con Micrometer integrado en el aspecto, ese mismo dato podría alimentar un histograma directamente en el momento de la interceptación, sin ningún procesamiento posterior.
El catálogo de estrategias también puede crecer según las necesidades de cada proyecto. Los tipos CREDIT_CARD, DOCUMENT, IBAN y TOKEN están definidos en el enumerado MaskType pero no tienen implementación en el proyecto de ejemplo. Añadir cada uno es exactamente el proceso de tres pasos que se describió antes. El sistema está diseñado para crecer en esa dirección sin ninguna fricción estructural.
Finalmente, el patrón de aspectos transversales que sostiene todo el sistema de observabilidad puede extenderse a otros dominios de preocupación que comparten la misma naturaleza: la auditoría de cambios de estado, el registro de accesos a datos sensibles para cumplimiento regulatorio, la validación automática de contratos entre capas. La mecánica es idéntica, y el código de negocio permanece completamente ajeno a esas preocupaciones.
Lo que esta serie ha documentado, más allá de los detalles técnicos de cada componente, es un argumento sobre cómo se relacionan la observabilidad y la privacidad cuando se tratan como decisiones arquitectónicas en lugar de como detalles de implementación. Cuando la observabilidad se diseña con los mismos principios que la lógica de negocio —separación de responsabilidades, consistencia y extensibilidad— y cuando la privacidad se declara donde tiene semántica, en el modelo de dominio junto al dato que protege, el resultado no es solo un sistema de logs que funciona. Es un sistema que el equipo puede confiar, extender y razonar, en producción, sin adivinar y sin comprometer la seguridad de los datos de los usuarios.
anexo markdown - Código fuente completo
# Anexo: Código fuente completo
El código completo del sistema de enmascaramiento se organiza en grupos funcionales que reflejan las responsabilidades de cada componente. Todos los artefactos están disponibles para que puedas reproducir el sistema en tu proyecto.
---
## Grupo 1 — Anotaciones de privacidad en el dominio
Las anotaciones que declaran la privacidad de los datos viven en el paquete de dominio y no tienen dependencias de infraestructura.
**Hidden.java** — Omite completamente un campo de los logs
```java
package com.app_247.blog.id202603212000art.domain.shared.serialization.masking.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Hidden {
}
```
**Masked.java** — Enmascara un campo según el tipo especificado
```java
package com.app_247.blog.id202603212000art.domain.shared.serialization.masking.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import com.app_247.blog.id202603212000art.domain.shared.serialization.masking.vo.MaskType;
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Masked {
MaskType type() default MaskType.FULL;
int visibleStart() default -1;
int visibleEnd() default -1;
char maskChar() default '*';
}
```
**NoMask.java** — Excluye explícitamente un campo del enmascaramiento
```java
package com.app_247.blog.id202603212000art.domain.shared.serialization.masking.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NoMask {
}
```
**MaskType.java** — Enum con los tipos de enmascaramiento disponibles
```java
package com.app_247.blog.id202603212000art.domain.shared.serialization.masking.vo;
public enum MaskType {
EMAIL,
PHONE,
CREDIT_CARD,
DOCUMENT,
PASSWORD,
TOKEN,
IBAN,
FULL,
CUSTOM
}
```
---
## Grupo 2 — Estrategias de enmascaramiento
Cada estrategia implementa una forma específica de ocultar datos sensibles.
**MaskingStrategy.java** — Interfaz que todas las estrategias deben cumplir
```java
package com.app_247.blog.id202603212000art.applications.shared.serialization.strategy;
import com.app_247.blog.id202603212000art.domain.shared.serialization.masking.annotation.Masked;
public interface MaskingStrategy {
String mask(String value, Masked annotation);
}
```
**MaskTypeHandler.java** — Anotación para registrar estrategias automáticamente
```java
package com.app_247.blog.id202603212000art.applications.shared.serialization.strategy;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.stereotype.Component;
import com.app_247.blog.id202603212000art.domain.shared.serialization.masking.vo.MaskType;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Component
public @interface MaskTypeHandler {
MaskType value();
}
```
**EmailMaskingStrategy.java** — Enmascara emails preservando el dominio
```java
package com.app_247.blog.id202603212000art.applications.shared.serialization.strategy.strategies;
import com.app_247.blog.id202603212000art.applications.shared.serialization.strategy.MaskTypeHandler;
import com.app_247.blog.id202603212000art.applications.shared.serialization.strategy.MaskingStrategy;
import com.app_247.blog.id202603212000art.domain.shared.serialization.masking.annotation.Masked;
import com.app_247.blog.id202603212000art.domain.shared.serialization.masking.vo.MaskType;
@MaskTypeHandler(MaskType.EMAIL)
public class EmailMaskingStrategy implements MaskingStrategy {
@Override
public String mask(String value, Masked annotation) {
if (value == null || !value.contains("@")) {
return value;
}
String[] parts = value.split("@", 2);
String local = parts[0];
String domain = parts[1];
if (local.length() <= 2) {
return "*@" + domain;
}
return local.substring(0, 2) + "***@" + domain;
}
}
```
**FullMaskingStrategy.java** — Reemplaza todo el valor con asteriscos
```java
package com.app_247.blog.id202603212000art.applications.shared.serialization.strategy.strategies;
import com.app_247.blog.id202603212000art.applications.shared.serialization.strategy.MaskTypeHandler;
import com.app_247.blog.id202603212000art.applications.shared.serialization.strategy.MaskingStrategy;
import com.app_247.blog.id202603212000art.domain.shared.serialization.masking.annotation.Masked;
import com.app_247.blog.id202603212000art.domain.shared.serialization.masking.vo.MaskType;
@MaskTypeHandler(MaskType.FULL)
public class FullMaskingStrategy implements MaskingStrategy {
@Override
public String mask(String value, Masked annotation) {
if (value == null) {
return null;
}
return "****";
}
}
```
**CustomMaskingStrategy.java** — Enmascara con control de caracteres visibles
```java
package com.app_247.blog.id202603212000art.applications.shared.serialization.strategy.strategies;
import com.app_247.blog.id202603212000art.applications.shared.serialization.strategy.MaskTypeHandler;
import com.app_247.blog.id202603212000art.applications.shared.serialization.strategy.MaskingStrategy;
import com.app_247.blog.id202603212000art.applications.shared.serialization.util.OffsetMasker;
import com.app_247.blog.id202603212000art.domain.shared.serialization.masking.annotation.Masked;
import com.app_247.blog.id202603212000art.domain.shared.serialization.masking.vo.MaskType;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@MaskTypeHandler(MaskType.CUSTOM)
public class CustomMaskingStrategy implements MaskingStrategy {
private final OffsetMasker offsetMasker;
@Override
public String mask(String value, Masked annotation) {
return offsetMasker.mask(value, annotation);
}
}
```
**OffsetMasker.java** — Utilidad para enmascarar con offsets configurables
```java
package com.app_247.blog.id202603212000art.applications.shared.serialization.util;
import java.util.Optional;
import org.springframework.stereotype.Component;
import com.app_247.blog.id202603212000art.domain.shared.serialization.masking.annotation.Masked;
@Component
public class OffsetMasker {
public String mask(String value, Masked annotation) {
return Optional.ofNullable(value)
.filter(v -> !v.isBlank())
.filter(v -> annotation != null)
.map(v -> {
int length = v.length();
int visibleStart = annotation.visibleStart();
int visibleEnd = annotation.visibleEnd();
if (visibleStart + visibleEnd >= length) {
return v;
}
String start = v.substring(0, visibleStart);
String end = v.substring(length - visibleEnd);
String fixedMask = String.valueOf(annotation.maskChar()).repeat(4);
return start + fixedMask + end;
})
.orElse(value);
}
}
```
**MaskingStrategyRegistry.java** — Registro centralizado de estrategias
```java
package com.app_247.blog.id202603212000art.applications.shared.serialization.strategy;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Component;
import com.app_247.blog.id202603212000art.domain.shared.serialization.masking.vo.MaskType;
@Component
public class MaskingStrategyRegistry {
private final Map<MaskType, MaskingStrategy> strategies = new EnumMap<>(MaskType.class);
public MaskingStrategyRegistry(List<MaskingStrategy> strategiesList) {
for (MaskingStrategy strategy : strategiesList) {
MaskTypeHandler annotation = strategy.getClass().getAnnotation(MaskTypeHandler.class);
if (annotation != null) {
strategies.put(annotation.value(), strategy);
}
}
}
public MaskingStrategy get(MaskType type) {
return strategies.get(type);
}
}
```
---
## Grupo 3 — Configuración de Jackson para enmascaramiento
Estos componentes configuran Jackson para que aplique el enmascaramiento durante la serialización.
**MaskingProperties.java** — Propiedades de configuración para reglas por nombre
```java
package com.app_247.blog.id202603212000art.applications.shared.serialization.config;
import java.util.Collections;
import java.util.Map;
import org.springframework.boot.context.properties.ConfigurationProperties;
import lombok.Data;
@Data
@ConfigurationProperties(prefix = "logging.masking")
public class MaskingProperties {
private Map<String, String> fieldNameRules = Collections.emptyMap();
}
```
**DomainAnnotationIntrospectorConfig.java** — Introspector personalizado de Jackson
```java
package com.app_247.blog.id202603212000art.applications.shared.serialization.config;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import com.app_247.blog.id202603212000art.applications.shared.serialization.strategy.MaskedSerializer;
import com.app_247.blog.id202603212000art.applications.shared.serialization.strategy.MaskingStrategyRegistry;
import com.app_247.blog.id202603212000art.domain.shared.serialization.masking.annotation.Hidden;
import com.app_247.blog.id202603212000art.domain.shared.serialization.masking.annotation.Masked;
import com.app_247.blog.id202603212000art.domain.shared.serialization.masking.annotation.NoMask;
import com.app_247.blog.id202603212000art.domain.shared.serialization.masking.vo.MaskType;
import com.fasterxml.jackson.databind.introspect.Annotated;
import com.fasterxml.jackson.databind.introspect.AnnotatedMember;
import com.fasterxml.jackson.databind.introspect.AnnotatedMethod;
import com.fasterxml.jackson.databind.introspect.JacksonAnnotationIntrospector;
public class DomainAnnotationIntrospectorConfig extends JacksonAnnotationIntrospector {
private final MaskingStrategyRegistry registry;
private final Map<String, MaskType> resolvedRules;
public DomainAnnotationIntrospectorConfig(
MaskingStrategyRegistry registry,
MaskingProperties maskingProperties) {
this.registry = registry;
this.resolvedRules = buildResolvedRules(maskingProperties.getFieldNameRules());
}
@Override
public boolean hasIgnoreMarker(AnnotatedMember m) {
if (m.hasAnnotation(NoMask.class)) {
return false;
}
return m.hasAnnotation(Hidden.class) || super.hasIgnoreMarker(m);
}
@Override
public Object findSerializer(Annotated am) {
if (am.hasAnnotation(NoMask.class)) {
return super.findSerializer(am);
}
Masked masked = am.getAnnotation(Masked.class);
if (masked != null) {
return new MaskedSerializer(registry, masked.type(), masked);
}
if (!resolvedRules.isEmpty() && am instanceof AnnotatedMethod && am.getName() != null) {
String fieldName = am.getName();
MaskType resolvedType = resolveByFieldName(fieldName);
if (resolvedType != null) {
Masked syntheticMasked = buildSyntheticMasked(resolvedType);
return new MaskedSerializer(registry, syntheticMasked.type(), syntheticMasked);
}
}
return super.findSerializer(am);
}
private MaskType resolveByFieldName(String fieldName) {
String fieldNameLower = fieldName.toLowerCase();
List<MaskType> matches = resolvedRules.entrySet().stream()
.filter(entry -> fieldNameLower.contains(entry.getKey()))
.map(Map.Entry::getValue)
.collect(Collectors.toList());
if (matches.isEmpty()) {
return null;
}
if (matches.size() > 1) {
return MaskType.FULL;
}
return matches.get(0);
}
private Map<String, MaskType> buildResolvedRules(Map<String, String> rawRules) {
if (rawRules == null || rawRules.isEmpty()) {
return Map.of();
}
return rawRules.entrySet().stream()
.collect(Collectors.toMap(
entry -> entry.getKey().toLowerCase().trim(),
entry -> resolveMaskType(entry.getValue())));
}
private MaskType resolveMaskType(String value) {
if (value == null || value.isBlank()) {
return MaskType.FULL;
}
try {
MaskType type = MaskType.valueOf(value.toUpperCase().trim());
if (type == MaskType.CUSTOM) {
return MaskType.FULL;
}
return type;
} catch (IllegalArgumentException e) {
return MaskType.FULL;
}
}
private Masked buildSyntheticMasked(MaskType maskType) {
return new Masked() {
@Override
public Class<? extends java.lang.annotation.Annotation> annotationType() {
return Masked.class;
}
@Override
public MaskType type() {
return maskType;
}
@Override
public int visibleStart() {
return -1;
}
@Override
public int visibleEnd() {
return -1;
}
@Override
public char maskChar() {
return '*';
}
};
}
}
```
**MaskedSerializer.java** — Serializador personalizado que aplica el enmascaramiento
```java
package com.app_247.blog.id202603212000art.applications.shared.serialization.strategy;
import java.io.IOException;
import com.app_247.blog.id202603212000art.domain.shared.serialization.masking.annotation.Masked;
import com.app_247.blog.id202603212000art.domain.shared.serialization.masking.vo.MaskType;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.BeanProperty;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.ser.ContextualSerializer;
public class MaskedSerializer extends JsonSerializer<Object> implements ContextualSerializer {
private final MaskingStrategyRegistry registry;
private final MaskType maskType;
private final Masked maskedAnnotation;
public MaskedSerializer(MaskingStrategyRegistry registry, MaskType maskType, Masked maskedAnnotation) {
this.registry = registry;
this.maskType = maskType;
this.maskedAnnotation = maskedAnnotation;
}
public MaskedSerializer(MaskingStrategyRegistry registry) {
this(registry, null, null);
}
@Override
public void serialize(Object value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
if (value == null) {
gen.writeNull();
return;
}
String strValue = value.toString();
if (maskType != null && maskedAnnotation != null) {
MaskingStrategy strategy = registry.get(maskType);
if (strategy != null) {
gen.writeString(strategy.mask(strValue, maskedAnnotation));
return;
}
}
gen.writeString("****");
}
@Override
public JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty property) {
if (property != null) {
Masked masked = property.getAnnotation(Masked.class);
if (masked != null) {
return new MaskedSerializer(registry, masked.type(), masked);
}
}
if (maskType != null && maskedAnnotation != null) {
return this;
}
return new MaskedSerializer(registry);
}
}
```
**JacksonConfig.java** — Configuración del ObjectMapper para logging
```java
package com.app_247.blog.id202603212000art.applications.shared.serialization.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import com.app_247.blog.id202603212000art.applications.shared.serialization.strategy.MaskingStrategyRegistry;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
@Configuration
public class JacksonConfig {
@Bean("loggingObjectMapper")
public ObjectMapper objectMapper(MaskingStrategyRegistry registry, MaskingProperties maskingProperties) {
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
mapper.setAnnotationIntrospector(new DomainAnnotationIntrospectorConfig(registry, maskingProperties));
return mapper;
}
}
```
**SpringJacksonConfig.java** — ObjectMapper principal sin enmascaramiento
```java
package com.app_247.blog.id202603212000art.applications.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
@Configuration
public class SpringJacksonConfig {
@Bean
@Primary
public ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
return mapper;
}
}
```
---
## Grupo 4 — Integración con el aspecto de logging
El aspecto de logging se actualiza para usar el enmascaramiento en valores simples.
**Fragmento relevante de MethodLoggingAspect.java** — Método que enmascara valores simples
```java
private Object maskIfSimpleType(String paramName, Object value) {
if (value == null) {
return null;
}
if (!LoggingUtils.isSimpleType(value)) {
return value;
}
Map<String, String> rules = maskingProperties.getFieldNameRules();
if (rules == null || rules.isEmpty()) {
return value;
}
String paramNameLower = paramName.toLowerCase();
List<MaskType> matches = rules.entrySet().stream()
.filter(entry -> paramNameLower.contains(entry.getKey().toLowerCase().trim()))
.map(entry -> LoggingUtils.resolveMaskType(entry.getValue()))
.collect(Collectors.toList());
if (matches.isEmpty()) {
return value;
}
MaskType maskType = matches.size() > 1 ? MaskType.FULL : matches.get(0);
MaskingStrategy strategy = maskingStrategyRegistry.get(maskType);
if (strategy == null) {
return "****";
}
return strategy.mask(value.toString(), LoggingUtils.buildSyntheticMasked(maskType));
}
```
**Fragmento de LoggingUtils.java** — Métodos auxiliares para enmascaramiento
```java
public static boolean isSimpleType(Object value) {
return value instanceof String
|| value instanceof Number
|| value instanceof Boolean
|| value instanceof Character;
}
public static MaskType resolveMaskType(String value) {
if (value == null || value.isBlank()) {
return MaskType.FULL;
}
try {
MaskType type = MaskType.valueOf(value.toUpperCase().trim());
return type == MaskType.CUSTOM ? MaskType.FULL : type;
} catch (IllegalArgumentException e) {
return MaskType.FULL;
}
}
public static Masked buildSyntheticMasked(MaskType maskType) {
return new Masked() {
@Override
public Class<? extends java.lang.annotation.Annotation> annotationType() {
return Masked.class;
}
@Override
public MaskType type() {
return maskType;
}
@Override
public int visibleStart() {
return -1;
}
@Override
public int visibleEnd() {
return -1;
}
@Override
public char maskChar() {
return '*';
}
};
}
```
---
## Grupo 5 — Ejemplo de uso en el modelo de dominio
**Usuario.java** — Entidad de dominio con campos anotados
```java
package com.app_247.blog.id202603212000art.domain.model.usuario;
import java.time.LocalDateTime;
import com.app_247.blog.id202603212000art.domain.shared.serialization.masking.annotation.Hidden;
import com.app_247.blog.id202603212000art.domain.shared.serialization.masking.annotation.Masked;
import com.app_247.blog.id202603212000art.domain.shared.serialization.masking.vo.MaskType;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Usuario {
private Long id;
private String nombre;
@Masked(type = MaskType.EMAIL)
private String email;
@Masked
private Integer edad;
@Masked(type = MaskType.CUSTOM, visibleStart = 2, visibleEnd = 2, maskChar = '*')
private String username;
@Hidden
private LocalDateTime fechaRegistro;
}
```
---
## Grupo 6 — Configuración de la aplicación
**application.properties** — Configuración de reglas de enmascaramiento
```properties
# ================================
# AOP LOGGING
# ================================
logging.aop.enabled=true
logging.aop.base-package=com.app_247.blog.id202603212000art
# UseCase
logging.aop.patterns[0].package-regex=com\\.app_247\\.blog\\.id202603212000art\\.domain\\.usecase.*
logging.aop.patterns[0].class-regex=.*UseCase
logging.aop.patterns[0].method-regex=.*
logging.aop.patterns[0].log-level=INFO
logging.aop.patterns[0].warn-threshold-ms=300
# Adapter de persistencia
logging.aop.patterns[1].package-regex=com\\.app_247\\.blog\\.id202603212000art\\.infrastructure\\.drivenadapters.*
logging.aop.patterns[1].class-regex=.*Adapter
logging.aop.patterns[1].method-regex=.*
logging.aop.patterns[1].log-level=DEBUG
logging.aop.patterns[1].warn-threshold-ms=100
# Controller
logging.aop.patterns[2].package-regex=com\\.app_247\\.blog\\.id202603212000art\\.infrastructure\\.entrypoints.*
logging.aop.patterns[2].class-regex=.*Controller
logging.aop.patterns[2].method-regex=.*
logging.aop.patterns[2].log-level=INFO
logging.aop.patterns[2].warn-threshold-ms=500
# ================================
# MASKING — Reglas por nombre de campo
# ================================
logging.masking.field-name-rules.email=EMAIL
logging.masking.field-name-rules.phone=PHONE
logging.masking.field-name-rules.password=FULL
logging.masking.field-name-rules.token=FULL
logging.masking.field-name-rules.identificacion=FULL
```
**Id202603212000artApplication.java** — Clase principal con habilitación de propiedades
```java
package com.app_247.blog.id202603212000art;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import com.app_247.blog.id202603212000art.applications.shared.log.config.LoggingAopProperties;
import com.app_247.blog.id202603212000art.applications.shared.serialization.config.MaskingProperties;
@SpringBootApplication
@EnableConfigurationProperties({
LoggingAopProperties.class,
MaskingProperties.class
})
public class Id202603212000artApplication {
public static void main(String[] args) {
SpringApplication.run(Id202603212000artApplication.class, args);
}
}
```
---
Con estos componentes tienes todo lo necesario para implementar el sistema completo de enmascaramiento de datos sensibles en logs. El código está organizado siguiendo los principios de arquitectura limpia: las anotaciones viven en el dominio sin dependencias de infraestructura, las estrategias son componentes aislados y extensibles, y la configuración de Jackson se mantiene separada del ObjectMapper principal que usa Spring MVC para las respuestas HTTP.
Cuando el sistema nuevo tiene que hablarle al sistema viejo en su idioma
- Mauricio ECR
- Arquitectura
- 31 May, 2026
Imagina que llevas meses construyendo un sistema moderno sobre PostgreSQL, desplegado en contenedores sobre una infraestructura en la nube. Los datos están bien estructurados, las relaciones son clara
Cuando el sistema nuevo tiene que hablarle al sistema viejo en su idioma
- Mauricio ECR
- Arquitectura
- 31 May, 2026
Imagina que llevas meses construyendo un sistema moderno sobre PostgreSQL, desplegado en contenedores sobre una infraestructura en la nube. Los datos están bien estructurados, las relaciones son claras, las consultas son rápidas. Un día descubres que el sistema con el que tienes que integrarte no sabe lo que es una API. Su protocolo de integración es un archivo CSV que aparece en una carpeta a las 2 de la mañana. Si el archivo llega, el sistema funciona. Si no llega, el negocio se detiene.
No es un escenario hipotético. Es la realidad operativa de una cantidad enorme de empresas que modernizaron una parte de su infraestructura sin poder modernizar todo al mismo tiempo. El sistema legado sigue ahí, inamovible, consumiendo archivos como siempre lo hizo, y el sistema nuevo tiene que aprender a hablarle en ese idioma.
La pregunta que surge de inmediato parece simple: ¿cómo tomas los datos que viven en tu base de datos y los conviertes en un archivo CSV que llega a S3 de forma confiable? Pero debajo de esa pregunta hay varias más que definen la complejidad real del problema, y entenderlas bien es lo que separa una integración sólida de una que falla en silencio cuando más importa.
El problema que se esconde detrás del problema
La primera reacción cuando te enfrentas a este reto suele ser optimista. Tienes los datos en la base de datos, sabes el formato que necesita el sistema legado, y S3 es solo una carpeta en la nube. La solución parece obvia: consultas los registros, los transformas y los subes. Tres pasos. Una tarde de trabajo.
Esa imagen se complica en cuanto empiezas a hacerte las preguntas correctas.
¿Cómo sabes qué registros procesar en cada ciclo? El proceso corre periódicamente y cada ejecución debe tomar exactamente los registros que le corresponden, sin repetir los del ciclo anterior ni perderse ninguno del actual. Necesitas algún mecanismo para rastrear qué ya se procesó y qué no. Luego está la pregunta de dónde viven realmente los datos que necesitas exportar: raramente en una sola tabla. Generalmente hay información distribuida en varias tablas relacionadas que hay que unir, transformar y formatear según la estructura exacta que el sistema legado espera, y esa transformación tiene un costo que no siempre es trivial. La más incómoda llega al final: ¿qué pasa si algo sale mal a mitad del proceso? ¿Los registros quedan marcados como procesados aunque el archivo nunca haya llegado a S3? ¿O el archivo llega pero la base de datos queda inconsistente porque el proceso murió antes de confirmar? ¿Y si el volumen crece y un solo proceso ya no alcanza a terminar a tiempo?
Cada una de esas preguntas revela una decisión de arquitectura que no puedes ignorar. Y hay una más que puede o no aplicar a tu caso: ¿el sistema legado espera un solo archivo por ciclo o varios, cada uno con un formato distinto? Si tu integración es simple y homogénea, la respuesta es uno. Pero si los datos que exportas representan entidades distintas con estructuras distintas, el sistema legado puede esperar un archivo por cada tipo. Vale la pena saberlo desde el principio, porque esa variable aparece en ambos enfoques y cambia algunas decisiones de implementación.
El flujo a grandes rasgos
Antes de entrar en los detalles, vale la pena tener clara la imagen completa de lo que estamos construyendo, porque esa imagen es la que le da sentido a cada decisión que viene después.
El sistema legado espera sus archivos a una hora determinada. No los solicita, no los consulta, no tiene una API a la que llamar: simplemente los recoge de una ubicación conocida en el momento que tiene programado. Eso impone una restricción que define toda la arquitectura: el proceso de exportación tiene que ejecutarse de forma programada, en un horario fijo, con suficiente anticipación para que los archivos estén listos cuando el sistema legado los busque.
El flujo general es el siguiente: a la hora programada se activa un proceso que extrae los registros pendientes de la base de datos, los transforma siguiendo el formato exacto que el sistema legado espera, genera los archivos CSV correspondientes y los deposita en S3. A partir de ahí el sistema legado toma esos archivos y los incorpora a su propio flujo de procesamiento.
Hora programada
↓
Proceso extrae registros pendientes de PostgreSQL
↓
Transforma y formatea según plantilla del sistema legado
↓
Deposita archivos CSV en S3
↓
Sistema legado recoge los archivos y los procesa
Simple en apariencia. Pero garantizar que ese flujo sea confiable, que cada registro se procese exactamente una vez y que los archivos lleguen siempre en un estado consistente, es donde está el verdadero reto.
Dos caminos, un mismo destino
Para resolver este problema existen dos enfoques que cubren la mayoría de los escenarios reales. Cuál usar depende principalmente del volumen de datos, de si el procesamiento por registro requiere lógica fuera de la base de datos y de qué tan detallada necesitas que sea la trazabilidad cuando algo falla.
El primero delega casi todo el trabajo a la base de datos. PostgreSQL tiene una instrucción llamada COPY TO STDOUT que puede leer registros, aplicar transformaciones y devolver el resultado en formato CSV directamente al proceso que hizo la consulta, todo en una sola operación. Es elegante, directo y eficiente cuando el dataset es acotado y predecible, y cuando un solo proceso tiene el tiempo y la memoria suficientes para manejarlo completo.
El segundo reconoce que hay situaciones donde esa elegancia no alcanza. Cuando el volumen es grande, cuando cada registro requiere procesamiento costoso fuera de la base de datos, o cuando un fallo no puede tirar todo el trabajo sino solo la parte afectada, necesitas un patrón más sofisticado: múltiples procesos trabajando en paralelo, cada uno tomando lotes pequeños, coordinándose a través de la misma base de datos y contribuyendo su parte a un archivo final que S3 ensambla mediante su mecanismo de carga en partes. Este mecanismo, sin embargo, exige que cada fragmento que se sube pese al menos 5MB, lo que convierte ese umbral en una condición técnica que determina si este enfoque es viable o no.
Ninguno de los dos es mejor en abstracto. La elección depende de tu escenario concreto, y para que esa decisión sea más fácil de tomar, aquí está el comparativo completo:
| Característica | Enfoque A: COPY TO STDOUT | Enfoque B: Procesos en paralelo |
|---|---|---|
| Volumen de datos | Acotado y predecible | Grande o impredecible |
| Tamaño del CSV por ciclo | Menor a 5MB | Mayor a 5MB |
| Procesamiento por registro | Vive en SQL | Requiere lógica fuera de la DB |
| Número de procesos | Un solo proceso | Múltiples procesos en paralelo |
| Trazabilidad por registro | No requerida | Requerida |
| Errores parciales | No se toleran | Se toleran y gestionan |
| Reintentos individuales | Reintentos individuales | No necesarios |
| Tipos de archivo | Pocos y fijos | Dinámicos según los datos |
| Escalabilidad futura | No se contempla | Se contempla o es necesaria |
Con la decisión tomada, entremos en los detalles de cada uno.
Enfoque A: dejar que la base de datos haga el trabajo pesado
Hay algo intuitivamente correcto en la idea de que quien tiene los datos es quien mejor puede procesarlos. PostgreSQL no es solo un lugar donde guardar información: es un motor de procesamiento con capacidades que muchos equipos subutilizan, y COPY TO STDOUT es una de esas capacidades.
Cuando ejecutas COPY TO STDOUT con una consulta SQL, PostgreSQL lee los registros, aplica las transformaciones que definas, los marca como procesados dentro de la misma operación y devuelve el resultado en formato CSV directamente al proceso a través de la conexión. El proceso que recibe ese stream solo tiene que subirlo a S3. El trabajo pesado de transformación y formateo ocurre dentro de la base de datos, no en el proceso que la llama.
Al recibir el resultado en un único stream, el archivo completo se sube a S3 en un solo envío sin restricciones de tamaño mínimo. Este enfoque es especialmente adecuado cuando el CSV resultante del ciclo no supera los 5MB, lo que en la práctica cubre la gran mayoría de integraciones con volúmenes diarios moderados. A partir de ese umbral, las opciones de consolidación disponibles cambian y vale la pena evaluar si este enfoque sigue siendo el más apropiado.
Una preparación que vale la pena
Antes de ejecutar esa consulta, hay una decisión de diseño que puede marcar la diferencia entre un proceso ágil y uno que pone la base de datos bajo presión innecesaria. Los registros que necesitas exportar raramente viven en una sola tabla con el formato exacto que necesita el CSV. Generalmente hay que unir varias tablas, transformar algunos campos y ordenar las columnas de cierta manera. Hacer toda esa lógica en el momento de la exportación, sobre todos los registros de un ciclo, puede ser costoso cuando el volumen es considerable.
La alternativa es preparar el terreno antes: mantener una tabla auxiliar cuya estructura sea idéntica al CSV destino. La lógica de transformación ocurre cuando los datos llegan al sistema, no cuando salen. Si el sistema que alimenta la base de datos puede escribir directamente en esa tabla con el formato correcto, perfecto. Si no puede, un disparador en PostgreSQL puede hacer esa transformación automáticamente cada vez que se inserta un registro nuevo, sin que el proceso de exportación tenga que preocuparse por eso.
En cualquier caso el resultado es el mismo: en el momento de exportar tienes una tabla limpia y lista donde la consulta del COPY TO STDOUT es un SELECT simple sin transformaciones costosas. Para este enfoque esa tabla no es una recomendación opcional: es prácticamente un requisito. Una consulta compleja sobre decenas de miles de registros con múltiples uniones puede tardar lo suficiente como para alcanzar los límites de tiempo de la conexión, y el impacto sobre la base de datos durante esa operación puede afectar otros procesos que corren al mismo tiempo.
El flujo completo
Con la tabla de exportación lista, el proceso arranca consultando qué tipos de registros existen con trabajo pendiente. Si solo se tiene un tipo de formato de CSV, este paso es trivial: siempre hay un único archivo que generar. Si tienes varios, cada tipo corresponde a una plantilla distinta y puede haber uno o varios archivos por ciclo. En cualquier caso el proceso abre una transacción por cada tipo, ejecuta el COPY TO STDOUT que lee los registros y los marca como procesados en la misma operación, recibe el stream CSV completo, lo sube a S3 y confirma la transacción si S3 respondió con éxito. Si S3 falla, hace rollback y ese tipo queda pendiente para el siguiente ciclo.
Proceso arranca
↓
Consulta qué tipos tienen registros pendientes
↓
Por cada tipo:
Abre transacción
↓
COPY TO STDOUT:
SELECT sobre tabla de exportación
+ marca registros como procesados
+ devuelve CSV listo
↓
Sube CSV a S3
↓
S3 exitoso → confirma transacción ✅
S3 falla → rollback → registros vuelven a pendiente 🔄
Aquí aparece una decisión de diseño que vale la pena tomar conscientemente antes de implementar: ¿el fallo de un tipo debe afectar a los demás o cada uno es independiente?
Cuando cada tipo vive su propia historia
Si los tipos son independientes entre sí, cada uno tiene su propia transacción. El sistema legado puede recibir los archivos de los tipos que funcionaron mientras el tipo fallido se reintenta en el siguiente ciclo. Es la opción más resiliente y la más simple de implementar porque los fallos están contenidos: un problema con un tipo no contamina a los demás.
Tipo A → transacción propia → éxito ✅
Tipo B → transacción propia → falla → rollback → pendiente 🔄
Tipo C → transacción propia → éxito ✅
El siguiente ciclo solo tiene trabajo pendiente del Tipo B. Los demás ya están procesados y no se vuelven a tocar.
Cuando todos los tipos son parte de un todo
Hay casos donde el sistema legado espera todos los archivos juntos o ninguno. Recibir una parte y no la otra puede generar inconsistencias en el proceso de negocio del otro lado. En esos casos necesitas que la subida a S3 sea atómica: o llegan todos los archivos o no llega ninguno.
S3 no ofrece esa atomicidad de forma nativa para múltiples archivos independientes. Pero hay una forma de conseguirla: comprimir todos los CSVs en un único archivo y hacer un solo envío a S3. O el archivo comprimido llega completo o no llega nada. No hay estado intermedio posible.
Abre una sola transacción
↓
Por cada tipo ejecuta COPY TO STDOUT
y acumula los CSVs en memoria
↓
Comprime todos los CSVs en un único archivo
↓
Un solo envío a S3
↓
Éxito → confirma transacción ✅
Falla → rollback → todos los registros vuelven a pendiente 🔄
Esta variante tiene un requisito adicional del lado del sistema legado: necesita poder descomprimir el archivo antes de procesarlo. Si el sistema legado no tiene esa capacidad —y muchos no la tienen precisamente porque son legados— la solución es una función Lambda en S3 que se dispara automáticamente cuando llega el archivo comprimido, lo descomprime y deja los CSVs individuales en la ubicación que el sistema legado espera. El sistema legado nunca sabe que hubo un archivo comprimido de por medio.
Lo que necesitas para implementarlo
Para que este enfoque funcione, la tabla de exportación debe tener al menos dos elementos además de las columnas del CSV: un campo de estado que indique si el registro está pendiente o ya fue procesado, y la estructura debe estar alimentada por el sistema origen o por un disparador según la capacidad disponible.
| Elemento | Detalle |
|---|---|
| Tabla de exportación | Estructura idéntica al CSV destino, con campo de estado |
| Campo de estado | PENDING, COMPLETED |
| Alimentación | Sistema origen o disparador en PostgreSQL |
| Lambda en S3 | Solo para la variante de tipos dependientes |
Enfoque B: cuando el trabajo es demasiado para uno solo
Hay un punto en el crecimiento de cualquier sistema donde un solo proceso ya no es suficiente. El volumen supera lo que puede procesarse en el tiempo disponible, o simplemente la infraestructura escala horizontalmente y levantar múltiples instancias del mismo proceso es la forma natural de responder a la demanda. Pero este enfoque tiene dos condiciones que deben cumplirse para que tenga sentido aplicarlo.
La primera es que haya procesamiento real y costoso por registro fuera de la base de datos: llamadas a APIs externas, validaciones complejas en código o transformaciones que no pueden vivir en SQL. Si todo el procesamiento puede ocurrir en la base de datos, el Enfoque A resuelve el problema con mucha menos complejidad.
La segunda es que el volumen de datos por ciclo supere los 5MB. Este número no es arbitrario: es el tamaño mínimo que S3 exige por cada parte en su mecanismo de carga en partes, que es el que permite consolidar el trabajo de múltiples pods en un único archivo final. Por debajo de ese umbral, el mecanismo de consolidación no es aplicable y el Enfoque A sigue siendo la opción correcta.
Si ambas condiciones se cumplen, este enfoque ofrece algo que el Enfoque A no puede dar: escala horizontal, trazabilidad por registro y manejo de errores individuales sin detener el ciclo completo.
El problema central: coordinación y consolidación
Cuando múltiples pods consultan la base de datos al mismo tiempo y encuentran los mismos registros pendientes, ambos intentarán procesarlos y terminarás con duplicados. Evitar eso sin introducir un componente externo de coordinación es precisamente lo que hace interesante este enfoque.
La solución para la coordinación está en PostgreSQL mismo. La instrucción SELECT FOR UPDATE SKIP LOCKED permite que un pod tome un conjunto de registros y los bloquee de forma que otros pods que ejecuten la misma consulta simplemente los salten y tomen registros distintos. El bloqueo dura solo el tiempo necesario para que el pod reclame esos registros como suyos, no durante todo el procesamiento. Así múltiples pods pueden trabajar en paralelo sobre el mismo conjunto de datos sin coordinación externa y sin duplicados.
El segundo problema es la consolidación: ¿cómo unen su trabajo múltiples pods en un único archivo final? La respuesta está en una tabla temporal en la misma base de datos. Cada pod, al terminar de procesar un registro, deposita la línea CSV ya formateada junto con su peso en bytes en esa tabla. Cualquier pod puede consultar el peso acumulado en esa tabla y cuando detecta que hay suficiente para una parte válida de 5MB, toma esas líneas y las sube al mecanismo de carga en partes de S3. El peso pre-calculado en bytes permite saber con precisión cuándo se tiene suficiente para una parte válida sin estimaciones ni aproximaciones.
La sesión como punto de coordinación
Para que múltiples pods trabajen sobre el mismo conjunto de registros de forma ordenada, necesitan compartir cierta información: qué registros les corresponde procesar en este ciclo y a qué carga en partes de S3 deben contribuir. Esa información vive en lo que llamamos una sesión de exportación.
Una sesión representa un ciclo completo de exportación. Contiene la ventana de registros que se van a procesar —definida por el identificador mínimo y máximo de los registros pendientes al inicio del ciclo— y los tipos de registro que existen dentro de esa ventana. Todos los pods del ciclo leen esa sesión para saber qué les toca hacer.
La sesión se crea una sola vez al inicio del ciclo y cierra una sola vez al final. Ambas operaciones son bloqueantes: cuando múltiples pods arrancan al mismo tiempo, el primero que llega crea la sesión mientras los demás esperan. El mecanismo que garantiza que solo un pod hace cada una de esas operaciones es el mismo bloqueo pesimista de PostgreSQL: SELECT FOR UPDATE sobre el registro de control de la sesión.
El ciclo de vida completo
El flujo completo tiene tres fases que se ejecutan en orden estricto.
Inicio de la sesión. Cuando el cron dispara los pods, todos intentan iniciar o unirse a una sesión activa. El primero que llega no encuentra sesión activa, bloquea el registro de control y asume la responsabilidad de inicializar el ciclo.
Lo primero que hace ese pod —antes de definir qué registros procesará— es revisar si quedaron registros atascados del ciclo anterior. Un pod puede morir en medio del procesamiento por razones fuera de su control: un fallo de infraestructura, un timeout, una excepción no manejada. Cuando eso ocurre, los registros que ese pod había tomado quedan marcados como en procesamiento pero nunca llegan a completarse. El pod que inicia la sesión los detecta buscando registros que lleven más tiempo del razonable en ese estado y los devuelve a pendiente antes de continuar.
Con los registros huérfanos recuperados, el pod determina la ventana del ciclo: consulta el identificador mínimo y máximo de los registros pendientes y fija esos valores como los límites del ciclo. Ningún registro que llegue después de ese momento entra en este ciclo. Luego consulta qué tipos de registros existen dentro de esa ventana y registra esa información en la sesión sin iniciar aún ninguna carga en S3. El Multipart Upload no se inicia en este momento porque todavía no se sabe si habrá suficiente volumen para justificarlo. Finalmente libera el bloqueo y los demás pods pueden empezar a trabajar.
Primer pod llega
↓
No encuentra sesión activa → bloquea registro de control
↓
Recupera registros huérfanos del ciclo anterior
↓
Determina ventana: min_id y max_id de registros pendientes
↓
Consulta tipos distintos dentro de la ventana
↓
Por cada tipo registra en DB:
- el tipo
- estado OPEN
- sin uploadId aún
↓
Marca sesión como activa y libera bloqueo
↓
Los demás pods leen la sesión y empiezan a trabajar
Procesamiento en lotes. Cada pod entra en un ciclo continuo donde toma lotes de registros, los procesa y deposita las líneas resultantes en la tabla temporal, hasta que no queden registros pendientes dentro de la ventana.
Por cada lote, el pod ejecuta SELECT FOR UPDATE SKIP LOCKED sobre los registros pendientes dentro de la ventana. Inmediatamente los marca como en procesamiento y cierra la transacción, liberando el bloqueo para que otros pods puedan seguir tomando registros distintos. Luego procesa cada registro consultando sus tablas relacionadas, validando los datos y formateando la línea CSV según la plantilla del tipo correspondiente.
Al terminar cada registro, el pod deposita en la tabla temporal la línea CSV formateada, su peso en bytes y el tipo al que pertenece, y marca el registro como completado. Si el procesamiento de un registro falla, registra el error en la tabla de auditoría. Si el registro lleva menos de tres intentos, vuelve a pendiente. Si ya acumula tres intentos fallidos, se marca como descartado y no vuelve a procesarse: sigue visible en la tabla de auditoría para revisión manual pero no bloquea el avance del ciclo.
En paralelo al procesamiento, cualquier pod consulta el peso acumulado en la tabla temporal por tipo. Cuando detecta que hay suficiente para una parte válida de 5MB, toma esas líneas mediante SELECT FOR UPDATE SKIP LOCKED, las marca como en subida y ejecuta el siguiente flujo:
Si es la primera parte que se sube para ese tipo, el pod inicia el Multipart Upload en S3, sube los headers del CSV junto con las líneas acumuladas como primera parte, y registra el uploadId en la tabla de partes por tipo. Incluir los headers en la primera parte real de datos es necesario porque S3 Multipart no permite subir una parte vacía o con solo encabezados: la primera parte debe tener contenido suficiente para alcanzar el mínimo de 5MB. Si el Multipart ya fue iniciado por otro pod, simplemente sube las líneas como la siguiente parte disponible.
Pod consulta peso acumulado en tabla temporal por tipo
↓
¿Hay 5MB pendientes de subir?
├── NO → sigue procesando registros
└── SÍ → toma líneas via SELECT FOR UPDATE SKIP LOCKED
marca líneas como en subida
↓
¿Existe uploadId para este tipo?
├── NO → inicia Multipart Upload en S3
sube headers + líneas acumuladas
como primera parte
registra uploadId en DB
└── SÍ → usa uploadId existente
sube líneas como siguiente parte
↓
Marca líneas como subidas
Registra número de parte en DB
Proceso entra en loop
↓
SELECT FOR UPDATE SKIP LOCKED
registros pendientes dentro de la ventana
↓
¿Hay registros?
├── No → sale del loop
└── Sí → marca como en procesamiento
cierra transacción → libera bloqueo
↓
Por cada registro:
consulta tablas relacionadas
valida y formatea línea CSV
deposita en tabla temporal con peso en bytes
marca registro como completado
↓
Para fallidos:
registra error en auditoría
< 3 intentos → vuelve a pendiente
≥ 3 intentos → marca como descartado
Cierre de la sesión. Al terminar cada lote, el pod verifica si quedan registros pendientes o con menos de tres intentos dentro de la ventana. Los registros descartados se consideran procesados porque ya superaron el límite de reintentos y no volverán a intentarse.
Si no quedan registros pendientes, el pod verifica si quedan líneas en la tabla temporal que no hayan sido subidas a S3, independientemente de si superan o no los 5MB. Estas son las líneas residuales del ciclo: los últimos registros procesados que no alcanzaron a formar una parte completa. El pod que detecta esta condición intenta ser el que cierra la sesión usando el mismo mecanismo bloqueante del inicio.
Bloquea el registro de control y verifica el estado de la sesión. Si otro pod ya la cerró, simplemente libera el bloqueo y termina. Si la sesión sigue activa, este pod es el responsable del cierre: toma las líneas residuales de la tabla temporal y las sube como última parte del Multipart Upload de cada tipo. S3 permite que la última parte sea menor a 5MB, por lo que no hay restricción de tamaño en este paso. Luego llama a completeMultipartUpload para consolidar todas las partes en el archivo final, marca la sesión como cerrada y libera el bloqueo.
Al terminar cada lote:
↓
¿Quedan registros pendientes o con menos de 3 intentos
dentro de la ventana?
├── Sí → siguiente lote
└── No → ¿Quedan líneas sin subir en tabla temporal?
↓
Bloquea registro de control
↓
¿Sesión ya cerrada?
├── Sí → libera bloqueo y termina
└── No → toma líneas residuales de tabla temporal
sube como última parte de cada tipo
llama completeMultipartUpload por cada tipo
marca sesión como cerrada
libera bloqueo
Lo que necesitas para implementarlo
Este enfoque requiere más elementos que el primero, pero cada uno tiene una razón de ser clara.
| Elemento | Detalle |
|---|---|
| Campo de estado en tabla de jobs | PENDING, PROCESSING, COMPLETED, FAILED, DEAD |
Campo locked_at en tabla de jobs |
Timestamp para detectar registros huérfanos |
| Tabla temporal de líneas CSV | Línea formateada, peso en bytes, tipo y estado de subida |
| Tabla de auditoría | Referencia al registro, número de intento, error y timestamp |
| Tabla de sesión | Ventana de procesamiento (min_id, max_id) y estado (OPEN, DONE) |
| Tabla de partes por tipo | Tipo, uploadId de S3 y estado (OPEN, DONE) |
| Tabla de exportación | Opcional, recomendada como buena práctica para simplificar el procesamiento por lote |
Lo que ninguno de los dos te dice hasta que fallas en producción
Después de ver los dos enfoques en detalle puede parecer que la decisión está tomada y la implementación es directa. Pero hay un punto que ninguno de los dos resuelve por sí solo y que, si no se atiende, puede hacer que cualquiera de las dos soluciones falle de una manera silenciosa y difícil de detectar.
PostgreSQL y S3 no viven en el mismo mundo transaccional. Cuando haces un cambio en la base de datos dentro de una transacción, puedes deshacerlo si algo falla: eso es precisamente lo que hace una transacción. Pero cuando subes un archivo a S3, esa operación no participa en ninguna transacción de base de datos. Es independiente, definitiva e irreversible desde el punto de vista de PostgreSQL. Si el archivo llega a S3 y luego la transacción de base de datos falla, el archivo ya está ahí y no hay forma de retirarlo automáticamente.
Esto crea una ventana de inconsistencia que puede ser devastadora si no se maneja. Imagina que marcas los registros como procesados, confirmas la transacción y luego intentas subir a S3. Si S3 falla, los registros ya están marcados como procesados en la base de datos pero el archivo nunca llegó. El sistema legado no recibe nada, pero el siguiente ciclo tampoco reintenta porque los registros ya no están pendientes. Los datos simplemente desaparecen del proceso sin que nadie lo detecte fácilmente.
La solución está en el orden de las operaciones, y ese orden debe ser disciplinado y consistente en toda la implementación: primero marcas los registros como procesados dentro de una transacción que aún no has confirmado, luego subes el archivo a S3, y solo si S3 confirma el éxito confirmas la transacción en PostgreSQL. Si S3 falla, haces rollback y los registros vuelven a su estado anterior, listos para reintentarse en el siguiente ciclo.
Marcar registros como procesados en DB (sin confirmar aún)
↓
Subir archivo a S3
↓
S3 exitoso → confirmar transacción en PostgreSQL ✅
S3 falla → rollback → registros vuelven a pendiente 🔄
Este orden no es una preferencia de implementación: es la única secuencia que garantiza consistencia entre los dos sistemas en todos los escenarios posibles. Con él, un fallo en S3 siempre deja la base de datos en un estado coherente y el siguiente ciclo retoma el trabajo sin intervención manual. Sin él, cualquier fallo entre la confirmación de la transacción y la subida a S3 crea un estado inconsistente que requiere corrección manual para detectar y resolver.
El sistema legado no va a cambiar. Pero tu integración sí puede ser confiable.
Volvamos al punto de partida. Tienes un sistema moderno que genera datos y un sistema legado que consume archivos. Entre los dos hay una brecha que no vas a poder cerrar cambiando el sistema legado, porque ese no es el juego. El juego es construir un puente confiable entre los dos mundos, y la complejidad de ese puente debe estar justificada por los problemas que resuelve, no por los que imaginas que podrían aparecer.
Desde aquí hay líneas naturales hacia las que vale la pena mirar. La observabilidad es la más inmediata: métricas de ciclo integradas en una herramienta de monitoreo permiten detectar degradaciones antes de que se conviertan en incidentes. La gestión operativa de registros descartados es la que más se subestima: una interfaz mínima para que un operador los inspeccione, corrija y reintroduzca al flujo es lo que transforma este sistema en algo verdaderamente autónomo. Y para quienes están en el extremo de mayor volumen, vale explorar si COPY TO STDOUT puede coexistir con el patrón distribuido, usando la capacidad de PostgreSQL para exportar directamente incluso en un escenario de múltiples procesos.
Cuando un sistema debe ejecutar lo mismo siempre y algo distinto cada vez
- Mauricio ECR
- Arquitectura
- 24 May, 2026
Imagina que estás diseñando el flujo de solicitud de productos financieros de un banco. Un cliente puede pedir una tarjeta de crédito o un crédito para comprar un vehículo. Los dos productos son disti
Cuando un sistema debe ejecutar lo mismo siempre y algo distinto cada vez
- Mauricio ECR
- Arquitectura
- 24 May, 2026
Imagina que estás diseñando el flujo de solicitud de productos financieros de un banco. Un cliente puede pedir una tarjeta de crédito o un crédito para comprar un vehículo. Los dos productos son distintos: tienen pasos diferentes, documentos diferentes, validaciones diferentes. Pero también comparten algo que no puede variar: antes de que cualquier producto se evalúe, el banco necesita saber quién es el cliente, confirmar su identidad y consultar su historial crediticio. Eso ocurre siempre, para cualquier producto, sin excepción.
La pregunta que se presenta de inmediato parece técnica pero es en realidad arquitectónica: ¿dónde vive ese comportamiento compartido? ¿Lo repites en cada flujo de producto? ¿Lo centralizas en algún lugar y los flujos de producto lo invocan? ¿Construyes un flujo único con condicionales que bifurcan la lógica según el tipo de producto?
Cualquiera de esas tres respuestas funciona mientras el sistema es pequeño. El problema aparece cuando el banco decide lanzar un tercer producto, luego un cuarto. Cuando un equipo necesita cambiar la validación de identidad sin tocar los flujos de tarjeta ni de vehículo. Cuando hay que agregar un paso transversal nuevo y ese cambio no puede romper nada de lo que ya está operando. Ahí es donde las soluciones aparentemente razonables revelan su costo real.
Si duplicaste la lógica compartida, ahora debes modificarla en tres o cuatro lugares y confiar en que todos los cambios sean consistentes. Si la centralizaste mediante invocaciones directas, los flujos de producto están acoplados a ese componente central y cualquier cambio en él requiere verificar el impacto en todos los consumidores. Si construiste un flujo con condicionales, cada nuevo producto aumenta la complejidad del núcleo hasta que nadie entiende del todo qué hace ese código.
La tensión es real: hay pasos que deben ejecutarse de forma consistente en toda instancia del flujo, pero cada caso de negocio introduce lógica específica que no puede ni debe generalizarse. Y esa tensión no se resuelve eligiendo uno de los dos lados. Se resuelve separándolos con precisión y definiendo el mecanismo exacto por el que conviven.
La separación que sostiene todo lo demás
El problema que tienen las tres soluciones descritas antes es que todas intentan resolver la tensión desde el mismo lugar: deciden quién ejecuta qué. Una duplica la ejecución, otra la centraliza, otra la condiciona. Pero ninguna se hace la pregunta más profunda: ¿quién tiene el gobierno del flujo en cada momento?
Esa distinción importa porque gobernar el flujo no es lo mismo que ejecutar un paso. Gobernar significa saber en qué punto está el proceso, decidir qué viene después y ser responsable de que el flujo llegue a su fin de forma consistente. Cuando esa responsabilidad está dispersa entre varios componentes que ejecutan partes del proceso, nadie la tiene completamente. Y cuando nadie la tiene completamente, el flujo se fragmenta.
La respuesta natural a ese problema es concentrar el gobierno. Que haya un único responsable del flujo completo que sepa en todo momento dónde está el proceso y qué debe ocurrir a continuación. Ese responsable ejecuta lo que es común a todos los casos y, cuando llega el momento en que cada caso tiene su propia lógica, cede el gobierno temporalmente a quien sabe cómo manejarla. No lo invoca, no lo llama como si fuera una función: le transfiere el control de forma explícita, permanece en espera y lo recupera cuando termina.
Eso es exactamente lo que hace el núcleo transversal: concentra el gobierno del flujo completo, ejecuta los pasos que son comunes a todos los casos y cede el control cuando la especificidad de cada caso debe intervenir. Y eso es exactamente lo que hace un módulo de extensión: recibe ese control, ejecuta la lógica propia de su caso y lo devuelve. El módulo no conoce al núcleo, no depende de él y no lo dirige. Solo sabe que en algún momento va a recibir el gobierno y que cuando termine debe devolverlo.
Para que esa cesión y esa devolución ocurran con precisión, ambas partes necesitan saber en todo momento dónde están y qué viene después. Ese mecanismo es una máquina de estados: un registro del punto exacto en que se encuentra el proceso y un conjunto de transiciones válidas desde ahí. El núcleo tiene la suya, que gobierna sus pasos transversales. Cada módulo tiene la propia, completamente independiente, que gobierna sus pasos específicos. Cuando el núcleo cede el gobierno, su máquina de estados transiciona a un estado que reconoce explícitamente esa cesión. Mientras está en ese estado, cualquier solicitud de navegación que llegue al núcleo es redirigida al módulo activo. Cuando el módulo termina, el núcleo recibe el control de vuelta y su máquina de estados avanza hacia el cierre.
El flujo, visto desde afuera, parece continuo. Visto desde adentro, está compuesto por pasos atómicos: unidades independientes que no conocen ni necesitan conocer los pasos anteriores ni los siguientes. Cada paso se ejecuta, produce un resultado, lo persiste y termina. La secuencia no es responsabilidad del paso, es responsabilidad de la máquina de estados que lo gobierna.
Esa independencia entre pasos le da al flujo algo que los modelos continuos no tienen: la capacidad de pausarse sin romperse. Si un paso necesita información del cliente, el flujo persiste su estado completo y se detiene. Cuando el cliente responde, la máquina de estados retoma exactamente desde donde estaba. Y si el cliente quiere corregir algo que ya ingresó, puede retroceder: la máquina activa, sea la del núcleo o la del módulo, hace la transición hacia atrás y el paso anterior vuelve a estar disponible.
Un núcleo que no cambia pero se adapta
La estabilidad del núcleo es una decisión de diseño, no una limitación técnica. Cuando se incorpora un nuevo producto al banco, el núcleo no se modifica. No necesita saber qué pasos tiene el nuevo módulo, qué validaciones aplica ni qué documentos solicita. Lo único que necesita es que el módulo cumpla un contrato: un acuerdo lógico formal que define las reglas mínimas de interacción entre el núcleo y cualquier módulo que quiera participar del flujo. El módulo no tiene referencia al núcleo ni a otros módulos. Su única dependencia es hacia ese contrato.
Ese contrato es deliberadamente mínimo. En el momento de la cesión, el núcleo no le entrega al módulo un paquete de información recopilada durante la parte transversal. Le entrega una sola cosa: el identificador de la transacción en curso, un identificador único que el núcleo genera cuando el flujo se inicia y que lo acompaña hasta el cierre. Con ese identificador, el módulo puede relacionar cada uno de sus pasos con la transacción correcta. Y si en algún punto de su ejecución necesita información que el núcleo recolectó durante la parte transversal, como los datos del cliente o el resultado de la validación de identidad, la solicita activamente a través de los endpoints que el núcleo expone para ese propósito.
Eso resuelve un problema que los modelos con contratos de entrada ricos suelen enfrentar: si el núcleo evoluciona y empieza a recolectar información nueva, no hay necesidad de modificar el contrato de cesión ni de actualizar los módulos existentes. El núcleo simplemente expone un endpoint nuevo. Los módulos que necesitan esa información lo adoptan cuando lo necesitan. Los que no lo necesitan no saben que existe y no se ven afectados.
Pero la estabilidad del núcleo no significa rigidez. Cuando un módulo se registra, puede declarar una configuración que adapta ciertos comportamientos del núcleo para su caso particular: activar o desactivar capacidades transversales, ajustar ciertas acciones según las respuestas esperadas. La distinción es precisa: el núcleo decide desde su diseño qué aspectos son adaptables y los expone de forma explícita. Un módulo solo puede moverse dentro de ese espacio predefinido, nunca ampliarlo ni redefinirlo. Lo que no fue diseñado como configurable permanece invariante sin importar qué módulo se registre.
Cómo se registra un módulo
Antes de que cualquier flujo pueda ejecutarse, cada módulo debe registrarse en el núcleo. Este proceso ocurre una única vez por módulo y es completamente independiente del flujo de ejecución.
El módulo se presenta ante el componente de registro del núcleo, declara su identidad y entrega su configuración. Como parte de esa configuración, declara también el listado completo de sus pasos: cuántos son y cómo se llama cada uno. Esa información queda almacenada en el núcleo como dato estático y se convierte en la fuente de verdad para el indicador de progreso que verá el cliente durante el flujo. El núcleo valida que el módulo cumpla el contrato de extensión y que su configuración sea válida dentro del espacio de adaptación permitido. Si todo es correcto, el módulo queda disponible para ser invocado.
A partir de ese momento el núcleo sabe que ese módulo existe, cómo debe comportarse cuando sea invocado y cuántos pasos lo componen. No sabe nada más. No conoce la lógica interna del módulo, no puede modificarla y no necesita hacerlo.
Cómo se ejecuta el flujo
Cuando el cliente inicia una solicitud e indica qué producto desea, el núcleo busca el módulo correspondiente, carga su configuración y adapta su comportamiento dentro de los límites predefinidos. Su máquina de estados transiciona al primer estado activo.
El primer paso es el preprocesamiento: el núcleo normaliza y construye el contexto inicial del flujo. En el caso del banco, esto incluye los datos básicos del cliente que llegaron con la solicitud. Al completarse, persiste el estado y la máquina de estados avanza al siguiente paso.
El siguiente paso es la validación transversal. El núcleo confirma la identidad del cliente y consulta su historial crediticio. Si alguna de esas validaciones requiere información adicional del cliente, el flujo se pausa, persiste su estado completo y espera. Cuando el cliente responde, la máquina de estados retoma exactamente desde donde estaba y la validación continúa. Al completarse, el estado se persiste y la máquina de estados avanza.
Es en este punto donde el flujo hace algo que ninguno de los tres modelos anteriores podía hacer limpiamente: reconoce que lo que sigue ya no le pertenece. La máquina de estados del núcleo transiciona al estado de control delegado y genera el identificador único de la transacción en curso. Ese identificador es lo único que el núcleo le entrega al módulo en el momento de la cesión. Con él, el módulo sabe a qué transacción pertenece cada uno de sus pasos. Y con él, puede consultar al núcleo cualquier información que necesite de la parte transversal ya completada.
A partir de ese instante, la máquina de estados del módulo toma el gobierno. El núcleo permanece en ese estado de espera activa, sin intervenir. Toda solicitud de navegación que llegue al núcleo durante este período es redirigida al módulo activo.
La máquina de estados del módulo activa sus pasos en la secuencia que ella misma define. Cada paso se ejecuta, produce un resultado y termina. Si un paso requiere interacción con el cliente, el flujo se pausa y espera exactamente igual que en la parte transversal. Después de cada paso, el estado se persiste y la máquina evalúa si hay un paso siguiente o si el módulo ha terminado.
Cuando no hay más pasos, el módulo devuelve el control al núcleo. La máquina de estados del núcleo transiciona desde el estado de control delegado hacia el cierre: registra la trazabilidad del flujo completo, persiste el estado final y notifica al cliente que el proceso ha concluido.
Lo que ve el cliente durante todo este proceso
Desde la perspectiva del cliente, el flujo es una secuencia continua de pantallas con un indicador de progreso que avanza. No hay ninguna señal visible de que en algún punto el gobierno pasó de una máquina de estados a otra. Esa continuidad no es cosmética: es el resultado de dos decisiones de diseño que trabajan juntas.
La primera es que el núcleo actúa como proxy de navegación. Toda instrucción del cliente, avanzar, retroceder, saltar a un paso anterior, cancelar, llega siempre al núcleo. El núcleo evalúa en qué punto del flujo se encuentra y decide si la ejecuta directamente o la redirige al módulo activo. El cliente nunca sabe esa distinción. Para él, siempre está hablando con el mismo interlocutor.
La segunda es que el indicador de progreso funciona sin necesidad de consultar al módulo en cada momento. El núcleo ya sabe cuántos pasos tiene el flujo completo desde el registro: el módulo declaró sus pasos al registrarse y esa información quedó almacenada de forma estática. Durante la ejecución, el núcleo solo necesita consultar al módulo por el paso actual, y únicamente cuando el control está delegado. El total de pasos nunca cambia y nunca necesita preguntarse de nuevo.
Con esas dos piezas en su lugar, cada pantalla puede ser completamente autónoma. No necesita conocer el flujo completo para saber qué mostrar ni con quién hablar. La navegación, avanzar, retroceder, saltar, cancelar, siempre pasa por el núcleo. Pero la interacción propia de cada paso, los datos que el cliente ingresa y las respuestas específicas de ese punto del flujo, van directamente al responsable de ese paso: el núcleo si el paso es transversal, el módulo si el paso le pertenece a él. Esto es posible porque cada pantalla es tan atómica como el paso que representa: desde el diseño se definen sus puntos de comunicación, con quién habla y para qué. No hay lógica en tiempo de ejecución que decida eso. La pantalla ya lo sabe.
Los comandos que el cliente puede dar en cualquier momento del flujo, y quién los resuelve, son los siguientes:
| Comando | Propósito | Quién resuelve la lógica |
|---|---|---|
| Consulta de pasos | Obtiene el total de pasos y el nombre de cada uno | Núcleo, usando el dato declarado en el registro del módulo |
| Consulta de progreso | Retorna el total de pasos, el paso actual y el último paso completado | Núcleo. Cuando el control está delegado, consulta al módulo por el paso actual |
| Siguiente | Avanza al siguiente paso lógico | Núcleo si el paso es transversal, módulo si el paso es del módulo |
| Atrás | Retrocede al paso anterior | Núcleo si el paso es transversal, módulo si el paso es del módulo |
| Salto | Navega a un paso específico, siempre que no supere el último paso alcanzado | Núcleo si el destino es transversal, módulo si el destino es del módulo |
| Cancelar | Termina el flujo completamente | Núcleo |
Lo que esta tabla muestra, más allá de los detalles técnicos, es que el cliente siempre tiene el mismo conjunto de comandos disponibles sin importar en qué parte del flujo se encuentra. El hecho de que algunos los resuelva el núcleo y otros el módulo es invisible para él. Y esa invisibilidad es exactamente lo que permite que el caso del banco, con sus dos productos distintos, se sienta como una sola experiencia coherente.
Lo que esta separación realmente cuesta
Sería deshonesto presentar este modelo sin nombrar lo que exige.
El contrato de extensión debe estar bien definido desde el principio. No en términos de la información que se transfiere en la cesión, que es mínima por diseño, sino en términos de las reglas de interacción: cómo se registra un módulo, qué debe declarar, cómo devuelve el control y qué formato tienen las respuestas que el núcleo espera. Si esas reglas están mal definidas o son ambiguas, los módulos las interpretarán de formas distintas y el flujo producirá comportamientos inconsistentes que son difíciles de rastrear porque la causa no está en la lógica de ningún paso sino en el acuerdo que los articula.
La máquina de estados de cada módulo requiere diseño cuidadoso. No es compleja en términos de implementación, pero sí requiere que quien diseña el módulo tenga claridad total sobre la secuencia de sus pasos, las transiciones válidas y los estados de pausa. Un módulo con una máquina de estados mal definida produce comportamientos inconsistentes que son difíciles de rastrear porque la lógica de secuencia está separada de la lógica de cada paso. Cuando algo falla, no es obvio si el problema está en el paso que se ejecutó o en la transición que lo activó.
Los endpoints que el núcleo expone para que los módulos consulten información transversal deben tratarse con la misma disciplina que el contrato de extensión. Son una interfaz pública que los módulos van a consumir, y cualquier cambio en ellos tiene el potencial de romper módulos existentes. Agregar endpoints nuevos es seguro: los módulos que no los necesitan simplemente no los usan. Pero modificar o eliminar endpoints existentes requiere coordinación con todos los módulos que los consumen, y esa coordinación tiene un costo real que crece con la cantidad de módulos operativos.
El modelo de proxy de navegación en el núcleo introduce una dependencia en tiempo de ejecución que debe estar bien resuelta. Cuando el núcleo redirige una solicitud de navegación al módulo activo, necesita tener una referencia válida a ese módulo. Si el módulo no está disponible por cualquier razón, esa solicitud falla. Esto no es diferente a cualquier otra dependencia en tiempo de ejecución, pero debe tenerse en cuenta en el diseño de tolerancia a fallos del sistema.
Finalmente, la disciplina de no modificar el núcleo es una restricción organizacional además de técnica. En la práctica, siempre hay presión para agregar una excepción aquí, un comportamiento especial allá. Cada vez que esa presión cede, el núcleo pierde algo de su estabilidad y el modelo empieza a degradarse. Mantener esa disciplina requiere que el equipo entienda bien por qué el núcleo es cerrado a modificación, no solo que sepa que lo es.
Todo ese costo tiene un punto de equilibrio. Si el sistema tiene un solo tipo de caso y es poco probable que eso cambie, el modelo agrega complejidad estructural sin beneficio real. La separación entre núcleo y módulos, las dos máquinas de estados, el contrato de extensión, los endpoints de consulta: todo eso se justifica cuando hay variabilidad real entre casos, cuando el comportamiento transversal necesita mantenerse consistente y evolucionar de forma independiente, y cuando la incorporación de nuevos casos debe ser posible sin riesgo sobre lo que ya está operando. Cuanto más de esas tres condiciones se cumplen, más sentido tiene asumir la exigencia que el modelo impone.
De vuelta al banco
Con el modelo completo sobre la mesa, el caso del banco deja de verse como un flujo de productos financieros y empieza a revelar el problema arquitectónico que realmente estaba presente desde el principio.
La dificultad nunca fue únicamente validar identidad, consultar historial crediticio o pedir documentos distintos según el producto. Eso podía resolverse de muchas maneras. El problema real era otro: cómo permitir que el sistema creciera sin que cada nuevo producto aumentara el acoplamiento, duplicara lógica o volviera más frágil el flujo completo.
Cuando el núcleo concentra únicamente las responsabilidades transversales y los productos viven en módulos independientes con su propia máquina de estados, el crecimiento deja de sentirse como una modificación del sistema existente y empieza a comportarse como una extensión controlada. Nuevos productos pueden incorporarse sin intervenir los flujos que ya operan, los equipos dejan de depender entre sí para evolucionar casos específicos y la complejidad deja de acumularse en un único lugar.
Hoy existen dos productos. Mañana habrá créditos hipotecarios, productos empresariales, validaciones regulatorias nuevas y recorridos especializados que todavía no existen. Cada uno traerá lógica distinta, pasos distintos y reglas distintas. Pero todos seguirán compartiendo la misma necesidad transversal: entender quién es el cliente antes de tomar cualquier decisión.
El valor del modelo no está en resolver bien los dos productos actuales. Está en evitar que el tercer producto convierta al sistema en algo más difícil de modificar que el segundo. Está en permitir que la variabilidad crezca sin que el núcleo pierda estabilidad. Está en separar la evolución de los productos de la evolución del flujo transversal.
Las tres soluciones iniciales parecían razonables mientras el sistema era pequeño. Duplicar lógica, centralizar mediante invocaciones directas o resolver todo con condicionales podían funcionar durante un tiempo. El problema aparecía después, cuando cada nuevo caso hacía que el sistema completo fuera más difícil de entender, probar y evolucionar. La pregunta que el banco se hacía al principio, dónde vive el comportamiento compartido, no tenía una respuesta técnica. Tenía una respuesta arquitectónica. Y la diferencia entre las dos es exactamente lo que determina si el cuarto producto se incorpora con la misma facilidad que el segundo o si para entonces ya nadie quiere tocar ese código.
Observabilidad sin Ruido: Diseñando un Sistema de Logs con AOP en Arquitecturas DDD — Parte II
- Mauricio ECR
- Arquitectura
- 18 May, 2026
La primera parte de este artículo construyó el argumento conceptual: por qué los logs dispersos se convierten en deuda técnica, cómo AOP permite centralizar la observabilidad sin contaminar la lógica
Observabilidad sin Ruido: Diseñando un Sistema de Logs con AOP en Arquitecturas DDD — Parte II
- Mauricio ECR
- Arquitectura
- 18 May, 2026
La primera parte de este artículo construyó el argumento conceptual: por qué los logs dispersos se convierten en deuda técnica, cómo AOP permite centralizar la observabilidad sin contaminar la lógica de negocio, qué información debe registrarse en cada capa de una arquitectura DDD, y cómo los campos estructurados convierten un archivo de texto en una fuente de inteligencia operacional. Lo que quedó pendiente fue la demostración concreta: cómo se construye ese sistema, qué decisiones se toman en cada pieza, y por qué cada una de ellas importa.
Eso es exactamente lo que ocupa esta segunda parte. El objetivo es preciso: que al terminar de leerla, un desarrollador con experiencia en Spring pueda reproducir el sistema completo en su proyecto. No como una lista de pasos a seguir ciegamente, sino con el entendimiento de por qué cada componente existe, qué problema resuelve y qué ocurriría si se omitiera o se implementara de otra manera.
El stack es Java 21, Spring Boot 3.5.x, Gradle. Las dependencias que habilitan el sistema son spring-boot-starter-aop, que trae AspectJ y el soporte de proxies de Spring, y jackson-datatype-jsr310, que permite serializar correctamente los tipos de fecha y hora de Java 8 en los logs. Lombok está presente por conveniencia, pero no es estructuralmente necesario. Ninguna dependencia adicional es requerida.
El proyecto de ejemplo
Para que cada decisión técnica tenga contexto real, el sistema de logs se implementa sobre un proyecto concreto: una API REST que registra usuarios. Es un caso de uso deliberadamente simple, lo suficiente para que el flujo sea fácil de seguir, pero con la estructura completa de una arquitectura DDD: entrada HTTP, caso de uso, validaciones de dominio, persistencia, y manejo de errores.
El proyecto tiene esta estructura:
src/main/java/com/app_247/blog/id202603212000art/
│
├── Id202603212000artApplication.java ★ [LOG]
│
├── applications/
│ └── aop/
│ ├── aspect/
│ │ └── MethodLoggingAspect.java ★ [LOG]
│ └── config/
│ ├── JacksonConfig.java ★ [LOG]
│ └── LoggingAopProperties.java ★ [LOG]
│
├── domain/
│ ├── model/
│ │ ├── exception/
│ │ │ ├── BusinessException.java
│ │ │ └── DomainValidationException.java
│ │ └── usuario/
│ │ ├── gateway/
│ │ │ └── IUsuarioGateway.java
│ │ └── Usuario.java
│ └── usecase/
│ └── registrarusuario/
│ ├── dto/
│ │ ├── RegistrarUsuarioIn.java
│ │ └── RegistrarUsuarioOut.java
│ ├── enricher/
│ │ └── UsernameEnricher.java
│ ├── validator/
│ │ ├── EdadValidator.java
│ │ ├── EmailDominioValidator.java
│ │ └── NombreValidator.java
│ └── RegistrarUsuarioUseCase.java
│
└── infrastructure/
├── drivenadapters/
│ └── jpa/
│ └── usuario/
│ ├── adapter/
│ │ └── UsuarioPersistenciaAdapter.java
│ ├── entity/
│ │ └── UsuarioEntity.java
│ ├── mapper/
│ │ └── UsuarioPersistenciaMapper.java
│ └── repository/
│ └── UsuarioJpaRepository.java
└── entrypoints/
└── api/
└── registrarusuario/
├── dto/
│ ├── RegistrarUsuarioRequest.java
│ └── RegistrarUsuarioResponse.java
├── mapper/
│ └── RegistrarUsuarioApiMapper.java
├── RegistrarUsuarioController.java
└── util/
└── Exception/
└── GlobalExceptionHandler.java
src/main/resources/
└── application.properties ★ [LOG]
Las clases marcadas con ★ [LOG] son las que forman el sistema de observabilidad. Todo lo demás es la lógica del negocio y la infraestructura del proyecto, que no tiene ninguna instrucción de log y no necesita tenerla.
El flujo de una solicitud
Antes de abrir cualquier clase del sistema de logs vale la pena recorrer el flujo completo de una solicitud de registro de usuario. Es el flujo que el aspecto va a observar, y entenderlo con claridad hace que cada decisión de implementación tenga sentido inmediato.
El cliente envía un POST /api/v1/usuarios con un cuerpo JSON que contiene nombre, email y edad. A partir de ahí, la solicitud atraviesa estas capas en orden:
RegistrarUsuarioController es el punto de entrada. Recibe el request HTTP, lo valida con Bean Validation (@Valid), y usa RegistrarUsuarioApiMapper para convertir el RegistrarUsuarioRequest en un RegistrarUsuarioIn, que es el DTO que entiende el dominio. Luego invoca el caso de uso y convierte el resultado de vuelta a un RegistrarUsuarioResponse para la respuesta HTTP. El controlador no tiene lógica de negocio: solo traduce entre el mundo HTTP y el mundo del dominio.
RegistrarUsuarioUseCase es donde ocurre la orquestación. Recibe el RegistrarUsuarioIn y ejecuta la secuencia de negocio: primero llama a NombreValidator, EdadValidator y EmailDominioValidator para validar que los datos cumplan las reglas del dominio. Luego consulta el gateway para verificar que el email no esté ya registrado. Si todo es válido, usa UsernameEnricher para generar el nombre de usuario a partir del email, construye el objeto Usuario y lo persiste a través del gateway. Finalmente construye y retorna el RegistrarUsuarioOut.
Es importante notar que NombreValidator, EdadValidator, EmailDominioValidator y UsernameEnricher son clases con métodos estáticos, sin estado, sin anotaciones de Spring. El UseCase los llama directamente como utilidades. No son beans y el aspecto no los ve, lo cual es correcto: su comportamiento queda capturado por la observación del UseCase que los invoca.
IUsuarioGateway es la interfaz del puerto de salida. El dominio la define; la infraestructura la implementa. El UseCase solo conoce la interfaz, nunca la implementación concreta.
UsuarioPersistenciaAdapter es la implementación del gateway. Está anotado con @Component, es un bean de Spring, y es aquí donde realmente ocurre la interacción con la base de datos. Usa UsuarioPersistenciaMapper para convertir entre el modelo de dominio Usuario y la entidad JPA UsuarioEntity, y delega en UsuarioJpaRepository para las operaciones sobre H2.
GlobalExceptionHandler intercepta cualquier excepción que no haya sido manejada antes de llegar al cliente. Para DomainValidationException devuelve un 422 con el detalle del campo que falló. Para BusinessException devuelve un 409 con el código de error. Para errores de validación de Bean Validation devuelve un 400 con el mapa de campos y mensajes.
Con ese recorrido claro, el flujo completo se puede representar así:
POST /api/v1/usuarios
│
▼
RegistrarUsuarioController ← @RestController ★ interceptado
│ toCommand()
▼
RegistrarUsuarioApiMapper ← @Component (MapStruct)
│ RegistrarUsuarioIn
▼
RegistrarUsuarioUseCase ← @Service ★ interceptado
│
├── NombreValidator.validar() ← clase plana, NO interceptada
├── EdadValidator.validar() ← clase plana, NO interceptada
├── EmailDominioValidator.validar() ← clase plana, NO interceptada
│
├── gateway.existeEmail()
│ └── UsuarioPersistenciaAdapter#existeEmail ← @Component ★ interceptado
│ └── UsuarioJpaRepository (Spring Data)
│
├── UsernameEnricher.generarUsername() ← clase plana, NO interceptada
│
└── gateway.guardar()
└── UsuarioPersistenciaAdapter#guardar ← @Component ★ interceptado
└── UsuarioJpaRepository (Spring Data)
│
▼ RegistrarUsuarioOut
RegistrarUsuarioController
│ toResponse()
▼
RegistrarUsuarioApiMapper
│
▼
RegistrarUsuarioResponse → HTTP 201
Este flujo es el que el aspecto va a observar en tiempo de ejecución. Cada clase marcada con ★ interceptado genera sus propios registros de INPUT, OUTPUT y TIMING sin que ninguna de ellas sepa que está siendo observada. Las clases planas que no son beans simplemente no aparecen en los logs, y eso es correcto: su comportamiento está implícito en la observación de las capas que las contienen.
La arquitectura del sistema de logs
Con el flujo del proyecto claro, el sistema de logs se puede describir con precisión. Son tres piezas con responsabilidades distintas que operan juntas:
LoggingAopProperties es la configuración. Define qué interceptar: qué paquetes, qué clases, qué métodos, en qué nivel de log y a partir de qué tiempo de ejecución emitir una advertencia. No sabe nada del aspecto ni de Jackson.
MethodLoggingAspect es el motor. Intercepta cada método elegible, mide el tiempo, serializa los argumentos y resultados, y emite los registros según las reglas que encontró en las propiedades. No sabe nada de la lógica de negocio del proyecto.
JacksonConfig proporciona el ObjectMapper que el aspecto usa para convertir objetos Java en texto JSON. Está configurado para manejar correctamente los tipos de fecha de Java 8, que sin esta configuración se serializarían como arrays de números en lugar de strings ISO.
La relación entre las tres piezas es deliberadamente asimétrica: LoggingAopProperties no sabe nada del aspecto, y el aspecto no sabe nada de Jackson más allá de que tiene un ObjectMapper disponible. Cada pieza tiene una responsabilidad única y bien delimitada.
LoggingAopProperties: el contrato de configuración
Todo el comportamiento del sistema de logs se controla desde application.properties a través de LoggingAopProperties. Esta clase es un @ConfigurationProperties que mapea el prefijo logging.aop a una estructura de objetos en memoria:
@Data
@ConfigurationProperties(prefix = "logging.aop")
public class LoggingAopProperties {
private boolean enabled = true;
private String basePackage = "com.app_247.blog.id202603212000art";
private List<PatternConfig> patterns = List.of();
@Data
public static class PatternConfig {
private String packageRegex = ".*";
private String classRegex = ".*";
private String methodRegex = ".*";
private String logLevel = "INFO";
private long warnThresholdMs = 500L;
}
}
La estructura interna PatternConfig representa una regla de interceptación. Tiene tres expresiones regulares que se evalúan contra el paquete, el nombre simple de la clase y el nombre del método. Si las tres hacen match, la regla aplica y sus otros dos campos determinan el comportamiento: logLevel controla en qué nivel se emiten los registros normales de esa capa, y warnThresholdMs define el umbral de tiempo a partir del cual el registro de timing se eleva automáticamente a WARN independientemente del nivel configurado.
Los valores por defecto de las tres regex son ".*", que en regex significa "cualquier cosa". Esto garantiza que una PatternConfig construida sin configuración explícita intercepta todo, lo cual es un default seguro para desarrollo pero que en producción se reemplaza por reglas precisas.
Para que Spring Boot cargue esta clase al arrancar, la clase principal de la aplicación debe registrarla explícitamente:
@SpringBootApplication
@EnableConfigurationProperties(LoggingAopProperties.class)
public class Id202603212000artApplication {
public static void main(String[] args) {
SpringApplication.run(Id202603212000artApplication.class, args);
}
}
@EnableConfigurationProperties es el mecanismo que le indica a Spring Boot que debe crear un bean de tipo LoggingAopProperties y enlazarlo con el prefijo logging.aop del archivo de propiedades. Sin esta anotación, la clase existe pero nunca se puebla: el aspecto recibiría una instancia con todos los valores por defecto y sin ningún patrón configurado, lo que significa que no interceptaría nada. Es un error silencioso difícil de diagnosticar si no se conoce el mecanismo.
La configuración del proyecto de ejemplo define tres patrones, uno por cada capa que se quiere observar:
logging.aop.enabled=true
logging.aop.base-package=com.app_247.blog.id202603212000art
# UseCase
logging.aop.patterns[0].package-regex=com\\.app_247\\.blog\\.id202603212000art\\.domain\\.usecase.*
logging.aop.patterns[0].class-regex=.*UseCase
logging.aop.patterns[0].method-regex=.*
logging.aop.patterns[0].log-level=INFO
logging.aop.patterns[0].warn-threshold-ms=300
# Adapter de persistencia
logging.aop.patterns[1].package-regex=com\\.app_247\\.blog\\.id202603212000art\\.infrastructure\\.drivenadapters.*
logging.aop.patterns[1].class-regex=.*Adapter
logging.aop.patterns[1].method-regex=.*
logging.aop.patterns[1].log-level=DEBUG
logging.aop.patterns[1].warn-threshold-ms=100
# Controller
logging.aop.patterns[2].package-regex=com\\.app_247\\.blog\\.id202603212000art\\.infrastructure\\.entrypoints.*
logging.aop.patterns[2].class-regex=.*Controller
logging.aop.patterns[2].method-regex=.*
logging.aop.patterns[2].log-level=INFO
logging.aop.patterns[2].warn-threshold-ms=500
Tres decisiones de diseño visibles en esta configuración merecen atención. Primera: el adapter de persistencia tiene log-level=DEBUG mientras que el UseCase y el Controller tienen log-level=INFO. Esto significa que en producción con nivel INFO configurado, los logs del adapter son invisibles por defecto y solo aparecen cuando se activa DEBUG dinámicamente para diagnosticar un problema. La lógica es que saber que el UseCase llamó al adapter y cuánto tardó ya es información suficiente en condiciones normales; el detalle de qué exactamente se guardó o consultó es información de diagnóstico que solo se necesita ocasionalmente.
Segunda: el umbral de WARN del adapter es de 100ms, mucho más estricto que los 300ms del UseCase y los 500ms del Controller. Esto refleja una expectativa operacional: una operación de base de datos que tarde más de 100ms en este proyecto es una señal de alerta, mientras que el UseCase puede acumular ese tiempo y más en su orquestación sin que sea necesariamente un problema.
Tercera: los patrones se evalúan en orden y se aplica el primero que haga match. Si en el futuro existiera una clase que fuera a la vez un UseCase y un Adapter, lo cual no debería ocurrir en una arquitectura DDD bien diseñada pero podría ocurrir en un proyecto en transición, el patrón 0 ganaría porque aparece primero. Esta semántica de primer match es predecible y fácil de razonar.
JacksonConfig: el ObjectMapper para los logs
El aspecto necesita convertir los argumentos y resultados de los métodos en texto para escribirlos en el log. Jackson es la herramienta natural para esto en un proyecto Spring, pero la configuración por defecto tiene un problema concreto con los tipos de fecha de Java 8.
Sin configuración adicional, un LocalDateTime como 2026-05-18T15:47:05.875 se serializa como un array de números: [2026,5,18,15,47,5,875000000]. En un log de producción eso es ilegible. La solución es registrar el módulo JavaTimeModule y deshabilitar la serialización de fechas como timestamps:
@Configuration
public class JacksonConfig {
@Bean
public ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
return mapper;
}
}
El resultado es que LocalDateTime aparece en los logs como "2026-05-18T15:47:05.8756894", que es exactamente lo que se ve en la salida de consola de referencia.
Una pregunta legítima es por qué esta configuración vive en el paquete applications/aop/config y no en un paquete de configuración general de la aplicación. La respuesta es de propiedad: este ObjectMapper existe para el sistema de logs, no para la aplicación en general. En el futuro, cuando se incorpore el sistema de enmascaramiento que se documentará en la tercera parte de esta serie, este mapper recibirá configuración adicional específica para logs que no debe afectar a las respuestas HTTP. Mantenerlo en el paquete del sistema de logs hace explícita esa propiedad desde el principio.
MethodLoggingAspect: el motor de la interceptación
Con la configuración clara y el ObjectMapper disponible, el aspecto puede construirse. MethodLoggingAspect es un @Component anotado con @Aspect que recibe por inyección las propiedades y el mapper:
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(prefix = "logging.aop", name = "enabled", havingValue = "true", matchIfMissing = true)
public class MethodLoggingAspect {
private final ObjectMapper objectMapper;
private final LoggingAopProperties properties;
private final ConcurrentHashMap<String, Optional<PatternConfig>> matchCache = new ConcurrentHashMap<>();
@ConditionalOnProperty con matchIfMissing = true significa que el aspecto está activo por defecto aunque la propiedad logging.aop.enabled no aparezca en el archivo de configuración. Solo se desactiva si la propiedad está explícitamente en false. Esto es un default sensato: en un proyecto nuevo donde todavía no se ha configurado nada, el sistema de logs funciona.
El matchCache es un ConcurrentHashMap de instancia, no estático. Esto es deliberado: si en algún escenario de pruebas o de recarga de contexto se creara una nueva instancia del aspecto, el cache empieza vacío y se reconstituye limpiamente. Un cache estático compartiría estado entre instancias del aspecto, lo que en tests de integración puede producir comportamientos inesperados difíciles de reproducir.
El pointcut y el filtro inicial
El pointcut captura todos los beans anotados con los estereotipos principales de Spring, excluyendo el propio paquete del aspecto:
@Around("(within(@org.springframework.stereotype.Service *) " +
"|| within(@org.springframework.stereotype.Component *) " +
"|| within(@org.springframework.web.bind.annotation.RestController *)" +
"|| within(@org.springframework.stereotype.Repository *)) " +
"&& !within(com.app_247.blog.id202603212000art.aop..*)")
public Object logMethod(ProceedingJoinPoint joinPoint) throws Throwable {
Usar within con estereotipos en lugar de una expresión de paquete tiene una implicación directa en DDD que ya se mencionó al describir el flujo: los validadores y enrichers del dominio, que son clases planas sin anotaciones de Spring, no son interceptados. El aspecto solo ve lo que Spring gestiona, y eso es exactamente lo correcto.
Lo primero que hace el advice una vez que captura una invocación es extraer la información del método y aplicar el filtro de paquete base:
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
String packageName = method.getDeclaringClass().getPackageName();
String className = method.getDeclaringClass().getSimpleName();
String methodName = method.getName();
if (!packageName.startsWith(properties.getBasePackage())) {
return joinPoint.proceed();
}
Este filtro descarta en una comparación de strings todas las invocaciones que provienen de beans de Spring propios del framework o de librerías de terceros. Es el filtro más barato posible y elimina la gran mayoría de las invocaciones que el pointcut captura pero que no son de la aplicación.
Las invocaciones que pasan ese filtro enfrentan la evaluación de patrones, protegida por el cache:
String cacheKey = packageName + "." + className + "#" + methodName;
Optional<PatternConfig> matchedPattern = matchCache.computeIfAbsent(
cacheKey,
k -> findMatchingPattern(packageName, className, methodName));
if (matchedPattern.isEmpty()) {
return joinPoint.proceed();
}
Si ningún patrón hace match, la invocación pasa sin ningún registro. Si hay match, el PatternConfig resultante determina todo el comportamiento posterior: nivel de log, umbral de tiempo, y por extensión qué tan visible es esa capa en producción.
La firma comprimida
Cada registro incluye una firma que identifica el método observado. La firma completa de un método en este proyecto puede ocupar una línea entera de log por sí sola. El aspecto la comprime preservando solo la inicial de cada segmento del paquete excepto el último:
private String compressPackage(String packageName) {
if (packageName == null || packageName.isBlank()) return "";
String[] segments = packageName.split("\\.");
if (segments.length == 1) return packageName;
StringBuilder sb = new StringBuilder();
for (int i = 0; i < segments.length - 1; i++) {
sb.append(segments[i].charAt(0)).append('.');
}
sb.append(segments[segments.length - 1]);
return sb.toString();
}
El resultado para com.app_247.blog.id202603212000art.infrastructure.drivenadapters.jpa.usuario.adapter es c.a.b.i.i.d.j.u.adapter. El último segmento se preserva completo porque es el que aporta contexto: adapter, usecase, entrypoints. Los segmentos anteriores son el prefijo que cualquier desarrollador del proyecto reconoce por su inicial. La firma completa que aparece en cada registro queda así:
c.a.b.i.i.d.j.u.adapter.UsuarioPersistenciaAdapter#guardar
Legible, compacta, y suficientemente precisa para ubicar el método en el árbol de archivos sin ambigüedad.
Los cuatro tipos de registro
El advice @Around tiene visibilidad completa sobre la invocación: puede ejecutar código antes, durante y en el camino de error. Esa visibilidad se materializa en cuatro tipos de registro con marcadores visuales distintos que permiten identificarlos de un vistazo en la consola:
private static final String INPUT_MARKER = ">>> [INPUT] |";
private static final String OUTPUT_MARKER = "<<< [OUTPUT] |";
private static final String TIMING_MARKER = "*** [TIMING] |";
private static final String ERROR_MARKER = "!!! [ERROR] |";
private static final String PROPAGATED_MARKER = "!!! [ERROR-PROPAGATED] |";
Los marcadores no son decorativos. En una consola con decenas de líneas por segundo, la diferencia visual entre >>>, <<<, *** y !!! permite al ojo localizar inmediatamente qué tipo de evento está leyendo sin procesar el texto completo de cada línea.
INPUT
El registro INPUT captura los argumentos del método en el momento de la invocación. La lógica recorre los parámetros usando reflexión para asociar cada valor con el nombre del parámetro declarado:
private void logInput(
String methodSignature,
MethodSignature signature,
Object[] args,
PatternConfig pattern) {
Parameter[] parameters = signature.getMethod().getParameters();
if (parameters.length == 0) {
logAtLevel(pattern, "{} {} args: (none)", methodSignature, INPUT_MARKER);
return;
}
Map<String, Object> inputMap = new LinkedHashMap<>();
IntStream.range(0, parameters.length)
.forEach(i -> inputMap.put(
parameters[i].getName(),
formatArg(args[i])));
logAtLevel(pattern, "{} {} args: {}", methodSignature, INPUT_MARKER, inputMap);
}
LinkedHashMap preserva el orden de inserción, que coincide con el orden de declaración de los parámetros. El resultado en el log es un mapa legible donde cada clave es el nombre exacto del parámetro y cada valor es la representación JSON del argumento. Para que los nombres de los parámetros estén disponibles en tiempo de ejecución a través de parameter.getName(), el proyecto debe compilarse con la opción -parameters. En Spring Boot esto está habilitado por defecto desde la versión 3.2, así que en este stack no requiere ninguna configuración adicional.
La serialización de cada argumento pasa por formatArg:
private String formatArg(Object arg) {
if (arg == null) return "null";
try {
return objectMapper.writeValueAsString(arg);
} catch (Exception e) {
e.printStackTrace();
return arg.toString();
}
}
Si Jackson no puede serializar el objeto, el método cae de vuelta a toString() como último recurso. Esto evita que un argumento no serializable rompa el flujo de logging y, por extensión, el flujo de negocio. El aspecto es un observador: nunca debe interferir con la ejecución que está observando.
OUTPUT
El registro OUTPUT captura el valor de retorno una vez que el método completa su ejecución normalmente:
private void logOutput(
String methodSignature,
Class<?> returnType,
Object result,
PatternConfig pattern) {
if (void.class.equals(returnType) || Void.class.equals(returnType)) {
logAtLevel(pattern, "{} {} return: void", methodSignature, OUTPUT_MARKER);
return;
}
logAtLevel(pattern, "{} {} return: {}",
methodSignature, OUTPUT_MARKER, formatArg(result));
}
El caso especial es cuando el tipo de retorno es void: no hay nada que serializar, pero sí vale la pena registrar que el método completó su ejecución. Un registro OUTPUT ausente en un flujo donde se esperaba puede ser la primera pista de que algo no terminó correctamente.
TIMING
El registro TIMING es el más rico en información operacional. Se emite siempre, tanto en el flujo normal como en el flujo de error, lo que garantiza que siempre hay una métrica de tiempo disponible independientemente de cómo terminó la ejecución:
private void logTiming(
String methodSignature,
Instant start,
Instant end,
long elapsedMs,
PatternConfig pattern) {
String startStr = formatInstant(start);
String endStr = formatInstant(end);
String elapsedFormatted = formatElapsed(elapsedMs);
if (elapsedMs >= pattern.getWarnThresholdMs()) {
log.warn("{} {} start: {} | end: {} | elapsed: {} ⚠️ superó umbral de {}ms",
methodSignature, TIMING_MARKER,
startStr, endStr,
elapsedFormatted,
pattern.getWarnThresholdMs());
return;
}
logAtLevel(pattern, "{} {} start: {} | end: {} | elapsed: {}",
methodSignature, TIMING_MARKER, startStr, endStr, elapsedFormatted);
}
La lógica del umbral merece atención: si elapsedMs supera warnThresholdMs, el registro se emite en WARN directamente con log.warn(), ignorando el nivel configurado en el patrón. Esto significa que aunque el adapter tenga log-level=DEBUG y en producción sus logs normales sean invisibles, un TIMING que supere el umbral siempre aparece en INFO y superior. La lentitud es siempre visible, independientemente del nivel de verbosidad configurado para esa capa.
El tiempo se formatea de forma legible según su magnitud:
private String formatElapsed(long elapsedMs) {
if (elapsedMs < 1_000) {
return elapsedMs + "ms";
} else if (elapsedMs < 60_000) {
return "%.3fs".formatted(elapsedMs / 1_000.0);
} else {
long minutes = elapsedMs / 60_000;
long seconds = (elapsedMs % 60_000) / 1_000;
long millis = elapsedMs % 1_000;
return "%dm %ds %dms".formatted(minutes, seconds, millis);
}
}
Menos de un segundo se muestra en milisegundos: 120ms. Entre un segundo y un minuto se muestra con tres decimales: 1.234s. Por encima de un minuto se desglosa en componentes: 2m 3s 456ms. Esta progresión hace que el número sea siempre legible en la unidad que le corresponde, sin que el ojo tenga que convertir 120000ms a 2 minutos mentalmente.
Los instantes de inicio y fin se formatean con precisión de milisegundos:
private static final DateTimeFormatter FORMATTER =
DateTimeFormatter.ofPattern("HH:mm:ss.SSS");
private String formatInstant(Instant instant) {
return LocalDateTime
.ofInstant(instant, ZoneId.systemDefault())
.format(FORMATTER);
}
El resultado en el log es start: 15:47:05.750 | end: 15:47:05.870 | elapsed: 120ms. Con esos tres valores en cada registro TIMING es posible reconstruir la línea de tiempo completa de una transacción sin necesidad de ninguna herramienta externa: basta con ordenar los registros por hora de inicio y la secuencia de etapas queda visible.
ERROR y ERROR-PROPAGATED
El manejo de errores es donde el diseño del aspecto muestra su complejidad más interesante. El problema a resolver es este: cuando una excepción sube por el stack, cada capa interceptada la captura en su bloque catch, lo que sin ningún mecanismo de control produciría un registro ERROR en cada capa que la excepción atraviesa. En el flujo del proyecto de ejemplo, una BusinessException lanzada en el UseCase sería logueada como ERROR tanto en el UseCase como en el Controller, duplicando la información y contaminando los dashboards con falsos positivos.
La solución usa dos ThreadLocal que trabajan juntos:
private static final ThreadLocal<Throwable> loggedExceptionHolder = new ThreadLocal<>();
private static final ThreadLocal<Integer> depthHolder =
ThreadLocal.withInitial(() -> 0);
depthHolder cuenta cuántos métodos interceptados están activos simultáneamente en el stack del hilo actual. Se incrementa al entrar a cada método interceptado y se decrementa al salir, tanto en el flujo normal como en el flujo de error. loggedExceptionHolder almacena una referencia a la excepción que ya fue logueada como ERROR origen.
La lógica en el bloque de error funciona así:
} catch (Throwable ex) {
Instant endInstant = Instant.now();
long elapsed = endInstant.toEpochMilli() - startInstant.toEpochMilli();
if (loggedExceptionHolder.get() == null) {
loggedExceptionHolder.set(ex);
logException(methodSignature, ex, elapsed, ERROR_MARKER);
} else {
logException(methodSignature, ex, elapsed, PROPAGATED_MARKER);
}
logTiming(methodSignature, startInstant, endInstant, elapsed, pattern);
int currentDepth = depthHolder.get() - 1;
depthHolder.set(currentDepth);
if (currentDepth == 0) {
loggedExceptionHolder.remove();
depthHolder.remove();
}
throw ex;
}
La primera capa interceptada que captura la excepción encuentra loggedExceptionHolder vacío, la registra con ERROR_MARKER y la almacena en el holder. Cada capa superior que captura la misma excepción encuentra el holder poblado y la registra con PROPAGATED_MARKER en nivel DEBUG. En la consola, el ERROR aparece exactamente una vez, en el punto donde se originó el problema, y las capas superiores emiten un DEBUG discreto que confirma la propagación sin duplicar el ruido.
La limpieza de los ThreadLocal ocurre cuando depthHolder llega a cero, es decir, cuando el método más externo del stack interceptado termina su manejo del error. Este punto de limpieza es crítico: los hilos en un servidor web son reutilizados de un request al siguiente a través de un pool. Si los ThreadLocal no se limpian, el hilo llega al siguiente request con valores residuales del request anterior. El efecto concreto sería que la primera excepción del nuevo request encontraría loggedExceptionHolder ya poblado y se registraría como ERROR-PROPAGATED en lugar de ERROR, perdiendo el origen real del error. Es un bug silencioso que solo aparece bajo carga, cuando los hilos se reutilizan frecuentemente, y que es extremadamente difícil de reproducir en desarrollo.
La razón por la que depthHolder es necesario además de loggedExceptionHolder es precisamente esta: no basta con saber que hay una excepción registrada; hay que saber cuándo es seguro limpiarla. Sin el contador de profundidad, el aspecto no puede distinguir entre el momento en que la excepción está siendo propagada por capas internas, donde el holder debe mantenerse, y el momento en que salió completamente del stack interceptado, donde el holder debe limpiarse.
El método que emite el registro de error diferencia los dos casos:
private void logException(
String methodSignature,
Throwable ex,
long elapsedMs,
String marker) {
if (marker.equals(PROPAGATED_MARKER)) {
log.debug("{} {} exception: {} - {} | elapsed: {}",
methodSignature,
marker,
ex.getClass().getSimpleName(),
ex.getMessage(),
formatElapsed(elapsedMs));
} else {
log.error("{} {} exception: {} - {} | elapsed: {}",
methodSignature,
marker,
ex.getClass().getSimpleName(),
ex.getMessage(),
formatElapsed(elapsedMs));
}
}
El ERROR origen siempre se emite en nivel ERROR, independientemente del nivel configurado en el patrón. La propagación se emite en DEBUG para que en producción con nivel INFO sea completamente invisible. Si se necesita ver la cadena de propagación para diagnosticar un problema, basta con activar DEBUG dinámicamente.
El flujo completo bajo la lupa
Con todos los componentes descritos, vale la pena recorrer la salida de consola real del proyecto para el flujo feliz y para el flujo de error. No como validación de que el código funciona, sino como lectura del sistema contando su propia historia.
Flujo feliz: registro exitoso de un usuario
La solicitud llega al Controller con nombre, email y edad. El aspecto captura la invocación antes de que el método ejecute su primera línea y emite el INPUT con los argumentos serializados:
INFO : c.a.b.i.i.e.a.registrarusuario.RegistrarUsuarioController#registrar
>>> [INPUT] | args: {request={"nombre":"Juan Perez","email":"[email protected]","edad":25}}
El Controller mapea el request a un RegistrarUsuarioIn e invoca el UseCase. El aspecto intercepta esa invocación también y emite el INPUT del UseCase con el comando ya mapeado:
INFO : c.a.b.i.d.u.registrarusuario.RegistrarUsuarioUseCase#ejecutar
>>> [INPUT] | args: {command={"nombre":"Juan Perez","email":"[email protected]","edad":25}}
Dentro del UseCase ocurren las validaciones de dominio: NombreValidator, EdadValidator y EmailDominioValidator se invocan secuencialmente. Son clases planas sin anotaciones de Spring, no son beans, y el aspecto no las ve. Su comportamiento queda implícito en el contexto del UseCase que las llama. Si alguna lanzara una excepción, aparecería en el log del UseCase como un ERROR, no en un log propio del validador.
Superadas las validaciones, el UseCase llama a gateway.existeEmail(). Spring resuelve esa llamada hacia UsuarioPersistenciaAdapter, que sí es un bean y sí está interceptado:
DEBUG : c.a.b.i.i.d.j.u.adapter.UsuarioPersistenciaAdapter#existeEmail
>>> [INPUT] | args: {email="[email protected]"}
DEBUG : c.a.b.i.i.d.j.u.adapter.UsuarioPersistenciaAdapter#existeEmail
<<< [OUTPUT] | return: false
WARN : c.a.b.i.i.d.j.u.adapter.UsuarioPersistenciaAdapter#existeEmail
*** [TIMING] | start: 15:47:05.750 | end: 15:47:05.870 | elapsed: 120ms ⚠️ superó umbral de 100ms
Tres registros para una sola llamada al adapter. El INPUT muestra exactamente qué email se consultó. El OUTPUT confirma que no existe. El TIMING revela que la operación tardó 120ms, superando el umbral de 100ms configurado para esta capa, lo que eleva automáticamente el registro a WARN aunque el nivel configurado para el adapter sea DEBUG. Este WARN es visible en producción con nivel INFO aunque todos los demás registros del adapter sean invisibles. La lentitud siempre se ve.
El UseCase continúa: genera el username, construye el objeto Usuario y llama a gateway.guardar(). El adapter es interceptado de nuevo:
DEBUG : c.a.b.i.i.d.j.u.adapter.UsuarioPersistenciaAdapter#guardar
>>> [INPUT] | args: {usuario={"id":null,"nombre":"Juan Perez","email":"[email protected]","edad":25,"username":"juanperez","fechaRegistro":"2026-05-18T15:47:05.8756894"}}
DEBUG : c.a.b.i.i.d.j.u.adapter.UsuarioPersistenciaAdapter#guardar
<<< [OUTPUT] | return: {"id":1,"nombre":"Juan Perez","email":"[email protected]","edad":25,"username":"juanperez","fechaRegistro":"2026-05-18T15:47:05.8756894"}
DEBUG : c.a.b.i.i.d.j.u.adapter.UsuarioPersistenciaAdapter#guardar
*** [TIMING] | start: 15:47:05.877 | end: 15:47:05.939 | elapsed: 62ms
El INPUT del guardar muestra el objeto completo antes de persistirse, con id en null porque todavía no ha pasado por la base de datos. El OUTPUT muestra el mismo objeto con el id asignado por H2 ya presente. El TIMING marca 62ms, dentro del umbral de 100ms, así que se emite en DEBUG normal.
El UseCase completa su ejecución y retorna el RegistrarUsuarioOut. El aspecto lo captura:
INFO : c.a.b.i.d.u.registrarusuario.RegistrarUsuarioUseCase#ejecutar
<<< [OUTPUT] | return: {"id":1,"nombre":"Juan Perez","email":"[email protected]","username":"juanperez","fechaRegistro":"2026-05-18T15:47:05.8756894"}
INFO : c.a.b.i.d.u.registrarusuario.RegistrarUsuarioUseCase#ejecutar
*** [TIMING] | start: 15:47:05.748 | end: 15:47:05.940 | elapsed: 192ms
El OUTPUT del UseCase no incluye el campo edad porque RegistrarUsuarioOut no lo tiene: ese DTO de salida solo expone lo que el contrato del caso de uso devuelve. El TIMING del UseCase registra 192ms totales de orquestación, que incluyen las validaciones, las dos llamadas al adapter y la construcción de objetos intermedios.
Finalmente el Controller recibe el resultado, lo mapea a RegistrarUsuarioResponse y retorna:
INFO : c.a.b.i.i.e.a.registrarusuario.RegistrarUsuarioController#registrar
<<< [OUTPUT] | return: {"id":1,"nombre":"Juan Perez","email":"[email protected]","username":"juanperez","fechaRegistro":"2026-05-18T15:47:05.8756894","mensaje":"Usuario registrado exitosamente"}
INFO : c.a.b.i.i.e.a.registrarusuario.RegistrarUsuarioController#registrar
*** [TIMING] | start: 15:47:05.747 | end: 15:47:05.944 | elapsed: 197ms
El OUTPUT del Controller incluye el campo mensaje que RegistrarUsuarioResponse agrega al mapear desde el RegistrarUsuarioOut. El TIMING del Controller registra 197ms de extremo a extremo, 5ms más que el UseCase, que es exactamente el overhead del Controller en mappers y serialización de la respuesta HTTP.
Con esos once registros, sin ninguna línea de log escrita en ninguna clase del proyecto, el sistema cuenta su historia completa: qué llegó, por qué capas pasó, cuánto tardó cada una, y qué salió.
Flujo de error: email duplicado
La misma solicitud llega por segunda vez. El Controller y el UseCase emiten sus INPUT normalmente, idénticos a los del flujo feliz. El adapter consulta si el email existe:
DEBUG : c.a.b.i.i.d.j.u.adapter.UsuarioPersistenciaAdapter#existeEmail
>>> [INPUT] | args: {email="[email protected]"}
DEBUG : c.a.b.i.i.d.j.u.adapter.UsuarioPersistenciaAdapter#existeEmail
<<< [OUTPUT] | return: true
DEBUG : c.a.b.i.i.d.j.u.adapter.UsuarioPersistenciaAdapter#existeEmail
*** [TIMING] | start: 15:47:12.883 | end: 15:47:12.887 | elapsed: 4ms
Esta vez el OUTPUT es true. El adapter completó su ejecución normalmente: encontró el email, retornó el resultado, el aspecto registró el TIMING. Hasta aquí no hay ningún error. El error ocurre en el UseCase, que recibe el true y lanza la BusinessException:
ERROR : c.a.b.i.d.u.registrarusuario.RegistrarUsuarioUseCase#ejecutar
!!! [ERROR] | exception: BusinessException - El email ya está registrado | elapsed: 4ms
INFO : c.a.b.i.d.u.registrarusuario.RegistrarUsuarioUseCase#ejecutar
*** [TIMING] | start: 15:47:12.883 | end: 15:47:12.887 | elapsed: 4ms
Dos registros para el camino de error del UseCase. El ERROR captura el tipo de excepción y su mensaje, que en este caso es suficientemente descriptivo para entender qué ocurrió sin necesidad de un stacktrace. El TIMING se emite de todas formas: 4ms desde que entró el comando hasta que la excepción salió del UseCase. Nótese que no hay OUTPUT: el método no completó normalmente, así que el aspecto nunca llega al código que lo emite. La ausencia del OUTPUT es en sí misma información.
La excepción sube al Controller. El aspecto la intercepta, encuentra loggedExceptionHolder ya poblado por el UseCase, y la registra como propagación:
DEBUG : c.a.b.i.i.e.a.registrarusuario.RegistrarUsuarioController#registrar
!!! [ERROR-PROPAGATED] | exception: BusinessException - El email ya está registrado | elapsed: 5ms
INFO : c.a.b.i.i.e.a.registrarusuario.RegistrarUsuarioController#registrar
*** [TIMING] | start: 15:47:12.883 | end: 15:47:12.888 | elapsed: 5ms
El ERROR-PROPAGATED se emite en DEBUG, invisible en producción con nivel INFO. El TIMING del Controller registra 5ms de extremo a extremo, 1ms más que el UseCase, que es el overhead del propio Controller antes de invocar el UseCase.
Desde el Controller la excepción sigue subiendo hasta el GlobalExceptionHandler, que la captura y construye la respuesta de error apropiada. El handler no está interceptado por el aspecto porque no tiene ninguno de los estereotipos del pointcut que coincida con un patrón configurado, así que su ejecución es completamente silenciosa desde el punto de vista del sistema de logs. El cliente recibe un HTTP 409 con el detalle del error.
Lo que este flujo demuestra es la distinción que se anticipó en la primera parte: un ERROR en el log del aspecto no siempre significa un fallo del sistema. Una BusinessException por email duplicado es una condición esperada del negocio. En un dashboard de monitoreo, filtrar por ERROR en los logs del aspecto va a incluir estos casos junto con los errores reales de infraestructura. La forma de separar ambos tipos es observar de dónde viene el ERROR: si viene de un UseCase lanzando una excepción de negocio, es ruido operacional esperado; si viene de un adapter fallando al conectar con la base de datos, es un problema genuino que requiere atención. El campo de la firma en el registro, que incluye la capa y la clase, es la clave para hacer esa distinción.
Producción sin redespliegue
Hay un escenario que todo sistema productivo enfrenta eventualmente: un comportamiento anómalo que no se reproduce en desarrollo y que requiere ver el detalle de las capas internas para diagnosticarse. En el modelo tradicional, la respuesta era subir el nivel de log a DEBUG, redesplegar, reproducir el problema, bajar el nivel, redesplegar de nuevo. En sistemas con tráfico real ese ciclo puede tomar horas y el volumen de logs generado puede saturar la infraestructura de observabilidad.
El diseño de este sistema evita ese ciclo de dos formas complementarias. La primera es estructural: los logs del adapter están en DEBUG por configuración, así que en producción con nivel INFO son completamente invisibles sin ningún costo operativo. No hay nada que desactivar porque nunca estuvieron activos. La segunda es dinámica: Spring Boot Actuator expone un endpoint que permite cambiar el nivel de log de cualquier paquete en tiempo de ejecución sin reiniciar la aplicación.
Para activarlo basta con incluir Actuator en las dependencias y exponer el endpoint de loggers en la configuración:
management.endpoints.web.exposure.include=loggers
management.endpoint.loggers.enabled=true
Con eso disponible, activar DEBUG para el paquete de los adapters en un ambiente productivo es una llamada HTTP:
POST /actuator/loggers/com.app_247.blog.id202603212000art.infrastructure.drivenadapters
Content-Type: application/json
{"configuredLevel": "DEBUG"}
A partir de ese momento, todos los registros del adapter que estaban silenciados aparecen en el log en tiempo real. Cuando el diagnóstico termina, una segunda llamada restaura el nivel a INFO y el silencio vuelve. Sin redespliegue, sin ventana de mantenimiento, sin riesgo de introducir cambios mientras se investiga un problema.
Este mecanismo refleja una filosofía más amplia que vale la pena nombrar explícitamente: el sistema de observabilidad debe poder adaptarse al momento sin modificar el sistema que está observando. La configuración por niveles y los patrones por capa son precisamente el mecanismo que hace eso posible.
Lo que se gana con este diseño
Vale la pena hacer explícito el inventario de lo que este sistema aporta, porque no todo es inmediatamente visible en el código.
La consistencia es quizás el beneficio más silencioso. Cada método interceptado produce exactamente el mismo formato de registro, con los mismos marcadores, la misma estructura de tiempo y la misma firma comprimida. No importa quién escribió la clase ni cuándo: el sistema de logs tiene siempre el mismo aspecto. En un equipo donde varias personas trabajan en paralelo sobre distintas partes del proyecto, esa consistencia es la diferencia entre un log que se puede leer y uno que requiere interpretación caso a caso.
La herencia automática es el segundo beneficio. Cada nueva clase que se añada al proyecto y que cumpla con los patrones configurados, un nuevo UseCase, un nuevo adapter, un nuevo Controller, hereda la observabilidad completa sin que nadie tenga que recordar añadir ninguna instrucción de log. El sistema crece y la observabilidad crece con él.
La separación de responsabilidades es el tercero. La lógica de negocio no sabe que está siendo observada. Los validadores de dominio no importan ninguna librería de logging. El UseCase no tiene ninguna instrucción de log. Si en el futuro el equipo decide cambiar el formato de los registros, añadir un campo nuevo a cada entrada, o integrar el sistema con OpenTelemetry, ese cambio ocurre en un único lugar: MethodLoggingAspect. Ninguna clase de negocio necesita ser modificada.
La granularidad controlable es el cuarto beneficio. El sistema tiene tres niveles de visibilidad configurables de forma independiente: los logs del Controller y el UseCase son INFO y siempre visibles, los logs del adapter son DEBUG y silenciosos en producción, y los WARN de latencia son siempre visibles independientemente del nivel de su capa. Esta estratificación permite operar en producción con un volumen de logs manejable mientras se mantiene la capacidad de activar el detalle completo en segundos cuando se necesita.
Mirando hacia adelante
Lo construido en este artículo es un sistema completo y funcional, pero no es un punto de llegada. Hay líneas naturales de evolución que vale la pena tener en el horizonte.
La más inmediata es el enmascaramiento de datos sensibles, que será el tema de la tercera parte de esta serie. El sistema actual serializa los argumentos y resultados tal como son: un email aparece en el log como texto plano, un número de identificación aparece completo. En muchos contextos eso es inaceptable desde el punto de vista de privacidad y cumplimiento regulatorio. La solución es extender el ObjectMapper que usa el aspecto con un introspector personalizado que lea anotaciones declaradas en el modelo de dominio y aplique estrategias de enmascaramiento antes de escribir el registro. El modelo de dominio declara qué es sensible; el sistema de logs lo respeta automáticamente.
Más allá del enmascaramiento, la integración con OpenTelemetry es otra extensión natural. Los registros estructurados que produce este sistema, con sus marcadores de capa y sus métricas de tiempo, son completamente compatibles con el modelo de spans de OpenTelemetry. Los mismos puntos de interceptación del aspecto que hoy emiten registros de texto podrían emitir spans instrumentados que una plataforma como Jaeger o Zipkin renderiza como árboles de llamadas con tiempos y metadatos. La transición no requeriría cambios en ninguna clase de negocio: solo en el aspecto.
La generación de métricas de aplicación a través de Micrometer desde los mismos puntos de intercepción es otra línea de evolución que elimina la duplicación entre el sistema de logs y el sistema de métricas. Hoy, para saber la latencia promedio de un adapter externo se necesita parsear los registros TIMING. Con Micrometer integrado en el aspecto, ese mismo dato podría alimentar un contador o un histograma directamente, sin pasar por texto. Una única fuente de verdad para logs y métricas, gestionada desde el mismo componente transversal.
Lo que todo esto ilustra, más allá de los detalles técnicos, es que un sistema de observabilidad diseñado con los mismos principios que se aplican a la lógica de negocio, separación de responsabilidades, consistencia, configurabilidad, no es una carga que el equipo arrastra sino una ventaja que el equipo usa. El código del proyecto queda limpio, la observabilidad queda centralizada, y la capacidad de entender qué está pasando en producción en cualquier momento queda disponible sin adivinar y sin redesplegar.
Anexo: Código fuente completo
Las clases que siguen son exactamente las que forman el sistema de logs. Todo lo demás, los validadores, los mappers, las entidades JPA, el handler de excepciones, es lógica del proyecto de ejemplo que no tiene ninguna relación con el sistema de observabilidad y que se puede reemplazar por la lógica propia de cualquier proyecto sin afectar el funcionamiento del aspecto.
Estructura de carpetas
src/main/java/com/app_247/blog/id202603212000art/
│
├── Id202603212000artApplication.java ★
│
└── applications/
└── aop/
├── aspect/
│ └── MethodLoggingAspect.java ★
└── config/
├── JacksonConfig.java ★
└── LoggingAopProperties.java ★
src/main/resources/
└── application.properties ★
Cinco artefactos. Tres en el paquete applications/aop, uno en la raíz de la aplicación y uno en recursos. Todo el sistema de observabilidad vive en esas cinco piezas.
Grupo 1 — Propiedades de configuración
LoggingAopProperties.java
package com.app_247.blog.id202603212000art.applications.aop.config;
import java.util.List;
import org.springframework.boot.context.properties.ConfigurationProperties;
import lombok.Data;
@Data
@ConfigurationProperties(prefix = "logging.aop")
public class LoggingAopProperties {
/** Habilita o deshabilita el aspecto completo */
private boolean enabled = true;
/** Paquete raíz de la aplicación, primer filtro antes de evaluar regex */
private String basePackage = "com.app_247.blog.id202603212000art";
/** Lista de patrones de interceptación */
private List<PatternConfig> patterns = List.of();
@Data
public static class PatternConfig {
/** Regex que debe cumplir el paquete completo */
private String packageRegex = ".*";
/** Regex que debe cumplir el nombre simple de la clase */
private String classRegex = ".*";
/** Regex que debe cumplir el nombre del método */
private String methodRegex = ".*";
/** Nivel de log: TRACE, DEBUG, INFO, WARN, ERROR */
private String logLevel = "INFO";
/** Umbral en ms a partir del cual se emite un WARN de tiempo */
private long warnThresholdMs = 500L;
}
}
Grupo 2 — Configuración de Jackson
JacksonConfig.java
package com.app_247.blog.id202603212000art.applications.aop.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
@Configuration
public class JacksonConfig {
@Bean
public ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
return mapper;
}
}
Grupo 3 — El aspecto
MethodLoggingAspect.java
package com.app_247.blog.id202603212000art.applications.aop.aspect;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.IntStream;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import com.app_247.blog.id202603212000art.applications.aop.config.LoggingAopProperties;
import com.app_247.blog.id202603212000art.applications.aop.config.LoggingAopProperties.PatternConfig;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(prefix = "logging.aop", name = "enabled", havingValue = "true", matchIfMissing = true)
public class MethodLoggingAspect {
private final ObjectMapper objectMapper;
private final LoggingAopProperties properties;
// -------------------------------------------------------------------------
// Marcadores visuales
// -------------------------------------------------------------------------
private static final String INPUT_MARKER = ">>> [INPUT] |";
private static final String OUTPUT_MARKER = "<<< [OUTPUT] |";
private static final String TIMING_MARKER = "*** [TIMING] |";
private static final String ERROR_MARKER = "!!! [ERROR] |";
private static final String PROPAGATED_MARKER = "!!! [ERROR-PROPAGATED] |";
private static final DateTimeFormatter FORMATTER =
DateTimeFormatter.ofPattern("HH:mm:ss.SSS");
// -------------------------------------------------------------------------
// ThreadLocal: registra la excepción que ya fue logueada como ERROR origen
// evita que capas superiores la vuelvan a loguear como ERROR
// -------------------------------------------------------------------------
private static final ThreadLocal<Throwable> loggedExceptionHolder =
new ThreadLocal<>();
// -------------------------------------------------------------------------
// ThreadLocal: contador de profundidad de métodos interceptados activos
// permite saber cuándo estamos en el método más externo del stack
// -------------------------------------------------------------------------
private static final ThreadLocal<Integer> depthHolder =
ThreadLocal.withInitial(() -> 0);
// -------------------------------------------------------------------------
// Cache de matching por firma de método
// Key: "com.app_247...RegistrarUsuarioUseCase#ejecutar"
// Value: PatternConfig que hizo match, o empty si no hubo match
// -------------------------------------------------------------------------
private final ConcurrentHashMap<String, Optional<PatternConfig>> matchCache =
new ConcurrentHashMap<>();
// -------------------------------------------------------------------------
// Pointcut: limitado a beans Spring, excluye el propio paquete aop
// -------------------------------------------------------------------------
@Around("(within(@org.springframework.stereotype.Service *) " +
"|| within(@org.springframework.stereotype.Component *) " +
"|| within(@org.springframework.web.bind.annotation.RestController *)" +
"|| within(@org.springframework.stereotype.Repository *)) " +
"&& !within(com.app_247.blog.id202603212000art.aop..*)")
public Object logMethod(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
String packageName = method.getDeclaringClass().getPackageName();
String className = method.getDeclaringClass().getSimpleName();
String methodName = method.getName();
// Filtro rápido por paquete base antes de evaluar regex
if (!packageName.startsWith(properties.getBasePackage())) {
return joinPoint.proceed();
}
// Cache de matching: evita re-evaluar regex en invocaciones repetidas
String cacheKey = packageName + "." + className + "#" + methodName;
Optional<PatternConfig> matchedPattern = matchCache.computeIfAbsent(
cacheKey,
k -> findMatchingPattern(packageName, className, methodName));
if (matchedPattern.isEmpty()) {
return joinPoint.proceed();
}
PatternConfig pattern = matchedPattern.get();
// Firma comprimida:
// c.a.b.i.d.u.registrarusuario.RegistrarUsuarioUseCase#ejecutar
String methodSignature = "%s.%s#%s".formatted(
compressPackage(packageName),
className,
methodName);
// Incrementar profundidad al entrar en un método interceptado
depthHolder.set(depthHolder.get() + 1);
logInput(methodSignature, signature, joinPoint.getArgs(), pattern);
Instant startInstant = Instant.now();
Object result;
try {
result = joinPoint.proceed();
} catch (Throwable ex) {
Instant endInstant = Instant.now();
long elapsed = endInstant.toEpochMilli() - startInstant.toEpochMilli();
if (loggedExceptionHolder.get() == null) {
// Primera captura → origen del error
loggedExceptionHolder.set(ex);
logException(methodSignature, ex, elapsed, ERROR_MARKER);
} else {
// Ya fue logueada más abajo → propagación
logException(methodSignature, ex, elapsed, PROPAGATED_MARKER);
}
logTiming(methodSignature, startInstant, endInstant, elapsed, pattern);
// Decrementar profundidad al salir con excepción
int currentDepth = depthHolder.get() - 1;
depthHolder.set(currentDepth);
// Limpiar ThreadLocals solo cuando salimos del método más externo
if (currentDepth == 0) {
loggedExceptionHolder.remove();
depthHolder.remove();
}
throw ex;
}
Instant endInstant = Instant.now();
long elapsed = endInstant.toEpochMilli() - startInstant.toEpochMilli();
// Decrementar profundidad al salir en flujo normal
depthHolder.set(depthHolder.get() - 1);
logOutput(methodSignature, method.getReturnType(), result, pattern);
logTiming(methodSignature, startInstant, endInstant, elapsed, pattern);
return result;
}
// -------------------------------------------------------------------------
// Compresión de paquete
// com.app_247.blog.id202603212000art.domain.usecase.registrarusuario
// → c.a.b.i.d.u.registrarusuario
// -------------------------------------------------------------------------
private String compressPackage(String packageName) {
if (packageName == null || packageName.isBlank()) return "";
String[] segments = packageName.split("\\.");
if (segments.length == 1) return packageName;
StringBuilder sb = new StringBuilder();
for (int i = 0; i < segments.length - 1; i++) {
sb.append(segments[i].charAt(0)).append('.');
}
sb.append(segments[segments.length - 1]);
return sb.toString();
}
// -------------------------------------------------------------------------
// Busca el primer patrón configurado que haga match con el método
// -------------------------------------------------------------------------
private Optional<PatternConfig> findMatchingPattern(
String packageName,
String className,
String methodName) {
return properties.getPatterns()
.stream()
.filter(pattern -> packageName.matches(pattern.getPackageRegex())
&& className.matches(pattern.getClassRegex())
&& methodName.matches(pattern.getMethodRegex()))
.findFirst();
}
// -------------------------------------------------------------------------
// Log INPUT
// -------------------------------------------------------------------------
private void logInput(
String methodSignature,
MethodSignature signature,
Object[] args,
PatternConfig pattern) {
Parameter[] parameters = signature.getMethod().getParameters();
if (parameters.length == 0) {
logAtLevel(pattern, "{} {} args: (none)", methodSignature, INPUT_MARKER);
return;
}
Map<String, Object> inputMap = new LinkedHashMap<>();
IntStream.range(0, parameters.length)
.forEach(i -> inputMap.put(
parameters[i].getName(),
formatArg(args[i])));
logAtLevel(pattern, "{} {} args: {}", methodSignature, INPUT_MARKER, inputMap);
}
// -------------------------------------------------------------------------
// Log OUTPUT
// -------------------------------------------------------------------------
private void logOutput(
String methodSignature,
Class<?> returnType,
Object result,
PatternConfig pattern) {
if (void.class.equals(returnType) || Void.class.equals(returnType)) {
logAtLevel(pattern, "{} {} return: void", methodSignature, OUTPUT_MARKER);
return;
}
logAtLevel(pattern, "{} {} return: {}",
methodSignature, OUTPUT_MARKER, formatArg(result));
}
// -------------------------------------------------------------------------
// Log TIMING
// -------------------------------------------------------------------------
private void logTiming(
String methodSignature,
Instant start,
Instant end,
long elapsedMs,
PatternConfig pattern) {
String startStr = formatInstant(start);
String endStr = formatInstant(end);
String elapsedFormatted = formatElapsed(elapsedMs);
if (elapsedMs >= pattern.getWarnThresholdMs()) {
log.warn("{} {} start: {} | end: {} | elapsed: {} ⚠️ superó umbral de {}ms",
methodSignature, TIMING_MARKER,
startStr, endStr,
elapsedFormatted,
pattern.getWarnThresholdMs());
return;
}
logAtLevel(pattern, "{} {} start: {} | end: {} | elapsed: {}",
methodSignature, TIMING_MARKER, startStr, endStr, elapsedFormatted);
}
// -------------------------------------------------------------------------
// Log ERROR / PROPAGATED
// El marcador se recibe como parámetro para distinguir origen de propagación
// Siempre se emite en ERROR independiente del nivel configurado en el patrón
// -------------------------------------------------------------------------
private void logException(
String methodSignature,
Throwable ex,
long elapsedMs,
String marker) {
if (marker.equals(PROPAGATED_MARKER)) {
// Solo informativo — el error real ya fue logueado en el origen
log.debug("{} {} exception: {} - {} | elapsed: {}",
methodSignature,
marker,
ex.getClass().getSimpleName(),
ex.getMessage(),
formatElapsed(elapsedMs));
} else {
// Origen del error — siempre visible
log.error("{} {} exception: {} - {} | elapsed: {}",
methodSignature,
marker,
ex.getClass().getSimpleName(),
ex.getMessage(),
formatElapsed(elapsedMs));
}
}
// -------------------------------------------------------------------------
// Emisión de log según nivel configurado en el patrón
// -------------------------------------------------------------------------
private void logAtLevel(PatternConfig pattern, String message, Object... args) {
switch (pattern.getLogLevel().toUpperCase()) {
case "TRACE" -> log.trace(message, args);
case "DEBUG" -> log.debug(message, args);
case "WARN" -> log.warn(message, args);
case "ERROR" -> log.error(message, args);
default -> log.info(message, args);
}
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private String formatInstant(Instant instant) {
return LocalDateTime
.ofInstant(instant, ZoneId.systemDefault())
.format(FORMATTER);
}
private String formatElapsed(long elapsedMs) {
if (elapsedMs < 1_000) {
return elapsedMs + "ms";
} else if (elapsedMs < 60_000) {
return "%.3fs".formatted(elapsedMs / 1_000.0);
} else {
long minutes = elapsedMs / 60_000;
long seconds = (elapsedMs % 60_000) / 1_000;
long millis = elapsedMs % 1_000;
return "%dm %ds %dms".formatted(minutes, seconds, millis);
}
}
private String formatArg(Object arg) {
if (arg == null) return "null";
try {
return objectMapper.writeValueAsString(arg);
} catch (Exception e) {
e.printStackTrace();
return arg.toString();
}
}
}
Grupo 4 — Bootstrap
Id202603212000artApplication.java
package com.app_247.blog.id202603212000art;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import com.app_247.blog.id202603212000art.applications.aop.config.LoggingAopProperties;
@SpringBootApplication
@EnableConfigurationProperties(LoggingAopProperties.class)
public class Id202603212000artApplication {
public static void main(String[] args) {
SpringApplication.run(Id202603212000artApplication.class, args);
}
}
Grupo 5 — Configuración de la aplicación
application.properties
spring.application.name=id202603212000art
# ================================
# SERVER
# ================================
server.port=8080
# ================================
# H2 DATABASE
# ================================
spring.datasource.url=jdbc:h2:mem:usuariosdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
# H2 Console (http://localhost:8080/h2-console)
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
# ================================
# JPA / HIBERNATE
# ================================
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=create-drop
# ================================
# JACKSON
# ================================
spring.jackson.serialization.write-dates-as-timestamps=false
spring.jackson.time-zone=America/Bogota
# ================================
# AOP LOGGING
# ================================
logging.aop.enabled=true
logging.aop.base-package=com.app_247.blog.id202603212000art
# UseCase
logging.aop.patterns[0].package-regex=com\\.app_247\\.blog\\.id202603212000art\\.domain\\.usecase.*
logging.aop.patterns[0].class-regex=.*UseCase
logging.aop.patterns[0].method-regex=.*
logging.aop.patterns[0].log-level=INFO
logging.aop.patterns[0].warn-threshold-ms=300
# Adapter de persistencia
logging.aop.patterns[1].package-regex=com\\.app_247\\.blog\\.id202603212000art\\.infrastructure\\.drivenadapters.*
logging.aop.patterns[1].class-regex=.*Adapter
logging.aop.patterns[1].method-regex=.*
logging.aop.patterns[1].log-level=DEBUG
logging.aop.patterns[1].warn-threshold-ms=100
# Controller
logging.aop.patterns[2].package-regex=com\\.app_247\\.blog\\.id202603212000art\\.infrastructure\\.entrypoints.*
logging.aop.patterns[2].class-regex=.*Controller
logging.aop.patterns[2].method-regex=.*
logging.aop.patterns[2].log-level=INFO
logging.aop.patterns[2].warn-threshold-ms=500
Con esas cinco piezas el sistema está completo. LoggingAopProperties define las reglas, JacksonConfig provee el serializador, MethodLoggingAspect aplica la observabilidad, Id202603212000artApplication registra las propiedades en el contenedor, y application.properties conecta la configuración con el comportamiento deseado para cada capa. Cualquier proyecto que adopte estas cinco piezas y ajuste los patrones a su propia estructura de paquetes tiene el sistema funcionando desde el primer arranque, sin ninguna modificación en las clases de negocio.
Arquitectura Modular por Contexto: Cuando la Teoría se Encuentra con la Realidad
- Mauricio ECR
- Arquitectura
- 21 Mar, 2026
Has estado ahí. Es lunes por la mañana, abres el proyecto en tu IDE, y necesitas modificar cómo se procesa un pedido. Treinta minutos después, todavía estás navegando entre carpetas intentando encontr
Arquitectura Modular por Contexto: Cuando la Teoría se Encuentra con la Realidad
- Mauricio ECR
- Arquitectura
- 21 Mar, 2026
Has estado ahí. Es lunes por la mañana, abres el proyecto en tu IDE, y necesitas modificar cómo se procesa un pedido. Treinta minutos después, todavía estás navegando entre carpetas intentando encontrar todas las piezas del rompecabezas. El caso de uso está en algún lugar del módulo de dominio, el controlador REST disperso en los entry points, el adaptador de base de datos perdido en persistencia, y probablemente algunos DTOs compartidos en carpetas que juraste que recordarías. Este es el dilema que enfrentamos constantemente: las herramientas que usamos nos imponen una estructura técnica impecable, pero nuestro cerebro humano necesita algo diferente. Necesitamos que todo lo relacionado con "procesar un pedido" esté junto, fácil de encontrar, fácil de entender, fácil de modificar.
La Estructura que las Herramientas Imponen
Para entender el problema, primero necesitamos entender cómo funcionan las herramientas de scaffolding modernas, particularmente Scaffolding of Clean Architecture—una herramienta que muchas organizaciones adoptan porque estandariza proyectos y acelera su inicio. Esta herramienta genera automáticamente una estructura basada en Clean Architecture, pero con una característica particular: todo se organiza estrictamente por naturaleza técnica a través de módulos independientes de Gradle. No son simples carpetas; son módulos que se compilan independientemente, gestionan sus propias dependencias, y establecen fronteras arquitectónicas reales. La estructura generada típicamente incluye: Un módulo de dominio completamente independiente, sin dependencias hacia otros módulos del proyecto. Aquí viven las entidades, los casos de uso, los servicios de dominio, y crucialmente, las interfaces (gateways) que definen qué operaciones necesita el dominio sin especificar cómo se implementan. Es el núcleo puro de la lógica de negocio. Un módulo de infraestructura que se subdivide en dos grandes grupos. Por un lado, los "driven adapters"—módulos para implementar persistencia (jpa-repository), para consumir servicios externos (rest-consumer), para publicar mensajes (message-sender), y otros adaptadores que implementan los contratos que el dominio define. Por otro lado, los "entry points"—módulos para exponer APIs REST (api-rest), para consumir eventos (message-listener), para tareas programadas (scheduled-task), y otros puntos de entrada al sistema. Un módulo de aplicación que ensambla todo, conteniendo la configuración que conecta las piezas, los aspectos transversales como logging y auditoría, y el punto de arranque que levanta el sistema. Desde una perspectiva arquitectónica pura, es hermoso. Inversión de dependencias impecable: el dominio define contratos, la infraestructura los implementa. Separación clara de responsabilidades: cada módulo tiene su propósito bien definido. Fronteras forzadas por el sistema de build: no puedes violar accidentalmente las dependencias porque Gradle simplemente no compilará.
El Problema que Nadie Quiere Admitir
Pero entonces llega el día a día del desarrollo, y la fricción se hace evidente.
Necesitas implementar una nueva funcionalidad: registrar un usuario. Ejecutas el comando de scaffolding para generar el caso de uso. La herramienta lo crea en domain/usecase/ en una estructura genérica. Ejecutas otro comando para generar el entry point REST. Se crea en infrastructure/entry-points/api-rest/ en otra ubicación genérica. Necesitas persistencia, ejecutas el comando para generar el adaptador JPA. Aparece en infrastructure/driven-adapters/jpa-repository/ en su propia ubicación técnica.
Cada componente vive exactamente donde debe vivir según su naturaleza técnica. El problema es que conceptualmente todos estos componentes están relacionados—todos son parte de "registrar un usuario"—pero físicamente están dispersos por toda la estructura del proyecto según su clasificación técnica.
El resultado es predecible: cinco pestañas abiertas en tu IDE, navegación constante entre módulos y carpetas, DTOs compartidos en ubicaciones centralizadas que sirven a múltiples propósitos, validadores reutilizables que intentan ser genéricos, y mappers comunes que traducen entre representaciones para varios casos de uso.
Y hay algo peor: seis meses después, cuando otro desarrollador necesita modificar esa funcionalidad de registro de usuarios, el proceso se repite. Buscar, navegar, intentar recordar dónde quedaron todas las piezas dispersas. El conocimiento está fragmentado, la comprensión es difícil, y cada modificación se siente como resolver un rompecabezas.
La pregunta natural surge: ¿por qué no simplemente abandonar esta estructura modular y volver a algo más simple donde todo esté junto? Porque entonces perdemos beneficios reales que los módulos independientes proporcionan: compilación incremental que solo recompila lo que cambió, gestión explícita de dependencias que previene acoplamiento accidental, y fronteras arquitectónicas forzadas que mantienen la integridad del diseño a largo plazo.
O podrías pensar: ¿por qué no compartir más componentes entre funcionalidades? Crear carpetas centralizadas de DTOs reutilizables, validadores comunes, mapeadores genéricos. Suena eficiente hasta que dos funcionalidades que comparten un validador divergen en sus necesidades. Entonces enfrentas la decisión imposible: ¿modificas el validador compartido arriesgando romper la otra funcionalidad, o duplicas el código que justamente intentabas evitar?
La Solución Está en la Dualidad
La respuesta no está en elegir entre estructura técnica o cohesión conceptual. La respuesta está en reconocer que ambas son valiosas pero en diferentes niveles.
Imagina mantener la estructura de módulos técnicos que Scaffolding of Clean Architecture genera—porque proporciona beneficios arquitectónicos reales—pero cambiar radicalmente cómo organizas el código dentro de cada módulo. En lugar de estructuras técnicas genéricas donde todos los componentes del mismo tipo conviven en carpetas planas, organizas por contextos de negocio donde cada funcionalidad tiene su propio espacio autocontenido.
El módulo de dominio sigue siendo un módulo de dominio, pero cuando lo abres, en lugar de encontrar una carpeta usecase/ con cincuenta casos de uso en una lista plana, encuentras algo diferente. Cada caso de uso vive en su propia carpeta de contexto: usecase/registrar-usuario/, usecase/procesar-pedido/, usecase/consultar-inventario/. Cada contexto agrupa todo lo que esa funcionalidad específica necesita.
Dentro de registrar-usuario/ no solo está el archivo del caso de uso. Está su carpeta dto/ con los DTOs de entrada y salida diseñados exactamente para lo que este caso de uso necesita—no DTOs genéricos compartidos que intentan servir múltiples propósitos. Está su carpeta mapper/ con traductores que mapean precisamente entre las representaciones que este caso de uso maneja. Está su carpeta validator/ con validadores que aplican las reglas específicas de negocio de registrar usuarios. Si necesita enriquecer datos desde otras fuentes, tiene su carpeta enricher/. Si requiere utilidades especializadas, tiene su carpeta util/.
Todo junto. Todo cohesivo. Todo autocontenido.
Lo mismo sucede en el módulo de entry points. En lugar de una carpeta genérica api-rest/ con todos los controladores mezclados, encuentras api-rest/registrar-usuario-api/ como su propio contexto. Dentro están los DTOs específicos de la API REST—diferentes de los DTOs del caso de uso porque representan el contrato externo, no el contrato de dominio. Están los mapeadores que traducen entre el mundo HTTP y el mundo del dominio. Están los validadores específicos de la capa de presentación que verifican formatos y restricciones del protocolo.
Y en el módulo de adaptadores, en lugar de entidades JPA genéricas en una carpeta común, encuentras jpa-repository/usuario-persistencia/ como contexto autocontenido con sus entidades JPA, sus repositorios Spring Data, su implementación del gateway del dominio, sus mapeadores entre entidades JPA y entidades de dominio, todo junto porque conceptualmente pertenece junto.
La estructura de módulos técnicos permanece intacta. El dominio sigue siendo independiente. Los adaptadores siguen implementando contratos del dominio. Los entry points siguen invocando casos de uso. Clean Architecture se mantiene en todo su esplendor. Pero dentro de cada módulo, la organización refleja el negocio, no solo la técnica.
Los Beneficios Tangibles que Cambian Todo
Esta dualidad—módulos técnicos afuera, contextos de negocio adentro—transforma radicalmente la experiencia de desarrollo.
Cuando necesitas modificar el registro de usuarios seis meses después de implementarlo, abres domain/usecase/registrar-usuario/ y todo está ahí. No hay búsquedas en carpetas compartidas. No hay intentos de recordar dónde quedó el validador o el mapper. La lógica del caso de uso, sus DTOs, sus validadores, sus enriquecedores, sus utilidades—todo en un solo lugar. Abres api-rest/registrar-usuario-api/ y encuentras todo lo relacionado con cómo esa funcionalidad se expone vía REST. Abres jpa-repository/usuario-persistencia/ y encuentras todo lo relacionado con cómo se persiste.
La velocidad de comprensión se dispara. Un desarrollador nuevo asignado a modificar una funcionalidad específica puede abrir su contexto y ver inmediatamente qué hace, cómo lo hace, y qué elementos utiliza. No necesita entender todo el sistema, solo el contexto específico con el que trabajará. El onboarding que solía tomar semanas ahora toma días porque el conocimiento no está disperso por todo el código base sino contenido en unidades comprensibles.
El mantenimiento se simplifica dramáticamente. Un bug en el procesamiento de pedidos significa ir a domain/usecase/procesar-pedido/. La mayoría de las veces, el problema y la solución están completamente contenidos en ese contexto. Haces el cambio, ejecutas los tests de ese contexto específico, y tienes alta confianza de que no rompiste nada más porque la independencia entre contextos minimiza los efectos colaterales.
La evolución del sistema se vuelve orgánica y natural. Una funcionalidad crítica del negocio crece en complejidad: agregas más validadores en su carpeta validator/, más enriquecedores en su carpeta enricher/, más utilidades en su carpeta util/. Otra funcionalidad permanece simple porque así lo requiere el negocio, con solo el caso de uso, un par de DTOs, y un mapper básico. No hay presión por mantener todo al mismo nivel de complejidad o estructura uniforme. Cada contexto crece según sus propias necesidades.
El trabajo en equipo fluye mejor sin fricción constante. Múltiples desarrolladores trabajan simultáneamente en diferentes contextos—uno en registrar usuarios, otro en procesar pedidos, un tercero en consultar inventario—sin colisionar porque el código está físicamente separado. Los conflictos de merge que solían ser diarios ahora son raros. Las revisiones de código son más efectivas porque los cambios están claramente contenidos: puedes ver exactamente qué se modificó dentro de un contexto específico y entender su alcance sin necesitar conocimiento exhaustivo de todo el sistema.
Y quizás lo más valioso: la confianza al hacer cambios. Cuando todo lo relacionado con una funcionalidad está junto y los contextos son genuinamente independientes, puedes modificar código con la confianza de que tus cambios no tendrán efectos colaterales sorpresa en funcionalidades no relacionadas. Los tests del contexto verifican que no rompiste esa funcionalidad específica, y la independencia entre contextos garantiza que no afectaste otras inadvertidamente.
El Principio de Duplicación Intencional
Pero hay un elefante en la habitación que necesitamos abordar directamente: verás código aparentemente duplicado. Y eso va a incomodarte.
Dos contextos tendrán validadores que lucen similares. Tres contextos tendrán mappers que parecen hacer traducciones parecidas. Varios contextos tendrán utilidades que se ven redundantes. Tu instinto—entrenado por años de escuchar "Don't Repeat Yourself"—gritará que esto está mal, que debes extraer, generalizar, compartir.
Necesitas resistir ese impulso porque está basado en una falsa equivalencia entre similitud y identidad.
Dos validadores que hoy lucen idénticos no son el mismo concepto. Uno valida emails en el contexto de registrar usuarios, donde quizás solo verificas el formato básico. Otro valida emails en el contexto de enviar campañas de marketing, donde quizás verificas que el dominio no esté en una lista de bloqueo, que el usuario haya dado consentimiento, que el email haya sido verificado previamente. Parecen el mismo código hoy, pero representan reglas de negocio de contextos diferentes que inevitablemente divergirán mañana.
Si hubieras compartido ese validador "para no duplicar código", cuando uno de los contextos necesite evolucionar—y lo necesitará—enfrentarás una decisión imposible. O modificas el validador compartido y arriesgas romper todos los contextos que lo usan, o agregas condicionales que verifican desde qué contexto se está llamando (acoplamiento horrible), o terminas duplicando el código de todas formas cuando la presión del deadline no te deja tiempo para refactorizaciones elegantes.
La duplicación intencional es el precio que pagas por la independencia. Y resulta ser un precio extraordinariamente bajo comparado con el costo del acoplamiento que crearías compartiendo componentes prematuramente.
Esto no significa nunca compartir nada. Significa compartir solo lo que tiene una razón de negocio genuina para ser compartido. Un modelo de dominio como Usuario que representa el mismo concepto fundamental a través de múltiples contextos merece vivir en domain/model/usuario/ como elemento transversal. Un servicio de dominio con lógica compleja de cálculo de precios que múltiples casos de uso invocan justifica su existencia en domain/service/calculo-precios/. Pero un validador que casualmente verifica el mismo formato en dos contextos diferentes no necesita ser compartido solo porque el código se ve similar.
La guía es simple: extrae como transversal solo cuando hay identidad conceptual de negocio, no cuando hay mera similitud técnica superficial. Y cuando dudes, prefiere duplicar. Es más fácil extraer código duplicado después cuando verdaderamente lo necesitas que desenredar dependencias compartidas cuando los contextos necesitan divergir.
Cómo Convive con las Herramientas de Scaffolding
La pregunta práctica que surge inmediatamente es: si Scaffolding of Clean Architecture genera código en ubicaciones genéricas basadas en naturaleza técnica, ¿cómo logras esta organización por contextos?
La respuesta es un flujo de trabajo disciplinado que combina generación automática con reorganización consciente.
Cuando necesitas crear un caso de uso, ejecutas el comando de scaffolding que lo genera en domain/usecase/ en una estructura base genérica. Inmediatamente después, antes de escribir una línea de lógica, creas manualmente la carpeta de contexto domain/usecase/nombre-funcionalidad/ y mueves el archivo generado ahí. Creas las subcarpetas que ese caso de uso específico necesitará: dto/, mapper/, validator/, etc.
Cuando generas un entry point REST, el scaffolding lo crea en infrastructure/entry-points/api-rest/ en ubicación genérica. De inmediato creas la carpeta de contexto api-rest/nombre-funcionalidad-api/ y reorganizas. Cuando generas un adaptador de persistencia, se crea en infrastructure/driven-adapters/jpa-repository/ genéricamente. Creas jpa-repository/contexto-persistencia/ y contextualizas.
El scaffolding proporciona el esqueleto técnico correcto en el módulo correcto con la estructura base apropiada. Tú proporcionas la organización conceptual que refleja el negocio. Es trabajo adicional, sí, pero es trabajo que pagas una vez y recuperas mil veces cada vez que necesitas encontrar, entender, o modificar código.
Esta reorganización no puede ser opcional ni algo que "haremos cuando tengamos tiempo". Debe ser parte no negociable del proceso de desarrollo desde el día uno. Cada componente generado se contextualiza inmediatamente antes de comenzar a escribir su lógica. Las revisiones de código verifican no solo que el código funciona sino que está correctamente organizado en su contexto apropiado.
La disciplina es crucial porque es fácil tomar atajos bajo presión. "Solo por esta vez dejaré el código donde el scaffolding lo generó, no tengo tiempo de reorganizar ahora." Pero esos atajos se acumulan. La estructura se vuelve inconsistente—algunos componentes contextualizados, otros dispersos genéricamente—y gradualmente pierdes todos los beneficios. Es como mantener limpia una cocina: si lavas los platos después de cada comida es fácil, si los dejas acumular se vuelve insoportable.
Maximizando los Beneficios: Desarrollo Outside-In
Con la estructura clara y el proceso de reorganización establecido, hay una práctica que potencia enormemente los beneficios de esta organización por contextos: el desarrollo outside-in. No es obligatorio seguir este enfoque, pero resulta extraordinariamente efectivo para avanzar con la seguridad de que cada paso está correctamente implementado antes de pasar al siguiente. Esta afirmación se entiende mejor al contrastarla con los inconvenientes de la forma tradicional de iniciar el desarrollo. En la forma tradicional normalmente se comienza desde el dominio y se trabaja hacia afuera. Esto implica que no puedes validar realmente que algo funciona hasta que todas las capas están implementadas. Pasas días o semanas escribiendo código sin poder ejecutar nada end-to-end, descubriendo problemas de integración solo al final cuando son más caros de resolver. El enfoque que mejor aprovecha esta estructura es el opuesto: comenzar desde el punto de entrada y avanzar hacia adentro, validando cada capa inmediatamente después de crearla.
Empiezas con el entry point. Si la funcionalidad se expondrá vía REST, generas el controlador con scaffolding, lo reorganizas en su contexto api-rest/crear-pedido-api/, creas sus DTOs de request y response, y haces que devuelva datos inventados pero con la estructura correcta. Levantas la aplicación. Haces una petición HTTP real. El endpoint responde en menos de treinta minutos desde que comenzaste. Son datos falsos, pero el contrato de la API está validado y tienes algo tangible que puedes mostrar.
Ahora creas el caso de uso. Generas con scaffolding, reorganizas en domain/usecase/crear-pedido/, creas sus DTOs—diferentes de los de la API—y lo haces devolver también datos simulados. Creas los mapeadores en api-rest/crear-pedido-api/mapper/ que traducen entre DTOs de API y DTOs de caso de uso. Inyectas el caso de uso en el controlador y conectas el flujo.
Levantas la aplicación nuevamente. Haces una petición. Los datos fluyen: API recibe → mapea a lenguaje de dominio → caso de uso procesa → mapea a lenguaje de API → responde. Todo funciona. Siguen siendo datos simulados, pero la arquitectura de comunicación entre capas está validada. Has probado que las abstracciones encajan correctamente.
Continúas capa por capa. Si el caso de uso necesita un modelo transversal que no existe, lo creas en domain/model/pedido/ con su gateway. Si necesita lógica reutilizable, creas el servicio en domain/service/calculo-descuentos/. Cada uno inicialmente con lógica simplificada o simulada.
Implementas el adaptador de persistencia. Generas con scaffolding, organizas en jpa-repository/pedido-persistencia/ con sus entidades JPA, repositorios, implementación del gateway, mapeadores. Lo pruebas de forma aislada con tests de integración contra base de datos de prueba. Solo cuando funciona correctamente lo conectas al caso de uso. Haces una petición end-to-end y por primera vez los datos realmente se persisten y recuperan.
Agregas validadores al caso de uso, uno a la vez, en domain/usecase/crear-pedido/validator/. Pruebas que rechazan correctamente datos inválidos. Agregas enriquecedores en enricher/ que complementan información. Implementas clientes para servicios externos, cada uno en su contexto en rest-consumer/servicio-inventario/. En cada paso tienes algo funcional que puedes probar.
Este flujo outside-in con retroalimentación temprana transforma el desarrollo. Nunca estás más de un paso alejado de algo que funciona. Los problemas de integración se descubren tempranamente cuando son fáciles de resolver. Siempre tienes una versión funcional—aunque incompleta—en lugar de un sistema completo que no funciona hasta el final. Y la presión psicológica desaparece porque constantemente ves progreso tangible.
Con la estructura clara y el proceso de reorganización establecido, hay una práctica que potencia enormemente los beneficios de esta organización por contextos: el desarrollo outside-in. No es obligatorio seguir este enfoque, pero resulta extraordinariamente efectivo para avanzar con la seguridad de que cada paso está correctamente implementado antes de pasar al siguiente. Esta afirmación se entiende mejor al contrastarla con los inconvenientes de la forma tradicional de iniciar el desarrollo. En la forma tradicional normalmente se comienza desde el dominio y se trabaja hacia afuera. Esto implica que no puedes validar realmente que algo funciona hasta que todas las capas están implementadas. Pasas días o semanas escribiendo código sin poder ejecutar nada end-to-end, descubriendo problemas de integración solo al final cuando son más caros de resolver. El enfoque que mejor aprovecha esta estructura es el opuesto: comenzar desde el punto de entrada y avanzar hacia adentro, validando cada capa inmediatamente después de crearla.
Con la estructura clara y el proceso de reorganización establecido, hay una práctica que potencia enormemente los beneficios de esta organización por contextos: el desarrollo outside-in. No es obligatorio seguir este enfoque, pero resulta extraordinariamente efectivo para avanzar con la seguridad de que cada paso está correctamente implementado antes de pasar al siguiente. Esta afirmación se entiende mejor al contrastarla con los inconvenientes de la forma tradicional de iniciar el desarrollo. En la forma tradicional normalmente se comienza desde el dominio y se trabaja hacia afuera. Esto implica que no puedes validar realmente que algo funciona hasta que todas las capas están implementadas. Pasas días o semanas escribiendo código sin poder ejecutar nada end-to-end, descubriendo problemas de integración solo al final cuando son más caros de resolver. El enfoque que mejor aprovecha esta estructura es el opuesto: comenzar desde el punto de entrada y avanzar hacia adentro, validando cada capa inmediatamente después de crearla.
Las Incomodidades Reales
Seamos honestos sobre los desafíos porque existen y necesitas conocerlos antes de adoptar esta aproximación. La disciplina de reorganización después de cada generación de scaffolding es real y constante. Bajo presión de deadlines, saltarse este paso es tentador. "Lo reorganizaré después" se convierte en "nunca". La solución no es intentar automatizar la reorganización—requiere juicio humano sobre qué constituye un contexto apropiado—sino hacer de la reorganización una parte no negociable del proceso. La definición de "done" incluye código correctamente contextualizado. Las revisiones de código lo verifican. Los desarrolladores nuevos son entrenados en esto desde el día uno. Las estructuras de carpetas serán más profundas. Más niveles de anidamiento que en estructuras tradicionales. Inicialmente esto se siente lento y confuso. Los IDEs modernos ayudan significativamente con búsquedas rápidas y navegación inteligente, pero aún así hay un período de adaptación de algunas semanas. Después, la mayoría de desarrolladores encuentra que localizar código es más rápido porque saben exactamente dónde buscar: todo lo relacionado con una funcionalidad está en su contexto. Decidir qué extraer como transversal y qué mantener en contextos requiere experiencia y juicio. No hay reglas absolutas que puedas seguir mecánicamente. Un modelo de dominio usado extensamente claramente debe ser transversal. Un servicio con lógica compleja reutilizable justifica extracción. Pero un mapper usado por un solo caso de uso debe permanecer en ese contexto. Esta decisión requiere práctica, y a veces te equivocarás y necesitarás refactorizar. Eso es normal y esperado. El tamaño del código base crecerá más que en aproximaciones tradicionales debido a la duplicación intencional. Esto puede parecer problemático especialmente en equipos acostumbrados a optimizar por menos líneas de código. Pero la métrica relevante no es el tamaño absoluto sino la mantenibilidad y comprensibilidad. Un código base más grande pero bien organizado, donde cada pieza tiene su lugar claro, es infinitamente más fácil de mantener que un código base más pequeño con componentes compartidos complejos y dependencias cruzadas que hacen imposible entender el impacto de los cambios.
Haciendo la Transición en Tu Equipo
Si esto resuena contigo y quieres adoptarlo, la transición requiere más que cambiar la estructura de carpetas. Comienza solo con código nuevo. Intentar refactorizar todo un código base existente de una vez es una receta para el fracasgo: demasiado costo, demasiado riesgo, demasiada resistencia del equipo. Aplica la filosofía a nuevas funcionalidades que implementes desde cero. Refactoriza código existente solo cuando ese código necesita modificaciones significativas de todas formas—entonces aprovechar para reorganizarlo en contextos apropiados es inversión que ya estás haciendo. Esta adopción gradual permite que el equipo aprenda sin el trauma de una reescritura masiva. Por algunos meses convivirán dos estilos: código viejo en estructura tradicional, código nuevo en contextos. Está bien. Eventualmente, a medida que el código viejo se modifica, se va reorganizando. En un año, la mayoría del código activo estará contextualizado. Invierte en documentación viva con ejemplos concretos de tu código base real. Abstracciones teóricas sobre "contextos autocontenidos" no funcionan tan bien como mostrar "mira, así organizamos el caso de uso de procesar pedidos, aquí están todos sus elementos, esta es la razón por la que cada uno está donde está". Cuando los desarrolladores pueden ver ejemplos reales del propio proyecto, la comprensión es inmediata. Las sesiones de pair programming donde desarrolladores experimentados en la filosofía trabajan con nuevos miembros aplicándola en práctica valen más que cualquier documento. Ver cómo alguien genera código con scaffolding y luego inmediatamente lo reorganiza, cómo decide qué subcarpetas crear, cómo identifica qué debe ser transversal versus específico del contexto—eso se aprende haciendo, no leyendo. Las revisiones de código son críticas para mantener integridad arquitectónica. Verifica que el código está en el contexto correcto, que sigue principios de autocontención, que no está creando acoplamiento innecesario. Esta verificación debe tener el mismo peso que verificar corrección funcional. Si el código funciona pero está mal organizado, solicitar cambios no es pedantería—es proteger la mantenibilidad a largo plazo del sistema. Y mantén flexibilidad dentro del marco. No todo contexto necesita la misma estructura. Un caso de uso simple no necesita todas las subcarpetas que uno complejo requiere. Lo importante son los principios—autocontención, cohesión conceptual, independencia—no seguir rígidamente una plantilla.
Performance: La Pregunta que Todos Hacen
Eventualmente alguien preguntará: "¿Toda esta separación y múltiples traducciones entre DTOs no tiene costo de performance prohibitivo?" La respuesta pragmática: en la vasta mayoría de aplicaciones empresariales, no. El costo de mapear entre DTOs de API, DTOs de caso de uso, entidades de dominio, y entidades JPA se mide en microsegundos. Las operaciones que realmente importan—queries a base de datos, llamadas HTTP a servicios externos, procesamiento de lógica de negocio compleja—se miden en milisegundos o más. Los mapeos son ruido estadístico en comparación. Cuando la performance es genuinamente crítica—procesamiento batch de millones de registros, sistemas de alta frecuencia, servicios con SLAs de latencia extremos—la arquitectura no lo prohíbe. Un caso de uso puede saltarse algunos mapeos, trabajando más directamente con representaciones de niveles inferiores si es necesario. La clave es que esto sea una decisión consciente, documentada, y justificada por mediciones reales de performance bajo carga real, no por optimización prematura basada en suposiciones. Las optimizaciones del compilador Java y la JVM también ayudan enormemente. El inlining de métodos pequeños significa que muchos mapeos que parecen caros en el código fuente son esencialmente gratuitos en el bytecode optimizado. La eliminación de código muerto elimina paths que nunca se ejecutan. El profile-guided optimization del JIT compiler optimiza los caminos que realmente se usan frecuentemente. La guía es clara: construye con la arquitectura limpia por defecto. Mide cuando tengas dudas reales. Optimiza solo donde las mediciones bajo carga real muestren necesidad. La claridad arquitectónica facilita la optimización cuando es necesaria porque es trivial identificar dónde está el cuello de botella—está en un contexto específico—y modificar solo esa parte sin afectar el resto.
El Impacto en la Cultura del Equipo
Más allá de la estructura de carpetas, esta filosofía cambia cómo los equipos trabajan y colaboran. El ownership del código se vuelve natural y claro. Cuando todo lo relacionado con una funcionalidad está en un contexto específico, es fácil asignar ownership de ese contexto a alguien. No significa que solo esa persona puede tocarlo—eso crearía silos de conocimiento—pero hay alguien responsable de su calidad, coherencia, y evolución. Cuando surge una pregunta sobre esa funcionalidad, hay un punto de contacto claro. Cuando necesita evolucionar, hay alguien que entiende su contexto completo. La planificación de sprints se simplifica porque las historias de usuario frecuentemente se mapean directamente a casos de uso, y los casos de uso son contextos autocontenidos. Estimar el esfuerzo se vuelve más predecible: implementar un caso de uso significa crear su contexto con los elementos que necesita. La variabilidad viene de cuántos y qué tipo de elementos específicos requiere—validadores complejos versus simples, múltiples enriquecedores versus ninguno—pero el patrón general es consistente. Las estimaciones mejoran porque hay menos incertidumbre sobre alcance y dependencias. La colaboración cambia de naturaleza. En lugar de conflictos constantes por múltiples personas modificando los mismos archivos compartidos, diferentes desarrolladores trabajan en diferentes contextos con mínima interferencia. Cuando necesitan coordinación, típicamente es a través de interfaces bien definidas—un caso de uso invocando un servicio de dominio, un entry point usando un caso de uso—no modificando los mismos archivos internos simultáneamente. El testing se vuelve más natural. Cada contexto puede probarse de forma aislada con sus dependencias mockeadas apropiadamente. Los tests unitarios se enfocan en lógica específica del contexto. Los tests de integración verifican que el contexto se comunica correctamente con sus dependencias reales. Los tests end-to-end verifican que el flujo completo funciona atravesando múltiples contextos. Esta separación hace que los tests sean más simples de escribir, más rápidos de ejecutar, y más fáciles de mantener porque el alcance de cada nivel de testing es claro. La rotación de personas—tanto salidas como nuevas incorporaciones—se maneja mejor. El conocimiento no está uniformemente distribuido por un código base monolítico donde entender cualquier parte requiere entender el todo. El conocimiento está organizado por contextos. Un desarrollador saliente puede documentar y traspasar los contextos de los que tenía ownership específico. Un desarrollador entrante puede comenzar tomando ownership de contextos particulares, aprendiendo el sistema incrementalmente en lugar de necesitar una descarga masiva de conocimiento de todo desde el día uno.
Evolución y Futuro
Esta filosofía híbrida no es un destino final sino un punto en la evolución continua de cómo organizamos código complejo. Las herramientas seguirán mejorando. Los IDEs se volverán más inteligentes en entender y navegar estructuras modulares complejas. Las herramientas de scaffolding podrían eventualmente aprender a generar código ya organizado por contextos, preguntando al desarrollador a qué contexto de negocio pertenece el componente antes de generarlo. La generación de código asistida por IA podría entender patrones arquitectónicos como esta filosofía de contextos y generar código que automáticamente se organiza correctamente, reduciendo la carga de disciplina manual. Las herramientas de análisis estático podrían detectar violaciones de la organización por contextos, identificando cuando un contexto accede directamente a detalles internos de otro o cuando la estructura se está volviendo inconsistente. A medida que más sistemas evolucionan hacia arquitecturas distribuidas, la clara separación de contextos se vuelve aún más valiosa. Los bounded contexts bien definidos facilitan decisiones sobre qué debe desplegarse junto y qué podría beneficiarse de despliegue independiente como microservicios. Los módulos Gradle proporcionan las fronteras naturales para estas decisiones, y la organización por contextos asegura que cada unidad desplegable sea cohesiva y completa. Pero más allá de las herramientas futuras, los principios permanecen: autocontención facilita comprensión, cohesión conceptual facilita mantenimiento, independencia entre contextos facilita evolución. Estos principios son atemporales incluso si los detalles de implementación evolucionan con nuevas tecnologías.
Casos Reales y Lecciones Aprendidas
En equipos que han adoptado esta aproximación, ciertos patrones emergen consistentemente. La transición inicial típicamente toma entre cuatro y ocho semanas. Las primeras dos semanas son de confusión y resistencia—"esto parece más complicado", "por qué estamos duplicando código", "no entiendo dónde poner las cosas". Las siguientes dos a cuatro semanas son de adaptación—el músculo de reorganizar después de scaffolding se desarrolla, las decisiones sobre qué contextualizar versus qué extraer se vuelven más naturales. Después de seis a ocho semanas, la mayoría de desarrolladores reporta que encontrar y modificar código se siente significativamente más fácil que antes. El momento "ajá" típicamente llega cuando un desarrollador necesita modificar una funcionalidad que implementó semanas antes. Abre el contexto esperando tener que buscar piezas dispersas por todo el proyecto, y descubre sorprendido que todo está ahí. "Oh, esto realmente funciona." Los equipos exitosos típicamente desarrollan sus propias convenciones específicas sobre nombrado de contextos, cuándo crear subcarpetas adicionales, cómo documentar decisiones de diseño dentro de contextos. Estas convenciones locales complementan los principios generales, adaptando la filosofía a las necesidades específicas del dominio y la cultura del equipo. Un error común es intentar que todos los contextos tengan exactamente la misma estructura. Un caso de uso complejo puede tener ocho subcarpetas diferentes. Uno simple puede tener solo tres. Ambos están bien. La estructura sirve a la funcionalidad, no al revés. Forzar uniformidad rígida crea carpetas vacías o artificialmente pobladas que no agregan valor. Otro error es ser demasiado conservador con la duplicación, intentando extraer cualquier similitud mínima. Esto recrea el problema original de componentes compartidos con dependencias complejas. La guía que funciona: cuando dudes si extraer, espera. Duplica inicialmente. Solo extrae cuando el tercer o cuarto contexto necesita exactamente lo mismo y tienes evidencia clara de que representa un concepto verdaderamente transversal del negocio, no solo similitud técnica superficial.
Relación con Otros Patrones
Esta filosofía no existe en vacío sino que complementa y se integra con otros patrones y prácticas establecidas. Domain-Driven Design proporciona el vocabulario para identificar y organizar contextos. Los bounded contexts de DDD se mapean naturalmente a agrupaciones de contextos en esta arquitectura. Las entidades, value objects, aggregates, y domain events de DDD encuentran su lugar en los contextos de modelo. Los servicios de dominio de DDD corresponden directamente a los servicios de dominio en esta estructura. CQRS puede aplicarse dentro de la organización por contextos. Los casos de uso que modifican estado (comandos) pueden organizarse claramente separados de los que solo leen (queries), permitiendo optimizaciones diferentes para cada tipo sin sacrificar claridad organizacional. Event Sourcing se integra naturalmente. Los domain events que las entidades generan pueden persistirse como event stream. Los adaptadores de persistencia implementan event stores. Los casos de uso publican eventos que otros contextos consumen, manteniendo independencia entre bounded contexts mientras permiten coordinación. La relación con Microservicios es interesante. Cada bounded context con sus casos de uso, servicios, y adaptadores podría potencialmente extraerse como microservicio independiente. Los módulos Gradle proporcionan fronteras naturales para esta extracción. Los gateways que actualmente se implementan con adaptadores locales podrían reemplazarse con adaptadores que hacen llamadas remotas. La organización por contextos facilita esta evolución porque las dependencias entre contextos son explícitas a través de gateways, haciendo visible el acoplamiento que necesitaría convertirse en comunicación remota.
El Verdadero Valor
Al final, todo esto se reduce a una verdad simple: la arquitectura de software existe para facilitar resolver problemas de negocio de manera efectiva y sostenible en el tiempo.
Una buena arquitectura es aquella que permite a los desarrolladores entender rápidamente qué hace el código, hacer cambios con confianza, y evolucionar el sistema según las necesidades del negocio cambian. No es la que se ve más elegante en un diagrama. No es la que usa las tecnologías más nuevas. No es la que tiene menos líneas de código. Es la que funciona para el equipo que la mantiene y el negocio que la necesita.
Esta filosofía híbrida de contextos dentro de módulos técnicos busca precisamente eso. No promete eliminar toda complejidad—la complejidad es inherente a sistemas empresariales que resuelven problemas complejos—pero promete organizarla de manera que sea manejable y comprensible.
Promete que cuando necesites modificar algo, sabrás dónde buscar porque todo lo relacionado está junto. Promete que tus cambios estarán contenidos y sus efectos predecibles porque los contextos son independientes. Promete que nuevos desarrolladores pueden comenzar a contribuir sin necesitar entender todo el sistema porque pueden tomar ownership de contextos específicos.
No es la única manera de organizar código, y no será la mejor para todos los proyectos y equipos. Pero para equipos que trabajan con herramientas de scaffolding que generan estructura modular, que construyen aplicaciones empresariales complejas donde el código vive y evoluciona durante años, y que valoran tanto la disciplina arquitectónica como la productividad práctica, ofrece un balance probado entre estructura y pragmatismo.
La adopción requiere más que cambiar carpetas. Requiere cambiar cómo piensas sobre organización de código. Requiere disposición a cuestionar dogmas como "nunca duplicar código" y reconocer que la duplicación intencional es frecuentemente mejor que el acoplamiento prematuro. Requiere disciplina para mantener integridad arquitectónica incluso bajo presión. Requiere inversión en documentación, entrenamiento, y procesos de revisión que refuercen los principios.
Pero para equipos dispuestos a hacer esa inversión, los retornos son reales y duraderos. Código que seis meses después todavía puedes entender rápidamente. Cambios que implementas con confianza sabiendo que no romperás cosas no relacionadas. Sistemas que crecen en funcionalidad sin colapsar bajo su propio peso. En un mundo donde el software exitoso inevitablemente crece en complejidad, eso no es poca cosa.
Así que la próxima vez que abras un proyecto y necesites modificar cómo se procesa un pedido, no pasarás treinta minutos buscando piezas dispersas por toda la estructura. Abrirás domain/usecase/procesar-pedido/ y todo estará ahí. Esa es la promesa. Esa es la diferencia.
Observabilidad sin Ruido: Diseñando un Sistema de Logs con AOP en Arquitecturas DDD
- Mauricio ECR
- Arquitectura
- 01 Mar, 2026
Hay una tensión que todo equipo de desarrollo enfrenta tarde o temprano: la necesidad de saber qué está pasando dentro del sistema sin que esa necesidad contamine el código que lo hace funcionar. Los
Observabilidad sin Ruido: Diseñando un Sistema de Logs con AOP en Arquitecturas DDD
- Mauricio ECR
- Arquitectura
- 01 Mar, 2026
Hay una tensión que todo equipo de desarrollo enfrenta tarde o temprano: la necesidad de saber qué está pasando dentro del sistema sin que esa necesidad contamine el código que lo hace funcionar. Los logs son la herramienta más inmediata para satisfacer esa necesidad, pero también son, cuando no se gestionan con criterio, una de las fuentes más frecuentes de deuda técnica, acoplamiento silencioso y dolores de cabeza en producción.
Lo que se propone en este artículo es un modelo de observabilidad para sistemas construidos con Java 21, Spring Boot 3.5.x, Gradle y arquitectura DDD. El objetivo no es solo definir dónde va cada logger.info(), sino construir un esquema en el que la observabilidad sea una preocupación transversal completamente separada de la lógica de negocio, implementada mediante Programación Orientada a Aspectos (AOP) y sostenida por convenciones que cualquier miembro del equipo pueda seguir sin ambigüedad.
El problema que queremos resolver
Antes de hablar de la solución vale la pena entender con precisión el problema. En la mayoría de los proyectos, los logs nacen de forma orgánica: el desarrollador que escribe un caso de uso añade un par de líneas de debug para entender qué está pasando durante el desarrollo, y esas líneas se quedan ahí. Llega otro desarrollador, añade las suyas, y así sucesivamente. El resultado, algunos meses después, es un codebase donde la lógica de negocio está entrelazada con instrucciones de log que nadie revisa, que no siguen ningún formato consistente, que en algunos métodos son excesivas y en otros brillan por su ausencia, y que en más de una ocasión exponen datos sensibles de los usuarios en texto plano.
El problema no es que los desarrolladores sean descuidados. El problema es estructural: cuando la responsabilidad de loguear está distribuida en cada clase del sistema, es inevitable que el resultado sea inconsistente. La única forma de garantizar consistencia es centralizar esa responsabilidad en un mecanismo que opere de forma transversal, sin depender de que cada desarrollador recuerde seguir una convención.
Eso es, en esencia, lo que ofrece la Programación Orientada a Aspectos.
AOP: observar sin intervenir
La idea central de AOP es simple aunque su implementación puede ser sofisticada: existen preocupaciones en un sistema, como la seguridad, las transacciones o el logging, que no pertenecen a ningún módulo en particular pero que afectan a todos. En lugar de dispersar el código que gestiona esas preocupaciones por todo el sistema, AOP permite encapsularlo en un componente separado llamado aspecto, que el framework inyecta de forma transparente en los puntos de ejecución que se le indiquen.
En el contexto de Spring, esto funciona a través de proxies. Cuando el contenedor de inversión de control crea un bean, puede envolverlo en un proxy que intercepta las llamadas a sus métodos. Ese proxy ejecuta el aspecto antes, después o alrededor de la llamada real. El método original no sabe que está siendo observado; simplemente hace su trabajo.
Para que este mecanismo funcione hay una condición que no siempre es obvia: los objetos deben ser beans de Spring. Si una clase no está gestionada por el contenedor, Spring no puede envolverla en un proxy y el aspecto no puede interceptarla. Este detalle tiene una implicación directa en cómo se diseña la observabilidad en una arquitectura DDD, y es precisamente el punto de partida para resolver uno de los dilemas más frecuentes en este tipo de proyectos.
La capa de dominio y el dilema del logging
En una arquitectura DDD estricta, la capa de dominio es la más interna y la más pura. No debe tener dependencias de infraestructura, no debe saber si está siendo ejecutada en una API REST o en un job batch, y definitivamente no debería importar librerías de logging. Esta pureza es lo que la hace testeable, portable y mantenible.
Pero esa misma pureza genera una pregunta legítima: ¿qué pasa con los servicios de dominio? Un servicio que valida si un cliente tiene crédito suficiente, que aplica reglas de descuento, que verifica el stock disponible, ¿no merece ser observado? Si algo falla en esa lógica, ¿cómo sabremos qué ocurrió?
La respuesta convencional suele ser una de dos: o se acepta contaminar el dominio con un logger, o se ignora completamente ese nivel de detalle y se espera que las excepciones cuenten la historia. Ninguna de las dos opciones es satisfactoria.
La salida está en un detalle de implementación que a veces pasa desapercibido: cuando los servicios de dominio y los casos de uso se registran como beans en el contenedor de Spring a través de la capa de aplicación, aunque el dominio no sabe nada de Spring, el contenedor sí los gestiona. Y si el contenedor los gestiona, AOP puede interceptarlos. El dominio sigue siendo puro porque no tiene ninguna dependencia en infraestructura. El aspecto lo observa desde afuera, a través del proxy, sin que el servicio de dominio sea consciente de ello.
Este es uno de esos casos donde las restricciones de una arquitectura, entendidas a fondo, abren posibilidades que no eran evidentes a primera vista.
Una sola responsabilidad por capa
Con ese fundamento claro, los puntos de observabilidad se organizan siguiendo la misma lógica que organiza la arquitectura: cada capa tiene su propio contrato de logging.
Los entry points, que son los controladores REST o cualquier otro mecanismo de entrada al sistema, son el primer y último punto que el aspecto intercepta en el flujo de una solicitud. Aquí se registra el request entrante con los datos de entrada saneados y, cuando el flujo termina, el response saliente con el tiempo total que tomó la operación. Es la vista más amplia del sistema: saber qué llegó y qué salió.
Los casos de uso aportan el siguiente nivel de granularidad. El aspecto registra el inicio y el fin de la orquestación, con el DTO de entrada ya mapeado y el resultado antes de que sea transformado para la respuesta. Esto permite correlacionar exactamente qué datos entran al corazón del sistema y qué produce como resultado.
Los servicios de dominio representan el nivel más detallado. Aquí el aspecto registra el resultado de cada validación, cada regla de negocio, cada decisión que toma el dominio. Este nivel de detalle, sin embargo, no necesita estar activo permanentemente en producción. Se emite en nivel DEBUG, lo que significa que en un ambiente productivo es invisible pero puede activarse dinámicamente en cuestión de segundos si se necesita diagnosticar un problema sin reiniciar la aplicación.
Finalmente, los driven adapters, que son las implementaciones de los puertos hacia el mundo exterior, tienen un requerimiento adicional que los diferencia de todas las demás capas: la latencia. No basta con saber que se hizo una llamada a un servicio externo o que se ejecutó una consulta a la base de datos; hay que saber cuánto tardó. Esa información es la que permite distinguir entre un problema de lógica interna y un problema de dependencia externa, una diferencia que en producción puede significar horas de diagnóstico incorrecto.
El tiempo como dato de primera clase
Medir el tiempo solo en las llamadas externas es un primer paso, pero insuficiente. Para entender verdaderamente el comportamiento de un sistema bajo carga es necesario conocer cuánto tarda cada etapa del flujo. Un total de 800 milisegundos en una solicitud puede ser perfectamente aceptable o completamente inaceptable dependiendo de dónde se origina ese tiempo.
Por eso cada punto de observabilidad debe registrar dos métricas de tiempo: durationMs, que mide cuánto tardó esa etapa específica, y elapsedMs, que mide el tiempo acumulado desde que llegó la solicitud hasta ese punto. Con ambas métricas en cada registro, reconstruir la línea de tiempo de una transacción en una herramienta de observabilidad es trivial.
A esto se suma un campo stage en cada registro, que identifica la capa que lo generó: ENTRY_POINT, USE_CASE, DOMAIN_SERVICE, EXTERNAL_CALL o REPOSITORY. Este campo convierte los logs de texto plano en datos estructurados sobre los que se pueden construir dashboards, alertas y análisis de performance sin necesidad de parsear mensajes de texto.
El valor de este diseño se hace evidente con un ejemplo concreto. Imaginemos una solicitud de creación de orden de compra que tarda 800 milisegundos en total. Sin el campo stage y sin durationMs por etapa, la única conclusión disponible es que la solicitud fue lenta. Con esos campos, el análisis revela en segundos que 600 de esos 800 milisegundos los consumió la API externa de cobertura logística, mientras que la lógica de dominio tomó menos de 15 milisegundos. La optimización correcta es evidente: no hay que tocar el dominio, hay que atacar la dependencia externa.
Privacidad por diseño, no por convención
Uno de los aspectos más delicados del logging es la privacidad. Cada vez que un sistema registra información existe el riesgo de que datos sensibles terminen en un archivo de log, en una herramienta de indexación o en el radar de una auditoría de seguridad. La respuesta habitual a este riesgo es la convención: "no logueen datos personales". El problema con las convenciones es que dependen de que cada desarrollador las recuerde y las aplique correctamente en cada caso.
Un enfoque más robusto es que la privacidad se declare en el modelo de datos, no en el código que loguea.
Para materializar esto se definen dos anotaciones que se aplican directamente sobre los campos del modelo de dominio. La primera, @NoLog, indica que un campo nunca debe aparecer en ningún log bajo ninguna circunstancia: omisión total. Se usa para campos como imágenes en Base64, documentos adjuntos, o cualquier objeto cuyo tamaño o naturaleza lo hace inadecuado para un registro. La segunda, @Confidential, indica que el campo contiene datos personales y que su valor debe enmascararse antes de escribirse. El aspecto aplica una función de máscara según el tipo configurado: una dirección de correo como [email protected] se convierte en j***@mail.com, un número de identificación se convierte en ***, un teléfono muestra solo los últimos cuatro dígitos.
Lo elegante de este diseño es que las reglas de privacidad viven donde tienen sentido: en el modelo de dominio, junto a la definición del dato. Cuando un desarrollador crea un campo en un modelo y lo anota con @Confidential, esa anotación se respeta automáticamente en todos los logs del sistema, sin necesidad de recordar actualizar ningún otro componente. La privacidad deja de ser una convención y se convierte en una propiedad del dato.
Más allá de las anotaciones, hay categorías de información que nunca deben aparecer en logs independientemente de si están anotadas: credenciales, tokens de autenticación, datos completos de tarjetas de crédito, cookies de sesión, datos biométricos. La regla práctica que sintetiza todos estos casos es directa: si el dato permite suplantar la identidad de un usuario o acceder a un sistema, no va en el log.
Trazabilidad: el hilo que conecta todo
Un log aislado tiene valor limitado. El valor real emerge cuando se pueden correlacionar todos los eventos de una transacción, desde que llega la solicitud hasta que sale la respuesta, incluyendo cada llamada externa y cada decisión de dominio que ocurrió en el camino.
El mecanismo que hace posible esta correlación es el message-id, un identificador único que se asigna a cada solicitud en el momento en que entra al sistema. Si el cliente lo envía en el header X-Message-Id, se reutiliza; si no viene, el sistema genera uno automáticamente. Este identificador se almacena en el MDC de SLF4J, que es un mapa de contexto asociado al hilo de ejecución. Todos los logs emitidos durante esa solicitud lo incluyen automáticamente.
El resultado es que en cualquier herramienta de observabilidad, filtrar por message-id produce exactamente la secuencia completa de eventos de una transacción, ordenada por tiempo, con cada etapa identificada por su stage y con sus métricas de duración. Lo que antes requería correlacionar manualmente decenas de líneas de log dispersas ahora es una consulta de una sola condición.
Este mecanismo presenta un desafío particular en operaciones asíncronas. Cuando Spring lanza un hilo para ejecutar una tarea marcada con @Async, ese hilo nuevo no hereda el MDC del hilo padre. El message-id y el tiempo de inicio de la solicitud se pierden, y los logs del hilo asíncrono quedan huérfanos sin correlación. La solución es un decorador de tareas que captura el MDC completo del hilo padre en el momento en que se lanza la tarea y lo restaura en el hilo hijo antes de ejecutarla. Este decorador se configura una sola vez en el executor del pool de hilos y aplica a todas las operaciones asíncronas del sistema sin ningún esfuerzo adicional por parte del desarrollador.
El mismo principio se extiende a arquitecturas de microservicios. Cuando el sistema hace una llamada HTTP a otro servicio, el message-id debe viajar en el header de la petición saliente. El microservicio receptor lo extrae, lo almacena en su propio MDC, y todos sus logs quedan correlacionados con la misma transacción origen. Esto se configura una sola vez en el cliente HTTP como un interceptor, y a partir de ahí todas las llamadas salientes propagan el identificador automáticamente. En una plataforma de observabilidad centralizada, una sola búsqueda por message-id puede reconstruir el árbol completo de llamadas entre servicios.
El flujo completo bajo la lupa
Para ilustrar cómo se manifiesta todo esto en la práctica, vale la pena recorrer un flujo real. Tomemos la creación de una orden de compra como caso de uso: el cliente envía los datos de la orden con sus productos, dirección de entrega e información personal; el sistema valida que el cliente esté activo, verifica el stock, homologa los códigos externos de los productos a los códigos internos del catálogo, consulta una API externa para validar la cobertura logística en la dirección indicada, persiste la orden en base de datos y devuelve el número de orden generado.
Sin escribir una sola línea de log en ninguno de esos componentes, el aspecto genera automáticamente doce registros a lo largo del flujo. El primero captura el request entrante con los datos saneados: el correo del cliente enmascarado, el número de identificación reemplazado por asteriscos, los documentos adjuntos simplemente omitidos. El segundo marca el inicio del caso de uso. Los registros tres, cuatro y cinco corresponden a los servicios de dominio: la validación del cliente, la validación de stock y la homologación de productos; estos se emiten en DEBUG y son invisibles en producción a menos que se activen dinámicamente. Los registros siete y ocho capturan la llamada a la API de cobertura logística con su latencia exacta. Los registros nueve y diez hacen lo mismo con la operación de base de datos. El registro once cierra el caso de uso con el tiempo total de orquestación. El doce emite el response saliente con el tiempo total de la solicitud de punta a punta.
El análisis de esos doce registros revela de inmediato la distribución del tiempo: cuatro milisegundos de overhead en los mappers de entrada, diez milisegundos en validaciones de dominio, doscientos diez milisegundos en la API de cobertura logística, cuarenta y cinco milisegundos en base de datos. Sin ningún profiler, sin instrumentación adicional, el sistema cuenta su propia historia con precisión quirúrgica.
El mismo flujo en un escenario de error muestra otra dimensión del diseño. Si el stock es insuficiente, el servicio de dominio lanza una excepción de negocio controlada. El aspecto la captura a nivel DEBUG en el servicio de dominio y la deja subir. El @ControllerAdvice la intercepta y emite un registro en nivel WARN, no ERROR, porque una validación fallida es una condición esperada del negocio, no un fallo del sistema. Sin stacktrace completo, solo el mensaje de negocio y el message-id. En cambio, si la API externa de cobertura logística devuelve un timeout, el adapter emite un registro en nivel ERROR con stacktrace completo y la latencia exacta que revela los cinco segundos de espera antes del fallo.
Esta distinción entre WARN y ERROR no es cosmética. En los dashboards de monitoreo permite separar el ruido normal del negocio de los fallos reales que requieren atención inmediata. Un equipo de operaciones que recibe alertas solo para registros ERROR puede confiar en que cada alerta representa un problema genuino del sistema, no una validación fallida que el usuario debe corregir.
Producción sin sorpresas
Hay un escenario que todo sistema productivo enfrenta eventualmente: un comportamiento anómalo que no se reproduce en desarrollo y que requiere ver el detalle de la lógica interna para diagnosticarse. En el modelo tradicional, la respuesta a este escenario era subir el nivel de log a DEBUG, redesplegar, esperar, bajar el nivel, redesplegar de nuevo. Un proceso lento, arriesgado y que en sistemas con tráfico real puede generar un volumen de logs suficiente para saturar la infraestructura de observabilidad.
Dos mecanismos complementarios evitan ese ciclo. El primero es la jerarquía de niveles ya descrita: los logs de DOMAIN_SERVICE se emiten en DEBUG, así que en producción con nivel INFO son completamente invisibles y no generan ningún costo operativo. El segundo es el cambio dinámico de nivel a través de un endpoint interno que delega en la API de loggers de Spring Boot Actuator. Activar DEBUG para un paquete específico, observar el comportamiento, y volver a INFO es una operación de segundos sin ningún redespliegue.
Este diseño refleja una filosofía más amplia: las herramientas de observabilidad deben poder adaptarse al momento sin modificar el sistema observado.
Lo que también importa, aunque no se vea en el código
Hay una dimensión del logging que no suele documentarse pero que es igualmente crítica: saber qué no loguear. Las anotaciones @NoLog y @Confidential cubren los datos que el modelo declara explícitamente como sensibles, pero hay categorías de información que nunca deben aparecer en logs independientemente de cualquier anotación.
Los tokens de autenticación son el ejemplo más obvio. Un JWT completo, una API key o un refresh token en un log es esencialmente una credencial expuesta que puede ser extraída por cualquiera con acceso a la plataforma de observabilidad, que en muchas organizaciones incluye a un número considerable de personas. Lo mismo aplica para contraseñas, PINs, datos completos de tarjetas de crédito, cookies de sesión y datos biométricos.
La lista no es exhaustiva ni puede serlo, porque los datos sensibles dependen del contexto de cada sistema. Lo que sí puede establecerse como hábito es hacerse la pregunta antes de que un dato llegue a un registro. Y en la duda, la respuesta correcta es siempre la omisión.
De los logs a la inteligencia operacional
Un sistema de logs bien diseñado no es solo un mecanismo de diagnóstico reactivo. Es la materia prima de la inteligencia operacional. Los campos estructurados que este esquema produce, stage, durationMs, elapsedMs, event, adapter, permiten derivar métricas sin instrumentación adicional en el código.
La latencia promedio por adapter externo permite monitorear el SLA de cada dependencia. La tasa de registros con event=BUSINESS_EXCEPTION agrupada por tipo de excepción permite entender qué reglas de negocio fallan con más frecuencia y orientar decisiones de producto. El tiempo total por endpoint permite construir alertas que disparen cuando la latencia supera el percentil 99 histórico. La correlación entre EXTERNAL_CALL_START sin su correspondiente EXTERNAL_CALL_END permite detectar llamadas que nunca respondieron.
Y quizás el beneficio más silencioso de todos: cada nueva funcionalidad que se añada al sistema hereda automáticamente la observabilidad con el mismo nivel de detalle y el mismo formato estructurado, simplemente por seguir la arquitectura. No hay nada que recordar, nada que configurar, nada que pueda olvidarse.
Mirando hacia adelante
Lo descrito en este artículo establece una base sólida, pero no es un punto de llegada. Hay líneas de evolución naturales que vale la pena tener en el horizonte.
La integración con sistemas de tracing distribuido como OpenTelemetry lleva la correlación entre microservicios un paso más allá, al construir árboles de spans que representan visualmente la jerarquía de llamadas, con tiempos y metadatos, en una interfaz diseñada específicamente para ese propósito. Los logs estructurados que produce este esquema son compatibles con ese modelo y pueden complementarlo sin contradicción.
La generación automática de métricas de aplicación a través de Micrometer desde los mismos puntos de intercepción del AOP es otra extensión natural, ya que evita la duplicación entre el sistema de logs y el sistema de métricas, manteniendo una única fuente de verdad para ambos tipos de datos.
El mismo patrón de aspectos transversales también puede extenderse a otros dominios de preocupación: auditoría de cambios de estado, registro de accesos a datos sensibles para cumplimiento regulatorio, o validación automática de contratos entre capas.
Lo que todo esto ilustra, más allá de los detalles técnicos, es que la observabilidad no tiene por qué ser un ciudadano de segunda clase en la arquitectura de un sistema. Cuando se diseña con la misma intención que se diseña la lógica de negocio, cuando se le aplican los mismos principios de separación de responsabilidades y consistencia, se convierte en una ventaja operacional genuina: el equipo gana la capacidad de entender qué está pasando en producción en cualquier momento, con el nivel de detalle que necesita, sin adivinar y sin contaminar el código que hace que el sistema funcione.
La implementación concreta de este diseño, con el código de cada artefacto, los aspectos completos, el sanitizador de datos y el ejemplo funcional del flujo de orden de compra, se documenta en detalle en la segunda parte de este artículo.
Estándar de Arquitectura: Transacciones Distribuidas (Patrón Saga)
- Mauricio ECR
- Arquitectura
- 14 Feb, 2026
PARTE I: PRINCIPIOS Y NORMATIVA 1. Fundamentos de Consistencia Eventual Debido a la naturaleza distribuida del sistema, se abandona el modelo ACID tradicional (Atomicidad inmediata con bloque
Estándar de Arquitectura: Transacciones Distribuidas (Patrón Saga)
- Mauricio ECR
- Arquitectura
- 14 Feb, 2026
PARTE I: PRINCIPIOS Y NORMATIVA
1. Fundamentos de Consistencia Eventual
Debido a la naturaleza distribuida del sistema, se abandona el modelo ACID tradicional (Atomicidad inmediata con bloqueos) en favor del modelo BASE (Basically Available, Soft state, Eventually consistent).
Implicaciones arquitectónicas:
- Los datos pueden estar temporalmente inconsistentes entre servicios
- La consistencia se alcanza mediante propagación de eventos y compensaciones
- Cada servicio mantiene su propia fuente de verdad (base de datos)
- No existen transacciones atómicas que abarquen múltiples servicios
Garantías del sistema:
- Disponibilidad: Los servicios responden incluso si otros están caídos
- Durabilidad: Los eventos se persisten antes de considerarse publicados
- Convergencia: El sistema eventualmente alcanzará un estado consistente
2. Patrones Permitidos
2.1 Patrón Primario: Saga basada en Coreografía
Los servicios participantes reaccionan a eventos de dominio de manera autónoma sin conocer el flujo completo de la transacción distribuida. Cada servicio:
- Escucha eventos relevantes de su dominio
- Ejecuta su lógica de negocio local
- Publica eventos de resultado
- No tiene conocimiento de qué otros servicios participan en el proceso
Ventajas: Bajo acoplamiento, alta escalabilidad, sin punto único de fallo.
Desventajas: Flujo implícito, difícil depuración, complejidad en el rastreo.
2.2 Patrón Complementario: Orquestación Ligera de Estado
Se permite que UN servicio actúe como Coordinador de Estado (no como orquestador tradicional) con las siguientes restricciones:
Permitido:
- Mantener una tabla de estado que rastree el progreso de la saga
- Escuchar todos los eventos relevantes del flujo
- Tomar decisiones de cancelación basadas en eventos recibidos
- Emitir comandos de compensación cuando sea necesario
- Proveer endpoints de consulta del estado de la transacción
Prohibido:
- Invocar directamente (HTTP/gRPC) a otros servicios para ejecutar pasos
- Mantener lógica de negocio que corresponde a otros dominios
- Actuar como proxy o gateway entre servicios
Clarificación: El coordinador observa y reacciona, no comanda y espera. La comunicación sigue siendo asíncrona mediante el bus de eventos.
2.3 Prohibiciones Absolutas
- Two-Phase Commit (2PC): No se permite debido a bloqueos prolongados y baja disponibilidad
- XA Transactions: Prohibido extender transacciones de base de datos entre servicios
- Distributed Locks: No se permiten bloqueos compartidos entre servicios (excepto Semantic Locks de negocio, ver Parte III)
- Llamadas Síncronas en Flujo Crítico: HTTP/REST/gRPC solo para consultas (queries), nunca para comandos (writes) en sagas
3. Comunicación y Mensajería
3.1 Intermediario Obligatorio
Toda comunicación entre pasos de una saga debe realizarse a través de un Message Broker con las siguientes características:
Requisitos mínimos del broker:
- Persistencia en disco (durabilidad de mensajes)
- Garantía de entrega "al menos una vez" (at-least-once delivery)
- Capacidad de reintento automático
- Soporte para Dead Letter Queues (DLQ)
- Ordenamiento por partición (opcional pero recomendado)
Brokers aprobados: Kafka, RabbitMQ, Amazon SQS/SNS, Azure Service Bus, Google Pub/Sub.
3.2 Topología de Mensajería
Para eventos de dominio:
- Utilizar patrón Publish-Subscribe (pub/sub)
- Múltiples consumidores pueden suscribirse al mismo evento
- Los productores no conocen a los consumidores
Para comandos directos (casos excepcionales):
- Utilizar colas punto-a-punto
- Un solo consumidor procesa el mensaje
- Incluir timeout de procesamiento
3.3 Restricciones de Comunicación Síncrona
Prohibido para:
- Ejecutar el siguiente paso de una transacción crítica
- Confirmar operaciones de escritura entre servicios
- Propagar cambios de estado en flujos transaccionales
Permitido para:
- Consultas de solo lectura (queries)
- Validaciones previas no bloqueantes
- Obtención de datos de referencia
- Healthchecks y monitoreo
4. Transactional Outbox Pattern (Obligatorio)
4.1 Definición del Problema
El Dual Write Problem ocurre cuando un servicio intenta:
- Actualizar su base de datos local
- Publicar un evento en el broker
Si la publicación falla después del commit de BD, el sistema queda inconsistente. Si falla antes, se pierde el evento.
4.2 Solución Mandatoria
Regla de Oro: Un servicio nunca debe publicar directamente en el broker dentro de su código de negocio.
Implementación del patrón:
Primera fase - Transacción Atómica Local:
- Iniciar transacción de base de datos
- Ejecutar operación de negocio (INSERT/UPDATE/DELETE en tablas de dominio)
- Insertar el evento a publicar en una tabla especial llamada OUTBOX
- Confirmar transacción completa (COMMIT atómico)
Segunda fase - Publicación Asíncrona: 5. Un proceso independiente (Relay/Publisher) lee continuamente la tabla OUTBOX 6. Publica los eventos pendientes en el broker 7. Marca los eventos como publicados o los elimina
Tabla OUTBOX - Estructura requerida:
Campos obligatorios:
- Identificador único del mensaje (UUID)
- Tipo de evento (nombre del evento de dominio)
- Cuerpo del evento (payload serializado)
- Timestamp de creación
- Estado de publicación (pendiente, publicado, fallido)
- Número de intentos de publicación
- Agregado raíz asociado (para ordenamiento)
- Versión del esquema del evento
4.3 Estrategias de Relay
Opción A - Polling:
- Proceso que consulta periódicamente la tabla OUTBOX
- Publica eventos pendientes ordenados por timestamp
- Marca como publicados tras confirmación del broker
- Intervalo recomendado: 100-500 milisegundos
Opción B - Change Data Capture (CDC):
- Herramienta que lee el transaction log de la base de datos
- Detecta inserts en OUTBOX en tiempo real
- Publica automáticamente en el broker
- Ejemplos: Debezium, Maxwell, AWS DMS
Opción C - Database Triggers:
- Trigger que se activa al insertar en OUTBOX
- Invoca procedimiento que publica en broker
- No recomendado por acoplamiento y menor resiliencia
5. Resiliencia e Idempotencia
5.1 Principio de Idempotencia
Dado que los brokers garantizan entrega "al menos una vez", es inevitable que algunos mensajes se entreguen duplicados. Todo consumidor de eventos DEBE ser idempotente.
Definición: Una operación es idempotente si ejecutarla múltiples veces produce el mismo resultado que ejecutarla una sola vez.
Verificación obligatoria: Antes de procesar un evento, el consumidor debe verificar si el identificador del mensaje ya fue procesado previamente.
5.2 Implementación de Deduplicación
Tabla de Registro de Mensajes Procesados:
Cada servicio debe mantener una tabla dedicada con:
- Identificador del mensaje (clave primaria)
- Tipo de evento procesado
- Timestamp de procesamiento
- Estado final (éxito/fallo)
- Índice en timestamp para limpieza periódica
Flujo de procesamiento idempotente:
- Recibir mensaje del broker
- Iniciar transacción de base de datos local
- Intentar insertar el identificador del mensaje en la tabla de registro
- Si la inserción falla por duplicado: hacer rollback y retornar éxito (ya fue procesado)
- Si la inserción es exitosa: ejecutar lógica de negocio
- Insertar evento resultante en tabla OUTBOX (si aplica)
- Confirmar transacción completa
- Enviar ACK al broker
Política de limpieza: Eliminar registros con más de siete días de antigüedad mediante proceso nocturno.
5.3 Estrategias de Compensación
Para toda operación de escritura que modifique estado de negocio, el servicio debe implementar una Transacción Compensatoria.
Definición: Acción lógicamente inversa que deshace (o mitiga) el efecto de una operación previamente confirmada.
Ejemplos de compensación:
Operación original → Compensación:
- CrearPedido → AnularPedido
- ReservarInventario → LiberarReserva
- CobrarPago → ReembolsarPago
- EnviarNotificacion → EnviarNotificacionCorreccion
- AsignarRecurso → DesasignarRecurso
Características de compensaciones:
- Debe ser idempotente (puede ejecutarse múltiples veces)
- Puede ser semántica (no necesariamente restaura estado exacto)
- Debe registrarse en logs de auditoría
- Debe emitir eventos de compensación para trazabilidad
Tipos de compensación:
- Compensación perfecta: Restaura el estado exacto anterior (ej: cancelar reserva)
- Compensación aproximada: Restaura un estado equivalente (ej: reembolso en créditos en vez de dinero)
- Compensación simbólica: Registra el intento de reversión cuando la compensación real es imposible (ej: no se puede "des-enviar" un email, pero se envía corrección)
5.4 Manejo de Errores y Reintentos
Clasificación de fallos:
Fallos Transitorios: Errores temporales que pueden resolverse reintentando
- Pérdida de conexión de red
- Timeouts de base de datos por carga
- Servicio dependiente temporalmente no disponible
- Límites de rate limiting
Fallos Permanentes: Errores que no se resolverán reintentando
- Validaciones de negocio fallidas
- Datos malformados o incompletos
- Violaciones de reglas de dominio
- Permisos insuficientes
- Recursos no encontrados
Estrategia de Reintentos - Exponential Backoff:
Para fallos transitorios se debe implementar:
- Espera inicial entre primer y segundo intento: 500 milisegundos
- Multiplicador exponencial: factor de 2
- Espera máxima entre intentos (techo): 60 segundos
- Número máximo de intentos: 5
- Jitter aleatorio: añadir variación del 10-25% para evitar thundering herd
Progresión ejemplo: 500ms → 1s → 2s → 4s → 8s → DLQ
Dead Letter Queue (DLQ):
Después de agotar los reintentos, el mensaje debe enviarse a una cola especial para:
- Análisis manual posterior
- Alertas al equipo de operaciones
- Posible reprocesamiento manual tras corrección
- Auditoría de fallos recurrentes
Propiedades requeridas en mensajes de DLQ:
- Mensaje original completo
- Número de intentos realizados
- Timestamps de cada intento
- Detalles de cada error ocurrido
- Trace completo del último error
6. Versionado y Evolución de Contratos
6.1 Esquemas Explícitos Obligatorios
Todo evento de dominio publicado en el bus debe tener un esquema formal que defina:
- Nombre y tipo de cada campo
- Campos obligatorios vs opcionales
- Tipos de datos permitidos
- Restricciones de validación
- Descripción semántica de cada campo
Formatos aprobados: Avro, Protocol Buffers (Protobuf), JSON Schema.
Prohibido: Publicar eventos con estructura ad-hoc sin definición formal.
6.2 Versionado Semántico de Eventos
Todo evento debe incluir un campo de metadatos que indique su versión siguiendo el formato semántico: MAJOR.MINOR.PATCH
Ejemplo: "schema_version": "1.2.0"
Interpretación de versiones:
MAJOR: Cambios incompatibles que requieren actualización del consumidor
- Eliminar campos
- Cambiar tipo de dato existente
- Cambiar semántica del campo
- Renombrar campos
MINOR: Cambios retrocompatibles que agregan funcionalidad
- Agregar nuevos campos opcionales
- Agregar nuevos valores a enumeraciones
- Deprecar campos (sin eliminarlos)
PATCH: Correcciones menores sin impacto funcional
- Corregir descripciones
- Mejorar documentación
- Correcciones de typos en nombres
6.3 Estrategias de Evolución
Para cambios ADITIVOS (Minor/Patch):
- Agregar solo campos opcionales con valores por defecto
- Los consumidores antiguos ignoran campos nuevos
- Los productores nuevos deben tolerar consumidores antiguos
- No requiere coordinación de despliegue
Para cambios BREAKING (Major):
- Crear un nuevo tipo de evento con sufijo de versión
- Ejemplo: "OrdenSolicitada_v2"
- Mantener publicación dual por período de transición
- El productor emite tanto evento v1 como v2
- Los consumidores migran gradualmente a la nueva versión
- Período mínimo de convivencia: 90 días calendario
- Después del período, deprecar y eliminar versión antigua
Política de deprecación:
- Anunciar deprecación con 90 días de anticipación
- Añadir warnings en logs cuando se use versión antigua
- Publicar métricas de uso de versiones obsoletas
- Coordinar migración con todos los equipos consumidores
- Eliminar soporte solo cuando uso sea cero por 30 días
6.4 Registro Centralizado de Esquemas
Obligatorio: Mantener un Schema Registry centralizado que:
- Almacena todas las versiones de esquemas de eventos
- Valida compatibilidad antes de registrar nuevas versiones
- Provee APIs para consulta programática de esquemas
- Genera documentación automática de contratos
- Permite validación en tiempo de runtime
Herramientas recomendadas: Confluent Schema Registry, AWS Glue Schema Registry, Apicurio Registry.
7. Observabilidad y Rastreabilidad
7.1 Rastreo Distribuido
Generación de Identificadores:
El servicio que inicia una saga debe generar los siguientes identificadores únicos:
Saga ID: Identificador global único (UUID versión 4) que representa toda la transacción distribuida. Se genera una sola vez al inicio y se propaga sin cambios.
Span ID: Identificador único para cada paso o evento individual dentro de la saga. Cada servicio que procesa genera su propio Span ID.
Parent Span ID: Referencia al Span ID del paso anterior, creando una jerarquía de trazas.
Propagación de Contexto:
Estos identificadores deben incluirse como headers/metadatos en todos los mensajes:
- Nombre del header de Saga ID: "X-Saga-ID"
- Nombre del header de Span ID: "X-Span-ID"
- Nombre del header de Parent Span: "X-Parent-Span-ID"
Los servicios intermedios deben:
- Preservar el Saga ID sin modificarlo
- Generar su propio Span ID
- Copiar el Span ID recibido como su Parent Span ID
- Propagar estos tres valores en todos los eventos que emitan
7.2 Logging Estructurado
Formato Obligatorio:
Cada entrada de log relacionada con procesamiento de eventos de saga debe ser estructurada (no texto plano) e incluir los siguientes campos:
Campos mandatorios de contexto:
- Identificador de saga (copiado del mensaje)
- Tipo de evento procesado
- Versión del esquema del evento
- Nombre del servicio que genera el log
- Timestamp en formato ISO-8601 con zona horaria UTC
- Identificador del span actual
- Identificador del span padre
Campos mandatorios de resultado:
- Estado del procesamiento: RECEIVED, PROCESSING, SUCCESS, FAILED, COMPENSATING, COMPENSATED
- Duración en milisegundos de la operación
- Número de intento (para reintentos)
Campos opcionales pero recomendados:
- Identificador de correlación de negocio (ej: número de orden)
- Identificador del usuario o entidad que inició la transacción
- Datos relevantes del payload (sin información sensible)
- Detalles del error (en caso de fallo)
- Nombre del nodo/instancia que procesó
Niveles de Log:
- INFO: Inicio y fin exitoso de procesamiento de evento
- WARN: Reintentos por fallos transitorios
- ERROR: Fallos permanentes, envío a DLQ
- DEBUG: Detalles de validaciones y decisiones de negocio
Ejemplo descriptivo de entrada de log:
Un log estructurado indicando que el servicio de Inventario procesó exitosamente un evento PagoExitoso en 150 milisegundos, este fue el primer intento, pertenece a la saga con ID alfa-123, el span actual es beta-456 hijo del span gamma-789, ocurrió el 7 de febrero de 2026 a las 10:30:00 UTC, procesó la versión 1.0 del evento, y resultó en éxito.
7.3 Métricas Requeridas
Todos los servicios participantes en sagas deben exponer las siguientes métricas en formato compatible con sistemas de monitoreo:
Métricas de duración:
- Nombre: saga_duration_seconds
- Tipo: Histogram
- Etiquetas: tipo_de_saga, estado_final (success, failed, compensated)
- Descripción: Tiempo total desde inicio hasta conclusión de la saga
Métricas de errores por paso:
- Nombre: saga_step_errors_total
- Tipo: Counter (contador acumulativo)
- Etiquetas: nombre_servicio, tipo_evento, tipo_error
- Descripción: Cantidad total de fallos al procesar eventos
Métricas de compensaciones:
- Nombre: saga_compensations_total
- Tipo: Counter
- Etiquetas: nombre_servicio, razón_compensación
- Descripción: Cantidad de transacciones compensatorias ejecutadas
Métricas de mensajes pendientes:
- Nombre: outbox_pending_messages
- Tipo: Gauge (valor instantáneo)
- Etiquetas: nombre_servicio
- Descripción: Cantidad de eventos en tabla OUTBOX pendientes de publicar
Métricas de mensajes en DLQ:
- Nombre: dlq_messages_total
- Tipo: Gauge
- Etiquetas: nombre_servicio, tipo_evento
- Descripción: Cantidad de mensajes en Dead Letter Queue por servicio
Formato de exportación: Prometheus, OpenMetrics, o CloudWatch.
7.4 Trazabilidad de Auditoría
Para procesos críticos de negocio se debe mantener:
- Tabla de auditoría de saga con todos los cambios de estado
- Timestamp de cada transición de estado
- Razón del cambio (evento que lo provocó)
- Usuario o sistema responsable del inicio
- Datos relevantes de negocio (sin información sensible duplicada)
- Retención mínima: según políticas regulatorias (típicamente 7 años)
8. Límites Operacionales y Timeouts
8.1 Parámetros Configurables Mandatorios
Todo servicio participante en sagas debe exponer y documentar los siguientes parámetros de configuración:
Reintentos:
Número máximo de intentos antes de enviar a DLQ
- Valor mínimo permitido: 3 intentos
- Valor recomendado: 5 intentos
Espera inicial entre primer y segundo intento (backoff inicial)
- Valor mínimo permitido: 100 milisegundos
- Valor recomendado: 500 milisegundos
Espera máxima entre intentos (techo de backoff)
- Valor mínimo permitido: 30 segundos
- Valor recomendado: 60 segundos
Timeouts de saga completa:
- Tiempo máximo total para completar toda la saga
- Valor mínimo permitido: 1 hora
- Valor recomendado: 24 horas
- Nota: Ajustar según naturaleza del proceso de negocio
Timeouts por paso individual:
- Tiempo máximo de espera por respuesta de un paso
- Valor mínimo permitido: 30 segundos
- Valor recomendado: 5 minutos (300 segundos)
Estos valores deben ser configurables sin recompilar código (variables de entorno, archivos de configuración, configuration server).
8.2 Política de Timeouts
Para timeouts de paso individual:
Si un evento esperado no llega dentro del plazo configurado:
- El servicio coordinador (si existe) debe emitir un evento de timeout
- Nombre del evento: "StepTimedOut" o similar
- Incluir en el payload: saga_id, paso esperado, tiempo transcurrido
- Iniciar proceso de compensación
- Registrar en logs con nivel ERROR
- Incrementar métrica de timeouts
Para timeouts de saga completa:
Si la saga no se completa dentro del plazo total configurado:
- Ejecutar compensación automática de todos los pasos confirmados
- Marcar la saga con estado TIMED_OUT
- Emitir evento de saga expirada para auditoría
- Notificar al usuario/sistema iniciador del fallo
- Generar alerta para equipo de operaciones
- No eliminar datos de auditoría (mantener para análisis)
8.3 Monitoreo de Umbrales
Alertas obligatorias:
- Si el porcentaje de sagas fallidas supera 5% en ventana de 15 minutos
- Si el tiempo promedio de saga supera el doble del baseline histórico
- Si la cantidad de mensajes en DLQ supera 10 por servicio
- Si hay mensajes en OUTBOX pendientes por más de 10 minutos
- Si una saga individual supera el 80% del timeout configurado
Niveles de severidad:
- CRITICAL: Afecta flujos de negocio críticos (pagos, pedidos)
- HIGH: Afecta funcionalidad importante pero no crítica
- MEDIUM: Degradación de rendimiento sin pérdida de funcionalidad
- LOW: Anomalías detectadas pero sin impacto inmediato
PARTE II: IMPLEMENTACIÓN DE REFERENCIA
1. Caso de Uso: Procesamiento de Órdenes con Validación Preventiva
Dominio de negocio: Sistema de comercio electrónico
Objetivo: Procesar una orden de compra asegurando disponibilidad de inventario antes de ejecutar el cobro, minimizando reembolsos por falta de stock.
Estrategia elegida: Check-Then-Act (Verificación antes de Acción Financiera)
Justificación: Reducir costos de transacciones bancarias fallidas y mejorar experiencia del cliente evitando cobros seguidos de reembolsos.
2. Definición del Flujo Transaccional
El proceso se divide en cuatro fases secuenciales:
Fase 1 - Intención:
- Acción: Registro inicial de la orden en el sistema
- Estado resultante: PENDIENTE_VALIDACION
- Evento emitido: OrdenSolicitada
Fase 2 - Validación:
- Acción: Consulta de disponibilidad de inventario sin reserva
- Tipo de operación: Lectura (SELECT) sin bloqueos
- Eventos posibles: StockVerificado o StockNoDisponible
Fase 3 - Cobro Condicional:
- Acción: Ejecución de transacción financiera
- Precondición: Solo si Fase 2 fue exitosa
- Eventos posibles: PagoExitoso o PagoRechazado
Fase 4 - Asignación con Bloqueo:
- Acción: Descuento definitivo de inventario
- Tipo de operación: Escritura (UPDATE) con bloqueo pesimista
- Eventos posibles: StockAsignado o FalloAsignacion
3. Servicios Participantes y Responsabilidades
3.1 Servicio: Gestor de Pedidos
Rol: Coordinador de estado (Orquestación Ligera)
Responsabilidades:
- Recibir la solicitud inicial del cliente
- Crear el registro de orden con estado inicial
- Generar el Saga ID único
- Emitir el evento OrdenSolicitada vía patrón Outbox
- Escuchar eventos de progreso del resto de participantes
- Mantener máquina de estados de la orden
- Actualizar estado según eventos recibidos
- Notificar al cliente sobre el resultado final
Transiciones de estado:
Estado PENDIENTE_VALIDACION al recibir:
- StockVerificado → PENDIENTE_PAGO
- StockNoDisponible → CANCELADA_SIN_STOCK (flujo termina)
- Timeout de validación → CANCELADA_TIMEOUT
Estado PENDIENTE_PAGO al recibir:
- PagoExitoso → PENDIENTE_ASIGNACION
- PagoRechazado → CANCELADA_PAGO_RECHAZADO
Estado PENDIENTE_ASIGNACION al recibir:
- StockAsignado → COMPLETADA (flujo exitoso)
- FalloAsignacion → CANCELADA_CON_REEMBOLSO (requiere compensación)
Eventos que escucha:
- StockVerificado
- StockNoDisponible
- PagoExitoso
- PagoRechazado
- StockAsignado
- FalloAsignacion
- ReembolsoEjecutado
Eventos que emite:
- OrdenSolicitada (inicio del flujo)
- OrdenCompletada (conclusión exitosa)
- OrdenCancelada (conclusión con fallo)
3.2 Servicio: Gestor de Inventario
Rol: Validador y Ejecutor (participa en dos momentos diferentes)
Responsabilidades:
Momento 1 - Validación (Fase 2):
- Escuchar evento OrdenSolicitada
- Consultar disponibilidad actual en base de datos
- Validar si existe stock suficiente para los ítems solicitados
- NO realizar ninguna reserva ni modificación de datos
- Emitir resultado de validación
Momento 2 - Asignación (Fase 4):
- Escuchar evento PagoExitoso
- Iniciar transacción de base de datos con bloqueo pesimista
- Re-verificar disponibilidad actual (puede haber cambiado desde Fase 2)
- Descontar las unidades si aún hay stock disponible
- Confirmar transacción o hacer rollback según resultado
- Emitir resultado de asignación
Lógica de validación (Momento 1):
Para cada ítem en la orden:
- Consultar tabla de productos con el SKU solicitado
- Leer campo de cantidad disponible actual
- Comparar cantidad solicitada vs cantidad disponible
- Si para TODOS los ítems hay stock suficiente: emitir StockVerificado
- Si para ALGÚN ítem no hay stock suficiente: emitir StockNoDisponible con detalles
Lógica de asignación (Momento 2):
- Iniciar transacción con nivel de aislamiento REPEATABLE_READ o superior
- Aplicar bloqueo pesimista (SELECT FOR UPDATE) sobre los productos afectados
- Re-leer cantidad disponible actual
- Validar nuevamente que hay stock suficiente
- Si validación exitosa:
- Ejecutar UPDATE restando las unidades
- Insertar evento StockAsignado en tabla OUTBOX
- COMMIT de transacción
- Si validación falla (race condition, stock consumido por otra transacción):
- Insertar evento FalloAsignacion en tabla OUTBOX
- COMMIT de transacción (el evento de fallo debe publicarse)
Eventos que escucha:
- OrdenSolicitada (trigger de validación)
- PagoExitoso (trigger de asignación)
Eventos que emite:
- StockVerificado
- StockNoDisponible
- StockAsignado
- FalloAsignacion
Compensación: Si recibe evento de compensación (por fallo en paso posterior):
- Restaurar las unidades de inventario sumando la cantidad original
- Emitir evento StockLiberado
3.3 Servicio: Procesador de Pagos
Rol: Intermediario financiero condicional
Responsabilidades:
Operación Normal:
- Escuchar evento StockVerificado (NO OrdenSolicitada, para evitar cobros sin stock)
- Extraer información de pago del payload del evento
- Invocar API de pasarela de pagos externa (Stripe, PayPal, etc.)
- Manejar respuesta de la pasarela
- Emitir resultado de la operación financiera
Operación de Compensación:
- Escuchar evento FalloAsignacion
- Identificar la transacción financiera original asociada
- Ejecutar reembolso o reversa en la pasarela de pagos
- Emitir evento de confirmación de compensación
Lógica de cobro:
- Recibir evento StockVerificado
- Validar que evento no fue procesado previamente (idempotencia)
- Extraer datos: monto, método de pago, token de tarjeta, etc.
- Llamar API de pasarela con timeout de 30 segundos
- Si respuesta es exitosa:
- Almacenar ID de transacción de la pasarela
- Insertar evento PagoExitoso en OUTBOX con referencia de transacción
- COMMIT
- Si respuesta es rechazo (fondos insuficientes, tarjeta inválida):
- Insertar evento PagoRechazado en OUTBOX con código de error
- COMMIT
- Si hay timeout o error de red:
- Aplicar política de reintentos con exponential backoff
- Tras agotar intentos: enviar a DLQ para revisión manual
Lógica de compensación:
- Recibir evento FalloAsignacion
- Extraer Saga ID para identificar la transacción financiera original
- Buscar en base de datos local el ID de transacción de la pasarela
- Llamar API de reembolso de la pasarela con el ID original
- Si reembolso exitoso:
- Insertar evento ReembolsoEjecutado en OUTBOX
- COMMIT
- Si reembolso falla:
- Reintentar con backoff exponencial
- Tras 5 intentos fallidos: enviar alerta crítica a equipo de finanzas
- Marcar para reembolso manual
Eventos que escucha:
- StockVerificado (trigger de cobro)
- FalloAsignacion (trigger de compensación)
Eventos que emite:
- PagoExitoso
- PagoRechazado
- ReembolsoEjecutado
- FalloReembolso (para casos críticos)
4. Escenarios de Ejecución
4.1 Escenario A: Flujo Exitoso (Happy Path)
Contexto inicial:
- Cliente solicita 1 unidad del producto SKU-123
- Inventario actual: 10 unidades disponibles
- No hay operaciones concurrentes
Secuencia de eventos:
Cliente envía solicitud HTTP POST al endpoint de Pedidos
Pedidos crea registro de orden con estado PENDIENTE_VALIDACION
Pedidos genera Saga ID: "550e8400-e29b-41d4-a716-446655440000"
Pedidos inserta en OUTBOX el evento OrdenSolicitada
Relay de Pedidos publica evento en topic "order-events"
Inventario consume evento OrdenSolicitada
Inventario consulta: SELECT cantidad FROM productos WHERE sku = 'SKU-123'
Inventario verifica: 10 unidades disponibles, pedido requiere 1, verificación OK
Inventario inserta en OUTBOX el evento StockVerificado
Relay de Inventario publica evento en topic "inventory-events"
Pedidos consume StockVerificado y actualiza estado a PENDIENTE_PAGO
Pagos consume StockVerificado
Pagos invoca API de pasarela: "Cobrar 50.00 USD a tarjeta terminada en 4242"
Pasarela responde: "Aprobado, transaction_id: txn_abc123"
Pagos inserta en OUTBOX el evento PagoExitoso con referencia txn_abc123
Relay de Pagos publica evento en topic "payment-events"
Pedidos consume PagoExitoso y actualiza estado a PENDIENTE_ASIGNACION
Inventario consume PagoExitoso
Inventario inicia transacción con: BEGIN; SELECT cantidad FROM productos WHERE sku = 'SKU-123' FOR UPDATE
Inventario lee cantidad bloqueada: 10 unidades
Inventario valida: 10 >= 1, OK
Inventario ejecuta: UPDATE productos SET cantidad = cantidad - 1 WHERE sku = 'SKU-123'
Inventario inserta en OUTBOX el evento StockAsignado
Inventario confirma: COMMIT
Relay de Inventario publica evento en topic "inventory-events"
Pedidos consume StockAsignado y actualiza estado a COMPLETADA
Pedidos emite evento OrdenCompletada
Pedidos envía notificación al cliente: "Tu orden ha sido confirmada"
Resultado final:
- Orden completada exitosamente
- Inventario reducido a 9 unidades
- Cliente cobrado y notificado
- Duración total: aproximadamente 2-5 segundos
4.2 Escenario B: Fallo Temprano (Sin Stock Disponible)
Contexto inicial:
- Cliente solicita 5 unidades del producto SKU-456
- Inventario actual: 0 unidades disponibles
- Optimización: evitar cobro innecesario
Secuencia de eventos:
Cliente envía solicitud HTTP POST al endpoint de Pedidos
Pedidos crea registro de orden con estado PENDIENTE_VALIDACION
Pedidos genera Saga ID: "7c9e6679-7425-40de-944b-e07fc1f90ae7"
Pedidos inserta en OUTBOX el evento OrdenSolicitada
Relay de Pedidos publica evento en topic "order-events"
Inventario consume evento OrdenSolicitada
Inventario consulta: SELECT cantidad FROM productos WHERE sku = 'SKU-456'
Inventario verifica: 0 unidades disponibles, pedido requiere 5, verificación FALLA
Inventario inserta en OUTBOX el evento StockNoDisponible con detalles
Relay de Inventario publica evento en topic "inventory-events"
Pedidos consume StockNoDisponible
Pedidos actualiza estado a CANCELADA_SIN_STOCK
Pedidos emite evento OrdenCancelada con razón "stock insuficiente"
Pedidos envía notificación al cliente: "Lo sentimos, el producto está agotado"
Comportamiento de Pagos:
- El servicio de Pagos NO escucha el evento StockNoDisponible
- Por tanto, NO se ejecuta ninguna operación financiera
- No hay cargos ni reembolsos
Resultado final:
- Orden cancelada rápidamente (en menos de 1 segundo)
- Cliente no fue cobrado
- Costo financiero: cero
- Experiencia de cliente: transparente y honesta
Beneficio del patrón: Esta arquitectura evita aproximadamente el 95% de reembolsos comparado con estrategias de "cobrar primero, verificar después".
4.3 Escenario C: Fallo Tardío (Race Condition)
Contexto inicial:
- Cliente A solicita 1 unidad del producto SKU-789
- Inventario actual: 1 unidad disponible
- Evento concurrente: Cliente B también solicita 1 unidad del mismo producto
Secuencia de eventos para Cliente A:
Pedidos A crea orden con Saga ID: "saga-aaa"
Pedidos A emite OrdenSolicitada
Inventario verifica disponibilidad para saga-aaa
Inventario consulta: 1 unidad disponible
Inventario emite StockVerificado para saga-aaa
Pagos procesa cobro para saga-aaa
Pasarela aprueba transacción: txn_xyz789
Pagos emite PagoExitoso para saga-aaa
Evento concurrente (Cliente B):
Mientras el flujo de Cliente A está entre Fase 3 y Fase 4:
- Pedidos B emite OrdenSolicitada para saga-bbb
- Inventario verifica: aún hay 1 unidad, emite StockVerificado para saga-bbb
- Pagos cobra a Cliente B: txn_def456
- Pagos emite PagoExitoso para saga-bbb
Continuación para Cliente A (intenta asignar primero):
Inventario consume PagoExitoso para saga-aaa
Inventario inicia: BEGIN; SELECT cantidad FROM productos WHERE sku = 'SKU-789' FOR UPDATE
Inventario lee cantidad: 1 unidad
Inventario valida: 1 >= 1, OK
Inventario ejecuta: UPDATE productos SET cantidad = 0
Inventario inserta evento StockAsignado para saga-aaa
Inventario confirma: COMMIT
Pedidos A recibe StockAsignado
Orden A finaliza con estado COMPLETADA
Continuación para Cliente B (llega segundo):
Inventario consume PagoExitoso para saga-bbb
Inventario inicia: BEGIN; SELECT cantidad FROM productos WHERE sku = 'SKU-789' FOR UPDATE
Inventario lee cantidad: 0 unidades (ya fue consumida por Cliente A)
Inventario valida: 0 >= 1, FALLA
Inventario NO ejecuta UPDATE
Inventario inserta evento FalloAsignacion para saga-bbb con razón "race condition"
Inventario confirma: COMMIT (importante: confirmar para que evento se publique)
Pagos consume FalloAsignacion para saga-bbb
Pagos busca transacción original: txn_def456
Pagos invoca API de pasarela: "Reembolsar txn_def456"
Pasarela confirma: "Reembolso procesado, refund_id: rfnd_xyz"
Pagos inserta evento ReembolsoEjecutado para saga-bbb
Relay de Pagos publica evento
Pedidos B consume ReembolsoEjecutado
Pedidos B actualiza estado a CANCELADA_CON_REEMBOLSO
Pedidos B envía notificación al Cliente B: "Tu pago ha sido reembolsado, el producto se agotó durante el proceso"
Resultado final:
- Cliente A: orden completada, inventario asignado
- Cliente B: orden cancelada, dinero reembolsado automáticamente
- Sistema mantuvo consistencia a pesar de concurrencia
- No hubo sobreventa (overselling)
Observación crítica: Este escenario demuestra por qué la verificación en Fase 2 es "optimista" (sin bloqueo) y la asignación en Fase 4 es "pesimista" (con bloqueo). El bloqueo temprano causaría alta contención. El bloqueo tardío minimiza ventana crítica.
5. Consideraciones de Implementación
5.1 Ordenamiento de Eventos
Problema: Los brokers garantizan orden dentro de una partición, no globalmente.
Solución para este caso de uso:
- Particionar eventos por Saga ID
- Todos los eventos de una misma saga van a la misma partición
- Configurar clave de partición: Saga ID
- Garantiza que eventos de UNA orden se procesan en orden
- No importa el orden entre órdenes diferentes
5.2 Manejo de Duplicados
Ejemplo de deduplicación en Inventario:
Cuando llega evento PagoExitoso:
- Extraer Message ID del header: "msg-12345"
- Iniciar transacción
- Intentar: INSERT INTO processed_messages (message_id, event_type) VALUES ('msg-12345', 'PagoExitoso')
- Si falla por clave duplicada:
- Significa que este mensaje ya fue procesado
- Hacer ROLLBACK
- Retornar ACK al broker (no reintentar)
- Registrar en log: "Evento duplicado ignorado"
- Si inserción exitosa:
- Proceder con lógica de asignación de stock
- COMMIT incluye tanto la nueva fila en processed_messages como el UPDATE de inventario
- Retornar ACK al broker
5.3 Consistencia de OUTBOX
Garantía crítica: El evento en OUTBOX y el cambio de estado de negocio deben confirmarse en la misma transacción atómica de base de datos.
Ejemplo en Pedidos al recibir StockVerificado:
Transacción única:
- BEGIN
- UPDATE orders SET status = 'PENDIENTE_PAGO' WHERE saga_id = '550e8400...'
- INSERT INTO outbox (event_type, payload, saga_id) VALUES ('OrdenActualizada', '{...}', '550e8400...')
- COMMIT
Si falla el COMMIT, ningún cambio se persiste. Si se confirma, ambos cambios quedan guardados atómicamente.
5.4 Configuración de Timeouts por Servicio
Pedidos:
- Timeout de validación de stock: 30 segundos
- Timeout de pago: 60 segundos (APIs bancarias pueden ser lentas)
- Timeout de asignación: 15 segundos
- Timeout total de saga: 2 horas
Inventario:
- Timeout de consulta de BD: 5 segundos
- Timeout de bloqueo pesimista: 10 segundos
Pagos:
- Timeout de llamada a pasarela: 30 segundos
- Reintentos en pasarela: 3 intentos con backoff de 2-8-18 segundos
- Timeout de reembolso: 60 segundos
PARTE III: PATRONES AVANZADOS (OPCIONAL)
1. Sagas de Larga Duración
Definición: Procesos que requieren más de una hora para completarse, típicamente por intervención humana, validaciones externas, o pasos asíncronos lentos.
Ejemplos:
- Proceso de aprobación de crédito (requiere revisión manual)
- Workflow de incorporación de empleado (múltiples pasos en días)
- Proceso de compra B2B con aprobaciones corporativas
- Integración con sistemas legacy batch que procesan nocturnamente
1.1 Desafíos Específicos
Problema 1: Estado en Memoria No es viable mantener el estado de la saga en memoria de un servicio durante horas o días. El servicio puede reiniciarse.
Problema 2: Escalabilidad Miles de sagas activas simultáneas durante días consumen recursos si no se gestionan apropiadamente.
Problema 3: Visibilidad Usuarios y operadores necesitan consultar el estado de procesos que toman días.
1.2 Estrategia de Implementación
Persistencia de Estado Explícita:
Crear una tabla dedicada para rastrear sagas activas:
Campos requeridos:
- Identificador único de saga (PK)
- Tipo de saga (ej: "AprobacionCredito")
- Estado actual (ej: "ESPERANDO_REVISION_MANUAL")
- Timestamp de inicio
- Timestamp de última actualización
- Paso actual en el flujo
- Contexto de negocio (datos necesarios para reanudar)
- Usuario o entidad propietaria
- Fecha de expiración o timeout
Timers Persistentes:
En lugar de mantener timers en memoria, usar:
- Scheduled jobs que consultan tabla de sagas periódicamente
- Buscar sagas en estado de espera cuyo timeout ha expirado
- Emitir eventos de timeout para reanudar o compensar
- Ejemplo: Job cada 5 minutos revisa sagas con "expected_event_by" < NOW()
Endpoints de Consulta:
Exponer APIs REST para que usuarios consulten estado:
- GET /sagas/saga-id/status → Retorna estado actual y progreso
- GET /sagas/saga-id/history → Retorna todos los eventos y transiciones
- POST /sagas/saga-id/cancel → Permite cancelación manual (ejecuta compensación)
1.3 Patrón de Reanudación
Caso de uso: Proceso pausado esperando aprobación humana.
Flujo:
- Saga llega a paso que requiere aprobación
- Servicio emite evento "AprobacionSolicitada"
- Servicio actualiza tabla de sagas: estado = "ESPERANDO_APROBACION"
- Se envía notificación a aprobador (email, dashboard, etc.)
- Servicio NO mantiene nada en memoria, libera recursos
- Horas o días después, aprobador toma decisión
- Sistema de UI/backoffice emite evento "AprobacionOtorgada" o "AprobacionRechazada"
- Servicio escucha evento, consulta estado de saga en tabla
- Servicio carga contexto de negocio desde tabla
- Servicio reanuda flujo desde el paso siguiente
- Servicio actualiza estado en tabla
Beneficio: El servicio es stateless, puede reiniciarse sin perder progreso.
2. Sub-Sagas Anidadas
Definición: Una saga que, como parte de uno de sus pasos, inicia otra saga completa e independiente.
Ejemplo:
- Saga principal: "ProcesarCompraEmpresarial"
- Paso 3 de la saga requiere: "ValidarCreditoProveedor"
- ValidarCreditoProveedor es en sí una saga con múltiples pasos (consultar bureaus, validar referencias, aprobar monto)
2.1 Reglas de Anidación
Máximo permitido: 2 niveles de profundidad
- Saga Nivel 0 (raíz)
- Saga Nivel 1 (hija directa)
- Prohibido: Saga Nivel 2 (nieta)
Razón: Complejidad exponencial de compensación. Si una saga de nivel 2 falla, hay que compensar nivel 2, luego nivel 1, luego nivel 0. El rastreo se vuelve intratable.
2.2 Responsabilidad de Compensación
Principio: La saga padre es responsable de compensar sagas hijas si el flujo general falla.
Ejemplo:
Saga Padre: ProcesarCompra
- Paso 1: CrearOrden → OK
- Paso 2: ValidarCredito (invoca sub-saga) → OK
- Paso 3: EnviarMercancia → FALLA
Compensación:
- Saga Padre emite evento de compensación para Paso 3 (no aplica, nunca ocurrió)
- Saga Padre emite evento "CancelarValidacionCredito" para sub-saga
- Sub-saga ejecuta su propia compensación interna (liberar límite de crédito reservado)
- Saga Padre compensa Paso 1 (CancelarOrden)
Implementación:
La saga padre debe:
- Mantener registro de todas las sub-sagas iniciadas (almacenar Sub-Saga IDs)
- Al compensar, emitir eventos de compensación dirigidos a cada sub-saga
- Esperar confirmación de compensación de sub-sagas antes de completar su propia compensación
- Implementar timeout: si sub-saga no confirma compensación en X tiempo, alertar para intervención manual
2.3 Propagación de Contexto
Identificadores requeridos:
- Saga ID del padre (Root Saga ID)
- Saga ID de la hija (Child Saga ID)
- Nivel de anidación (0 = raíz, 1 = hija)
Headers en eventos de sub-saga:
- X-Root-Saga-ID: ID de la saga raíz
- X-Parent-Saga-ID: ID de la saga que inició esta
- X-Saga-Level: Nivel numérico de anidación
Uso en observabilidad: Permite visualizar jerarquía completa en herramientas de tracing:
- Root Saga: ProcesarCompra-123
- Child Saga: ValidarCredito-456
- Event: ConsultarBureau
- Event: ValidarReferencias
- Event: EnviarMercancia
- Child Saga: ValidarCredito-456
3. Semantic Lock (Bloqueo Semántico de Negocio)
Problema: Prevenir race conditions en recursos críticos sin recurrir a bloqueos pesimistas de base de datos que reducen throughput.
Ejemplo del problema: Dos usuarios intentan reservar el mismo asiento de avión simultáneamente. Sin bloqueo, ambos podrían ver "asiento disponible" y ambos intentar comprarlo.
3.1 Concepto de Reserva Soft
En lugar de modificar inmediatamente el estado del recurso, se marca como "reservado temporalmente" con:
- Identificador de quién reservó (Saga ID)
- Timestamp de expiración (TTL - Time To Live)
Diferencia con bloqueo tradicional:
- Bloqueo tradicional (SELECT FOR UPDATE): Mantiene lock de base de datos hasta commit/rollback
- Semantic Lock: Marca lógica en el dato que otros respetan, lock se libera automáticamente por TTL
3.2 Flujo de Tres Fases
Fase 1 - Reserva Soft (Check):
- Servicio verifica disponibilidad del recurso
- Si está disponible, marca como "reservado" con Saga ID y TTL de 5 minutos
- Emite evento "RecursoReservado"
- NO compromete definitivamente el recurso
Fase 2 - Operación Crítica (Execute):
- Se ejecuta la operación costosa (ej: cobro de pago)
- Si falla, la reserva expira automáticamente por TTL
- Si tiene éxito, emite evento para confirmar
Fase 3 - Confirmación Hard (Commit):
- Servicio escucha evento de éxito de operación crítica
- Convierte reserva soft en asignación definitiva
- Cambia estado de "reservado" a "vendido"
- Limpia TTL
Fase Alternativa - Liberación Automática:
- Si saga falla o expira, el TTL llega a cero
- Job periódico (cada minuto) busca reservas expiradas
- Cambia estado de "reservado" a "disponible"
- Recurso queda libre para otros
3.3 Ejemplo Completo: Reserva de Asiento
Tabla de asientos:
Campos:
- id_asiento (PK)
- numero_asiento
- estado: DISPONIBLE, RESERVADO, VENDIDO
- reservado_por_saga_id (nullable)
- reservado_hasta_timestamp (nullable)
Paso 1 - Validación y Reserva:
Servicio de Asientos escucha OrdenDeVueloSolicitada:
- Consultar: SELECT estado, reservado_hasta FROM asientos WHERE numero = '12A'
- Si estado = VENDIDO: emitir AsientoNoDisponible
- Si estado = RESERVADO AND reservado_hasta > NOW: emitir AsientoNoDisponible
- Si estado = DISPONIBLE OR (estado = RESERVADO AND reservado_hasta <= NOW):
- UPDATE asientos SET estado = 'RESERVADO', reservado_por_saga_id = 'saga-123', reservado_hasta = NOW + 5 minutos
- Emitir AsientoReservado
- COMMIT
Paso 2 - Pago:
Servicio de Pagos procesa cobro (toma 30 segundos):
- Si éxito: emite PagoExitoso
- Si fallo: NO emite nada, saga expira
Paso 3 - Confirmación:
Servicio de Asientos escucha PagoExitoso:
- Verificar que saga_id coincide: SELECT reservado_por_saga_id FROM asientos WHERE numero = '12A'
- UPDATE asientos SET estado = 'VENDIDO', reservado_por_saga_id = NULL, reservado_hasta = NULL
- Emitir AsientoConfirmado
- COMMIT
Job de Limpieza (cada 1 minuto):
- SELECT numero FROM asientos WHERE estado = 'RESERVADO' AND reservado_hasta <= NOW
- Para cada asiento encontrado:
- UPDATE asientos SET estado = 'DISPONIBLE', reservado_por_saga_id = NULL, reservado_hasta = NULL
- Registrar en log: "Reserva expirada para asiento X de saga Y"
Ventajas de este patrón:
- Alta concurrencia: No bloquea filas durante el pago
- Auto-recuperación: Fallos liberan recursos automáticamente
- Fairness: Primer solicitante obtiene reserva temporal
- Sin deadlocks: No hay bloqueos de base de datos
Desventajas:
- Complejidad adicional en lógica de negocio
- Requiere job de limpieza confiable
- Ventana de race condition muy pequeña (pero existe) en actualización de reserva
PARTE IV: GUÍAS DE IMPLEMENTACIÓN
1. Checklist de Desarrollo
Todo servicio que participe en una saga debe cumplir los siguientes requisitos antes de pasar a producción:
1.1 Persistencia y Mensajería
Verificar que:
- Existe tabla OUTBOX con todos los campos mandatorios
- Existe tabla de mensajes procesados para deduplicación
- Existe proceso Relay que publica eventos desde OUTBOX al broker
- El Relay se ejecuta con intervalo no mayor a 1 segundo
- Todos los eventos incluyen campo schema_version
- Todos los eventos tienen esquemas registrados en Schema Registry
1.2 Idempotencia
Verificar que:
- Todo handler de evento verifica Message ID antes de procesar
- La verificación y el procesamiento están en la misma transacción
- Existe test automatizado que envía mismo mensaje 3 veces y verifica resultado único
- Existe limpieza automática de tabla de mensajes procesados
1.3 Compensación
Verificar que:
- Para cada operación de escritura existe handler de compensación
- La compensación es idempotente (puede ejecutarse N veces)
- Existen tests que verifican que compensación revierte el estado
- La compensación emite evento de confirmación
- La compensación se registra en logs de auditoría
1.4 Configuración
Verificar que:
- Todos los parámetros de timeout son configurables externamente
- Valores por defecto cumplen con mínimos del estándar
- Configuración se carga al inicio y se valida
- Cambios de configuración no requieren recompilación
1.5 Observabilidad
Verificar que:
- Todos los logs de saga son estructurados (no texto plano)
- Saga ID se propaga en todos los eventos emitidos
- Todas las métricas mandatorias están implementadas
- Existe dashboard de monitoreo con visualización de métricas
- Existen alertas configuradas para casos anómalos
1.6 Testing
Verificar que:
- Existen tests de happy path completo
- Existen tests de cada escenario de compensación
- Existen tests de race conditions simuladas
- Existen tests de chaos engineering (matar servicio en medio de saga)
- Existe test de duplicación de mensaje
2. Templates de Eventos Estándar
Todos los eventos deben seguir una estructura consistente para facilitar consumo y rastreo.
2.1 Estructura Base de Evento
Todo evento publicado debe incluir tres secciones:
Sección 1 - Metadatos de Rastreo:
- Identificador único del mensaje (UUID)
- Identificador de la saga (UUID)
- Identificador del span actual (UUID)
- Identificador del span padre (UUID o null si es raíz)
- Versión del esquema del evento (formato semántico)
- Timestamp de creación en UTC ISO-8601
- Nombre del servicio que emitió el evento
- Tipo de evento (nombre descriptivo)
Sección 2 - Datos de Negocio (Payload):
- Información específica del dominio
- Solo datos necesarios para los consumidores
- Sin información sensible sin encriptar
- Con tipos de datos explícitos
Sección 3 - Metadatos Adicionales (Opcional):
- Identificador de correlación de negocio
- Identificador del usuario que inició el flujo
- Información de contexto relevante
- Tags o labels para filtrado
2.2 Ejemplo Descriptivo: Evento OrdenSolicitada
Metadatos de rastreo:
- message_id contiene un UUID único generado al crear el evento
- saga_id contiene el identificador de la saga completa de procesamiento de orden
- span_id contiene un UUID único para este evento específico
- parent_span_id contiene null porque este es el evento inicial
- schema_version contiene el string "1.0.0"
- timestamp contiene la fecha y hora de creación en formato ISO-8601 UTC
- source_service contiene el string "pedidos"
- event_type contiene el string "OrdenSolicitada"
Payload de negocio:
- order_id contiene el identificador único de la orden en el sistema de pedidos
- customer_id contiene el identificador del cliente que hizo la orden
- items es una lista de objetos, cada uno contiene:
- sku: código del producto
- quantity: cantidad solicitada (número entero)
- price: precio unitario (número decimal con dos decimales)
- total_amount contiene el monto total de la orden (número decimal)
- shipping_address es un objeto que contiene:
- street: calle
- city: ciudad
- country: país
- postal_code: código postal
- payment_method es un objeto que contiene:
- type: tipo de pago (string: "credit_card", "paypal", etc.)
- token: token de pago tokenizado (NO el número de tarjeta real)
Metadatos adicionales:
- correlation_id contiene un identificador de correlación de negocio (ej: número de orden visible al cliente)
- user_id contiene el identificador del usuario autenticado
- channel contiene el canal de origen: "web", "mobile", "api"
3. Estrategias de Testing
3.1 Tests de Unidad para Idempotencia
Objetivo: Verificar que procesar el mismo evento múltiples veces produce el mismo resultado.
Escenario de test:
- Preparar estado inicial de base de datos
- Crear un evento de prueba con Message ID específico
- Ejecutar handler del evento por primera vez
- Capturar estado resultante de base de datos
- Ejecutar handler del mismo evento (mismo Message ID) por segunda vez
- Verificar que estado de base de datos es idéntico al del paso 4
- Ejecutar handler por tercera vez
- Verificar nuevamente estado idéntico
- Verificar que tabla de mensajes procesados tiene solo UNA entrada para ese Message ID
Resultado esperado:
- Operación de negocio se ejecutó solo una vez
- Llamadas subsecuentes fueron ignoradas
- No hubo duplicación de datos
- No hubo errores
3.2 Tests de Compensación
Objetivo: Verificar que la transacción compensatoria revierte el efecto de la operación original.
Escenario de test para Inventario:
- Estado inicial: Producto SKU-999 tiene 100 unidades
- Publicar evento PagoExitoso para orden de 5 unidades de SKU-999
- Esperar procesamiento
- Verificar: Producto SKU-999 tiene 95 unidades
- Publicar evento FalloAsignacion (trigger de compensación)
- Esperar procesamiento de compensación
- Verificar: Producto SKU-999 tiene 100 unidades nuevamente
- Verificar en logs: Existe entrada de compensación ejecutada
- Verificar: Se emitió evento StockLiberado
Casos adicionales a probar:
- Compensar cuando la operación original nunca ocurrió (compensación debe ser no-op)
- Compensar dos veces (debe ser idempotente)
- Compensar cuando el recurso ya no existe (debe manejarse gracefully)
3.3 Tests de Chaos Engineering
Objetivo: Verificar resiliencia ante fallos de infraestructura.
Escenario 1 - Matar Servicio Después de COMMIT:
- Iniciar saga de prueba
- Instrumentar código para matar proceso inmediatamente después de COMMIT en base de datos pero ANTES de enviar ACK al broker
- Iniciar servicio
- Esperar que saga procese
- Servicio muere
- Verificar: Cambio en BD se persistió
- Verificar: Evento en OUTBOX se persistió
- Reiniciar servicio
- Verificar: Relay publica evento pendiente
- Verificar: Saga continúa normalmente
Escenario 2 - Desconectar Broker Durante Saga:
- Iniciar saga
- Cuando llegue al paso 2, simular desconexión de red al broker
- Verificar: Servicio reintenta con exponential backoff
- Verificar: Eventos quedan pendientes en OUTBOX
- Restaurar conexión después de 2 minutos
- Verificar: Relay publica eventos pendientes
- Verificar: Saga completa exitosamente
Escenario 3 - Latencia Extrema de Base de Datos:
- Simular latencia de 10 segundos en todas las queries de BD
- Iniciar saga
- Verificar: Timeouts se activan correctamente
- Verificar: Mensajes van a DLQ después de reintentos
- Verificar: Se emiten alertas apropiadas
- Verificar: No hay deadlocks ni procesos zombies
3.4 Tests End-to-End de Saga Completa
Objetivo: Verificar flujo completo en ambiente similar a producción.
Infraestructura de test:
- Broker real (Kafka o RabbitMQ dockerizado)
- Bases de datos reales por servicio (PostgreSQL dockerizado)
- Los tres servicios corriendo (Pedidos, Inventario, Pagos)
- Mock de pasarela de pagos externa
Test de Happy Path:
- Insertar datos de prueba en BD de Inventario: SKU-TEST con 50 unidades
- Enviar request HTTP POST a servicio de Pedidos: crear orden de 10 unidades de SKU-TEST
- Esperar máximo 10 segundos
- Verificar en BD de Pedidos: orden existe con estado COMPLETADA
- Verificar en BD de Inventario: SKU-TEST tiene 40 unidades
- Verificar en BD de Pagos: existe registro de transacción aprobada
- Verificar en logs: todos los eventos se emitieron en orden correcto
- Verificar en métricas: saga_duration_seconds registró tiempo total
Test de Fallo y Compensación:
- Configurar mock de pasarela para rechazar pagos
- Insertar datos: SKU-TEST2 con 20 unidades
- Enviar request: crear orden de 5 unidades de SKU-TEST2
- Esperar procesamiento
- Verificar: Orden en estado CANCELADA_PAGO_RECHAZADO
- Verificar: Inventario NO se modificó (sigue en 20 unidades)
- Verificar: NO existe registro de transacción en BD de Pagos
- Verificar en logs: evento PagoRechazado fue emitido
Test de Race Condition:
- Insertar datos: SKU-TEST3 con 1 unidad
- Lanzar DOS requests simultáneos: ambos piden 1 unidad de SKU-TEST3
- Esperar procesamiento
- Verificar: UNA orden en estado COMPLETADA
- Verificar: OTRA orden en estado CANCELADA_CON_REEMBOLSO
- Verificar: Inventario en 0 unidades (solo una asignación exitosa)
- Verificar en logs: evento FalloAsignacion emitido para segunda saga
- Verificar: Mock de pasarela recibió llamada de reembolso
ANEXO A: Architecture Decision Records (ADR)
ADR-001: Prohibición de Two-Phase Commit y XA Transactions
Fecha: 2026-02-01
Estado: Aceptado
Contexto:
En arquitecturas de microservicios distribuidos, la coordinación de transacciones entre múltiples servicios requiere decisiones sobre consistencia vs disponibilidad. El protocolo Two-Phase Commit (2PC) y las transacciones XA ofrecen atomicidad estricta pero con costos significativos.
Decisión:
Se prohíbe el uso de transacciones distribuidas bloqueantes (2PC, XA Transactions) en todo el ecosistema de microservicios. En su lugar, se adopta el patrón Saga con consistencia eventual.
Razones:
Disponibilidad: 2PC requiere que todos los participantes estén disponibles simultáneamente. Si un servicio está caído, toda la transacción se bloquea. En sistemas distribuidos con múltiples servicios, la probabilidad de que algún componente esté temporalmente no disponible es alta.
Latencia: El protocolo requiere múltiples roundtrips de red (prepare, vote, commit). Esto incrementa significativamente la latencia percibida por usuarios finales.
Bloqueos: Los recursos (filas de base de datos) quedan bloqueados durante todo el protocolo. En sistemas de alta concurrencia, esto reduce dramáticamente el throughput.
Complejidad Operacional: Requiere coordinador transaccional centralizado (Transaction Manager) que se convierte en punto único de fallo. La recuperación de fallos en 2PC es compleja y propensa a estados inconsistentes.
Escalabilidad Limitada: No escala horizontalmente bien porque el coordinador se convierte en bottleneck.
Consecuencias:
Positivas:
- Mayor disponibilidad del sistema (cada servicio puede operar independientemente)
- Mejor throughput en operaciones concurrentes (sin bloqueos prolongados)
- Escalabilidad horizontal sin límites de coordinador central
- Resiliencia ante fallos parciales (saga puede progresar aunque un servicio esté caído temporalmente)
Negativas:
- Complejidad lógica incrementada (implementación de compensaciones)
- Ventanas de inconsistencia temporal (datos pueden estar desincronizados por segundos o minutos)
- Mayor complejidad en testing (necesidad de probar escenarios de compensación)
- Debugging más complejo (flujo implícito, necesidad de rastreo distribuido)
Mitigaciones de Consecuencias Negativas:
- Implementar Transactional Outbox Pattern para garantizar publicación de eventos
- Establecer observabilidad robusta con rastreo distribuido
- Documentar claramente lógica de compensación
- Implementar idempotencia estricta en todos los consumidores
- Definir límites de timeout para evitar sagas infinitas
ADR-002: Validación de Inventario Antes de Pago
Fecha: 2026-02-01
Estado: Aceptado
Contexto:
En sistemas de comercio electrónico, existen dos estrategias principales para manejar inventario en el flujo de compra:
- Estrategia A (Pay-First): Cobrar primero, luego verificar/asignar inventario. Si no hay stock, reembolsar.
- Estrategia B (Check-Then-Pay): Verificar inventario primero, luego cobrar solo si hay disponibilidad.
Decisión:
Se adopta la estrategia Check-Then-Pay: validar disponibilidad de inventario ANTES de ejecutar el cobro financiero.
Razones:
Costos Financieros: Cada transacción con pasarela de pagos tiene un costo (típicamente 2.9% + 0.30 USD). Los reembolsos también incurren en costos. La estrategia Pay-First genera costos innecesarios cuando el pedido finalmente se cancela por falta de stock.
Experiencia de Usuario: Los clientes perciben negativamente ser cobrados y luego reembolsados días después. Genera desconfianza y fricción. La estrategia Check-Then-Pay ofrece feedback inmediato sobre disponibilidad.
Complejidad Contable: Los reembolsos complican la contabilidad y reconciliación bancaria. Requieren procesos adicionales de seguimiento.
Carga en Soporte: Clientes cobrados y luego reembolsados generan tickets de soporte preguntando por el cargo temporal.
Trade-offs Considerados:
Ventana de Race Condition: Entre la validación (Fase 2) y la asignación (Fase 4), el inventario puede ser consumido por otra transacción concurrente. Esto resulta en que algunos pagos exitosos requieran reembolso de todos modos.
Latencia Adicional: Agregar paso de validación aumenta latencia total del flujo en aproximadamente 200-500ms.
Mitigación del Race Condition:
- Implementar re-verificación con bloqueo pesimista en Fase 4
- Implementar compensación automática de pago (reembolso) cuando ocurra race condition
- Monitorear tasa de race conditions y ajustar inventario buffer si es muy alta
Consecuencias:
Positivas:
- Reducción del 95% en costos de transacciones fallidas (según estudios de caso de e-commerce)
- Mejor experiencia de usuario (feedback inmediato de falta de stock)
- Menor carga operacional (menos reembolsos manuales)
- Contabilidad más limpia
Negativas:
- Posibilidad de race condition (aproximadamente 5% de casos en alta concurrencia)
- Latencia adicional de 200-500ms por validación previa
- Complejidad de implementar lógica de re-verificación con bloqueo
Métricas de Éxito:
- Tasa de reembolsos por falta de stock < 5%
- Latencia total de checkout < 3 segundos en percentil 95
- Tasa de quejas de clientes por cobros incorrectos < 0.1%
ADR-003: Coreografía con Coordinador de Estado vs Coreografía Pura
Fecha: 2026-02-01
Estado: Aceptado
Contexto:
Las sagas pueden implementarse mediante dos patrones principales:
Coreografía Pura: Cada servicio escucha eventos relevantes y emite eventos de resultado sin conocer el flujo completo. No existe ningún componente central.
Orquestación: Un servicio centralizado (orquestador) coordina toda la saga invocando directamente a participantes y esperando respuestas.
Híbrido (Coreografía con Coordinador de Estado): Servicios se comunican vía eventos (coreografía) pero uno de ellos mantiene estado de la saga para visibilidad y decisiones de alto nivel.
Decisión:
Se adopta el patrón híbrido: Coreografía con Coordinador de Estado opcional. Se prohíbe orquestación tradicional con llamadas síncronas.
Razones a Favor de Coreografía:
- Bajo acoplamiento entre servicios
- Alta escalabilidad y resiliencia
- Facilita evolución independiente de servicios
- No hay punto único de fallo
Razones Contra Coreografía Pura:
- Difícil rastrear estado completo de una saga
- Complejo identificar en qué punto está una transacción de negocio
- No hay lugar obvio para implementar timeouts de saga completa
- Testing end-to-end más complejo
Razones Contra Orquestación Tradicional:
- Orquestador se convierte en bottleneck
- Alto acoplamiento (orquestador conoce todos los servicios)
- Punto único de fallo
- Dificulta escalabilidad horizontal
Solución Híbrida:
Permitir que el servicio iniciador (ej: Pedidos) actúe como Coordinador de Estado con restricciones:
- Puede mantener tabla de estado de saga
- Puede escuchar eventos de progreso
- Puede tomar decisiones de compensación
- NO puede invocar directamente a otros servicios
- NO puede ejecutar lógica de negocio de otros dominios
Consecuencias:
Positivas:
- Mantiene beneficios de coreografía (bajo acoplamiento, escalabilidad)
- Provee visibilidad centralizada de estado de saga
- Facilita implementación de timeouts
- Simplifica queries de estado para usuarios
- Facilita testing y debugging
Negativas:
- Incremento leve de complejidad en servicio coordinador
- Riesgo de que coordinador acumule lógica que debería estar en otros servicios (debe vigilarse en code reviews)
Reglas de Implementación:
- El coordinador solo mantiene estado, no ejecuta lógica de negocio
- Toda comunicación sigue siendo asíncrona vía eventos
- El coordinador es stateless (estado persiste en BD, no en memoria)
- Debe ser posible eliminar el coordinador sin romper el flujo (otros servicios siguen funcionando)
ADR-004: Outbox Pattern como Estándar Obligatorio
Fecha: 2026-02-01
Estado: Aceptado
Contexto:
Al trabajar con microservicios y messaging, existe el problema de Dual Writes: un servicio necesita actualizar su base de datos local Y publicar un evento en el broker. No existe transacción distribuida que abarque ambos sistemas.
Escenarios problemáticos sin Outbox:
- Escenario 1: Servicio actualiza BD, luego intenta publicar en broker, pero broker está caído → Cambio persiste pero evento nunca se publica → Inconsistencia
- Escenario 2: Servicio publica en broker exitosamente, luego intenta commit en BD, pero falla → Evento publicado pero cambio no persiste → Inconsistencia
- Escenario 3: Servicio hace commit en BD, luego proceso muere antes de publicar → Evento perdido → Inconsistencia
Decisión:
Hacer obligatorio el uso de Transactional Outbox Pattern en todos los servicios que participen en sagas.
Razones:
Atomicidad Garantizada: La tabla OUTBOX está en la misma base de datos que las tablas de negocio. Un commit atómico garantiza que ambos (cambio de negocio + evento) se persistan juntos o ninguno se persista.
Resiliencia ante Fallos: Si el proceso muere después del commit pero antes de publicar, el Relay independiente eventualmente publicará el evento pendiente.
Simplicidad Conceptual: La lógica de negocio se simplifica: solo se preocupa por persistir en BD local. La publicación en broker es responsabilidad del Relay.
Debugging Facilitado: Todos los eventos a publicar quedan registrados en una tabla. Se puede auditar qué eventos se publicaron, cuándo, cuántos intentos tomó, etc.
Implementaciones Consideradas:
Opción A - Polling: Proceso que consulta tabla OUTBOX periódicamente
- Pros: Simple de implementar, funciona con cualquier BD
- Contras: Latencia adicional (según intervalo de polling)
Opción B - Change Data Capture (CDC): Herramienta lee transaction log de BD
- Pros: Latencia mínima (casi real-time), no impacta rendimiento de BD
- Contras: Requiere herramienta adicional (Debezium), complejidad operacional
Opción C - Triggers de BD: Trigger que publica al insertar en OUTBOX
- Pros: Latencia cero
- Contras: Acopla BD con broker, dificulta testing, problemas de resiliencia
Decisión de Implementación:
- Opción A (Polling) es mandatoria para todos los servicios
- Opción B (CDC) es recomendada para servicios críticos de alto volumen
- Opción C (Triggers) está prohibida por acoplamiento
Consecuencias:
Positivas:
- Cero pérdida de eventos
- Garantía de atomicidad entre cambio de negocio y publicación
- Resiliencia ante fallos de broker o red
- Auditoría completa de eventos
Negativas:
- Latencia adicional (100-500ms típicamente con polling)
- Necesidad de proceso Relay adicional
- Tabla OUTBOX crece y requiere limpieza
- Complejidad adicional en infraestructura
Mitigaciones:
- Optimizar intervalo de polling (100-500ms es aceptable)
- Implementar limpieza automática de eventos publicados después de 7 días
- Usar índices apropiados en tabla OUTBOX para queries eficientes
- Monitorear tamaño de OUTBOX y alertar si crece anormalmente
ANEXO B: Glosario de Términos
BASE: Modelo de consistencia para sistemas distribuidos. Acrónimo de Basically Available (Básicamente Disponible), Soft state (Estado Suave), Eventually consistent (Eventualmente Consistente). Contrasta con ACID.
Broker de Mensajes: Sistema intermediario que facilita comunicación asíncrona entre servicios mediante colas y topics. Ejemplos: Kafka, RabbitMQ.
Change Data Capture (CDC): Técnica para detectar y capturar cambios en base de datos mediante lectura del transaction log. Usado para publicar eventos sin Dual Write Problem.
Compensación: Transacción lógicamente inversa que deshace o mitiga el efecto de una operación previamente confirmada. Ejemplo: Si se cargó una tarjeta, la compensación es reembolsar.
Consistencia Eventual: Propiedad de sistemas distribuidos donde, en ausencia de nuevas actualizaciones, eventualmente todas las réplicas convergerán al mismo estado. No garantiza cuándo ocurrirá.
Coreografía: Patrón de saga donde cada servicio escucha eventos relevantes y reacciona autónomamente sin coordinación central. Comparable a bailarines que siguen música sin director.
Correlation ID: Identificador único que se propaga a través de múltiples servicios y operaciones para rastrear una transacción de negocio completa en logs y métricas distribuidos.
Dead Letter Queue (DLQ): Cola especial donde se envían mensajes que fallaron repetidamente después de múltiples intentos de procesamiento. Permite análisis y reprocesamiento manual.
Dual Write Problem: Problema de consistencia que ocurre al intentar escribir en dos sistemas diferentes (ej: base de datos + message broker) sin transacción atómica que abarque ambos.
Exponential Backoff: Estrategia de reintentos donde el tiempo de espera entre intentos crece exponencialmente. Ejemplo: 1s, 2s, 4s, 8s, 16s. Previene sobrecarga durante fallos.
Idempotencia: Propiedad de una operación que puede ejecutarse múltiples veces sin cambiar el resultado más allá de la primera ejecución. Ejemplo: "Establecer X = 5" es idempotente, "Incrementar X" no lo es.
Message Broker: Ver Broker de Mensajes.
Orquestación: Patrón de saga donde un componente central (orquestador) coordina explícitamente todos los pasos invocando a participantes y esperando respuestas.
Outbox Pattern: Ver Transactional Outbox Pattern.
Race Condition: Situación donde el resultado de una operación depende del tiempo relativo de eventos concurrentes. Ejemplo: dos transacciones leyendo el mismo inventario antes de que cualquiera lo actualice.
Relay: Proceso que lee eventos de la tabla OUTBOX y los publica en el message broker. Puede ser implementado via polling o CDC.
Saga: Patrón de diseño para manejar transacciones distribuidas mediante secuencia de transacciones locales coordinadas por eventos, con compensaciones para revertir en caso de fallo.
Schema Registry: Servicio centralizado que almacena y versiona esquemas de eventos/mensajes. Permite validación de compatibilidad y generación de documentación.
Semantic Lock: Bloqueo lógico a nivel de negocio (no de base de datos) que marca un recurso como "reservado temporalmente" con TTL, permitiendo alta concurrencia.
Span: En rastreo distribuido, representa una unidad de trabajo individual. Una saga completa contiene múltiples spans (uno por cada paso/evento).
Timeout: Límite de tiempo máximo para esperar una respuesta o completar una operación. Previene esperas infinitas ante fallos.
Transactional Outbox Pattern: Patrón que resuelve Dual Write Problem insertando eventos en tabla local (OUTBOX) dentro de la misma transacción de negocio, para luego publicarlos asíncronamente.
TTL (Time To Live): Tiempo de vida de un recurso o dato después del cual expira automáticamente. Usado en Semantic Locks para liberar reservas no confirmadas.
Two-Phase Commit (2PC): Protocolo de transacciones distribuidas bloqueantes que garantiza atomicidad mediante fase de preparación y fase de commit. Prohibido en este estándar.
ANEXO C: Referencias y Recursos
Documentación Técnica Recomendada
Libros:
- "Designing Data-Intensive Applications" por Martin Kleppmann - Capítulos 7-9 sobre transacciones distribuidas y consistencia
- "Microservices Patterns" por Chris Richardson - Capítulo 4 completo sobre Sagas
- "Building Microservices" por Sam Newman - Segunda edición, capítulo sobre workflows y consistencia
Papers Académicos:
- "Sagas" por Hector Garcia-Molina y Kenneth Salem (1987) - Paper original que define el patrón
- "Life beyond Distributed Transactions: an Apostate's Opinion" por Pat Helland - Argumentos contra transacciones distribuidas
- "Building on Quicksand" por Pat Helland y Dave Campbell - Fundamentos de consistencia eventual
Recursos Online:
- Microservices.io - Patrón Saga: https://microservices.io/patterns/data/saga.html
- Documentación de Debezium para CDC: https://debezium.io
- Confluent Schema Registry documentation: https://docs.confluent.io/platform/current/schema-registry/
Bibliotecas y Frameworks Recomendados
Para Java/JVM:
- Eventuate Tram Saga Framework - Framework especializado en sagas con outbox pattern
- Axon Framework - CQRS y Event Sourcing con soporte para sagas
- Apache Camel - Integración con múltiples brokers y patrones de mensajería
Para .NET:
- MassTransit - Framework de messaging con soporte nativo para sagas
- NServiceBus - Bus de servicios con soporte para sagas de larga duración
- Rebus - Bus de mensajes ligero con soporte para sagas
Para Node.js:
- Moleculer - Framework de microservicios con soporte para sagas
- NestJS con Bull - Soporte para colas y workflows complejos
Para Python:
- Nameko - Framework de microservicios con soporte para eventos
- Celery - Sistema de colas distribuidas con soporte para workflows
Herramientas de Observabilidad
Rastreo Distribuido:
- Jaeger - Sistema open-source de rastreo distribuido
- Zipkin - Rastreo distribuido con múltiples integraciones
- AWS X-Ray - Servicio administrado de AWS para rastreo
- Google Cloud Trace - Servicio de rastreo para GCP
Logging:
- ELK Stack (Elasticsearch, Logstash, Kibana) - Stack completo de logging
- Grafana Loki - Sistema de agregación de logs
- Splunk - Plataforma enterprise de análisis de logs
Métricas:
- Prometheus - Sistema de métricas con modelo pull
- Grafana - Visualización de métricas
- Datadog - Plataforma SaaS completa de observabilidad
Comunidades y Foros
- CNCF Slack - Canal #microservices
- Stack Overflow - Tag "saga-pattern" y "distributed-transactions"
- Reddit r/microservices
- DDD/CQRS Google Group
FIN DEL DOCUMENTO
Última Actualización: Febrero 2026
Próxima Revisión: Agosto 2026
Responsable: Equipo de Arquitectura Empresarial
Contacto: [email protected]
Arquitectura distribuida y el abandono consciente de ACID
- Mauricio ECR
- Arquitectura
- 14 Feb, 2026
En el mundo de los sistemas distribuidos, hay una verdad incómoda que enfrentamos tarde o temprano: no podemos tenerlo todo. La promesa de las transacciones ACID tradicionales —esa garantía tranquiliz
Arquitectura distribuida y el abandono consciente de ACID
- Mauricio ECR
- Arquitectura
- 14 Feb, 2026
En el mundo de los sistemas distribuidos, hay una verdad incómoda que enfrentamos tarde o temprano: no podemos tenerlo todo. La promesa de las transacciones ACID tradicionales —esa garantía tranquilizadora de que nuestros datos siempre estarán perfectamente sincronizados— se desvanece en el momento en que decidimos distribuir nuestra aplicación monolítica en múltiples servicios independientes. Es un momento de madurez arquitectónica que muchos equipos experimentan con cierta resistencia, similar a cuando un adolescente comprende que el mundo no es tan simple como parecía en la infancia.
Esta transformación no es meramente técnica; representa un cambio filosófico en cómo concebimos la consistencia de datos. Abandonamos el confort de las transacciones atómicas inmediatas, donde todo sucede o nada sucede en un instante perfecto, y abrazamos algo más orgánico, más real: la consistencia eventual. Los datos pueden estar temporalmente desalineados entre servicios, como músicos de una orqestra que momentáneamente pierden el compás para luego reconectarse con la melodía principal. La belleza de este modelo —conocido como BASE (Basically Available, Soft state, Eventually consistent)— radica en su pragmatismo: el sistema responde incluso cuando algunos servicios están caídos, los eventos se persisten antes de considerarse publicados, y eventualmente, con la paciencia de un jardinero que espera la floración, el sistema converge hacia un estado consistente.
El Arte de la Coreografía Distribuida
Imagina un ballet donde los bailarines no siguen a un director de orquesta central, sino que responden a las acciones de sus compañeros de manera autónoma. Este es el corazón del patrón Saga basado en coreografía. Cada servicio actúa como un participante independiente que escucha eventos relevantes de su dominio, ejecuta su lógica de negocio local, publica eventos de resultado y —esto es crucial— no tiene conocimiento completo de qué otros servicios participan en el proceso general.
Esta autonomía trae consigo ventajas significativas: bajo acoplamiento entre servicios, alta escalabilidad y la ausencia de un punto único de fallo. Pero también presenta desafíos genuinos. El flujo completo de la transacción se vuelve implícito, emergente de las interacciones locales, lo que puede hacer que la depuración se asemeje a seguir las huellas de un animal esquivo en el bosque. La complejidad en el rastreo es real, y no debemos minimizarla.
Para contextos donde necesitamos mayor visibilidad del flujo completo, existe una alternativa complementaria: la orquestación ligera de estado. Aquí, un servicio actúa como coordinador de estado —no como un tirano centralizado que comanda cada movimiento, sino como un observador atento que rastrea el progreso, mantiene una tabla de estado de la saga, escucha todos los eventos relevantes y puede tomar decisiones de cancelación cuando sea necesario. La distinción es sutil pero fundamental: el coordinador observa y reacciona, no comanda y espera. La comunicación sigue siendo asíncrona mediante el bus de eventos, preservando así los beneficios de la arquitectura desacoplada.
Hay, por supuesto, caminos que debemos evitar absolutamente. El Two-Phase Commit (2PC) y las transacciones XA, que extienden transacciones de base de datos entre servicios, están prohibidos debido a los bloqueos prolongados que generan y la baja disponibilidad resultante. Los bloqueos distribuidos compartidos entre servicios son igualmente problemáticos, con la excepción de los bloqueos semánticos de negocio que discutiremos más adelante. Las llamadas síncronas en flujos críticos —HTTP, REST, gRPC— deben reservarse exclusivamente para consultas de solo lectura, nunca para comandos que modifican estado en el contexto de una saga.
El Problema Dual Write y su Solución Elegante
Uno de los desafíos más insidiosos en sistemas distribuidos es el "Dual Write Problem". El escenario es simple pero traicionero: un servicio necesita actualizar su base de datos local y publicar un evento en el broker de mensajería. Si la publicación falla después del commit de la base de datos, el sistema queda inconsistente. Si falla antes, perdemos el evento y el flujo se interrumpe sin que nadie lo sepa.
La solución a este dilema es el patrón Transactional Outbox, una técnica elegante que convierte un problema de coordinación distribuida en dos problemas locales secuenciales. La regla de oro es simple: un servicio nunca debe publicar directamente en el broker dentro de su código de negocio. En su lugar, la operación se divide en dos fases distintas.
La primera fase es una transacción atómica local donde todo sucede dentro de los límites seguros de una sola base de datos. Iniciamos la transacción, ejecutamos nuestra operación de negocio —quizás un INSERT, UPDATE o DELETE en las tablas de dominio— e inmediatamente después insertamos el evento que queremos publicar en una tabla especial llamada OUTBOX. Finalmente, confirmamos la transacción completa. El punto crítico aquí es que ambas escrituras están protegidas por el mismo commit atómico: o ambas suceden, o ninguna sucede.
La segunda fase es completamente asíncrona y resiliente. Un proceso independiente —típicamente llamado Relay o Publisher— lee continuamente la tabla OUTBOX, encuentra eventos pendientes, los publica en el broker y los marca como publicados o los elimina. Este proceso puede fallar, reintentar, detenerse y reiniciarse sin comprometer la integridad de nuestros datos, porque la fuente de verdad —la tabla OUTBOX— está segura en nuestra base de datos.
La estructura de la tabla OUTBOX debe incluir campos obligatorios que garanticen su funcionamiento correcto: un identificador único del mensaje (típicamente un UUID), el tipo de evento de dominio, el cuerpo del evento serializado, timestamp de creación, estado de publicación (pendiente, publicado, fallido), número de intentos, el agregado raíz asociado para ordenamiento, y la versión del esquema del evento.
Para implementar el proceso de relay tenemos tres opciones principales. El polling tradicional es simple y confiable: un proceso consulta periódicamente la tabla OUTBOX —recomendamos intervalos de 100 a 500 milisegundos— publica eventos pendientes ordenados por timestamp y los marca como publicados tras confirmación del broker. Change Data Capture (CDC) es más sofisticado: herramientas como Debezium, Maxwell o AWS DMS leen el transaction log de la base de datos, detectan inserts en OUTBOX en tiempo real y publican automáticamente en el broker. Los triggers de base de datos son la opción menos recomendada debido al acoplamiento que generan y su menor resiliencia, aunque técnicamente funcional: un trigger se activa al insertar en OUTBOX e invoca un procedimiento que publica en el broker.
Idempotencia: El Escudo Contra la Duplicación
Aquí nos encontramos con otra verdad incómoda de los sistemas distribuidos: los brokers de mensajería garantizan entrega "al menos una vez", lo que significa que inevitablemente algunos mensajes llegarán duplicados. No es un error del broker, es una característica inherente a sistemas que priorizan la disponibilidad sobre la consistencia perfecta. Por tanto, todo consumidor de eventos debe ser idempotente.
La definición es directa pero profunda: una operación es idempotente si ejecutarla múltiples veces produce el mismo resultado que ejecutarla una sola vez. Es como presionar el botón del elevador repetidamente —el elevador viene una sola vez, sin importar cuántas veces presionamos el botón. Cada servicio debe mantener una tabla dedicada de mensajes procesados con el identificador del mensaje como clave primaria, el tipo de evento procesado, timestamp de procesamiento, estado final (éxito o fallo) y un índice en timestamp para limpieza periódica.
El flujo de procesamiento idempotente se convierte en un ritual casi ceremonial. Al recibir un mensaje del broker, iniciamos una transacción de base de datos local e intentamos insertar el identificador del mensaje en la tabla de registro. Si la inserción falla por clave duplicada, sabemos que este mensaje ya fue procesado anteriormente: hacemos rollback y retornamos éxito al broker sin ejecutar nada. Si la inserción es exitosa, procedemos con la lógica de negocio, potencialmente insertamos un evento resultante en nuestra tabla OUTBOX, confirmamos la transacción completa y enviamos ACK al broker. Como medida de higiene, eliminamos registros con más de siete días de antigüedad mediante un proceso nocturno.
La Danza de la Compensación
Cuando las cosas van mal en un sistema distribuido —y eventualmente lo harán— necesitamos una estrategia para deshacer operaciones que ya fueron confirmadas. Aquí entra el concepto de transacción compensatoria: una acción lógicamente inversa que deshace o mitiga el efecto de una operación previamente confirmada.
Los ejemplos son intuitivos una vez que comprendemos el patrón. CrearPedido se compensa con AnularPedido. ReservarInventario con LiberarReserva. CobrarPago con ReembolsarPago. EnviarNotificacion con EnviarNotificacionCorreccion. AsignarRecurso con DesasignarRecurso. Pero la compensación no siempre es perfecta en el sentido matemático de restaurar el estado exacto anterior.
Existen tres tipos de compensación, cada uno con su propia filosofía. La compensación perfecta restaura el estado exacto anterior —como cancelar una reserva que aún no ha sido utilizada. La compensación aproximada restaura un estado equivalente pero no idéntico —como ofrecer un reembolso en créditos de la tienda en lugar de dinero, cuando ambos tienen valor similar para el cliente. La compensación simbólica es la más interesante: registra el intento de reversión cuando la compensación real es físicamente imposible. No podemos "des-enviar" un email una vez que salió, pero podemos enviar un email de corrección o aclaración.
Las compensaciones deben ser idempotentes (pueden ejecutarse múltiples veces sin efectos adversos), deben registrarse en logs de auditoría para trazabilidad, y deben emitir eventos de compensación para que otros servicios puedan reaccionar apropiadamente.
El Arte del Reintento Inteligente
No todos los errores son creados iguales. Esta distinción es fundamental para implementar una estrategia de reintentos efectiva. Los fallos transitorios son errores temporales que pueden resolverse reintentando: pérdida de conexión de red, timeouts de base de datos por carga momentánea, un servicio dependiente temporalmente no disponible, o límites de rate limiting alcanzados. Los fallos permanentes no se resolverán sin intervención humana o cambios en el código: validaciones de negocio fallidas, datos malformados o incompletos, violaciones de reglas de dominio, permisos insuficientes, o recursos no encontrados.
Para fallos transitorios implementamos exponential backoff con jitter. La progresión es elegante: empezamos con una espera inicial de 500 milisegundos entre el primer y segundo intento, luego multiplicamos por un factor de 2 en cada intento subsecuente, con un techo máximo de 60 segundos entre intentos y un límite de 5 intentos totales. Añadimos jitter aleatorio —una variación del 10-25%— para evitar el fenómeno de "thundering herd" donde múltiples procesos reintentan simultáneamente, creando picos de carga que agravan el problema original. La progresión típica sería: 500ms → 1s → 2s → 4s → 8s → Dead Letter Queue.
La Dead Letter Queue (DLQ) es nuestro hospital para mensajes enfermos. Después de agotar los reintentos automáticos, el mensaje se envía a esta cola especial donde espera análisis manual posterior, genera alertas al equipo de operaciones, puede ser reprocesado manualmente tras corrección del problema subyacente, y sirve para auditoría de fallos recurrentes. Los mensajes en DLQ deben preservar información forense completa: el mensaje original completo, número de intentos realizados, timestamps de cada intento, detalles de cada error ocurrido, y el trace completo del último error.
Evolución sin Ruptura: El Versionado de Contratos
Los sistemas vivos evolucionan, y los contratos entre servicios deben evolucionar con ellos sin romper el ecosistema existente. Todo evento de dominio publicado debe tener un esquema formal —Avro, Protocol Buffers o JSON Schema— que defina nombre y tipo de cada campo, campos obligatorios versus opcionales, tipos de datos permitidos, restricciones de validación, y descripción semántica de cada campo.
El versionado semántico se convierte en nuestra brújula. Cada evento incluye un campo "schema_version" con formato MAJOR.MINOR.PATCH. Un cambio MAJOR indica incompatibilidad que requiere actualización del consumidor: eliminar campos, cambiar tipos de datos existentes, cambiar la semántica de un campo, o renombrar campos. Un cambio MINOR representa adiciones retrocompatibles: agregar nuevos campos opcionales, agregar nuevos valores a enumeraciones, o deprecar campos sin eliminarlos. Un PATCH es para correcciones menores sin impacto funcional: corregir descripciones, mejorar documentación, o corregir typos en nombres.
Para cambios aditivos (Minor o Patch), agregamos solo campos opcionales con valores por defecto. Los consumidores antiguos ignoran campos nuevos que no conocen, los productores nuevos toleran consumidores antiguos, y no se requiere coordinación de despliegue. Es evolución pacífica y gradual.
Para cambios breaking (Major), necesitamos una estrategia más cuidadosa. Creamos un nuevo tipo de evento con sufijo de versión —por ejemplo, "OrdenSolicitada_v2"— y mantenemos publicación dual por un período de transición. El productor emite tanto el evento v1 como el v2, permitiendo que los consumidores migren gradualmente. El período mínimo de convivencia es 90 días calendario. Solo después de este período podemos deprecar y eventualmente eliminar la versión antigua.
La política de deprecación es deliberadamente conservadora: anunciamos la deprecación con 90 días de anticipación, añadimos warnings en logs cuando se use la versión antigua, publicamos métricas de uso de versiones obsoletas, coordinamos la migración con todos los equipos consumidores, y eliminamos soporte solo cuando el uso sea cero por 30 días consecutivos. Es un proceso tedioso, sí, pero necesario para la salud del ecosistema.
Observabilidad: Iluminando la Caja Negra
Un sistema distribuido sin observabilidad es como navegar un barco en niebla densa sin instrumentos. El rastreo distribuido nos permite seguir el viaje completo de una transacción a través de múltiples servicios. El servicio que inicia una saga genera un Saga ID —un identificador global único, típicamente UUID versión 4— que representa toda la transacción distribuida. Este ID se genera una sola vez al inicio y se propaga sin cambios a través de todos los pasos subsecuentes.
Adicionalmente, cada servicio que procesa genera su propio Span ID —un identificador único para ese paso específico— y mantiene referencia al Parent Span ID del paso anterior, creando así una jerarquía de trazas como un árbol genealógico de operaciones. Estos identificadores viajan como headers en todos los mensajes: "X-Saga-ID", "X-Span-ID" y "X-Parent-Span-ID". Los servicios intermedios preservan el Saga ID sin modificarlo, generan su propio Span ID, copian el Span ID recibido como su Parent Span ID, y propagan estos tres valores en todos los eventos que emitan.
El logging estructurado es nuestra memoria colectiva. Cada entrada de log relacionada con procesamiento de eventos debe incluir campos mandatorios de contexto —identificador de saga, tipo de evento, versión del esquema, nombre del servicio, timestamp en ISO-8601 con zona horaria UTC, identificador del span actual y padre— junto con campos mandatorios de resultado: estado del procesamiento (RECEIVED, PROCESSING, SUCCESS, FAILED, COMPENSATING, COMPENSATED), duración en milisegundos de la operación, y número de intento para reintentos.
Las métricas son nuestros sensores vitales. saga_duration_seconds es un histograma que mide el tiempo total desde inicio hasta conclusión, etiquetado por tipo de saga y estado final. saga_step_errors_total es un contador acumulativo de fallos al procesar eventos, etiquetado por nombre de servicio, tipo de evento y tipo de error. saga_compensations_total cuenta las transacciones compensatorias ejecutadas. outbox_pending_messages es un gauge que muestra cuántos eventos están pendientes de publicar en cada servicio. dlq_messages_total indica la cantidad de mensajes problemáticos en Dead Letter Queue.
Para procesos críticos de negocio mantenemos tablas de auditoría completas con todos los cambios de estado de la saga, timestamp de cada transición, razón del cambio (evento que lo provocó), usuario o sistema responsable del inicio, y datos relevantes de negocio. La retención mínima sigue políticas regulatorias, típicamente siete años para sectores financieros.
Límites Temporales y la Paciencia del Sistema
Todo proceso distribuido debe tener límites temporales claramente definidos. No podemos esperar indefinidamente. Los parámetros configurables son nuestra red de seguridad: número máximo de intentos antes de enviar a DLQ (recomendamos 5 intentos con mínimo de 3), espera inicial entre primer y segundo intento (recomendamos 500 milisegundos con mínimo de 100), espera máxima entre intentos (recomendamos 60 segundos con mínimo de 30), timeout de saga completa (recomendamos 24 horas con mínimo de 1 hora, ajustable según naturaleza del proceso), y timeout por paso individual (recomendamos 5 minutos con mínimo de 30 segundos).
La filosofía de timeouts tiene dos niveles. Para timeouts de paso individual, si un evento esperado no llega dentro del plazo configurado, el coordinador emite un evento de timeout, inicia proceso de compensación, registra en logs con nivel ERROR e incrementa métricas de timeouts. Para timeouts de saga completa, si la saga no se completa dentro del plazo total, ejecutamos compensación automática de todos los pasos confirmados, marcamos la saga con estado TIMED_OUT, emitimos evento de saga expirada para auditoría, notificamos al usuario o sistema iniciador del fallo, y generamos alerta para el equipo de operaciones. Crucialmente, no eliminamos datos de auditoría —los mantenemos para análisis post-mortem.
Las alertas obligatorias actúan como sistema de alerta temprana: si el porcentaje de sagas fallidas supera 5% en ventana de 15 minutos, si el tiempo promedio supera el doble del baseline histórico, si la cantidad de mensajes en DLQ supera 10 por servicio, si hay mensajes en OUTBOX pendientes por más de 10 minutos, o si una saga individual supera el 80% del timeout configurado. Cada alerta tiene su nivel de severidad: CRITICAL para flujos de negocio críticos como pagos y pedidos, HIGH para funcionalidad importante pero no crítica, MEDIUM para degradación de rendimiento sin pérdida de funcionalidad, y LOW para anomalías sin impacto inmediato.
Del Concepto a la Realidad: Un Caso Práctico
La teoría cobra vida cuando la aplicamos a un caso concreto. Consideremos un sistema de comercio electrónico donde necesitamos procesar una orden de compra asegurando disponibilidad de inventario antes de ejecutar el cobro. El objetivo es claro: minimizar reembolsos por falta de stock, reducir costos de transacciones bancarias fallidas, y mejorar la experiencia del cliente evitando cobros seguidos de reembolsos inmediatos.
Nuestra estrategia es Check-Then-Act: verificación antes de acción financiera. El proceso se divide en cuatro fases secuenciales, cada una con su propósito específico. En la fase de intención, registramos la orden inicial con estado PENDIENTE_VALIDACION y emitimos el evento OrdenSolicitada. En la fase de validación, consultamos disponibilidad de inventario sin realizar ninguna reserva —es una operación de lectura pura, sin bloqueos— que resulta en StockVerificado o StockNoDisponible. En la fase de cobro condicional, ejecutamos la transacción financiera solo si la fase anterior fue exitosa, resultando en PagoExitoso o PagoRechazado. Finalmente, en la fase de asignación con bloqueo, realizamos el descuento definitivo de inventario con un UPDATE que usa bloqueo pesimista, resultando en StockAsignado o FalloAsignacion.
Tres servicios orquestan este ballet: el Gestor de Pedidos actúa como coordinador de estado, el Gestor de Inventario participa en dos momentos diferentes (validación y asignación), y el Procesador de Pagos actúa como intermediario financiero condicional.
El Gestor de Pedidos mantiene la máquina de estados de la orden. Cuando está en estado PENDIENTE_VALIDACION y recibe StockVerificado, transiciona a PENDIENTE_PAGO. Si recibe StockNoDisponible, va directamente a CANCELADA_SIN_STOCK y el flujo termina. Desde PENDIENTE_PAGO, al recibir PagoExitoso transiciona a PENDIENTE_ASIGNACION, pero si recibe PagoRechazado va a CANCELADA_PAGO_RECHAZADO. Finalmente, desde PENDIENTE_ASIGNACION, StockAsignado lleva a COMPLETADA (el flujo exitoso), mientras que FalloAsignacion resulta en CANCELADA_CON_REEMBOLSO y requiere compensación.
El Gestor de Inventario tiene una doble vida fascinante. En el momento de validación, al escuchar OrdenSolicitada, simplemente consulta disponibilidad actual sin tocar nada. Es como asomarse a la despensa para ver si hay suficiente harina sin tomar nada todavía. Si hay stock suficiente para todos los ítems, emite StockVerificado. Si algún ítem no tiene stock suficiente, emite StockNoDisponible con detalles. En el momento de asignación, al escuchar PagoExitoso, la cosa se pone seria: inicia una transacción con bloqueo pesimista (SELECT FOR UPDATE), re-verifica disponibilidad actual —que puede haber cambiado desde la validación inicial— descuenta las unidades si aún hay stock, y confirma transacción o hace rollback según el resultado.
Esta re-verificación en la fase de asignación es crítica. Durante el tiempo transcurrido entre la validación optimista (fase 2) y la asignación pesimista (fase 4), otra transacción concurrente podría haber consumido ese stock. El bloqueo pesimista en la fase 4 garantiza que, una vez que obtenemos el lock, nadie más puede modificar esos registros hasta que terminemos.
El Procesador de Pagos es deliberadamente ciego a OrdenSolicitada. Solo reacciona a StockVerificado, lo que previene cobros innecesarios cuando no hay stock disponible. Al recibir StockVerificado, valida que el evento no fue procesado previamente (idempotencia), extrae datos de pago, llama a la API de la pasarela con timeout de 30 segundos, y maneja la respuesta apropiadamente. Si la pasarela aprueba, almacena el ID de transacción e inserta PagoExitoso en OUTBOX. Si hay rechazo por fondos insuficientes o tarjeta inválida, inserta PagoRechazado. Si hay timeout o error de red, aplica la política de reintentos con exponential backoff, y tras agotar intentos envía a DLQ para revisión manual.
La compensación en Pagos es igualmente importante. Al recibir FalloAsignacion, extrae el Saga ID para identificar la transacción financiera original, busca en su base de datos local el ID de transacción de la pasarela, llama a la API de reembolso, y si es exitoso inserta ReembolsoEjecutado en OUTBOX. Si el reembolso falla, reintenta con backoff exponencial, y tras 5 intentos fallidos envía alerta crítica al equipo de finanzas y marca para reembolso manual.
Tres Historias, Tres Destinos
El flujo exitoso —el happy path que todos queremos ver— es casi poético en su simplicidad. Un cliente solicita 1 unidad del producto SKU-123. El inventario actual tiene 10 unidades disponibles. No hay operaciones concurrentes. El cliente envía su solicitud HTTP POST, Pedidos crea el registro con estado PENDIENTE_VALIDACION, genera un Saga ID único, inserta OrdenSolicitada en OUTBOX. El relay publica el evento. Inventario lo consume, consulta la base de datos, verifica que hay 10 unidades y el pedido requiere solo 1, inserta StockVerificado en OUTBOX. Pagos consume StockVerificado, invoca la API de la pasarela que aprueba el cobro, inserta PagoExitoso. Inventario consume PagoExitoso, inicia transacción con bloqueo pesimista, re-verifica que aún hay stock, ejecuta el UPDATE restando 1 unidad, inserta StockAsignado, confirma la transacción. Pedidos consume StockAsignado, actualiza estado a COMPLETADA, emite OrdenCompletada, y notifica al cliente. Todo el proceso toma aproximadamente 2-5 segundos. Elegante, eficiente, exitoso.
El fallo temprano es igualmente instructivo. Un cliente solicita 5 unidades del producto SKU-456. El inventario actual tiene 0 unidades. Pedidos crea la orden y emite OrdenSolicitada. Inventario consulta, verifica que hay 0 unidades pero el pedido requiere 5, inmediatamente inserta StockNoDisponible en OUTBOX. Pedidos consume este evento, actualiza estado a CANCELADA_SIN_STOCK, emite OrdenCancelada, y notifica al cliente que el producto está agotado. Lo crucial aquí es que el servicio de Pagos nunca se entera de nada —no escucha StockNoDisponible— por tanto no se ejecuta ninguna operación financiera. No hay cargos, no hay reembolsos. La orden se cancela en menos de 1 segundo. El costo financiero es cero. Esta arquitectura evita aproximadamente el 95% de reembolsos comparado con estrategias de "cobrar primero, verificar después".
El fallo tardío —la race condition— es donde el diseño realmente brilla. Cliente A solicita 1 unidad del producto SKU-789. Inventario actual: 1 unidad disponible. Cliente B también solicita 1 unidad del mismo producto simultáneamente. Ambos pasan la validación optimista porque en ese momento había 1 unidad disponible. Ambos son cobrados exitosamente por la pasarela de pagos. Pero en la fase de asignación, solo uno puede ganar.
Digamos que Cliente A llega primero a la fase de asignación. Inventario inicia transacción con SELECT FOR UPDATE, obtiene el lock, lee 1 unidad disponible, valida que 1 >= 1, ejecuta UPDATE restando la unidad, deja el inventario en 0, inserta StockAsignado, confirma. Cliente A recibe su orden completa. Segundos después, Cliente B llega a la fase de asignación. Inventario inicia otra transacción con SELECT FOR UPDATE, obtiene el lock (Cliente A ya liberó el lock al hacer COMMIT), pero ahora lee 0 unidades disponibles, valida que 0 < 1, la validación falla, no ejecuta el UPDATE, pero —esto es crucial— sí confirma la transacción para que el evento FalloAsignacion se publique correctamente.
Pagos consume FalloAsignacion para Cliente B, busca la transacción original que había aprobado, invoca la API de reembolso de la pasarela, recibe confirmación, inserta ReembolsoEjecutado. Pedidos consume este evento, actualiza estado a CANCELADA_CON_REEMBOLSO, y notifica a Cliente B que su pago ha sido reembolsado porque el producto se agotó durante el proceso. Cliente A tiene su orden completada, Cliente B tiene su dinero de vuelta automáticamente, el sistema mantuvo consistencia a pesar de la concurrencia, y crucialmente, no hubo sobreventa (overselling).
Este escenario demuestra por qué la verificación en fase 2 es optimista (sin bloqueo) y la asignación en fase 4 es pesimista (con bloqueo). Si bloqueáramos en la fase de validación, tendríamos alta contención —cada consulta de disponibilidad bloquearía las filas, forzando a otras transacciones a esperar. El bloqueo tardío minimiza la ventana crítica a solo el momento de la asignación definitiva.
Consideraciones Finales de Implementación
El ordenamiento de eventos merece atención especial. Los brokers garantizan orden dentro de una partición, no globalmente. La solución es particionar eventos por Saga ID, asegurando que todos los eventos de una misma saga vayan a la misma partición. Configuramos la clave de partición como el Saga ID. Esto garantiza que eventos de una orden específica se procesen en orden correcto, aunque no importa el orden entre órdenes diferentes —cada orden es independiente.
El manejo de duplicados es un ritual bien definido. Cuando llega un evento PagoExitoso al servicio de Inventario, extraemos el Message ID del header, iniciamos una transacción, e intentamos insertar ese ID en la tabla processed_messages. Si la inserción falla por clave duplicada, sabemos que este mensaje ya fue procesado: hacemos rollback, retornamos ACK al broker sin hacer nada más, y registramos en logs "Evento duplicado ignorado". Si la inserción es exitosa, procedemos con la lógica de asignación de stock, y el COMMIT incluye tanto la nueva fila en processed_messages como el UPDATE de inventario. Ambos cambios o ninguno —atomicidad local garantizada.
La consistencia de OUTBOX es una garantía crítica inviolable: el evento en OUTBOX y el cambio de estado de negocio deben confirmarse en la misma transacción atómica de base de datos. Por ejemplo, cuando Pedidos recibe StockVerificado, en una sola transacción ejecuta UPDATE de la orden cambiando estado a PENDIENTE_PAGO e INSERT en OUTBOX del evento OrdenActualizada, seguido de COMMIT. Si el COMMIT falla, ningún cambio se persiste. Si se confirma, ambos cambios quedan guardados atómicamente. Esta es la base de toda la confiabilidad del sistema.
Los timeouts deben configurarse pensando en las características reales de cada operación. Para Pedidos: timeout de validación de stock de 30 segundos (consultas de base de datos son rápidas), timeout de pago de 60 segundos (APIs bancarias pueden ser lentas), timeout de asignación de 15 segundos (es un UPDATE simple), y timeout total de saga de 2 horas (permitiendo delays en procesamiento de eventos). Para Inventario: timeout de consulta de base de datos de 5 segundos, timeout de bloqueo pesimista de 10 segundos. Para Pagos: timeout de llamada a pasarela de 30 segundos, 3 reintentos con backoff de 2-8-18 segundos, y timeout de reembolso de 60 segundos.
Reflexiones sobre la Consistencia Eventual
Implementar el patrón Saga es, en esencia, aceptar la naturaleza distribuida de la realidad. No estamos simulando un sistema monolítico con trucos de coordinación; estamos abrazando honestamente que nuestros servicios son entidades autónomas que colaboran a través de eventos. La consistencia eventual no es una limitación que debemos lamentar, sino una propiedad emergente que podemos diseñar deliberadamente.
Los desafíos son reales: flujos implícitos, depuración compleja, compensaciones que requieren pensamiento cuidadoso, race conditions que debemos anticipar, y la necesidad constante de idempotencia. Pero las recompensas también son sustanciales: servicios verdaderamente desacoplados que pueden evolucionar independientemente, escalabilidad horizontal sin límites artificiales, resiliencia ante fallos parciales, y la capacidad de razonar sobre procesos de negocio complejos mediante eventos de dominio.
La clave está en la disciplina. El patrón Transactional Outbox elimina el dual write problem. La deduplicación sistemática maneja mensajes duplicados. Las compensaciones bien diseñadas permiten deshacer operaciones. Los reintentos inteligentes distinguen entre fallos transitorios y permanentes. El versionado cuidadoso permite evolución sin ruptura. La observabilidad exhaustiva ilumina lo que de otra manera sería opaco.
Cada uno de estos elementos es un pilar que sostiene el edificio completo. Eliminar cualquiera de ellos compromete la integridad estructural. Pero implementados en conjunto, con la atención al detalle que merece cada uno, nos permiten construir sistemas distribuidos que no solo funcionan sino que son comprensibles, mantenibles y confiables a largo plazo.
Al final, el patrón Saga nos enseña una lección más amplia sobre la arquitectura de software: los sistemas complejos emergen de la composición de partes simples que interactúan mediante protocolos bien definidos. No necesitamos coordinación central omnisciente. No necesitamos transacciones globales mágicas. Necesitamos servicios que comprendan sus responsabilidades, eventos que comuniquen intenciones claras, y mecanismos de compensación que permitan corrección de errores. Con estos ingredientes, la consistencia eventual emerge naturalmente, como un patrón que se forma en la arena cuando las olas retroceden.
Esta es la belleza del diseño distribuido: aceptamos las limitaciones fundamentales de la física —la información viaja a velocidad finita, los sistemas fallan parcialmente, el tiempo no es absoluto— y construimos abstracciones que funcionan dentro de estas limitaciones en lugar de pretender superarlas. El patrón Saga es nuestra forma de bailar con la entropía en lugar de luchar contra ella.
Arquitectura de Base de Datos para Identidad, Autenticación y Autorización (IAM)
- Mauricio ECR
- Arquitectura
- 01 Feb, 2026
Cuando se habla de seguridad en el contexto de una aplicación, la conversación casi siempre gira en torno a las capas visibles: el cifrado en tránsito, las políticas de contraseñas, los tokens de aute
Arquitectura de Base de Datos para Identidad, Autenticación y Autorización (IAM)
- Mauricio ECR
- Arquitectura
- 01 Feb, 2026
Cuando se habla de seguridad en el contexto de una aplicación, la conversación casi siempre gira en torno a las capas visibles: el cifrado en tránsito, las políticas de contraseñas, los tokens de autenticación. Son piezas visibles e importantes, pero debajo de todas ellas existe una estructura que determina si un sistema IAM puede sostenerse en producción o si eventualmente colapsará bajo su propia complejidad. Esa estructura es el modelo de datos.
Diseñar una base de datos para gestionar identidad, autenticación y autorización no es simplemente crear una tabla de usuarios con nombre, email y contraseña. Es resolver, desde el nivel más fundamental, preguntas como: ¿quién es una entidad dentro del sistema? ¿cómo demuestra que es quien dice ser? ¿qué puede hacer? ¿a qué organización pertenece? Y hacerlo de una forma que no requiera rediseños costosos cuando la aplicación crezca de diez usuarios a diez millones.
Este artículo explora un modelo de datos completo para IAM, desde sus principios arquitectónicos más fundamentales hasta las decisiones de implementación que determinan si el sistema puede escalar, cumplir requisitos de seguridad en producción y adaptarse a entornos enterprise sin romperse en el proceso.
La separación entre identidad y autenticación
El punto de partida de todo el modelo es una decisión que parece obvia pero que la mayoría de las implementaciones no respetan: la identidad de una entidad y el mecanismo por el cual se autentica son dos cosas completamente distintas.
En la práctica, esto se traduce en separar el concepto de actor del concepto de cuenta de acceso. Un actor es cualquier entidad que existe en el sistema: una persona, una empresa cliente, un bot interno, un proveedor externo. Una cuenta de acceso es el mecanismo que permite a ese actor demostrarlo cuando lo necesita. Y la clave es que no todos los actores necesitan una cuenta.
Piense en el escenario de una plataforma SaaS donde un usuario registra una empresa como cliente. En ese momento la empresa existe como entidad en el sistema, tiene datos de contacto y puede ser referenciada desde otros registros. Pero quizás nadie en esa empresa necesita ingresar al sistema aún. Si el modelo obligara a crear una cuenta de autenticación cada vez que se crea una entidad, ese escenario sería imposible sin truismos como usuarios ficticios o campos nulos que van acumulando deuda técnica.
La tabla actor en el modelo es deliberadamente mínima: un identificador único, un tipo de entidad y un nombre. Todo lo demás se construye a partir de ahí.
CREATE TABLE actor (
actor_id UUIDv7 PRIMARY KEY,
tipo_actor UUIDv7 NOT NULL REFERENCES actor_tipo(tipo_id),
nombre VARCHAR(256) NOT NULL,
eliminado_en TIMESTAMP NULL
);
El campo eliminado_en merece una mención especial porque representa otra decisión fundamental: estas entidades nunca se eliminan físicamente en producción. En lugar de borrar un registro, se marca con una fecha lógica de eliminación. La razón es pragmática: la tabla de auditoría va a referenciar este actor durante años. Si el registro desapareciera, esa referencia estaría rota, y con ella cualquier intento de reconstruir qué sucedió y cuándo. Además, regulaciones como el GDPR exigen retención de datos por períodos definidos, lo cual es imposible si ya no existen.
Los datos específicos de cada tipo de actor se almacenan por separado, en tablas que se relacionan al actor mediante una clave foránea que es simultáneamente clave primaria. Así, la información de una persona natural —nombre, apellidos— vive en actor_persona, los documentos de identidad en actor_documento, y los contactos en tablas propias para correos, teléfonos y direcciones. Cada uno de estos, además, soporta múltiples valores con un campo de contexto que distingue entre un correo personal, uno laboral o uno de facturación, sin que la aplicación tenga que adivinar cuál usar según la circunstancia.
Por qué UUIDv7 y no otro identificador
Una decisión que atraviesa todo el modelo es el uso de UUIDv7 como identificador primario en todas las tablas principales. Es una elección que tiene consecuencias concretas en rendimiento y operación, no solo en diseño.
El problema con usar valores como el email o el número de documento como clave es simple: cambian. Un usuario puede cambiar su correo electrónico mañana. Si ese email era la clave que todos los otros registros usaban para referirlo, cada uno de ellos necesitaría actualizarse en cascada, con el riesgo de inconsistencias y la costosa operación de actualizar claves foráneas en decenas de tablas.
UUIDv4, el estándar más común, resuelve eso: genera identificadores únicos que no cambian. Pero UUIDv7 va un paso más allá. Sus primeros 48 bits contienen un timestamp del momento de creación. Eso tiene dos efectos inmediatos en la base de datos.
El primero es de rendimiento. Los índices B-tree, que son la estructura por defecto en la gran mayoría de bases de datos relacional, se mantienen ordenados por el valor de la clave. Con UUIDv7, ese orden es automáticamente cronológico. En una tabla como la de auditoría, que puede crecer a millones de filas por día, las consultas que buscan los eventos de las últimas 24 horas no necesitan un sort adicional: los datos ya están en ese orden físicamente.
El segundo es de operación diaria. Cuando un desarrollador ve un ID en un log de producción a las 3 de la mañana investigando un incidente, con UUIDv7 puede derivar aproximadamente cuándo fue creado ese registro sin tener que ir a la base de datos. Es un detalle pequeño, pero en las situaciones de mayor presión esos detalles marcan la diferencia entre resolver un problema en minutos o en horas.
La cuenta y sus credenciales
La cuenta de acceso (cuenta_acceso) es el puente entre un actor y el sistema de autenticación. Se crea únicamente cuando el actor necesita autenticarse, y contiene los campos que el sistema necesita para controlar el acceso en tiempo real: el estado de la cuenta, un contador de intentos fallidos consecutivos y una fecha de bloqueo automático.
CREATE TABLE cuenta_acceso (
cuenta_id UUIDv7 PRIMARY KEY,
actor_id UUIDv7 NOT NULL REFERENCES actor(actor_id),
estado VARCHAR(20) NOT NULL DEFAULT 'activa',
intentos_fallidos_consecutivos INT NOT NULL DEFAULT 0,
bloqueada_hasta TIMESTAMP NULL,
creado_en TIMESTAMP NOT NULL DEFAULT NOW(),
eliminado_en TIMESTAMP NULL
);
El diseño de los campos de lockout en esta tabla no es accidental. El contador intentos_fallidos_consecutivos se incrementa con cada fallo y se reinicia al cero en cada login exitoso. Cuando supera el umbral configurado por la organización —por defecto cinco intentos— el sistema calcula una fecha de bloqueo y la escribe en bloqueada_hasta. Desde ese momento, cualquier intento de login contra esa cuenta falla automáticamente hasta que la fecha pase. Este mecanismo es necesario para defender contra ataques de fuerza bruta, pero el umbral y la duración del bloqueo no están hardcoded: cada organización puede configurarlos de forma independiente según sus requisitos de seguridad.
Las credenciales reales viven en una tabla separada, cuenta_credencial, y aquí el modelo hace otra cosa interesante. Una sola cuenta puede tener varias credenciales simultáneamente. Un usuario puede entrar con contraseña local, conectar su cuenta de Google y usar SAML corporativo, todo bajo la misma cuenta. Cada credencial tiene un campo proveedor que indica de dónde viene —LOCAL, GOOGLE, SAML— y el sistema sabe cómo procesar cada una según ese valor.
CREATE TABLE cuenta_credencial (
credencial_id UUIDv7 PRIMARY KEY,
cuenta_id UUIDv7 NOT NULL REFERENCES cuenta_acceso(cuenta_id),
proveedor VARCHAR(40) NOT NULL,
identificador VARCHAR(320) NOT NULL,
secreto_hash VARCHAR(256) NULL,
algoritmo_hash VARCHAR(40) NULL,
parametros_hash JSONB NULL,
creado_en TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (cuenta_id, proveedor, identificador)
);
Para las credenciales locales, la contraseña se almacena como hash —nunca en texto plano— y junto a ella se guardan dos campos que habitualmente se olvidan: algoritmo_hash y parametros_hash. La razón de su existencia tiene que ver con un problema real que aparece cuando una aplicación madura. En el momento en que el sistema decide migrar de bcrypt a Argon2id —algo que eventualmente todo sistema serio debe hacer— no es posible invalidar todas las contraseñas de la base de datos de una vez. La solución es lo que se conoce como migración lazy: cuando un usuario hace login, si su hash fue generado con un algoritmo antiguo, el sistema lo regenera con el algoritmo actual sin pedir que el usuario cambie su contraseña. Para que eso funcione, el sistema necesita saber exactamente qué algoritmo y qué parámetros usó para generar cada hash. De ahí vienen esos dos campos.
Sesiones, tokens y el problema de la revocación
Una vez que un usuario se autentica exitosamente, el sistema crea una sesión. La sesión es el registro que representa una instancia activa de uso desde un dispositivo específico, y es el lugar donde vive una de las decisiones más importantes del modelo desde el punto de vista de seguridad.
CREATE TABLE cuenta_sesion (
sesion_id UUIDv7 PRIMARY KEY,
cuenta_id UUIDv7 NOT NULL REFERENCES cuenta_acceso(cuenta_id),
version BIGINT NOT NULL DEFAULT 1,
dispositivo_id UUIDv7 NULL REFERENCES cuenta_dispositivo(dispositivo_id),
creada_en TIMESTAMP NOT NULL DEFAULT NOW(),
expira_en TIMESTAMP NOT NULL,
ip INET NOT NULL,
user_agent TEXT NOT NULL
);
El campo version es el que hace la diferencia. En un sistema típico que usa JWT —JSON Web Tokens— como mecanismo de autenticación por token, cada access token tiene una duración fija, por ejemplo quince minutos. Si un token es robado, el sistema no puede invalidarlo antes de que llegue a expirar: el token es autosuficiente por diseño. Durante esos quince minutos, el token robado es completamente válido. En una cuenta que ha sido comprometida, quince minutos pueden ser más que suficientes para causas daños significativos.
La solución que implementa este modelo es elegante en su simplicidad. Cada access token emitido incluye la versión actual de la sesión en su payload. Cuando el usuario realiza una acción que debe invalidar sus tokens —cambiar contraseña, habilitar MFA, cerrar sesión remota— el sistema simplemente incrementa la versión en la base de datos. El middleware de autenticación compara la versión del token con la versión almacenada. Si no coinciden, el token es rechazado inmediatamente, en milisegundos, sin esperar a que expiré.
Los refresh tokens operan en un esquema complementario. Son de duración larga —hasta treinta días— y su propósito es permitir obtener nuevos access tokens sin que el usuario vuelva a ingresar sus credenciales. La tabla cuenta_refresh_token incluye un campo que vale la pena destacar: reemplazado_por. Cuando un refresh token se rota —lo cual debe ocurrir cada vez que se genera un nuevo access token— el antiguo apunta al nuevo mediante ese campo. Esto crea una cadena auditable, pero más importante, permite detectar ataques. Si alguien roba un refresh token y lo usa después de que ya fue rotado, el sistema detecta que el token de la cadena ya fue reemplazado y puede revocar toda la sesión.
Protección contra ataques y el arte de no revelar demasiado
El control de intentos de login es donde la seguridad se enfrenta directamente con la experiencia del usuario, y donde las decisiones de diseño en la base de datos tienen consecuencias de seguridad reales.
CREATE TABLE cuenta_intento_login (
intento_id UUIDv7 PRIMARY KEY,
cuenta_id UUIDv7 NULL REFERENCES cuenta_acceso(cuenta_id),
identificador VARCHAR(320) NOT NULL,
exito BOOLEAN NOT NULL,
ip INET NOT NULL,
user_agent TEXT NOT NULL,
motivo_fallo VARCHAR(64) NULL,
creado_en TIMESTAMP NOT NULL DEFAULT NOW()
);
El detalle más sutil de esta tabla es que cuenta_id es nulable. Cuando alguien intenta hacer login con un email que no existe en el sistema, el registro se crea con cuenta_id = NULL. La razón tiene que ver con un ataque conocido como enumeración de usuarios: si el sistema respondiera de forma diferente ante un email no registrado versus una contraseña incorrecta, un atacante podría ir probando emails hasta confirmar cuáles están registrados. Al registrar todos los intentos de forma uniforme, independientemente de si la cuenta existe o no, y al no distinguir entre tipos de error en la respuesta al cliente, el sistema cierra esa puerta.
El campo motivo_fallo almacena la información detallada del fallo, pero esta información es para uso interno exclusivamente. El sistema nunca la retorna al cliente: desde afuera, un fallo es simplemente un fallo, sin distingos. Es un patrón que aparece repetido en varios lugares del modelo, esta idea de que hay información que el sistema necesita almacenar para su propia operación pero que no debe revelar hacia afuera.
El lockout funciona en dos niveles independientes. El primero es por cuenta: cuando los intentos fallidos consecutivos supera el umbral, la cuenta se bloquea por un período configurable. El segundo es por IP: si una misma dirección IP genera demasiados intentos fallidos contra cuentas diferentes en un período corto, se activa rate limiting a nivel de red. Ese segundo nivel es el que detecta credential stuffing, uno de los ataques más comunes hoy.
Políticas de contraseña como modelo de datos
Las políticas de contraseñas en la mayoría de las aplicaciones se implementan como constantes en el código: mínimo ocho caracteres, debe tener mayúscula, debe tener número. El problema con ese enfoque es que es imposible auditorlo, imposible que cada organización tenga requisitos diferentes, y requiere un deploy cada vez que cambie una regla.
En este modelo las políticas son una tabla en sí misma, con un campo tenant_id nulable que determina su alcance. Si es NULL, es la política global que aplica a todos; si tiene valor, es específica de esa organización y tiene prioridad.
CREATE TABLE politica_password (
politica_id UUIDv7 PRIMARY KEY,
tenant_id UUIDv7 NULL REFERENCES tenant(tenant_id),
min_longitud INT NOT NULL DEFAULT 12,
max_longitud INT NOT NULL DEFAULT 128,
requiere_mayuscula BOOLEAN NOT NULL DEFAULT TRUE,
requiere_minuscula BOOLEAN NOT NULL DEFAULT TRUE,
requiere_numeros BOOLEAN NOT NULL DEFAULT TRUE,
requiere_especiales BOOLEAN NOT NULL DEFAULT TRUE,
max_edad_dias INT NULL,
historial_prohibido INT NOT NULL DEFAULT 5,
creado_en TIMESTAMP NOT NULL DEFAULT NOW()
);
El campo historial_prohibido merece explicación porque implica la existencia de otra tabla: cuenta_historial_password. Cuando un usuario cambia su contraseña, el sistema necesita verificar que la nueva no coincida con las últimas N que tuvo. Para poder hacer esa comparación, los hashes de las contraseñas anteriores deben estar almacenados en algún lugar. En esa tabla, junto con cada hash antiguo se guardan también el algoritmo y los parámetros que fueron usados para generarlo, por la misma razón de compatibilidad que ya mencionamos: el algoritmo puede haber cambiado entre cuando se creó ese hash y el momento en que se necesita compararlo.
El límite máximo de longitud, que a primera vista parece arbitrario, tiene una razón de seguridad: una contraseña extremadamente larga puede usarse para un ataque de denegación de servicio, ya que calcular el hash de una cadena de miles de caracteres consume recursos significativos.
Multi-tenancy: aislamiento sin multiplicar la base de datos
Cuando una aplicación necesita servir a múltiples organizaciones independientes, la tentación es crear una base de datos separada por cada una. Es la solución más aislada, pero también la más costosa en operación: N bases de datos significan N planes de backup, N procesos de monitoreo, N niveles de mantenimiento.
El modelo propone la alternativa estándar en la industria: una sola base de datos compartida donde cada organización —llamada tenant— tiene sus datos lógicamente aislados mediante una clave tenant_id que atraviesa todas las tablas relevantes.
CREATE TABLE tenant (
tenant_id UUIDv7 PRIMARY KEY,
nombre VARCHAR(256) NOT NULL,
estado VARCHAR(20) NOT NULL DEFAULT 'activo',
plan VARCHAR(64) NULL,
configuracion JSONB NULL,
creado_en TIMESTAMP NOT NULL DEFAULT NOW(),
eliminado_en TIMESTAMP NULL
);
Un actor puede ser miembro de múltiples tenants simultáneamente, lo cual refleja la realidad de consultores o profesionales que trabajan con varias empresas. La membresía se gestiona en tenant_miembro, y las invitaciones en tenant_invitacion, donde tanto el email del invitado como el token de validación se almacenan como hash. Esto evita que alguien con acceso directo a la base de datos pueda enumerar qué emails tienen invitaciones pendientes en cada organización, un vector de ataque que suena teórico hasta que alguien lo explota.
Cada organización puede además tener sus propios requisitos de seguridad: cuántos intentos fallidos se permiten antes del lockout, si el MFA es obligatorio, qué métodos de MFA están permitidos, cuál es la política de contraseñas. Todas estas configuraciones se almacenan en tablas de configuración por tenant, lo cual significa que una organización enterprise puede exigir FIDO2 como único método de MFA mientras otra más pequeña se conforma con TOTP, sin que eso afecte al resto del sistema.
Autorización por roles y grupos
El módulo de autorización implementa RBAC, Role-Based Access Control, con una extensión importante: soporte para grupos organizacionales. La estructura básica es la clásica: roles que contienen permisos, y usuarios que tienen roles asignados. Pero la realidad de las organizaciones grandes no funciona así de simple.
En una empresa real, los permisos no se asignan uno por uno a cada persona. Se asignan por departamento, por unidad organizacional. Cuando un nuevo empleado entra al área de finanzas, debería recibir automáticamente los permisos que corresponden a esa función, sin que un administrador los configure manualmente uno por uno.
Para resolver eso, el modelo incluye una capa de grupos dentro de cada tenant. Un grupo como "Finanzas" tiene asignado el rol CONTADOR. Cuando alguien se agrega al grupo, hereda automáticamente todos los permisos de ese rol. Si además necesita algo extra —un permiso que no aplica a todo el grupo— se le asigna un rol adicional a nivel individual mediante la tabla miembro_rol. Las dos vías coexisten sin conflicto.
CREATE TABLE tenant_grupo (
grupo_id UUIDv7 PRIMARY KEY,
tenant_id UUIDv7 NOT NULL REFERENCES tenant(tenant_id),
nombre VARCHAR(128) NOT NULL,
descripcion VARCHAR(256) NULL,
UNIQUE (tenant_id, nombre)
);
La restricción UNIQUE (tenant_id, nombre) es un detalle que evita un error de diseño frecuente: que dos grupos con el mismo nombre existan en la misma organización, lo cual generaría confusión tanto en la interfaz administrativa como en la lógica de asignación.
Delegación temporal y dispositivos confiables
Hay dos escenarios frecuentes en organizaciones que RBAC por sí solo no resuelve. El primero es la delegación temporal: un manager que se va de vacaciones y necesita que alguien apruebe en su lugar durante dos semanas. El segundo es la experiencia del usuario en sistemas con MFA: si ya completaste la verificación en tu laptop esta mañana, no deberías tener que hacerlo de nuevo cada quince minutos.
La delegación se implementa mediante delegacion_permiso, una tabla que tiene una fecha de inicio y una fecha de fin obligatoria. No existen delegaciones perpetuas en el modelo, y es una decisión consciente: cualquier delegación sin límite temporal es un riesgo de seguridad que eventualmente alguien olvidará revocar. La delegación puede ser un rol completo —"durante mis vacaciones, esta persona actúa como aprobador"— o permisos individuales —"solo necesita firmar esta factura específica".
Los dispositivos confiables funcionan con un concepto de fingerprint: una combinación de browser, sistema operativo y otros atributos del dispositivo, almacenada como hash. Cuando un usuario completa MFA exitosamente en un dispositivo y acepta marcarlo como confiable, el sistema crea un registro con una fecha de expiración de la confianza —configurada por tenant. En logins futuros desde ese mismo dispositivo, si la confianza aún no ha expirado, el paso de MFA se omite automáticamente.
CREATE TABLE cuenta_dispositivo (
dispositivo_id UUIDv7 PRIMARY KEY,
cuenta_id UUIDv7 NOT NULL REFERENCES cuenta_acceso(cuenta_id),
fingerprint VARCHAR(256) NOT NULL,
nombre VARCHAR(128) NULL,
confiable BOOLEAN NOT NULL DEFAULT FALSE,
confiable_hasta TIMESTAMP NULL,
creado_en TIMESTAMP NOT NULL DEFAULT NOW(),
ultimo_uso_en TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (cuenta_id, fingerprint)
);
El campo ultimo_uso_en tiene un propósito de seguridad que no es inmediatamente obvio: permite mostrar al usuario cuándo fue usado cada dispositivo registrado, lo cual es la mecanismo por el cual un usuario puede detectar que alguien más está usando su cuenta desde un dispositivo que no reconoce.
Extensiones enterprise: SSO, SCIM y ABAC
Cuando una aplicación necesita integrarse con el ecosistema de identidad corporativo existente —Active Directory, Okta, Azure AD— el modelo incluye las tablas necesarias para eso sin rediseñar lo que ya existe.
SSO se implementa mediante cuenta_identidad_externa, que vincula una cuenta del sistema con un identificador externo proporcionado por el proveedor de identidad corporativo. SCIM, el estándar de provisioning automático, agrega otra dimensión: cuando un empleado se crea o elimina en el directorio corporativo, los cambios se reflejan automáticamente en el sistema. La tabla scim_provisioning registra el estado de cada usuario sincronizado y sus metadatos, los cuales pueden ser usados por las políticas ABAC.
ABAC —Attribute-Based Access Control— es la extensión más potente del sistema de autorización. En lugar de depender únicamente de roles estáticos, permite definir políticas basadas en atributos dinámicos tanto del usuario como del recurso.
CREATE TABLE politica_abac (
politica_id UUIDv7 PRIMARY KEY,
tenant_id UUIDv7 NULL REFERENCES tenant(tenant_id),
nombre VARCHAR(128) NOT NULL,
expresion TEXT NOT NULL,
efecto VARCHAR(10) NOT NULL,
activa BOOLEAN NOT NULL DEFAULT TRUE,
creado_en TIMESTAMP NOT NULL DEFAULT NOW()
);
Una política ABAC puede expresar reglas como "permitir el acceso si el departamento del usuario coincide con el departamento del recurso y el nivel del usuario es mayor o igual a 2". El campo expresion debe estar en un lenguaje controlado evaluado por un motor dedicado —como OPA con Rego— y no como texto libre, precisamente para evitar que esas expresiones sean vectores de inyección y para poder testarlas de forma aislada antes de ponerlas en producción.
Auditoría: el registro que no puede faltar
La tabla de auditoría es el componente que conecta todo el modelo con los requisitos de compliance. Cada evento de seguridad que ocurre en el sistema —login, logout, cambio de contraseña, creación de usuario, modificación de roles— se registra aquí con el actor que realizó la acción, el actor afectado si es diferente, el tenant en cuyo contexto ocurrió, y metadatos adicionales.
CREATE TABLE auditoria_seguridad (
evento_id UUIDv7 PRIMARY KEY,
actor_id UUIDv7 NOT NULL REFERENCES actor(actor_id),
actor_afectado_id UUIDv7 NULL REFERENCES actor(actor_id),
tenant_id UUIDv7 NULL REFERENCES tenant(tenant_id),
accion VARCHAR(64) NOT NULL,
objeto_tipo VARCHAR(64) NULL,
objeto_id VARCHAR(256) NULL,
fecha TIMESTAMP NOT NULL DEFAULT NOW(),
ip INET NULL,
user_agent TEXT NULL,
metadata JSONB NULL
) PARTITION BY RANGE (fecha);
El detalle más importante desde el punto de vista de operación es la última línea: PARTITION BY RANGE (fecha). Esta tabla puede crecer a millones de filas por día en un sistema de actividad moderada. Sin partitioning, las consultas de auditoría se convierten en sequential scans que se vuelven más lentas semana tras semana hasta que el sistema necesita una intervención de emergencia. Con partitioning por mes, cada consulta solo escanea la partición relevante. Las particiones antiguas —más de doce meses— se archivan a almacenamiento frío y se eliminan de la base activa, manteniendo el tamaño operacional manejable.
Cifrado y clasificación de datos
No todos los datos requieren el mismo nivel de protección, y el modelo reconoce eso con una clasificación en cuatro niveles. Los datos críticos como hashes de contraseñas y tokens nunca se almacenan en texto plano. Los datos de identidad personal —email, teléfono, documento— se cifran en reposo a nivel de columna mediante cifrado a nivel de aplicación con AES-256-GCM, con las claves de cifrado almacenadas en un KMS externo, no en la base de datos.
Para campos PII que necesitan ser buscables, como el email, el modelo propone almacenar junto al valor cifrado un hash determinístico separado. El hash permite hacer búsquedas —"¿existe un usuario con este email?"— sin que la base de datos contenga el email en texto plano. Es un patrón que aparece también en las invitaciones y en los tokens de refresh.
Índices estratégicos
Los índices son donde el modelo pasa de ser un diseño teórico a ser un sistema que puede operar en producción. Las queries más frecuentes en un sistema IAM son predecibles: login, verificación de permisos, búsqueda de sesiones activas, consultas de auditoría. Sin los índices correctos para cada una de estas operaciones, cada request adicional de un usuario se convierte en un scan secuencial que crece linealmente con el tamaño de la tabla.
-- Login: el camino crítico de cada autenticación
CREATE UNIQUE INDEX idx_credencial_proveedor_identificador
ON cuenta_credencial (proveedor, identificador);
-- Sesiones: buscar y limpiar sesiones expiradas
CREATE INDEX idx_sesion_cuenta_expira
ON cuenta_sesion (cuenta_id, expira_en);
-- Intentos: detectar ataques por IP (los más recientes primero)
CREATE INDEX idx_intento_ip_fecha
ON cuenta_intento_login (ip, creado_en DESC);
-- Auditoría: queries de compliance por actor
CREATE INDEX idx_auditoria_actor_fecha
ON auditoria_seguridad (actor_id, fecha DESC);
-- Auditoría: queries de compliance por tenant
CREATE INDEX idx_auditoria_tenant_fecha
ON auditoria_seguridad (tenant_id, fecha DESC);
El orden de las columnas en los índices compuestos no es arbitrario. En un índice como (tenant_id, actor_id), la primera columna debe ser la que aparece más frecuentemente en las condiciones de búsqueda. Como las queries siempre filtran por organización antes de filtrar por usuario, tenant_id va primero. Los índices DESC en columnas de fecha evitan que la base de datos necesite ordenar los resultados después de buscarlos cuando la query busca los últimos N registros.
Crecimiento progresivo sin rediseños
Una de las frustraciones más comunes al diseñar sistemas IAM es que la arquitectura inicial no anticipa la complejidad futura y eventualmente requiere rediseños que costo tiempo y dinero. Este modelo resuelve esa fricción con una estratificación por niveles de implementación.
El nivel más básico —login con email y contraseña— requiere apenas las tablas de actor, cuenta, credencial, intentos y política de contraseñas. Es suficiente para una aplicación interna pequeña. A partir de ahí, cada nivel agrega las tablas correspondientes sin modificar las existentes: sesiones y MFA para aplicaciones modernas, roles para paneles administrativos, tenants y grupos para plataformas SaaS, y finalmente SSO, SCIM, ABAC y OAuth para entornos enterprise.
Esta progresión no es solo teórica. Cada tabla en el modelo fue diseñada con las relaciones futuras en mente, de modo que agregar el nivel siguiente es una operación aditiva, no una refactorización. La clave que habilita eso es la separación original entre actor y cuenta, y el uso de identificadores inmutables que no cambian independientemente de cuántas tablas se agreguen después.
Líneas futuras y consideraciones de madurez
El modelo que se describe aquí cubre las necesidades de la gran mayoría de aplicaciones desde un login básico hasta un sistema enterprise. Sin embargo, existen áreas donde la complejidad puede crecer más allá de lo que un modelo relacional estándar puede manejar eficientemente.
La primera es la verificación de permisos en tiempo real en sistemas de alta concurrencia. Cuando la cantidad de roles, grupos y políticas ABAC crecen significativamente, la consulta que determina si un actor puede realizar una acción puede volverse costosa. Una línea de investigación viable es implementar una capa de caché —Redis o similar— que almacene las resoluciones de permisos por actor y se invalide cuando ocurren cambios en roles o políticas. La tabla de auditoría puede ser el trigger para esa invalidación.
La segunda área es la escalabilidad horizontal de la tabla de auditoría. Aunque el partitioning por fecha resuelve el problema de crecimiento a mediano plazo, en sistemas con millones de eventos diarios puede ser necesario considerar la migración de datos históricos a un almacenamiento analítico separado —un data warehouse o un sistema de búsqueda como Elasticsearch— mientras que la base relacional retiene solo los datos activos necesarios para la operación.
Finalmente, la evolución hacia modelos de identidad decentralizada —DID, credenciales verificables— es una dirección que la industria está explorando activamente. El modelo actual, al separar la identidad del mecanismo de autenticación desde el principio, tiene la estructura necesaria para adaptarse a esos cambios sin rediseño fundamental. Es otra prueba de que las decisiones arquitectónicas correctas no solo resuelven los problemas de hoy, sino que reducen la fricción de los cambios que vienen después.
Arquitectura DDD y Hexagonal: Construyendo Software para el Futuro
- Mauricio ECR
- Arquitectura
- 05 Jul, 2025
En el dinámico mundo del desarrollo de software, la complejidad es el enemigo silencioso. Las aplicaciones crecen, los requisitos cambian y, sin una guía clara, el código puede convertirse rápidamente
Arquitectura DDD y Hexagonal: Construyendo Software para el Futuro
- Mauricio ECR
- Arquitectura
- 05 Jul, 2025
En el dinámico mundo del desarrollo de software, la complejidad es el enemigo silencioso. Las aplicaciones crecen, los requisitos cambian y, sin una guía clara, el código puede convertirse rápidamente en un laberinto frágil y costoso de mantener. ¿La solución? No es un framework de moda, sino una filosofía de diseño sólida. Esta guía ofrece un mapa detallado para construir software robusto, escalable y, sobre todo, alineado con el negocio, fusionando los principios del Diseño Guiado por el Dominio (DDD), la Arquitectura Limpia (Clean Architecture) y la Arquitectura Hexagonal (Puertos y Adaptadores).
Olvídate de las capas anémicas y el acoplamiento tecnológico. Aquí aprenderás a colocar el corazón de tu negocio —el dominio— en el centro del universo, protegido y aislado de los detalles mundanos de la tecnología. Prepárate para diseñar sistemas donde la lógica de negocio es la reina, la infraestructura es un sirviente intercambiable y el cambio es una oportunidad, no una amenaza.
🎯 Capa de Dominio: El Corazón del Negocio
Esta es la capa más sagrada y protegida de la arquitectura. Su único propósito es encapsular la lógica y las reglas de negocio puras, utilizando el Lenguaje Ubicuo (Ubiquitous Language) del problema que se está resolviendo. Es completamente agnóstica a la tecnología; no debe existir ninguna referencia a frameworks, bases de datos o APIs. En Arquitectura Hexagonal, esta capa es el "hexágono" central, y en Arquitectura Limpia, corresponde a los círculos internos de Entidades y Casos de Uso.
Aquí residen los componentes que modelan el negocio: Agregados, Entidades, Objetos de Valor, Eventos de Dominio, Servicios de Dominio, las interfaces de los Repositorios (que actúan como Puertos) y los Casos de Uso que orquestan toda la lógica.
| Componente | Rol y Responsabilidad Clave |
|---|---|
| Agregado (Aggregate) | Unidad de consistencia transaccional. Agrupa entidades y objetos de valor bajo una raíz (Aggregate Root) que protege las reglas de negocio del clúster. |
| Entidad (Entity) | Objeto con una identidad única que perdura en el tiempo y un ciclo de vida definido. Su identidad es lo que lo define, no sus atributos. |
| Objeto de Valor (VO) | Objeto inmutable definido por sus atributos, sin una identidad propia. Se utiliza para medir, cuantificar o describir cosas (ej. Dinero, FechaRango). |
| Evento de Dominio | Representa un suceso de negocio relevante que ya ha ocurrido. Sirve para comunicar cambios y desacoplar la lógica entre diferentes partes del sistema. |
| Servicio de Dominio | Encapsula lógica de negocio sin estado que no pertenece de forma natural a ninguna entidad u objeto de valor, a menudo coordinando varios de ellos. |
| Repositorio (Puerto) | Define el contrato para persistir y recuperar agregados. Es una interfaz que dicta las necesidades del dominio sin conocer la tecnología subyacente. |
| Caso de Uso | Orquesta el flujo de una operación. Es el punto de entrada a la lógica de dominio, recibiendo datos de entrada y utilizando los puertos para ejecutar la acción. |
🔌 Capa de Infraestructura: El Mundo de la Tecnología
Esta capa contiene todos los detalles técnicos y las implementaciones concretas. Su finalidad es servir como un conjunto de adaptadores que traducen las interacciones del mundo exterior al lenguaje del dominio, y viceversa. Implementa los puertos definidos en la Capa de Dominio, cumpliendo con la sagrada Regla de la Dependencia: la infraestructura siempre depende del dominio. Aquí residen los frameworks web, las conexiones a bases de datos, los clientes de servicios externos y cualquier otra dependencia del mundo real.
Los componentes clave son los Entry-Points (adaptadores que invocan los casos de uso, como controladores de API REST) y los Driven-Adapters (implementaciones de los puertos del dominio, como un repositorio JPA), junto con sus artefactos de apoyo como DTOs, Modelos de Persistencia y Mappers.
| Componente | Rol y Responsabilidad Clave |
|---|---|
| Entry-Point | Adaptador que recibe una señal externa (ej. una petición HTTP, un mensaje de una cola) y la traduce en una llamada a un Caso de Uso. |
| DTO (Data Transfer Object) | Define la estructura de datos para la comunicación externa. Se usa en los Entry-Points para modelar peticiones y respuestas, aislando el dominio. |
| Driven-Adapter | Implementación de un puerto del dominio. Por ejemplo, un repositorio que usa JPA para hablar con una base de datos o un cliente HTTP para consumir otra API. |
| Modelo de Persistencia | Clase que mapea a una estructura de base de datos (ej. una tabla). Es un detalle de implementación del adaptador de persistencia, no es la entidad de dominio. |
| Mapper / Traductor | Utilidad para convertir datos entre capas: DTO ↔ Entidad, Modelo de Persistencia ↔ Entidad. Es el pegamento que permite el desacoplamiento. |
🚀 Capa de Aplicación: El Ensamblador
Esta es la capa más externa y conceptualmente simple. Su única finalidad es ensamblar la aplicación y ponerla en marcha. No contiene lógica de negocio. Es responsable de inicializar el sistema, configurar el contenedor de Inyección de Dependencias (IoC) para conectar las implementaciones de la infraestructura (Driven-Adapters) con las abstracciones del dominio (Puertos), y leer configuraciones externas.
| Componente | Rol y Responsabilidad Clave |
|---|---|
| Contenedor IoC | Configuración de la Inyección de Dependencias. Define "recetas" para construir los objetos, especificando qué Driven-Adapter se debe usar para un Puerto. |
| Configuración | Gestiona los parámetros externos de la aplicación (URLs, credenciales, etc.) a través de archivos (.yml, .properties) o variables de entorno. |
| Punto de Entrada | La clase que contiene el método public static void main(String[] args). Su única función es arrancar el framework y, con él, toda la aplicación. |
📂 Estructura de Módulos Sugerida
Una representación visual de una estructura de módulos (por ejemplo, en Gradle o Maven) que materializa esta arquitectura de forma limpia.
mi-proyecto-escalable/
├── build.gradle.kts
├── settings.gradle.kts # Define los módulos del proyecto
│
├── applications/
│ └── app-service/ # Capa de Aplicación: Ensambla y corre la app
│ └── src/main/java/com/miempresa/app/MainApplication.java
│ └── build.gradle.kts # Depende de 'domain' e 'infrastructure'
│
├── domain/ # Capa de Dominio: Lógica de negocio pura
│ ├── model/ # El modelo: agregados, entidades, VOs, eventos...
│ │ └── src/main/java/com/miempresa/domain/model/producto/Producto.java
│ │ └── src/main/java/com/miempresa/domain/model/producto/gateways/ProductoRepository.java # Puerto (Interfaz)
│ │ └── build.gradle.kts # No tiene dependencias de otras capas
│ └── usecase/ # Los casos de uso que orquestan el modelo
│ └── src/main/java/com/miempresa/domain/usecase/producto/ListarProductosUseCase.java
│ └── build.gradle.kts # Depende de 'domain/model'
│
└── infrastructure/ # Capa de Infraestructura: Detalles tecnológicos
├── entry-points/ # Adaptadores de entrada (ej. API REST)
│ └── rest-api/
│ └── src/main/java/com/miempresa/infrastructure/entrypoints/producto/ProductoController.java
│ └── build.gradle.kts # Depende de 'domain/usecase'
│
└── driven-adapters/ # Adaptadores de salida (ej. Repositorio JPA)
└── jpa-repository/
│ └── src/main/java/com/miempresa/infrastructure/drivenadapters/producto/ProductoData.java # Entidad JPA
│ └── src/main/java/com/miempresa/infrastructure/drivenadapters/producto/ProductoRepositoryAdapter.java # Adaptador
│ └── build.gradle.kts # Depende de 'domain/model'
🗺️ Diagrama de Flujo y Dependencias
Este diagrama ilustra la Regla de la Dependencia (flechas sólidas de dependencia ->) y el Flujo de Control (flechas punteadas de ejecución ...> ). Observa cómo las dependencias siempre apuntan hacia el interior, hacia el dominio, mientras que el flujo de control atraviesa las capas.
+-------------------------------------------------------------------------------------------------+
| Capa de Aplicación (Ensamblador, main) |
+-------------------------------------------------------------------------------------------------+
|
| Inicia y configura
V
+-------------------------------------------------------------------------------------------------+
| Capa de Infraestructura (Adaptadores: REST, DB, etc.) <-- Las flechas de DEPENDENCIA apuntan aquí |
| |
| +---------------+ FLUJO DE CONTROL ...> +--------------------+ |
| | Entry-Point | | Driven-Adapter | |
| | (Controller) | ... ... ... ... ... ... ... ... | (JPA Repository) | |
| +---------------+ . +--------------------+ |
| | ^ . ^ | |
| (Llama) | (Retorna DTO) . (Implementa) | (Habla con DB) |
| | . . . . . | V |
| V | +---------------+ |
| <DEPENDENCIA> <DEPENDENCIA> | Mundo Externo | |
+------+------------------------------------------------------+----------+---------------+-------------+
| |
V V
+-------------------------------------------------------------------------------------------------+
| Capa de Dominio (Lógica de Negocio Pura) <-- TODAS las DEPENDENCIAS apuntan aquí |
| |
| +---------------+ FLUJO DE CONTROL ...> +--------------------+ |
| | Caso de Uso | | Repositorio (Port) | |
| | (Orquestador) | ... ... ... ... ... ... ... ... | (Interfaz) | |
| +---------------+ . +--------------------+ |
| ^ | . |
| | V . |
| | +----------+ |
| +-> | Agregado | <... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...|
| +----------+ |
+-------------------------------------------------------------------------------------------------+
💡 Ejemplo Avanzado: POST /orders (Crear un Pedido)
Veamos cómo fluyen las interacciones en un proceso de negocio real y complejo, como la creación de un nuevo pedido.
1. La Petición del Cliente (Capa de Infraestructura)
- Componente:
OrderController(Entry-Point). - Acción: Recibe una petición
POSTen/orders. Su rol es validar el formato de la petición (usando unPlaceOrderRequestDTO) y delegar inmediatamente al caso de uso correspondiente. No sabe cómo se procesa un pedido, solo a quién llamar. - Artefacto:
PlaceOrderRequestDTO(DTO). Modela el JSON de entrada (customerId,items, etc.). Usa validaciones de framework (@NotNull,@Size) para un rechazo temprano.
2. La Orquestación Central (Capa de Dominio)
- Componente:
PlaceOrderUseCase(Caso de Uso). - Acción: Este es el director de orquesta. No contiene lógica de negocio en sí mismo, pero coordina los pasos en el orden correcto. Su constructor recibe, mediante inyección de dependencias, varios puertos (interfaces):
CustomerRepository,ProductRepository,OrderPricingService,PaymentGateway, yOrderRepository.
3. Recolección y Validación de Negocio (Dominio interactuando con Infraestructura)
- Paso 3.1: Validar Cliente: El caso de uso invoca
customerRepository.findById(customerId). ElCustomerRepositoryAdapter(en infraestructura) lo buscará en la base de datos. Si no existe, el dominio lanza una excepción de negocio (CustomerNotFoundException). - Paso 3.2: Validar Productos y Stock: Para cada ítem, invoca
productRepository.findById(productId). El AgregadoProductrecuperado es responsable de validar sus propias reglas, comoproduct.hasSufficientStock(quantity).
4. Ejecución de Lógica de Negocio Compleja (Dominio)
- Componente:
OrderPricingService(Servicio de Dominio). - Acción: El caso de uso le pasa el cliente y los productos. Este servicio, cuya lógica no encaja en un único agregado, calcula el precio total, aplicando descuentos por lealtad o promociones. Devuelve un Objeto de Valor
Money.
5. Creación del Nuevo Agregado (Dominio)
- Componente:
Order(Agregado Raíz). - Acción: Con todos los datos validados y el precio calculado, el caso de uso invoca un método de fábrica estático:
Order.create(customer, items, totalPrice). El agregadoOrderse crea en un estado inicial válido y registra un evento,OrderPlacedEvent.
6. Interacción con Servicios Externos (Infraestructura)
- Componente:
PaymentGateway(Puertoen el dominio) yStripePaymentAdapter(Driven-Adapteren infraestructura). - Acción: El caso de uso llama a
paymentGateway.processPayment(...). El dominio solo conoce la interfaz. La infraestructura proporciona la implementación concreta (StripePaymentAdapter) que se comunica con la API de Stripe. Si el pago falla, se lanza una excepción que aborta el caso de uso.
7. Persistencia y Efectos Secundarios (Dominio y Infraestructura)
- Paso 7.1: Guardar el Pedido: Si el pago es exitoso, el caso de uso llama a
orderRepository.save(order). ElOrderRepositoryAdapter, usando unOrderDataMapperpara convertir el agregado a unOrderData(entidad JPA), persiste el pedido en la base de datos de forma transaccional. - Paso 7.2: Publicar Evento de Dominio: Tras guardar exitosamente, el caso de uso (o un decorador del repositorio) invoca a un
DomainEventPublisher. Esto despacha elOrderPlacedEventregistrado previamente. Otros módulos del sistema, comoNotificationsoInventory, pueden escuchar este evento y reaccionar de forma totalmente desacoplada (enviar un email, actualizar el stock).
8. La Respuesta Final (Infraestructura)
- Componente:
OrderController(de nuevo). - Acción: Recibe el resultado exitoso del caso de uso (el agregado
Orderrecién creado), lo mapea a unPlaceOrderResponseDTOy devuelve una respuesta201 Createdcon el ID del nuevo pedido.
🏁 Conclusión: Más Allá del Código
Adoptar una arquitectura basada en DDD y Hexagonal no es simplemente organizar carpetas; es un cambio de mentalidad. Nos obliga a dialogar con los expertos del negocio, a modelar la complejidad del mundo real y a proteger esa lógica invaluable de los detalles efímeros de la tecnología.
Los beneficios clave son innegables:
- Testabilidad Superior: La lógica de negocio pura en el dominio puede ser probada unitariamente sin necesidad de frameworks, bases de datos o servidores web.
- Mantenibilidad y Evolución: Cambiar de una base de datos PostgreSQL a MongoDB, o de una API REST a gRPC, se convierte en la tarea de escribir un nuevo adaptador, sin tocar el núcleo del negocio.
- Enfoque en el Negocio: El equipo se centra en resolver problemas de negocio reales, ya que el código refleja directamente el lenguaje y los procesos de la empresa.
- Escalabilidad Organizacional: Diferentes equipos pueden trabajar en distintos adaptadores o módulos del dominio de forma paralela con un bajo riesgo de conflictos.
Esta arquitectura sienta las bases para patrones aún más avanzados como CQRS (Command Query Responsibility Segregation) y Event Sourcing, permitiendo que tus sistemas no solo respondan a las necesidades actuales, sino que estén preparados para prosperar ante los desafíos del futuro.
Kafka 7: Patrones Avanzados y Anti-Patrones con Kafka
- Mauricio ECR
- Arquitectura
- 08 Jun, 2025
Hemos recorrido un camino considerable en nuestra serie sobre Apache Kafka. Desde sus fundamentos y arquitectura interna hasta la interacción con productores y consumidores, las herramientas de proces
Kafka 7: Patrones Avanzados y Anti-Patrones con Kafka
- Mauricio ECR
- Arquitectura
- 08 Jun, 2025
Hemos recorrido un camino considerable en nuestra serie sobre Apache Kafka. Desde sus fundamentos y arquitectura interna hasta la interacción con productores y consumidores, las herramientas de procesamiento de stream y los aspectos críticos de despliegue, seguridad y optimización. Ahora que comprendemos cómo funciona Kafka y cómo operarlo, es momento de elevar la conversación a un nivel más estratégico: cómo diseñar sistemas robustos y resilientes utilizando Kafka y, quizás igual de importante, qué errores comunes debemos evitar.
Kafka, como cualquier tecnología potente, puede ser mal utilizado. Comprender los patrones de diseño que aprovechan sus fortalezas y los anti-patrones que conducen a problemas es crucial para construir arquitecturas basadas en eventos exitosas. Este artículo explorará algunas de las estrategias de diseño más efectivas que los profesionales usan con Kafka y destacará las trampas comunes en las que es fácil caer.
Patrones Avanzados: Aprovechando el Poder de Kafka
Integrar Kafka en arquitecturas de software modernas abre la puerta a patrones de diseño muy potentes que promueven el desacoplamiento, la escalabilidad y la resiliencia.
Event Sourcing + CQRS
Estos dos patrones a menudo van de la mano y encuentran en Kafka un aliado natural:
- Event Sourcing: En lugar de almacenar solo el estado actual de una entidad (como una fila en una base de datos tradicional), el Event Sourcing almacena la secuencia completa de eventos que llevaron a ese estado. Cada cambio en la entidad se registra como un evento inmutable. Kafka, con su naturaleza de log de eventos inmutable y persistente, es el almacén ideal para estos "logs de eventos". Almacenar todos los eventos permite reconstruir el estado de la entidad en cualquier punto del tiempo y proporciona una auditoría completa.
- CQRS (Command Query Responsibility Segregation): Separa el modelo utilizado para actualizar la información (Command side) del modelo utilizado para leer la información (Query side). Los comandos generan eventos que se escriben en Kafka (Event Sourcing). Estos eventos son luego consumidos y procesados por diferentes proyecciones (listeners) para actualizar modelos de lectura optimizados para consultas específicas (ej: una base de datos relacional para reportes, un almacén de documentos para búsqueda). Esta separación permite escalar y optimizar cada lado de forma independiente y responder a diferentes necesidades de lectura y escritura.
Saga Pattern para Microservicios
En una arquitectura de microservicios, las transacciones de negocio a menudo se extienden a través de múltiples servicios. A diferencia de las transacciones ACID en una base de datos monolítica, las transacciones distribuidas en microservicios son complejas y a menudo implican compensaciones. El Saga Pattern es una forma de gestionar la consistencia de datos en transacciones distribuidas.
Una Saga es una secuencia de transacciones locales, donde cada transacción local actualiza la base de datos de un servicio participante y publica un evento. Si una transacción local falla, la Saga ejecuta transacciones de compensación para deshacer los cambios realizados por las transacciones locales anteriores. Kafka sirve como el bus de eventos para coordinar la Saga, publicando eventos de éxito o fallo de las transacciones locales para que otros servicios puedan reaccionar y avanzar o compensar la Saga.
Dead Letter Queues (DLQ) para Manejo de Errores
En sistemas distribuidos, los errores son inevitables. Un consumidor de Kafka puede fallar al procesar un mensaje debido a datos corruptos, un error de lógica en la aplicación, o una dependencia externa no disponible. Si un consumidor simplemente reintenta el mismo mensaje fallido en un bucle, puede detener el procesamiento de la partición (conocido como "poison pill").
Las Dead Letter Queues (DLQ) son un patrón para manejar estos mensajes fallidos de forma elegante. Cuando un consumidor encuentra un mensaje que no puede procesar después de varios reintentos, en lugar de bloquearse, publica ese mensaje (quizás con información adicional sobre el error) en un Topic dedicado a mensajes fallidos: el DLQ. Esto permite:
- El consumidor principal puede continuar procesando otros mensajes de la partición.
- Los mensajes en el DLQ pueden ser inspeccionados manualmente, depurados y, si es posible, reprocesados o descartados.
Anti-Patrones Comunes: Errores a Evitar
Aunque Kafka es muy potente, usarlo incorrectamente puede llevar a problemas de rendimiento, complejidad operativa y fiabilidad. Reconocer y evitar estos anti-patrones es tan importante como aplicar los patrones correctos.
Too Many Partitions (Demasiadas Particiones)
Un error común, especialmente para los recién llegados, es crear un número excesivo de particiones para un Topic, pensando que "más es mejor" para el paralelismo. Sin embargo, un número excesivo de particiones puede:
- Aumentar la Latencia: Más particiones significan más ficheros de log a gestionar por broker, más conexiones TCP, más metadatos para el clúster (ZooKeeper/KRaft), y un mayor impacto durante los rebalanceos.
- Aumentar el Consumo de Recursos: Cada partición tiene un coste de memoria y CPU asociado en los brokers.
- Sobrecarga de Rebalanceo: Un Consumer Group con un gran número de particiones experimentará rebalanceos más lentos y más intensivos en recursos cuando los consumidores se unan o salgan.
- Limitar el Paralelismo del Consumidor: Aunque las particiones permiten paralelismo, un consumidor solo puede leer de una partición a la vez. Si el procesamiento de un solo mensaje es muy rápido, puede que no necesites tantas particiones para saturar a tus consumidores.
// Ejemplo de creación de un topic con un número excesivo de particiones (anti-patrón)
Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
AdminClient adminClient = AdminClient.create(props);
// ¡NO HACER ESTO EN PRODUCCIÓN SIN UNA RAZÓN MUY SÓLIDA!
NewTopic newTopic = new NewTopic("mi-topic-con-demasiadas-particiones", 1000, (short) 3);
adminClient.createTopics(Collections.singleton(newTopic));
Regla General: Empieza con un número de particiones que se ajuste a tus requisitos de paralelismo iniciales y a la capacidad de tus brokers (ej: 10-20 particiones por broker). Puedes añadir más particiones más tarde (aunque no eliminarlas fácilmente).
Ignorar el Rebalanceo
El rebalanceo de Consumer Groups es una parte normal del funcionamiento de Kafka, pero ignorar sus implicaciones es un anti-patrón. Un rebalanceo ocurre cuando:
- Un consumidor se une o sale del grupo.
- Un consumidor deja de enviar "heartbeats" (latidos) al broker (por ejemplo, debido a un fallo o una pausa GC prolongada).
- Se añade una nueva partición a un Topic al que el grupo está suscrito.
Durante un rebalanceo, los consumidores dejan de procesar mensajes mientras se reasignan las particiones. Un rebalanceo frecuente o de larga duración puede:
- Impactar la Latencia: Introducir pausas en el procesamiento de mensajes.
- Aumentar la Complejidad Operacional: Dificultar la depuración de problemas.
- Causar Problemas de Disponibilidad: Si el rebalanceo es inestable, los consumidores pueden estar constantemente en proceso de reasignación.
// Configuración de un consumidor de Kafka para manejar el rebalanceo
Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
props.put("group.id", "mi-grupo-consumidor");
props.put("enable.auto.commit", "false"); // Mejor control del commit de offsets
props.put("session.timeout.ms", "10000"); // Aumentar si las pausas GC son un problema
props.put("heartbeat.interval.ms", "3000"); // Debe ser menor que session.timeout.ms
// props.put("group.instance.id", "instancia-unica-1"); // Para static membership
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(Collections.singletonList("mi-topic"));
// Implementar un ConsumerRebalanceListener para manejar el rebalanceo
consumer.subscribe(Collections.singletonList("my-topic"), new ConsumerRebalanceListener() {
@Override
public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
// Commitear offsets antes de que las particiones sean revocadas
consumer.commitSync();
}
@Override
public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
// Opcional: buscar un offset específico si es necesario
}
});
Solución: Monitoriza la frecuencia y duración de los rebalanceos. Ajusta el session.timeout.ms y heartbeat.interval.ms de los consumidores. Considera usar Static Membership (group.instance.id) para consumidores que se reinician con frecuencia, como vimos en el Artículo 3. Asegúrate de que los consumidores commiteen offsets de forma manual y atómica para evitar duplicados masivos o pérdida de datos durante los rebalanceos.
No Planear la Retención de Datos
Kafka es un log de eventos persistente, no una base de datos eterna por defecto. Un anti-patrón es no planificar adecuadamente la política de retención de datos en los Topics (log.retention.ms o log.retention.bytes).
Si no se configura la retención o se establece a un valor muy alto (ej: infinito), los datos se acumularán indefinidamente en los brokers, lo que puede llevar a:
- Agotamiento de Espacio en Disco: Una causa común de fallos en el clúster.
- Impacto en el Rendimiento: Más datos en disco pueden ralentizar operaciones como la recuperación de brokers.
- Aumento de Costos: Especialmente en la nube.
# Ejemplo de configuración de retención en un Topic (Kafka CLI)
# Retención de 7 días (604800000 ms)
kafka-topics.sh --bootstrap-server localhost:9092 \
--alter --topic mi-topic \
--config retention.ms=604800000
# Retención de 10 GB
kafka-topics.sh --bootstrap-server localhost:9092 \
--alter --topic mi-topic \
--config retention.bytes=10737418240
# Para Topics compactados (log.cleanup.policy=compact)
kafka-topics.sh --bootstrap-server localhost:9092 \
--alter --topic mi-topic-compactado \
--config cleanup.policy=compact
Solución: Entiende los requisitos de tu aplicación para la retención de datos. La mayoría de los Topics pueden tener una retención corta (días o semanas). Si necesitas datos históricos a largo plazo, considera transferirlos a un almacén de datos más adecuado (data lake, data warehouse) utilizando Kafka Connect o Kafka Streams. Para Topics compactados (donde solo se mantiene el último valor por clave), asegúrate de que tus claves de mensajes sean apropiadas para la compactación.
Conclusión
Hemos llegado al final de nuestra exploración de los patrones avanzados y anti-patrones comunes en el uso de Apache Kafka. Entender cómo implementar patrones como Event Sourcing, CQRS y Saga Pattern con Kafka te permite construir sistemas distribuidos mucho más potentes y resilientes. Al mismo tiempo, reconocer y evitar errores como el exceso de particiones, la negligencia del rebalanceo o la falta de planificación de la retención, te ayudará a mantener un clúster de Kafka saludable y eficiente.
La clave para el éxito con Kafka no solo reside en comprender sus componentes, sino en aplicarlos con sabiduría de diseño. Con estos patrones y anti-patrones en mente, estás mejor equipado para tomar decisiones arquitectónicas sólidas y evitar escollos comunes. En nuestro artículo final, miraremos hacia el horizonte: las tendencias y el futuro de Kafka, incluyendo el impacto de KRaft, la integración con otras tecnologías de procesamiento de stream y su papel emergente en el edge computing.
Spring WebFlux 4: Comunicación Avanzada, Pruebas y Producción
- Mauricio ECR
- Arquitectura
- 31 May, 2025
La serie Spring WebFlux nos ha llevado a través de un viaje fascinante por el mundo de la programación reactiva, desde sus fundamentos y el poder de Project Reactor hasta la construcción de arquit
Spring WebFlux 4: Comunicación Avanzada, Pruebas y Producción
- Mauricio ECR
- Arquitectura
- 31 May, 2025
La serie Spring WebFlux nos ha llevado a través de un viaje fascinante por el mundo de la programación reactiva, desde sus fundamentos y el poder de Project Reactor hasta la construcción de arquitecturas altamente concurrentes y la gestión de la comunicación con servicios externos y bases de datos. En esta cuarta parte, profundizaremos en aspectos más avanzados y críticos para el desarrollo y despliegue de aplicaciones WebFlux robustas y eficientes. Exploraremos desde la comunicación en tiempo real con Server-Sent Events y WebSockets, hasta la crucial gestión de la contrapresión, el contexto reactivo, las estrategias de testing y, por supuesto, la seguridad y las buenas prácticas en producción.
1. Server-Sent Events (SSE): Flujos de Eventos Unidireccionales
Los Server-Sent Events (SSE) son una tecnología web que permite a un servidor enviar actualizaciones automáticamente a un cliente a través de una conexión HTTP persistente y unidireccional. A diferencia de los WebSockets, que son bidireccionales y más complejos, los SSE están diseñados específicamente para escenarios donde el cliente solo necesita recibir datos del servidor. Piensa en ellos como un flujo continuo de noticias, actualizaciones de cotizaciones bursátiles o notificaciones en tiempo real.
¿Cómo funcionan los SSE?
El cliente establece una conexión HTTP normal con el servidor. Sin embargo, en lugar de cerrar la conexión después de enviar la respuesta inicial, el servidor la mantiene abierta y envía datos de forma continua. Cada "evento" se envía como un bloque de texto formateado de una manera específica, seguido de un salto de línea. El navegador o cliente (usando la API EventSource de JavaScript) interpreta estos bloques como eventos individuales.
SSE con Spring WebFlux
En Spring WebFlux, implementar SSE es sorprendentemente sencillo gracias a la naturaleza reactiva de Flux. Dado que un Flux puede emitir 0 a N elementos de forma asíncrona, es la elección natural para representar un flujo de eventos.
Para enviar eventos, simplemente necesitas devolver un Flux desde tu controlador. Spring WebFlux se encargará automáticamente de configurar los encabezados HTTP (Content-Type: text/event-stream) y formatear los datos para que el cliente los reciba como SSE.
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
import java.time.Duration;
import java.time.LocalDateTime;
@RestController
public class SseController {
@GetMapping(value = "/eventos", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> getEvents() {
return Flux.interval(Duration.ofSeconds(1)) // Emite un elemento cada segundo
.map(sequence -> "Evento #" + sequence + " a las " + LocalDateTime.now());
}
@GetMapping(value = "/data-stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<MyData> streamMyData() {
return Flux.interval(Duration.ofSeconds(2))
.map(sequence -> new MyData("Item " + sequence, Math.random() * 100))
.take(5); // Limita el número de elementos
}
}
En este ejemplo:
getEvents()envía una cadena de texto cada segundo.streamMyData()envía objetosMyData(que se serializarán a JSON automáticamente) cada dos segundos, limitando la emisión a 5 elementos.
Del lado del cliente (JavaScript):
const eventSource = new EventSource('/eventos');
eventSource.onmessage = function(event) {
console.log("Mensaje recibido:", event.data);
};
eventSource.onerror = function(error) {
console.error("Error en el flujo de eventos:", error);
eventSource.close();
};
// Si el servidor envía eventos con un 'event' type específico:
// eventSource.addEventListener('nombreDeEvento', function(event) {
// console.log("Evento con nombre específico:", event.data);
// });
Los SSE son ideales para dashboards en tiempo real, feeds de actividad o cualquier escenario donde se necesiten actualizaciones push del servidor sin la complejidad de una conexión bidireccional completa.
2. Backpressure: Gestionando el Flujo de Datos
El concepto de backpressure (contrapresión) es fundamental en la programación reactiva y, en particular, en Project Reactor y Spring WebFlux. Se refiere a la capacidad de un suscriptor (consumidor) de señalar a un publicador (productor) qué tan rápido o cuántos elementos puede procesar. En un flujo reactivo, si el productor es mucho más rápido que el consumidor, los datos se acumularán en el buffer del consumidor, lo que puede llevar a problemas de memoria o a la caída del sistema. La contrapresión resuelve esto permitiendo que el consumidor "tire" de los datos solo cuando está listo para manejarlos.
¿Por qué es crucial la contrapresión?
Imagina un río (el publicador) que fluye muy rápido hacia un balde (el suscriptor) que solo puede contener una pequeña cantidad de agua a la vez. Sin contrapresión, el balde se desbordaría rápidamente. Con contrapresión, el balde puede indicarle al río que disminuya el caudal o que le envíe agua solo cuando haya espacio.
En el contexto de Spring WebFlux, la contrapresión es vital para la estabilidad y eficiencia del sistema. Evita que un servicio backend sobrecargue a un cliente más lento (como un navegador o una API externa con límites de tasa) o que una base de datos reactiva inunde el servicio con resultados que no puede procesar a tiempo.
Implementación en Reactor
Project Reactor implementa la contrapresión según las especificaciones de Reactive Streams. Esto significa que los operadores de Mono y Flux manejan la contrapresión de forma nativa. Cuando un Subscriber se suscribe a un Publisher, lo primero que hace es solicitar un número inicial de elementos. Luego, a medida que procesa esos elementos, puede solicitar más (request(n)).
import reactor.core.publisher.Flux;
import org.reactivestreams.Subscription;
import org.reactivestreams.Subscriber;
public class BackpressureExample {
public static void main(String[] args) {
Flux.range(1, 100) // Publicador que emite 100 números
.subscribe(new Subscriber<Integer>() {
private Subscription s;
private int count = 0;
@Override
public void onSubscribe(Subscription s) {
this.s = s;
System.out.println("Suscrito. Solicitando 2 elementos.");
s.request(2); // Solicita inicialmente 2 elementos
}
@Override
public void onNext(Integer integer) {
System.out.println("Procesando: " + integer);
count++;
if (count % 2 == 0) { // Después de procesar 2 elementos, solicita 2 más
System.out.println("Procesados 2. Solicitando 2 más.");
s.request(2);
}
}
@Override
public void onError(Throwable t) {
System.err.println("Error: " + t);
}
@Override
public void onComplete() {
System.out.println("Completado.");
}
});
}
}
En este ejemplo simplificado, el Subscriber controla la velocidad de emisión al solicitar solo dos elementos a la vez. Este mecanismo es transparente en la mayoría de los casos cuando usas operadores de Reactor, pero es crucial entender que está ocurriendo "bajo el capó" para un comportamiento predecible y robusto.
3. Contexto Reactivo: Compartiendo Información
En la programación tradicional, ThreadLocal se utiliza comúnmente para compartir información a través de diferentes métodos en el mismo hilo de ejecución, como el contexto de seguridad o un ID de correlación para logging. Sin embargo, en un entorno reactivo y no bloqueante como Spring WebFlux, donde las operaciones pueden cambiar de hilo de forma asíncrona, ThreadLocal ya no es una opción viable porque la información se perdería entre los cambios de hilo.
Aquí es donde entra el Contexto Reactivo (Context) de Project Reactor. El Context es una característica que permite adjuntar datos a un flujo reactivo, haciéndolos disponibles para cualquier operador o suscriptor a lo largo de la cadena, independientemente de qué hilo esté ejecutando la operación.
¿Cómo funciona el Contexto Reactivo?
Cada flujo Mono o Flux tiene asociado un Context. Este Context es una estructura de datos inmutable (similar a un Map) que se propaga a lo largo de la cadena de operadores. Cuando un operador necesita acceder a información del contexto, puede hacerlo a través de métodos como contextWrite().
import reactor.core.publisher.Mono;
import reactor.core.publisher.Flux;
import reactor.util.context.Context;
public class ReactiveContextExample {
public static void main(String[] args) {
String correlationId = "corr-123";
Mono<String> dataMono = Mono.just("Hello")
.doOnNext(s -> {
// Acceder al contexto para obtener el correlationId
Mono.deferContextual(ctx -> {
String id = ctx.get("correlationId");
System.out.println("doOnNext: Data = " + s + ", Correlation ID from Context = " + id);
return Mono.empty();
}).subscribe(); // Suscribirse para activar el deferContextual
})
.contextWrite(Context.of("correlationId", correlationId)); // Escribir en el contexto
dataMono.subscribe(
data -> System.out.println("Subscriber: Data = " + data),
error -> System.err.println("Subscriber Error: " + error),
() -> System.out.println("Subscriber: Completed")
);
System.out.println("\n--- Otro ejemplo con Flux y múltiples valores ---");
Flux.just("Item A", "Item B")
.contextWrite(Context.of("traceId", "trace-xyz")) // Escribir en el contexto
.flatMap(item ->
Mono.deferContextual(ctx -> {
String traceId = ctx.get("traceId");
return Mono.just("Procesando " + item + " con Trace ID: " + traceId);
})
)
.subscribe(
result -> System.out.println("Subscriber: " + result),
error -> System.err.println("Subscriber Error: " + error)
);
}
}
Aplicaciones Comunes del Contexto Reactivo
- Propagación de IDs de Correlación/Traza: Esencial para el logging distribuido y la observabilidad. Puedes insertar un ID de correlación al inicio del flujo y que esté disponible en cada operador y en la capa de persistencia.
- Contexto de Seguridad: Información del usuario autenticado, roles, permisos.
- Parámetros de Configuración Dinámicos: Valores que pueden variar por solicitud pero que no son parte de la carga útil principal.
- Datos Transaccionales: Si bien Spring Data R2DBC maneja las transacciones reactivas, el contexto podría usarse para almacenar metadatos relacionados con la transacción.
El Context proporciona una forma segura y reactiva de pasar información a través de los límites de los hilos, manteniendo la integridad del flujo de datos.
4. Testing Reactivo: Garantizando la Robustez
Probar aplicaciones reactivas requiere un enfoque ligeramente diferente al de las aplicaciones síncronas debido a la naturaleza asíncrona y no bloqueante de los flujos. Spring WebFlux y Project Reactor ofrecen herramientas poderosas para facilitar este proceso, asegurando que tus flujos de datos se comporten como esperas.
TestUtils de Reactor: StepVerifier
La herramienta más importante para probar flujos Mono y Flux es StepVerifier de Project Reactor. Permite probar secuencias reactivas de manera determinista, verificando los valores emitidos, los errores y la finalización, e incluso simulando el tiempo para probar operadores basados en tiempo.
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import java.time.Duration;
class ReactiveTestingExample {
// Prueba de un Mono simple
@Test
void testMono() {
Mono<String> mono = Mono.just("Hello Reactive!");
StepVerifier.create(mono)
.expectNext("Hello Reactive!") // Espera un valor específico
.expectComplete() // Espera que el flujo se complete
.verify(); // Inicia la verificación
}
// Prueba de un Flux con múltiples elementos
@Test
void testFlux() {
Flux<Integer> flux = Flux.just(1, 2, 3);
StepVerifier.create(flux)
.expectNext(1)
.expectNext(2)
.expectNext(3)
.expectComplete()
.verify();
}
// Prueba de un Flux con un error
@Test
void testFluxWithError() {
Flux<String> flux = Flux.just("data1", "data2")
.concatWith(Mono.error(new RuntimeException("Oops!")));
StepVerifier.create(flux)
.expectNext("data1", "data2")
.expectError(RuntimeException.class) // Espera un error de tipo RuntimeException
.verify();
}
// Prueba de un Flux con retardo (simulando tiempo)
@Test
void testFluxWithDelay() {
Flux<Long> flux = Flux.interval(Duration.ofSeconds(1)).take(3);
StepVerifier.withVirtualTime(() -> flux) // Usa tiempo virtual para acelerar la prueba
.expectSubscription()
.expectNoEvent(Duration.ofSeconds(1)) // No espera eventos por 1 segundo
.expectNext(0L)
.thenAwait(Duration.ofSeconds(1)) // Avanza el tiempo virtual 1 segundo
.expectNext(1L)
.thenAwait(Duration.ofSeconds(1))
.expectNext(2L)
.expectComplete()
.verify();
}
}
StepVerifier ofrece una API fluida y encadenable para definir las expectativas sobre el flujo. withVirtualTime() es particularmente útil para probar operadores basados en tiempo sin tener que esperar el tiempo real, acelerando significativamente las pruebas.
Testing de Controladores WebFlux
Para probar controladores WebFlux, puedes usar WebTestClient. Este cliente no bloqueante permite realizar solicitudes HTTP simuladas a tu aplicación WebFlux y verificar las respuestas reactivas. Es ideal para pruebas de integración o de slice.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.reactive.server.WebTestClient;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import static org.mockito.Mockito.when;
@WebFluxTest(MyReactiveController.class) // Especifica el controlador a probar
class MyReactiveControllerTest {
@Autowired
private WebTestClient webTestClient; // Cliente para realizar solicitudes HTTP
@MockBean // Simula dependencias del controlador
private MyReactiveService myReactiveService;
@Test
void testGetHello() {
when(myReactiveService.getHelloMessage()).thenReturn(Mono.just("Hello from Service!"));
webTestClient.get().uri("/hello")
.exchange() // Realiza la solicitud
.expectStatus().isOk() // Verifica el código de estado HTTP
.expectBody(String.class).isEqualTo("Hello from Service!"); // Verifica el cuerpo de la respuesta
}
@Test
void testGetAllItems() {
when(myReactiveService.getAllItems()).thenReturn(Flux.just("Item1", "Item2"));
webTestClient.get().uri("/items")
.exchange()
.expectStatus().isOk()
.expectBodyList(String.class).containsExactly("Item1", "Item2"); // Verifica una lista de elementos
}
}
En este ejemplo:
@WebFluxTestconfigura un contexto de aplicación limitado para probar solo el controlador especificado.@MockBeanpermite simular las dependencias del controlador, lo que es crucial para aislar la lógica del controlador.WebTestClientsimula las solicitudes HTTP y permite verificar la respuesta de manera reactiva.
Combinando StepVerifier para la lógica reactiva de negocio y WebTestClient para las interacciones HTTP, puedes construir un conjunto de pruebas robusto para tus aplicaciones Spring WebFlux.
5. Seguridad en Aplicaciones WebFlux (Spring Security Reactivo)
La seguridad es un pilar fundamental en cualquier aplicación, y las aplicaciones reactivas no son la excepción. Spring Security Reactivo proporciona una integración fluida con Spring WebFlux, ofreciendo un modelo de seguridad no bloqueante que se adapta perfectamente al paradigma reactivo. A diferencia del Spring Security tradicional, que se basa en ThreadLocal y filtros de Servlet, la versión reactiva opera con Mono y Flux para mantener la reactividad de principio a fin.
Componentes Clave de Spring Security Reactivo
SecurityWebFilterChain: Reemplaza alFilterChainde Servlets y define la cadena de filtros de seguridad reactivos.ReactiveUserDetailsService: Para cargar detalles del usuario de forma reactiva.ReactiveAuthenticationManager: Para autenticar usuarios de forma reactiva.SecurityContextRepository: Para guardar y cargar el contexto de seguridad (ej. para sesiones o JWT).
Configuración Básica
Para habilitar Spring Security Reactivo, necesitas añadir la dependencia spring-boot-starter-security y configurar tu SecurityWebFilterChain.
// build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'org.springframework.boot:spring-boot-starter-security'
// ...
}
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.core.userdetails.MapReactiveUserDetailsService;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.server.SecurityWebFilterChain;
@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {
@Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
return http
.csrf(ServerHttpSecurity.CsrfSpec::disable) // Deshabilita CSRF para APIs sin estado
.authorizeExchange(exchanges -> exchanges
.pathMatchers("/public/**").permitAll() // Rutas públicas accesibles sin autenticación
.pathMatchers("/admin/**").hasRole("ADMIN") // Rutas solo para ADMIN
.anyExchange().authenticated() // Todas las demás rutas requieren autenticación
)
.httpBasic(httpBasic -> httpBasic.init(http)) // Habilita autenticación HTTP Basic
.formLogin(formLogin -> formLogin.disable()) // Deshabilita el formulario de login por defecto
.build();
}
@Bean
public MapReactiveUserDetailsService userDetailsService(PasswordEncoder passwordEncoder) {
UserDetails user = User.withUsername("user")
.password(passwordEncoder.encode("password"))
.roles("USER")
.build();
UserDetails admin = User.withUsername("admin")
.password(passwordEncoder.encode("adminpass"))
.roles("ADMIN")
.build();
return new MapReactiveUserDetailsService(user, admin);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
En este ejemplo:
- Deshabilitamos CSRF (común para APIs RESTful sin estado).
- Definimos reglas de autorización para diferentes rutas (
/publices accesible por todos,/adminsolo por usuarios con rolADMIN). - Configuramos
HTTP Basicpara la autenticación simple. - Se define un
MapReactiveUserDetailsServicepara usuarios en memoria, aunque en un entorno real se usaría una base de datos reactiva.
Accediendo al Usuario Autenticado
En Spring WebFlux, puedes acceder al usuario autenticado usando Mono<Principal> o Mono<Authentication> en tus controladores o servicios.
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
import java.security.Principal;
@RestController
public class SecuredController {
@GetMapping("/secure/user-info")
public Mono<String> getUserInfo(Mono<Principal> principalMono) {
return principalMono.map(principal -> "Hola, " + principal.getName() + "! Eres un usuario autenticado.");
}
@GetMapping("/admin/dashboard")
public Mono<String> getAdminDashboard(Mono<Authentication> authenticationMono) {
return authenticationMono.map(auth -> "Bienvenido al Dashboard de Admin, " + auth.getName() + "! Roles: " + auth.getAuthorities());
}
}
Uso de JWT (JSON Web Tokens)
Para aplicaciones sin estado, el uso de JWT es una práctica común. Spring Security Reactivo facilita la implementación de autenticación basada en JWT. Generalmente, esto implica:
- Un endpoint de login que recibe credenciales y devuelve un JWT.
- Un filtro de seguridad que intercepta las solicitudes, valida el JWT en el encabezado
Authorizationy construye unAuthenticationreactivo.
Puedes crear tu propio ServerWebExchangeMatcher y ServerAuthenticationConverter para procesar el token y autenticar al usuario sin necesidad de sesiones.
Spring Security Reactivo se integra perfectamente con el modelo de programación reactiva, asegurando que tus mecanismos de seguridad no introduzcan bloqueos o cuellos de botella en tus aplicaciones de alto rendimiento.
6. WebSockets con WebFlux: Comunicación Bidireccional en Tiempo Real
Mientras que Server-Sent Events (SSE) son excelentes para la comunicación unidireccional del servidor al cliente, las aplicaciones que requieren comunicación bidireccional en tiempo real, como chats, juegos en línea o herramientas de colaboración, necesitan WebSockets. WebSockets proporcionan un canal de comunicación dúplex completo a través de una única conexión TCP. Spring WebFlux ofrece un soporte robusto y reactivo para WebSockets.
¿Cómo funcionan los WebSockets?
A diferencia de HTTP, que es de corta duración y sin estado, los WebSockets comienzan con un handshake HTTP. Una vez que este handshake es exitoso, la conexión se "actualiza" a un protocolo WebSocket, permaneciendo abierta indefinidamente. Esto permite que tanto el cliente como el servidor envíen mensajes de forma asíncrona en cualquier momento.
WebSockets con Spring WebFlux
Spring WebFlux proporciona una API funcional para manejar WebSockets, aprovechando Flux y Mono para la gestión de mensajes reactivos.
WebSocketHandler: Es la interfaz principal que implementas para manejar la lógica de la conexión WebSocket. El métodohandlerecibe unWebSocketSessionque te permite enviar y recibir mensajes.WebSocketHandlerAdapterySimpleUrlHandlerMapping: Estos beans son necesarios para mapear las URLs a tusWebSocketHandlers específicos.
Configuración de WebSocket
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.handler.SimpleUrlHandlerMapping;
import org.springframework.web.reactive.socket.WebSocketHandler;
import org.springframework.web.reactive.socket.server.WebSocketService;
import org.springframework.web.reactive.socket.server.support.HandshakeWebSocketService;
import org.springframework.web.reactive.socket.server.support.WebSocketHandlerAdapter;
import org.springframework.web.reactive.socket.server.upgrade.ReactorNettyRequestUpgradeStrategy;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
@Configuration
public class WebSocketConfig {
@Bean
public SimpleUrlHandlerMapping webSocketHandlerMapping(WebSocketHandler echoHandler) {
Map<String, WebSocketHandler> map = new HashMap<>();
map.put("/echo", echoHandler); // Mapea /echo a nuestro handler
map.put("/time-stream", new TimeStreamWebSocketHandler()); // Otro handler
return new SimpleUrlHandlerMapping(map);
}
@Bean
public WebSocketHandlerAdapter handlerAdapter(WebSocketService webSocketService) {
return new WebSocketHandlerAdapter(webSocketService);
}
@Bean
public WebSocketService webSocketService() {
// Usa Reactor Netty por defecto, que es el servidor webflux por defecto
return new HandshakeWebSocketService(new ReactorNettyRequestUpgradeStrategy());
}
@Bean
public WebSocketHandler echoHandler() {
return session -> session.send(
session.receive() // Recibe mensajes del cliente
.doOnNext(message -> System.out.println("Received: " + message.getPayloadAsText()))
.map(message -> session.textMessage("ECHO: " + message.getPayloadAsText())) // Eco de vuelta
).and(session.receive()
.doOnError(throwable -> System.err.println("Error en la conexión WebSocket: " + throwable.getMessage()))
.then()); // Mantener la conexión abierta hasta que se complete o haya un error
}
}
Creando un WebSocketHandler
Aquí tienes un ejemplo de un WebSocketHandler que envía la hora actual cada segundo:
// En un archivo separado o como inner class
import org.springframework.web.reactive.socket.WebSocketHandler;
import org.springframework.web.reactive.socket.WebSocketMessage;
import org.springframework.web.reactive.socket.WebSocketSession;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.Duration;
import java.time.LocalDateTime;
public class TimeStreamWebSocketHandler implements WebSocketHandler {
@Override
public Mono<Void> handle(WebSocketSession session) {
// Envía un mensaje cada segundo al cliente
Flux<WebSocketMessage> output = Flux.interval(Duration.ofSeconds(1))
.map(value -> session.textMessage("Current Time: " + LocalDateTime.now()));
// Mantén la conexión abierta para recibir mensajes (aunque este handler no los procese)
// La conexión se cierra cuando el Mono<Void> retornado se completa
return session.send(output)
.and(session.receive() // Esto es importante para mantener la conexión abierta
.doOnNext(message -> System.out.println("Received from client on time stream: " + message.getPayloadAsText()))
.then()); // No hacemos nada con los mensajes recibidos aquí, solo los logueamos
}
}
Cliente JavaScript para WebSockets
const ws = new WebSocket('ws://localhost:8080/echo'); // Para el handler de eco
ws.onopen = function(event) {
console.log("Conectado al WebSocket!");
ws.send("Hola desde el cliente!");
};
ws.onmessage = function(event) {
console.log("Mensaje recibido del servidor:", event.data);
};
ws.onclose = function(event) {
console.log("Conexión WebSocket cerrada:", event.code, event.reason);
};
ws.onerror = function(error) {
console.error("Error WebSocket:", error);
};
// Para enviar más mensajes:
// ws.send("Otro mensaje...");
// Para el handler de tiempo:
// const wsTime = new WebSocket('ws://localhost:8080/time-stream');
// wsTime.onmessage = function(event) {
// console.log("Tiempo recibido:", event.data);
// };
WebSockets con WebFlux te permiten construir aplicaciones de comunicación en tiempo real altamente eficientes, aprovechando la capacidad de Spring para manejar flujos de datos reactivos de forma nativa.
7. Buenas Prácticas en Producción para Aplicaciones WebFlux
Desarrollar una aplicación WebFlux es solo una parte del desafío; desplegarla y mantenerla en producción requiere atención a varias buenas prácticas para asegurar su rendimiento, estabilidad y observabilidad.
1. Monitoreo y Observabilidad
Las aplicaciones reactivas pueden ser más difíciles de depurar sin las herramientas adecuadas debido a la naturaleza asíncrona y la transición de hilos.
- Métricas (Micrometer/Prometheus): Spring Boot Actuator, combinado con Micrometer, facilita la exposición de métricas (JVM, WebFlux, Reactor, etc.) que pueden ser recolectadas por sistemas como Prometheus y visualizadas en Grafana. Monitorea la latencia, el rendimiento del Event Loop, el uso de memoria y el número de conexiones activas.
- Logging (Structured Logging): Utiliza un sistema de logging que soporte logging estructurado (ej. SLF4J con Logback configurado para JSON) para facilitar el análisis con herramientas como ELK Stack (Elasticsearch, Logstash, Kibana) o Grafana Loki.
- APM (Application Performance Monitoring): Herramientas como Dynatrace, New Relic o AppDynamics pueden proporcionar visibilidad profunda en el rendimiento de tu aplicación, incluyendo la trazabilidad de transacciones a través de hilos y servicios.
- Tracing (Brave/OpenTelemetry): Implementa Distributed Tracing (ej. con Spring Cloud Sleuth y Zipkin/Jaeger) para seguir el rastro de una solicitud a través de múltiples servicios, especialmente crucial en arquitecturas de microservicios reactivos.
2. Gestión de Recursos
- Connection Pooling: Asegúrate de que tus conexiones a bases de datos reactivas (R2DBC, MongoDB reactive drivers) o a otros servicios externos (WebClient) utilicen connection pooling para evitar la sobrecarga y el agotamiento de recursos.
- Timeouts: Configura timeouts apropiados en
WebClienty en tus servidores para evitar que las solicitudes de larga duración o los servicios externos lentos bloqueen los recursos del Event Loop. - Límites de Conexión: Establece límites de conexión adecuados en tus servidores (Netty, Undertow) para prevenir la sobrecarga.
3. Contrapresión Efectiva
Aunque Reactor maneja la contrapresión de forma nativa, es crucial entender cuándo y cómo se aplica, especialmente al integrar con sistemas que no son reactivos o que no la soportan. Asegúrate de que tus flujos de datos estén diseñados para manejar el backpressure correctamente para evitar la sobrecarga del consumidor.
4. Seguridad
- Principio de Mínimo Privilegio: Asegúrate de que tu aplicación solo tenga los permisos necesarios para realizar sus funciones.
- Secret Management: No guardes credenciales directamente en el código o en archivos de configuración. Utiliza soluciones de gestión de secretos como HashiCorp Vault, AWS Secrets Manager o Kubernetes Secrets.
- Actualizaciones y Parches: Mantén tus dependencias de Spring Boot, Spring Security y Reactor actualizadas para beneficiarte de las últimas correcciones de seguridad.
- HTTPS: Siempre utiliza HTTPS en producción para asegurar la comunicación cliente-servidor.
5. Configuración y Despliegue
- Externalización de la Configuración: Utiliza Spring Cloud Config Server, o simplemente
application.properties/application.ymlcon perfiles, y variables de entorno para gestionar la configuración de forma externa al artefacto de despliegue. - Contenedores (Docker/Kubernetes): Empaquetar tu aplicación en un contenedor Docker facilita el despliegue, la escalabilidad y la gestión de dependencias en entornos como Kubernetes.
- Liveness y Readiness Probes: En Kubernetes, configura Liveness y Readiness Probes para que el orquestador pueda saber cuándo tu aplicación está saludable y lista para recibir tráfico. Spring Boot Actuator proporciona endpoints
/actuator/healthque son perfectos para esto. - Escalabilidad: Las aplicaciones WebFlux son inherentemente escalables horizontalmente. Asegúrate de que tu infraestructura de despliegue (Kubernetes, balanceadores de carga) pueda escalar tu aplicación de manera eficiente.
6. Pruebas de Carga y Rendimiento
Realiza pruebas de carga exhaustivas para simular escenarios de alto tráfico y verificar cómo se comporta tu aplicación WebFlux bajo presión. Esto te ayudará a identificar cuellos de botella y a optimizar la configuración.
7. Manejo de Errores Robustos
- ErrorWebExceptionHandler: Asegúrate de tener un
ErrorWebExceptionHandlerglobal bien configurado para manejar excepciones no capturadas y proporcionar respuestas de error consistentes y amigables para el cliente, sin exponer detalles internos. - Circuit Breakers: Implementa patrones de Circuit Breaker (ej. con Resilience4j) al interactuar con servicios externos para evitar cascadas de fallos cuando un servicio dependiente no está disponible o es lento.
Al seguir estas buenas prácticas, puedes asegurar que tus aplicaciones Spring WebFlux no solo sean rápidas y eficientes en desarrollo, sino también robustas, seguras y fáciles de operar en producción.
Conclusión
En esta cuarta entrega de nuestra serie sobre Spring WebFlux, hemos explorado características avanzadas y cruciales que elevan el desarrollo de aplicaciones reactivas. Desde la implementación de Server-Sent Events (SSE) para flujos de datos unidireccionales hasta la robusta comunicación WebSocket para interacciones bidireccionales en tiempo real, hemos visto cómo Spring WebFlux simplifica la construcción de aplicaciones de tiempo real.
Hemos profundizado en la importancia de la contrapresión (backpressure), un mecanismo vital para garantizar la estabilidad del sistema al permitir que los consumidores controlen el flujo de datos. La gestión del contexto reactivo se ha revelado como una solución elegante para compartir información a través de los límites de los hilos en un entorno asíncrono, mientras que las herramientas de testing reactivo como StepVerifier y WebTestClient demuestran ser indispensables para asegurar la corrección de nuestros flujos. Finalmente, abordamos la integración de Spring Security Reactivo para asegurar nuestras aplicaciones de forma no bloqueante y delineamos un conjunto de buenas prácticas para la producción, fundamentales para el monitoreo, la estabilidad y la escalabilidad de nuestras aplicaciones WebFlux.
Esta serie ha cubierto los pilares esenciales para construir aplicaciones reactivas de alto rendimiento con Spring WebFlux. Con una base sólida en fundamentos, arquitectura, comunicación de datos, seguridad, pruebas y consideraciones de producción, estás bien preparado para enfrentar desafíos reales y llevar tus aplicaciones reactivas al siguiente nivel.
Como continuación natural de este camino, te recomendamos explorar algunas áreas complementarias que potenciarán aún más tus habilidades en entornos reactivos:
- R2DBC a profundidad: Explora la integración con bases de datos relacionales reactivas, optimización de consultas y rendimiento en entornos de alta demanda.
- Spring Cloud Gateway: Descubre cómo usar esta herramienta basada en WebFlux para implementar enrutamiento, seguridad y resiliencia en arquitecturas de microservicios.
- Programación Reactiva en el Frontend: Investiga cómo frameworks como React, Angular o Vue pueden conectarse eficientemente con backends WebFlux en escenarios de tiempo real.
- WebFlux y GraalVM Native Image: Evalúa las ventajas de empaquetar tus aplicaciones como imágenes nativas para mejorar el rendimiento y reducir el consumo de recursos.
- Patrones de resiliencia con WebFlux: Profundiza en técnicas como Circuit Breaker, Retry, Timeout y Rate Limiting mediante el uso de Resilience4j en entornos reactivos.
Explorar estos temas no solo ampliará tu dominio técnico, sino que también te permitirá diseñar soluciones más eficientes, resilientes y adaptadas a los retos actuales del desarrollo moderno. La programación reactiva, bien aplicada, abre la puerta a aplicaciones verdaderamente escalables y sensibles a la demanda del usuario.
Spring WebFlux 3: Comunicación, Datos y Errores Reactivos
- Mauricio ECR
- Arquitectura
- 24 May, 2025
¡Continuemos nuestro viaje por el fascinante mundo de Spring WebFlux! En la Parte 1, sentamos las bases de la programación reactiva y exploramos Project Reactor, el corazón de WebFlux. En la **Pa
Spring WebFlux 3: Comunicación, Datos y Errores Reactivos
- Mauricio ECR
- Arquitectura
- 24 May, 2025
¡Continuemos nuestro viaje por el fascinante mundo de Spring WebFlux!
En la Parte 1, sentamos las bases de la programación reactiva y exploramos Project Reactor, el corazón de WebFlux. En la Parte 2, nos adentramos en la arquitectura de WebFlux y aprendimos a construir endpoints utilizando tanto anotaciones como el enfoque funcional.
Ahora, en esta Parte 3, nos enfocaremos en cómo las aplicaciones WebFlux interactúan con el mundo exterior: cómo consumen otros servicios de manera reactiva, cómo persisten y recuperan datos en bases de datos reactivas, y, crucialmente, cómo gestionamos los errores que inevitablemente surgen en estos flujos asíncronos.
Comunicación con Servicios Externos (WebClient)
En el ecosistema de microservicios actual, es muy común que nuestras aplicaciones necesiten consumir APIs externas. Spring WebFlux nos proporciona una herramienta poderosa y reactiva para esto: WebClient. Es la contraparte no bloqueante de RestTemplate y la forma recomendada de hacer llamadas HTTP en un contexto reactivo.
WebClient
WebClient es un cliente HTTP no bloqueante que forma parte del módulo spring-webflux. Está diseñado para aprovechar la pila reactiva de principio a fin, lo que significa que no bloqueará hilos mientras espera respuestas de servicios externos, maximizando la eficiencia de tu aplicación WebFlux.
Su API es fluida y declarativa, similar a la forma en que construyes flujos con Mono y Flux.
Configuración Básica:
Puedes configurar WebClient de diversas maneras. La forma más común es inyectarlo como un bean en tu clase, o construir una instancia en línea. Puedes especificar una URL base, encabezados comunes, timeouts, filtros y más.
// Configuración como Bean (ejemplo en una clase @Configuration)
@Configuration
public class WebClientConfig {
@Bean
public WebClient externalApiClient(WebClient.Builder webClientBuilder) {
return webClientBuilder
.baseUrl("https://api.example.com") // URL base para todas las peticiones
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) // Encabezado por defecto
.clientConnector(new ReactorClientHttpConnector(
HttpClient.create().responseTimeout(Duration.ofSeconds(5)) // Timeout de 5 segundos
))
.build();
}
}
Consumo de Respuestas Reactivas:
Después de definir la petición (GET, POST, PUT, DELETE, etc.), usas métodos como:
.retrieve(): Inicia la recuperación de la respuesta..bodyToMono(Class<T> type): Convierte el cuerpo de la respuesta en unMonode un objeto de tipoT. Útil cuando esperas una única respuesta (ej., un objeto JSON)..bodyToFlux(Class<T> type): Convierte el cuerpo de la respuesta en unFluxde objetos de tipoT. Útil para listas o streams de datos (ej., una lista de objetos JSON)..bodyToMono(ParameterizedTypeReference<T> typeRef)/.bodyToFlux(ParameterizedTypeReference<T> typeRef): Útil para tipos genéricos (ej.,List<MyObject>)..toEntity(Class<T> type)/.toEntityList(Class<T> type)/.toEntityFlux(Class<T> type): Devuelve unMono<ResponseEntity<T>>oMono<ResponseEntity<List<T>>>para acceder a la respuesta completa (estado HTTP, cabeceras, cuerpo).
Casos Típicos/Práctica
Llamada GET a un servicio externo y procesar la respuesta reactivamente:
Asumiendo que
externalApiClientes unWebClientbean inyectado.public Mono<MyObject> getObjectById(String id) { return externalApiClient.get() // Inicia una petición GET .uri("/objects/{id}", id) // Define la URI con PathVariable .retrieve() // Recupera la respuesta .bodyToMono(MyObject.class); // Convierte el cuerpo a Mono<MyObject> }Llamada POST enviando un
Mono<?>como body:public Mono<MyObject> createObject(Mono<MyObject> newObjectMono) { return externalApiClient.post() // Inicia una petición POST .uri("/objects") .body(newObjectMono, MyObject.class) // Envía el Mono<MyObject> como cuerpo .retrieve() .bodyToMono(MyObject.class); // Espera la respuesta como Mono<MyObject> }Manejar múltiples llamadas a servicios externos en paralelo (
Mono.zip,Flux.merge,flatMap):Mono.zip: Combina los resultados de múltiplesMonos (oFluxs que emiten un solo elemento) en un soloMonoque contiene una tupla de sus resultados. Las operaciones se ejecutan en paralelo. Ideal para combinar resultados de diferentes tipos que son necesarios simultáneamente.public Mono<CombinedData> getCombinedData(String id) { Mono<User> userMono = externalApiClient.get().uri("/users/{id}", id).retrieve().bodyToMono(User.class); Mono<Order> orderMono = externalApiClient.get().uri("/orders/{id}", id).retrieve().bodyToMono(Order.class); return Mono.zip(userMono, orderMono, (user, order) -> { // Aquí se combinan los resultados cuando ambos Monos han completado return new CombinedData(user, order); }); }Flux.merge: Combina múltiplesPublishers (Mono o Flux) en un únicoFlux, entrelazando sus elementos tan pronto como son emitidos. Las operaciones se ejecutan en paralelo, y el orden de los elementos resultantes no está garantizado.public Flux<Item> getItemsFromMultipleSources() { Flux<Item> source1 = externalApiClient.get().uri("/items/source1").retrieve().bodyToFlux(Item.class); Flux<Item> source2 = externalApiClient.get().uri("/items/source2").retrieve().bodyToFlux(Item.class); return Flux.merge(source1, source2); // Los ítems de source1 y source2 se entrelazan }flatMap: (Ya cubierto en Parte 1, pero clave aquí) Úsalo cuando la transformación de un elemento inicial te lleva a realizar otra operación asíncrona que devuelve unMonooFlux. Permite encadenar operaciones secuenciales asíncronas.public Mono<OrderDetail> getOrderDetails(String orderId) { return externalApiClient.get().uri("/orders/{id}", orderId).retrieve().bodyToMono(Order.class) // 1. Obtener la orden .flatMap(order -> externalApiClient .get() .uri("/products/{id}", order.getProductId()).retrieve().bodyToMono(Product.class) // 2. Obtener el producto de la orden .map(product -> new OrderDetail(order, product))); // 3. Combinar y devolver OrderDetail }
Manejar errores de un servicio externo llamado con WebClient:
WebClientlanzaWebClientResponseException(o subclases comoWebClientResponseException.NotFound) si la respuesta HTTP es un error (4xx, 5xx). Puedes usar operadores de manejo de errores de Reactor comoonErrorResumeoonErrorReturn.public Mono<MyObject> getObjectByIdHandlingError(String id) { return externalApiClient.get() .uri("/objects/{id}", id) .retrieve() .onStatus(HttpStatus.NOT_FOUND::equals, // Si el estado es 404 response -> Mono.error(new MyCustomNotFoundException("Object not found: " + id))) // Mapea a una excepción personalizada .onStatus(HttpStatus::is5xxServerError, // Si es un error 5xx response -> Mono.error(new RuntimeException("External service error"))) // Mapea a otra excepción .bodyToMono(MyObject.class) .onErrorResume(MyCustomNotFoundException.class, e -> { // Si es MyCustomNotFoundException, devuelve un Mono.empty() o un default System.err.println("Handling not found: " + e.getMessage()); return Mono.empty(); // O Mono.just(new MyObject("Default object")); }) .onErrorReturn(RuntimeException.class, new MyObject("Error occurred, returning default")); // Si es RuntimeException, devuelve un objeto por defecto }
Manejo de Datos Reactivos
Una aplicación reactiva es más eficiente si toda su pila es no bloqueante, y esto incluye la capa de persistencia de datos. Acceder a bases de datos de forma reactiva es crucial para evitar cuellos de botella por I/O bloqueante.
Integración de WebFlux con Bases de Datos Reactivas
Para bases de datos relacionales, la API estándar para acceso reactivo es R2DBC (Reactive Relational Database Connectivity). Es el equivalente reactivo de JDBC, pero diseñado desde cero para ser no bloqueante y asíncrono. Spring Data ha adoptado R2DBC, proporcionando integraciones para bases de datos como PostgreSQL, H2, MySQL (con driver de terceros) y SQL Server.
Para bases de datos NoSQL, muchos de los drivers ya están diseñados para ser reactivos. Por ejemplo, Spring Data tiene módulos reactivos para:
- MongoDB:
spring-data-mongodb-reactive - Cassandra:
spring-data-cassandra-reactive - Redis:
spring-data-redis-reactive
Repositorios Reactivos:
Spring Data extiende sus interfaces de repositorio para el contexto reactivo. En lugar de extender CrudRepository, extiendes interfaces como ReactiveCrudRepository, ReactiveMongoRepository, ReactiveCassandraRepository, etc. Los métodos de estas interfaces devuelven Mono<?> o Flux<?>.
Casos Típicos/Práctica
Asumiendo una entidad User y un repositorio UserRepository que extiende ReactiveCrudRepository<User, Long> (para R2DBC) o ReactiveMongoRepository<User, String> (para MongoDB).
Guardar (
save):// En un servicio @Autowired private UserRepository userRepository; public Mono<User> saveUser(User user) { return userRepository.save(user); // Devuelve Mono<User> }Encontrar por ID (
findById):public Mono<User> findUserById(Long id) { return userRepository.findById(id); // Devuelve Mono<User> }Encontrar todos (
findAll):public Flux<User> findAllUsers() { return userRepository.findAll(); // Devuelve Flux<User> }Manejo de Transacciones en un Contexto Reactivo: Este es un tema un poco más avanzado y complejo. En un contexto bloqueante, las transacciones se manejan con
@Transactional, que delega a unThreadLocal. Sin embargo, losThreadLocalno funcionan en un contexto reactivo porque los elementos pueden pasar por diferentes hilos en diferentes momentos.Para transacciones reactivas, Spring Data proporciona la anotación
@Transactionalen combinación con la infraestructura de transacciones reactivas de Spring (por ejemplo,ReactiveTransactionManagerpara R2DBC). Cuando usas@Transactionalen un método reactivo, Spring se asegura de que todas las operaciones reactivas dentro de ese método (que interactúan con la misma base de datos) se ejecuten dentro de la misma transacción.Es importante entender que una transacción se "adjunta" al
MonooFluxque se crea, no al hilo. Es decir, las operaciones dentro del flujo reactivo, si son parte de la misma transacción, se aseguran de comprometerse o revertirse juntas.@Service public class UserServiceImpl implements UserService { @Autowired private UserRepository userRepository; @Transactional // Esta anotación ahora trabaja con ReactiveTransactionManager public Mono<User> createUserAndAudit(User user) { return userRepository.save(user) // Guarda el usuario .flatMap(savedUser -> { // Simula una operación de auditoría que debe ser parte de la misma transacción // Si AuditRepository fuera reactivo y manejara transacciones. // return auditRepository.save(new AuditLog(savedUser.getId(), "User created")); System.out.println("User saved, attempting audit for: " + savedUser.getUsername()); return Mono.just(savedUser); // Devolver el usuario guardado }) .doOnError(e -> System.err.println("Transaction rolled back due to: " + e.getMessage())); // Manejo de error de transacción } }El desafío es que todas las operaciones dentro de la transacción deben ser reactivas y deben usar la misma conexión transaccional. Es un área donde la depuración puede ser más compleja que con las transacciones síncronas.
Manejo de Errores en Streams Reactivos
El manejo de errores es crucial en cualquier aplicación, y en los flujos reactivos tiene sus propias particularidades. Como ya mencionamos, cuando un error es emitido (onError), la secuencia se termina. Para evitar que toda la aplicación se caiga o para proporcionar una recuperación elegante, Reactor ofrece operadores específicos.
Operadores de Manejo de Errores
onErrorReturn(T fallbackValue): Cuando elPublisheremite un error, este operador intercepta el error, emite un valor de respaldo (fallbackValue), y luego completa la secuencia normalmente (onComplete). El error original es consumido.// Si ocurre un error, devuelve el valor por defecto "Default Message" Mono.error(new RuntimeException("Simulated error")) .onErrorReturn("Default Message") .subscribe(System.out::println, System.err::println); // Imprime "Default Message"onErrorResume(Function<Throwable, Mono<T>> fallbackMonoProvider): Si ocurre un error, este operador intercepta el error y cambia a unPublisheralternativo (fallbackMonoProvider). Es útil cuando necesitas ejecutar una lógica asíncrona para recuperarte del error.// Si ocurre un error, cambia a un Mono que simula una recuperación Mono.error(new RuntimeException("Simulated error")) .onErrorResume(e -> { System.err.println("Error caught, resuming with alternative: " + e.getMessage()); return Mono.just("Recovered from error!"); }) .subscribe(System.out::println, System.err::println); // Imprime "Recovered from error!"onErrorMap(Function<Throwable, Throwable> errorMapper): Transforma un tipo de excepción en otro. Esto es útil para encapsular excepciones internas en excepciones más significativas para tu dominio de negocio.// Transforma RuntimeException en CustomBusinessException Mono.error(new RuntimeException("Database error")) .onErrorMap(RuntimeException.class, e -> new MyCustomBusinessException("Failed to process data: " + e.getMessage())) .subscribe(System.out::println, System.err::println); // Lanza MyCustomBusinessExceptiondoOnError(Consumer<Throwable> errorConsumer): Ejecuta una acción de efecto secundario cuando un error ocurre, pero no consume el error. El error continúa propagándose por el stream. Útil para logging o métricas sin alterar el flujo de error.// Logea el error, pero el error sigue propagándose Mono.error(new RuntimeException("Another simulated error")) .doOnError(e -> System.err.println("Logging error before propagation: " + e.getMessage())) .subscribe(System.out::println, System.err::println); // Imprime el log y luego lanza RuntimeExceptionretry(long numRetries)/retryWhen(Function<Flux<Throwable>, Publisher<?>> retrySignal): Intenta re-suscribirse alPublisheroriginal un número de veces o bajo ciertas condiciones.
Manejo Global de Errores en WebFlux (ErrorWebExceptionHandler)
Para centralizar el manejo de errores y proporcionar respuestas HTTP consistentes (ej. JSON con un formato de error estándar), WebFlux proporciona la interfaz ErrorWebExceptionHandler. Puedes implementar esta interfaz y registrarla como un bean para manejar todas las excepciones no capturadas por los operadores en tus flujos.
@Component
@Order(-1) // Asegura que este handler sea el primero en la cadena
public class GlobalErrorWebExceptionHandler implements ErrorWebExceptionHandler {
@Override
public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) {
HttpStatus status;
String errorMessage;
if (ex instanceof MyCustomNotFoundException) {
status = HttpStatus.NOT_FOUND;
errorMessage = ex.getMessage();
} else if (ex instanceof IllegalArgumentException) {
status = HttpStatus.BAD_REQUEST;
errorMessage = "Invalid input: " + ex.getMessage();
} else {
status = HttpStatus.INTERNAL_SERVER_ERROR;
errorMessage = "An unexpected error occurred: " + ex.getMessage();
// Considerar logear la excepción aquí
}
// Construir la respuesta de error JSON
ErrorResponse errorResponse = new ErrorResponse(status.value(), errorMessage);
DataBufferFactory bufferFactory = exchange.getResponse().bufferFactory();
DataBuffer buffer = bufferFactory.wrap(toJson(errorResponse).getBytes()); // Convierte el objeto a JSON
exchange.getResponse().setStatusCode(status);
exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
return exchange.getResponse().writeWith(Mono.just(buffer));
}
private String toJson(Object obj) {
// Implementa la lógica para convertir el objeto a JSON (ej. con ObjectMapper de Jackson)
try {
return new ObjectMapper().writeValueAsString(obj);
} catch (JsonProcessingException e) {
return "{\"status\":500, \"message\":\"Error converting error response to JSON\"}";
}
}
// Clase auxiliar para la respuesta de error
private static class ErrorResponse {
public int status;
public String message;
public ErrorResponse(int status, String message) { this.status = status; this.message = message; }
}
}
Casos Típicos/Práctica
Manejo de un error específico dentro de una cadena de operadores: Supongamos un servicio que busca un usuario, pero puede lanzar
UserNotFoundExceptionsi no lo encuentra.public Mono<User> getUserProfile(String userId) { return userRepository.findById(userId) // Simula buscar en DB .switchIfEmpty(Mono.error(new UserNotFoundException("User not found with ID: " + userId))) // Si Mono.empty(), lanza excepción .onErrorResume(UserNotFoundException.class, e -> { System.err.println("Handled specific UserNotFoundException: " + e.getMessage()); return Mono.just(new User("defaultUser", "Default User")); // Devuelve un usuario por defecto }); }Centralizar el manejo de errores para devolver respuestas HTTP consistentes: Como se mostró en el ejemplo de
GlobalErrorWebExceptionHandlerarriba.- 404 Not Found: Mapear
MyCustomNotFoundExceptionaHttpStatus.NOT_FOUND. - 500 Internal Server Error: Para excepciones inesperadas, mapear a
HttpStatus.INTERNAL_SERVER_ERROR. - 400 Bad Request: Para errores de validación o entrada incorrecta, mapear a
HttpStatus.BAD_REQUEST.
El
GlobalErrorWebExceptionHandleres el lugar ideal para definir el formato JSON estándar de tus mensajes de error y sus códigos de estado HTTP asociados, asegurando que todos los errores que atraviesan tu aplicación sean presentados de manera uniforme al cliente.- 404 Not Found: Mapear
Conclusión
En esta tercera entrega, hemos cubierto pilares fundamentales para construir aplicaciones WebFlux robustas: la comunicación reactiva con servicios externos utilizando WebClient, la persistencia de datos con bases de datos reactivas a través de Spring Data R2DBC o drivers NoSQL, y el vital manejo de errores en los flujos reactivos, tanto a nivel de operador como de forma global con ErrorWebExceptionHandler.
Estos conocimientos son esenciales para construir aplicaciones que no solo sean rápidas y escalables, sino también resilientes y fáciles de mantener. En la Parte 4 y final de nuestra serie, abordaremos temas más avanzados como Server-Sent Events, el concepto de Backpressure y el Contexto Reactivo, y, por supuesto, cómo probar eficazmente nuestras aplicaciones WebFlux.
¡Nos vemos en la última parte para solidificar aún más tu conocimiento en WebFlux!
Kafka 6: Despliegue, Seguridad y Optimización
- Mauricio ECR
- Arquitectura
- 14 May, 2025
Hemos explorado la arquitectura fundamental de Apache Kafka, la dinámica entre productores y consumidores, sus potentes capacidades para el procesamiento de flujos de datos y las herramientas que enri
Kafka 6: Despliegue, Seguridad y Optimización
- Mauricio ECR
- Arquitectura
- 14 May, 2025
Hemos explorado la arquitectura fundamental de Apache Kafka, la dinámica entre productores y consumidores, sus potentes capacidades para el procesamiento de flujos de datos y las herramientas que enriquecen su ecosistema. Con esta base, ya podemos empezar a diseñar aplicaciones que interactúen con esta potente tubería central de datos. Sin embargo, la transición de un entorno de desarrollo o pruebas a un entorno de producción real introduce una nueva capa de complejidad y consideraciones cruciales.
En producción, donde manejamos datos sensibles y operamos bajo estrictos requisitos de alta disponibilidad y rendimiento, es imperativo dominar los pilares operacionales: cómo desplegar un clúster de Kafka de manera efectiva, cómo protegerlo contra accesos no autorizados y salvaguardar los datos, y cómo ajustar su configuración para maximizar su rendimiento. Dominar estos aspectos es fundamental para garantizar que tu implementación de Kafka no solo funcione, sino que lo haga de forma segura, estable y eficiente a escala. Este artículo se sumerge en estas consideraciones prácticas, proporcionando una guía detallada para operar Kafka en el mundo real.
1. Despliegue en Producción: Eligiendo el Hogar de tu Clúster
La primera decisión operativa de calado es determinar dónde y cómo se desplegará tu clúster de Kafka. Fundamentalmente, existen dos grandes opciones: autogestionar el clúster o utilizar un servicio gestionado.
Autogestionado (On-premise o en tu propia VPC Cloud): Elegir esta vía implica que tu equipo asume la responsabilidad total del ciclo de vida del clúster. Esto incluye la instalación y configuración detallada de cada componente (brokers, y el modo de metadatos KRaft en versiones recientes), el escalado horizontal (añadir o retirar brokers, balancear particiones), la implementación de sistemas de monitoreo y alertas robustos, la gestión de copias de seguridad y la planificación de la recuperación ante desastres, así como la aplicación de parches y actualizaciones. La principal ventaja es el máximo control sobre la infraestructura y la configuración a bajo nivel. La contraparte es que requiere un conocimiento profundo de Kafka, experiencia significativa en la operación de sistemas distribuidos y un esfuerzo considerable de ingeniería. Puedes desplegarlo en tus propios centros de datos o en máquinas virtuales en la nube pública. En entornos de nube, Kubernetes se ha convertido en un orquestador popular para desplegar Kafka, utilizando herramientas como operadores (Strimzi, Confluent for Kubernetes) que automatizan tareas complejas como escalabilidad, recuperación de fallos y actualizaciones de forma declarativa. Los Helm Charts también son una opción popular para empaquetar y desplegar configuraciones rápidamente en Kubernetes.
Servicios Gestionados (Managed Services): Aquí, la mayor parte del trabajo operativo recae en un proveedor externo. Ellos se encargan del despliegue, los parches, el escalado (a menudo automático), el monitoreo básico y la tolerancia a fallos, liberando a tu equipo para que se centre en las aplicaciones que consumen y producen datos. Ejemplos notables en la nube pública incluyen Amazon MSK (Managed Streaming for Kafka), Confluent Cloud (que además ofrece acceso a herramientas de la Confluent Platform como Schema Registry y Connectors gestionados) y Azure Event Hubs para Kafka. También existen alternativas compatibles con la API de Kafka como Redpanda, diseñada para alto rendimiento y baja latencia, aunque no es Apache Kafka puro, o Aiven for Kafka. Los pros de los servicios gestionados son una menor carga operativa, escalado a menudo automático y SLAs (Acuerdos de Nivel de Servicio) incluidos. Las contras suelen ser restricciones en la configuración fina, un costo potencialmente mayor y una dependencia del proveedor.
Recomendación: Si tu equipo tiene poca experiencia operativa en sistemas distribuidos o necesitas un entorno productivo rápidamente con garantías de SLA, un servicio gestionado puede acelerar la adopción. Para entornos muy regulados con requisitos de seguridad estrictos o necesidades de personalización a muy bajo nivel, un despliegue autogestionado en una VPC privada puede ser preferible.
2. Configuración de Brokers: Gestión de Logs y Retención
Independientemente de la opción de despliegue, la configuración de los brokers es fundamental y impacta directamente en el uso de disco, el rendimiento de I/O y la disponibilidad de los datos.
log.segment.bytes: Este parámetro define el tamaño máximo de cada segmento de log individual en disco. Las particiones de Kafka se dividen en segmentos; cuando uno se llena, se crea uno nuevo. Un tamaño adecuado afecta la eficiencia de la gestión de ficheros y la limpieza de logs. Valores típicos recomendados varían entre 512 MB y 2 GB, dependiendo del patrón de tamaño de mensajes y la frecuencia de limpieza.log.retention.msylog.retention.bytes: Estos dos parámetros controlan durante cuánto tiempo se retienen los mensajes en una partición antes de ser elegibles para su eliminación.log.retention.msestablece una retención basada en el tiempo (en milisegundos), mientras quelog.retention.byteslo hace basada en el tamaño total de datos por partición. Es crucial ajustar estas políticas de retención según los requisitos de tu aplicación, las regulaciones (como GDPR) y las necesidades de reprocesamiento. Por defecto, la retención suele ser de 7 días, pero establecer límites de tamaño (log.retention.byteshabilitado) es vital para prevenir el llenado inesperado de disco. Un ejemplo de configuración para retención híbrida podría ser establecer un límite de tiempo (ej: 30 días) o un límite de tamaño (ej: 1 TB), lo que ocurra primero.message.max.bytes: Define el tamaño máximo permitido para un mensaje individual. Debes ajustarlo si necesitas procesar mensajes grandes, como imágenes o documentos.
Desde Kafka 3.6, la funcionalidad de Tiered Storage (Almacenamiento por Niveles) permite una gestión más flexible de la retención. Puedes configurar Kafka para que los segmentos de logs más antiguos sean movidos a sistemas de almacenamiento de objetos de menor costo como S3 o GCS. Esto reduce la presión sobre el almacenamiento en disco local de los brokers y facilita retenciones prolongadas a menor coste, ideal para análisis históricos o cumplimiento normativo.
3. Seguridad: Protegiendo tu Flujo de Datos
Dado que Kafka a menudo transporta datos críticos para el negocio, implementar medidas de seguridad robustas es imprescindible. La seguridad en Kafka se estructura principalmente en tres pilares: Autenticación, Cifrado y Autorización (ACLs).
Autenticación (¿Quién Eres?): Este pilar se centra en verificar la identidad de cualquier cliente (productores, consumidores, otros brokers, herramientas de administración) que intente conectarse al clúster. Kafka soporta múltiples mecanismos:
- SASL (Simple Authentication and Security Layer): Es el mecanismo más común. Incluye opciones como PLAIN (usuario/contraseña, requiere TLS), SCRAM (más seguro, usando challenge-response) y GSSAPI (Kerberos) para integración con entornos de autenticación centralizada.
- SSL/TLS Mutual Authentication: Permite que tanto el broker como el cliente se autentiquen mutuamente utilizando certificados X.509.
- OAuth2: Las versiones recientes soportan autenticación utilizando tokens JWT, lo cual es ideal para arquitecturas modernas basadas en microservicios y entornos cloud-native. Una buena práctica es centralizar la gestión de credenciales y automatizar su rotación (contraseñas SASL/SCRAM, certificados TLS) utilizando herramientas como Vault o AWS Secrets Manager.
Cifrado: Protegiendo los Datos en Tránsito y en Reposo: El cifrado asegura que tus datos sean ilegibles para cualquiera que no deba tener acceso a ellos.
- Cifrado en Tránsito: Kafka utiliza TLS/SSL para proteger las comunicaciones de red. Es crucial configurar TLS para las conexiones cliente-broker (garantizando que los datos se cifren al viajar entre aplicaciones y brokers) y broker-broker (protegiendo los datos mientras se replican entre los brokers del clúster). Implementar TLS requiere gestionar certificados (Autoridad de Certificación, certificados de broker) y configurar truststores en los clientes. Se recomienda usar protocolos TLS 1.2/1.3, certificados de una CA confiable y habilitar "perfect forward secrecy".
- Cifrado en Reposo: Kafka por sí mismo no maneja la encriptación de datos en reposo en los archivos de logs. Sin embargo, esto se logra a nivel de infraestructura subyacente mediante la encriptación de discos (ej: LUKS en Linux, servicios de encriptación en la nube como EBS con SSE-KMS) o utilizando sistemas de archivos encriptados integrados con herramientas de gestión de claves como HashiCorp Vault.
Autorización: ACLs (Access Control Lists) - ¿Qué Puedes Hacer?: Una vez que un cliente ha sido autenticado, la autorización define qué acciones específicas se le permite realizar sobre qué recursos de Kafka. Esto se implementa mediante ACLs. Una regla ACL especifica quién (el Principal, es decir, la identidad autenticada), qué puede hacer (la Operación, ej: READ, WRITE, CREATE), sobre qué recurso (Topic, Consumer Group, Cluster, Transacción), desde dónde (Host opcional), y si el permiso es ALLOW o DENY. Configurar ACLs granulares y aplicando el principio de mínimo privilegio es vital para restringir el acceso solo a lo necesario. Por ejemplo, permitir que solo ciertos usuarios o servicios puedan escribir en topics específicos o leer de ciertos grupos de consumidores. Se recomienda auditar periódicamente las ACLs existentes y utilizar herramientas como Terraform o Ansible para versionar y automatizar su gestión.
4. Optimización: Afinando el Rendimiento
Operar Kafka con rendimiento óptimo es un proceso iterativo que se basa en el monitoreo continuo y el análisis de métricas.
Tuning de la JVM: Los brokers de Kafka se ejecutan sobre la Java Virtual Machine (JVM). Configurar correctamente el tamaño del Heap Size (la memoria RAM asignada, típicamente entre 4 GB y 16 GB, evitando heaps > 32 GB para minimizar pausas del recolector de basura) y seleccionar un Recolector de Basura (GC) adecuado (G1GC es la opción recomendada) es crucial para la estabilidad y la latencia.
Compresión: Reduciendo Carga de Red y Disco: La compresión es una herramienta potente para reducir el ancho de banda de red consumido y el espacio en disco utilizado por los datos de los mensajes. Se configura en el productor mediante el parámetro
compression.type. Los brokers almacenan los mensajes comprimidos y los consumidores los descomprimen. Los códecs como snappy y lz4 ofrecen un buen equilibrio entre velocidad y tasa de compresión, siendo rápidos y con baja latencia. gzip y zstd logran tasas de compresión mayores, pero a costa de un mayor uso de CPU. La elección depende del equilibrio entre ahorro de recursos y el impacto en la CPU.Ajustes a Nivel de Red y Sistema Operativo: Optimizar el sistema operativo subyacente es importante. Esto incluye aumentar los límites de archivos abiertos (file descriptors,
ulimit -na 100000 o más), optimizar los montajes de disco (ej: con opciones comonoatimey usando sistemas de archivos optimizados para logs como XFS), y aumentar los buffers TCP (net.core.wmem_max,net.core.rmem_max). En entornos on-premise, usar redes de alto ancho de banda (10Gbps+) es fundamental.Hardware y Almacenamiento: La elección del hardware tiene un impacto directo. Se recomiendan discos SSD NVMe con altas IOPS sostenidas para el almacenamiento de logs de Kafka, dada la intensa carga de I/O.
Diseño de Topics y Particiones: Aunque cubierto en artículos anteriores, es vital recordar que un diseño deficiente de topics y particiones (demasiadas o muy pocas, o claves de particionamiento ineficientes) puede ser un cuello de botella significativo. Mantener un número razonable de particiones por broker (ej: 100-200) y configurar Rack Awareness para distribuir réplicas entre diferentes zonas o racks mejora la tolerancia a fallos.
Monitoreo y Alertas: La optimización es imposible sin una visibilidad clara del rendimiento del clúster. Herramientas como Prometheus + Grafana (exportando métricas JMX de Kafka con JMX Exporter), Confluent Control Center o Datadog son clave. Es crucial monitorear métricas críticas como
UnderReplicatedPartitions(problemas de replicación),RequestHandlerAvgIdlePercent(posibles cuellos de botella en brokers si es bajo),NetworkProcessorAvgIdlePercent(estrés en manejo de conexiones) y la utilización del disco a nivel de sistema operativo. Establecer alertas proactivas para estas métricas permite reaccionar antes de que los problemas impacten a las aplicaciones.
Operaciones Avanzadas y Recuperación ante Desastres
Un aspecto crítico en producción es contar con un plan de recuperación ante desastres (DR) robusto, especialmente en despliegues autogestionados. Esto incluye:
- Backups de Configuración: Mantener copias de seguridad de configuraciones importantes como los scripts de ACLs, la configuración de topics y la configuración de clientes.
- Réplicas Geográficas: Para tolerancia a fallos a nivel regional o de datacenter, se puede replicar datos entre clústeres en diferentes ubicaciones utilizando herramientas como MirrorMaker2 o Confluent Replicator.
- Simulacros de Fallos: Probar regularmente la recuperación de snapshots de disco (si aplica) y los procedimientos de conmutación por error es esencial para validar el plan de DR.
Otras operaciones avanzadas incluyen la configuración de Cuotas para limitar el ancho de banda o las solicitudes por cliente (client.quota.producer_byte_rate, consumer_byte_rate) y evitar así que un cliente acapare recursos.
Conclusión
Operar Apache Kafka en producción implica un equilibrio cuidadoso entre el control operativo y la simplicidad. La elección entre un despliegue autogestionado o un servicio gestionado es el punto de partida, cada uno con sus ventajas y desafíos. Sin embargo, independientemente del "hogar" del clúster, la seguridad debe ser una prioridad innegociable, implementando capas de protección como autenticación sólida (SASL, mTLS, OAuth2), cifrado end-to-end (TLS) y en reposo (a nivel de infraestructura), y autorización granular con ACLs.
La optimización no es una tarea única, sino un proceso continuo que requiere monitoreo constante, análisis de métricas críticas y ajustes finos en la configuración de brokers, JVM, red y sistema operativo.
Al abordar de manera proactiva el despliegue, la seguridad y la optimización, y al incorporar un plan sólido de recuperación ante desastres, tu clúster de Kafka no solo será seguro y eficiente, sino también altamente resiliente frente a los imprevistos inevitables en entornos productivos a gran escala.
Con la comprensión de la arquitectura, la interacción cliente, las capacidades de procesamiento, las herramientas del ecosistema y ahora los aspectos operativos, poseemos un panorama completo para implementar y operar Kafka. En nuestra próxima exploración, profundizaremos en Patrones Avanzados y Anti-Patrones comunes, mostrando cómo aplicar correctamente Kafka para problemas complejos y qué errores debemos evitar para asegurar que nuestra implementación sea tan elegante como robusta.
Spring WebFlux 2: Alta Concurrencia sin Más Hilos
- Mauricio ECR
- Arquitectura
- 12 May, 2025
¡Bienvenido de nuevo a nuestra inmersión en Spring WebFlux! 👋 En la primera parte de esta serie, exploramos el "por qué" de la programación reactiva, entendiendo los problemas del bloqueo y descubri
Spring WebFlux 2: Alta Concurrencia sin Más Hilos
- Mauricio ECR
- Arquitectura
- 12 May, 2025
¡Bienvenido de nuevo a nuestra inmersión en Spring WebFlux! 👋
En la primera parte de esta serie, exploramos el "por qué" de la programación reactiva, entendiendo los problemas del bloqueo y descubriendo a Project Reactor como el motor que impulsa los flujos de datos asíncronos. Ahora que tenemos una base sólida sobre los principios reactivos y los tipos Mono/Flux, es momento de subir un nivel y entender cómo Spring WebFlux aplica estos conceptos para construir aplicaciones web eficientes y escalables.
En esta segunda entrega, nos centraremos en la arquitectura que diferencia a WebFlux de su predecesor, Spring MVC, y aprenderemos las dos formas principales de definir los endpoints de nuestra API reactiva.
3. Arquitectura de Spring WebFlux
Si Spring MVC se construyó sobre la API de Servlets (diseñada originalmente para un modelo síncrono de un hilo por petición), Spring WebFlux se construye sobre una pila completamente reactiva y no bloqueante. Esta diferencia fundamental es la clave de su capacidad para manejar alta concurrencia.
Teoría: Componentes Clave
La arquitectura de WebFlux se basa en:
- Servidores No Bloqueantes: A diferencia de depender de un Contenedor de Servlets (como Tomcat, Jetty) configurado de forma tradicional, WebFlux utiliza servidores web diseñados para manejar I/O no bloqueante. El servidor por defecto integrado con Spring Boot WebFlux es Netty, un framework asíncrono basado en eventos muy popular en la industria por su rendimiento. Sin embargo, WebFlux es flexible y también soporta otros servidores reactivos como Undertow o incluso Servlets 3.1+ API en modo no bloqueante (aunque el uso de Netty o Undertow es más común y eficiente para aprovechar plenamente el potencial reactivo).
- EventLoop: El corazón del procesamiento no bloqueante. En lugar de asignar un hilo por petición, WebFlux (y los servidores como Netty) utilizan un pequeño número de hilos llamados "Event Loop threads". Estos hilos no realizan operaciones de I/O bloqueantes directamente. En cambio, delegan la operación al sistema operativo y quedan libres para procesar otras tareas o peticiones. Cuando la operación de I/O se completa (por ejemplo, llega la respuesta de una base de datos o un servicio externo), el sistema operativo notifica al Event Loop, que entonces toma el resultado y continúa el procesamiento del flujo reactivo asociado a esa petición.
- Reactor Core: Como vimos en la Parte 1, Project Reactor proporciona los tipos
MonoyFluxy los operadores para componer la lógica asíncrona. WebFlux se integra estrechamente con Reactor. - Spring Web Reactive Framework: Capas por encima de Reactor y el servidor para proporcionar la funcionalidad web: manejo de peticiones, ruteo, serialización/deserialización, manejo de errores, etc.
Cómo WebFlux Maneja las Peticiones (El Pipeline Reactivo)
Cuando una petición HTTP llega a un servidor WebFlux:
- Uno de los Event Loop threads del servidor la recibe.
- La petición pasa a través de la cadena de procesamiento de WebFlux (filtros, ruteo).
- La petición llega al Handler (controlador o función manejadora) correspondiente.
- El Handler ejecuta la lógica de negocio, que típicamente involucra operaciones que devuelven
MonooFlux(ej: llamar a un servicio, acceder a una base de datos reactiva). - Estas operaciones, al ser reactivas y no bloqueantes, no detienen el Event Loop thread. El thread delega la tarea (ej: consulta a DB) y queda libre.
- Cuando la operación asíncrona finaliza (ej: la DB devuelve resultados), uno de los Event Loop threads recibe la notificación.
- Los resultados fluyen de vuelta a través de la cadena de operadores definida en el
Mono/Flux. - El resultado final del
Mono/Fluxse convierte en una respuesta HTTP y se envía de vuelta al cliente, de nuevo, utilizando los Event Loop threads de forma no bloqueante.
Todo el procesamiento, desde la recepción de la petición hasta el envío de la respuesta, se maneja sin bloquear los hilos principales, permitiendo que un pequeño número de hilos gestione una alta concurrencia.
Diferencias Arquitectónicas Fundamentales con Spring MVC
| Característica | Spring MVC (Tradicional) | Spring WebFlux (Reactivo) |
|---|---|---|
| Modelo de Hilos | Thread-per-request (Bloqueante) | Event Loop (No Bloqueante) |
| Contenedor/Servidor | Basado en Servlet API (Tomcat, Jetty, etc.) | Basado en servidores reactivos (Netty, Undertow) o Servlet 3.1+ no bloqueante |
| Manejo de I/O | Bloqueante (por defecto) | No Bloqueante |
| Dependencies Base | spring-webmvc |
spring-webflux |
| Tipos de Retorno | Objetos POJO, ResponseEntity, ModelAndView, etc. |
Mono<?>, Flux<?>, ResponseEntity<Mono<?>>, etc. |
| Backpressure | No aplica directamente | Soportado nativamente a través de Reactive Streams |
¿Puedes usar Spring MVC y Spring WebFlux en el mismo proyecto?
Generalmente no. Aunque es técnicamente posible tener ambas dependencias en el classpath, Spring Boot configurará automáticamente solo una de las dos pilas web (MVC o WebFlux) basándose en la que encuentre primero o una configuración explícita. Son dos arquitecturas de manejo de peticiones fundamentalmente diferentes que no están diseñadas para coexistir y procesar la misma petición dentro del mismo contexto de aplicación Spring de forma híbrida y coherente. Debes elegir una u otra para tu aplicación web principal.
Casos Típicos/Práctica
Flujo de una Petición Típica en WebFlux:
- Llega petición HTTP a Netty (Event Loop thread A la recibe).
- WebFlux la rutea a un
HandlerFunction(el mismo thread A). - El Handler llama a un
UserService.findById(id)que devuelveMono<User>. UserServiceusa unReactiveUserRepository.findById(id)(que usa un driver R2DBC no bloqueante).- El Event Loop thread A delega la consulta a la DB y queda libre.
- Cuando la DB responde, otro Event Loop thread (B) recibe la notificación.
- El thread B retoma el flujo del
Mono<User>. - El resultado
Userfluye de regreso al Handler. - El Handler devuelve el
Mono<User>, que WebFlux serializa a JSON. - El Event Loop thread B envía la respuesta HTTP de vuelta al cliente.
Modelo de Hilos de Spring MVC vs. WebFlux:
- MVC: Un pico de 1000 peticiones concurrentes esperando por una DB lenta podría requerir 1000 hilos (o el tamaño máximo del pool), muchos de ellos inactivos.
- WebFlux: Esas mismas 1000 peticiones podrían ser manejadas por 4-8 Event Loop threads, que nunca esperan, simplemente gestionan el estado de las operaciones asíncronas pendientes. Esto libera recursos para otras tareas.
4. Creación de Endpoints (Controladores y Endpoints Funcionales)
Spring WebFlux ofrece dos enfoques principales para definir los puntos finales de tu API: el modelo tradicional basado en anotaciones y un modelo más funcional.
Teoría: Dos Enfoques
- Basado en Anotaciones: Similar a Spring MVC, usas anotaciones como
@RestController,@RequestMapping,@GetMapping,@PostMapping,@RequestBody, etc. La diferencia clave es que los métodos del controlador deben devolver tipos reactivos (Mono<?>oFlux<?>). - Endpoints Funcionales: Un enfoque más funcional y declarativo. Defines las rutas usando
RouterFunctiony los manejadores de peticiones usandoHandlerFunction. No hay anotaciones a nivel de método o clase; es todo código Java.
Uso de Anotaciones con Tipos Reactivos
Es el enfoque más familiar si vienes de Spring MVC. Simplemente creas clases con @RestController y métodos con anotaciones de mapeo HTTP. La diferencia crucial es el tipo de retorno:
- Devuelve
Mono<T>si esperas 0 o 1 objetoTen la respuesta. - Devuelve
Flux<T>si esperas 0 a N objetosTen la respuesta (esto puede ser un array JSON o un stream de datos, por ejemplo, en Server-Sent Events). - Puedes envolver el tipo reactivo en
ResponseEntitypara tener control sobre el estado HTTP, cabeceras, etc.:Mono<ResponseEntity<T>>oResponseEntity<Flux<T>>.
Recibir datos en el cuerpo de la petición también se hace reactivamente: usas @RequestBody con Mono<T>.
Uso de Endpoints Funcionales
Este enfoque desacopla completamente la definición de la ruta de la lógica de manejo de la petición.
RouterFunction<ServerResponse>: Define cómo las peticiones se rutean a losHandlerFunctionbasándose en predicados (métodos HTTP, rutas, cabeceras, etc.). Usas la claseRouterFunctionspara construirlas (route(RequestPredicate, HandlerFunction)).HandlerFunction<ServerResponse>: Contiene la lógica de negocio para manejar una petición. Recibe unServerRequestcomo entrada y devuelve unMono<ServerResponse>. La claseServerResponsese usa para construir la respuesta (estado HTTP, cuerpo, cabeceras).
Ventajas del Enfoque Funcional:
- Mayor separación de preocupaciones (ruteo vs. manejo).
- Más fácil de testear unitariamente (HandlerFunction es solo una función pura).
- Permite una construcción de rutas más programática y dinámica.
- Evita el uso de reflexion asociado a las anotaciones (micro-optimización).
Desventajas del Enfoque Funcional:
- Puede ser menos conciso y legible para APIs REST simples comparado con las anotaciones.
- Menos familiar para desarrolladores acostumbrados al modelo de anotaciones.
Casos Típicos/Práctica
Endpoint GET que devuelva un
Mono<MyObject>(Anotaciones):Asumiendo una clase
MyObject { String message; }@RestController @RequestMapping("/api/greeting") public class GreetingController { @GetMapping("/{name}") public Mono<MyObject> getGreeting(@PathVariable String name) { // Simula una operación asíncrona que devuelve un solo objeto return Mono.just(new MyObject("Hello, " + name)) .delayElement(Duration.ofMillis(500)); // Simula latencia } }Endpoint GET que devuelva un
Flux<MyObject>(Stream de datos) (Anotaciones):@RestController @RequestMapping("/api/numbers") public class NumberStreamController { @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) // Importante: MediaType.TEXT_EVENT_STREAM_VALUE para SSE public Flux<String> streamNumbers() { // Emite un número cada segundo indefinidamente return Flux.interval(Duration.ofSeconds(1)) .map(sequence -> "Event: " + sequence); } @GetMapping("/list") // Devuelve como JSON array public Flux<MyObject> getObjectsList() { return Flux.just(new MyObject("one"), new MyObject("two"), new MyObject("three")) .delayElements(Duration.ofMillis(100)); } }Endpoint POST que reciba un
Mono<MyObject>en el body (Anotaciones):@RestController @RequestMapping("/api/objects") public class ObjectController { @PostMapping public Mono<String> createObject(@RequestBody Mono<MyObject> objectMono) { // Recibe un Mono<MyObject> del cuerpo de la petición // flatMap es necesario porque objectMono es un Publisher y save es otro Publisher return objectMono .flatMap(obj -> { System.out.println("Recibido objeto: " + obj.getMessage()); // Simula guardar el objeto asíncronamente y devolver un ID return Mono.just("Object saved with ID: " + obj.getMessage().hashCode()) .delayElement(Duration.ofMillis(300)); }); } }Definir una ruta y su manejador usando el enfoque funcional:
Primero, el
HandlerFunction:// En un archivo separado, por ejemplo, src/main/java/com/example/demo/handler/GreetingHandler.java @Component // Spring lo detecta como un Bean public class GreetingHandler { public Mono<ServerResponse> getGreeting(ServerRequest request) { String name = request.pathVariable("name"); return Mono.just(new MyObject("Hello, " + name)) .delayElement(Duration.ofMillis(500)) // Simula latencia .flatMap(obj -> ServerResponse.ok() // Construye la respuesta HTTP 200 .contentType(MediaType.APPLICATION_JSON) // Define el tipo de contenido .bodyValue(obj)); // Pone el objeto en el cuerpo de la respuesta } public Mono<ServerResponse> createObject(ServerRequest request) { return request.bodyToMono(MyObject.class) // Extrae el cuerpo a un Mono<MyObject> .flatMap(obj -> { System.out.println("Recibido objeto (Funcional): " + obj.getMessage()); // Simula guardar return Mono.just("Object saved (Funcional) with ID: " + obj.getMessage().hashCode()) .delayElement(Duration.ofMillis(300)); }) .flatMap(responseString -> ServerResponse.status(HttpStatus.CREATED) // Construye respuesta 201 Created .contentType(MediaType.TEXT_PLAIN) .bodyValue(responseString)); } }Luego, el
RouterFunction(en una clase de configuración, por ejemplo):// En una clase de configuración, por ejemplo, src/main/java/com/example/demo/config/RoutingConfig.java @Configuration public class RoutingConfig { @Bean public RouterFunction<ServerResponse> route(GreetingHandler greetingHandler) { return RouterFunctions.route(GET("/api/functional/greeting/{name}").and(accept(MediaType.APPLICATION_JSON)), greetingHandler::getGreeting) .andRoute(POST("/api/functional/objects").and(contentType(MediaType.APPLICATION_JSON)), greetingHandler::createObject); // Combina con otras rutas } }¿Cuándo elegirías anotaciones vs. endpoints funcionales?
- Anotaciones: Ideal para proyectos que migran de Spring MVC, equipos familiarizados con el modelo de anotaciones, o APIs REST con estructuras estándar. Es a menudo más rápido de implementar para casos simples o CRUDs.
- Funcionales: Preferible para APIs con lógica de ruteo compleja o dinámica, si buscas una mayor separación de preocupaciones para facilitar el testing unitario de la lógica del manejador, o si simplemente prefieres un estilo más funcional y programático. Puede tener una curva de aprendizaje inicial si no estás acostumbrado.
Conclusión
En esta segunda entrega, hemos explorado la arquitectura fundamental de Spring WebFlux, entendiendo cómo su modelo no bloqueante basado en EventLoop y servidores como Netty le permite manejar eficientemente la alta concurrencia, a diferencia del modelo tradicional de Spring MVC. También hemos aprendido las dos vías principales para construir endpoints: el familiar enfoque basado en anotaciones (adaptado para devolver tipos reactivos) y el modelo más programático y funcional de RouterFunction y HandlerFunction, comprendiendo las fortalezas de cada uno y cuándo considerar usarlos.
Con la arquitectura y la creación de endpoints cubiertas, estamos listos para abordar la interacción de nuestra aplicación WebFlux con el mundo exterior y el manejo de datos y errores. En la próxima parte, nos sumergiremos en el uso de WebClient para consumir servicios externos reactivamente, la integración con bases de datos reactivas (R2DBC, drivers NoSQL) y las estrategias para gestionar errores en los flujos reactivos.
¡Hasta la próxima entrega de nuestra serie sobre WebFlux!
Kafka 5: Más Allá del Core, Explorando el Ecosistema de Apache Kafka
- Mauricio ECR
- Arquitectura
- 10 May, 2025
Hemos navegado por las entrañas de Apache Kafka, comprendiendo su funcionamiento interno, la interacción entre productores y consumidores, e incluso cómo procesar datos en tiempo real con Kafka Stream
Kafka 5: Más Allá del Core, Explorando el Ecosistema de Apache Kafka
- Mauricio ECR
- Arquitectura
- 10 May, 2025
Hemos navegado por las entrañas de Apache Kafka, comprendiendo su funcionamiento interno, la interacción entre productores y consumidores, e incluso cómo procesar datos en tiempo real con Kafka Streams y ksqlDB. Sin embargo, en un entorno de producción, Kafka rara vez opera de forma aislada. Para construir pipelines de datos completas, robustas y fáciles de gestionar a escala, se necesita un conjunto de herramientas y componentes que complementen sus capacidades fundamentales.
Este artículo se sumerge en el vibrante ecosistema que rodea a Kafka, destacando herramientas clave que simplifican tareas críticas como la gestión de esquemas de datos, la integración con sistemas externos y la monitorización del clúster. Una parte significativa de estas herramientas ha sido desarrollada por Confluent, la empresa fundada por los creadores originales de Kafka, aunque también exploraremos alternativas open-source relevantes. Entender este ecosistema es crucial para llevar tus proyectos de Kafka de una prueba de concepto a una operación a escala en producción.
La Confluent Platform y el Ecosistema Kafka
Si bien Apache Kafka es el corazón del sistema de streaming de eventos, la Confluent Platform es un conjunto de herramientas y servicios, que incluyen componentes tanto open-source como comerciales, diseñados para extender las capacidades de Kafka y facilitar su uso en entornos empresariales. Exploraremos algunos de los componentes más relevantes de este ecosistema.
Confluent Schema Registry: El Guardián de Tus Datos
En arquitecturas basadas en eventos donde múltiples aplicaciones interactúan con Kafka (leyendo y escribiendo datos), la gestión de los formatos o esquemas de esos datos es fundamental. Sin una gestión centralizada, un productor podría enviar datos en un formato inesperado, causando fallos en los consumidores que esperan un formato diferente. Aquí es donde el Schema Registry se vuelve indispensable.
El Confluent Schema Registry es un almacén centralizado y distribuido diseñado específicamente para gestionar esquemas de datos. Funciona especialmente bien con formatos de serialización basados en esquema como Avro, Protobuf o JSON Schema. Los productores pueden registrar el esquema de los mensajes que publican en el Registry, y los consumidores, al leer estos mensajes, pueden obtener el esquema correspondiente del Registry para deserializar los datos correctamente.
Los beneficios clave del Schema Registry son varios:
- Gestión Centralizada: Todos los esquemas se almacenan en un único lugar, lo que simplifica su descubrimiento y gestión.
- Validación de Esquemas: Los productores pueden configurarse para validar los mensajes contra el esquema registrado antes de publicarlos, lo que previene que datos mal formados lleguen a los topics de Kafka.
- Evolución de Esquemas con Compatibilidad: Permite definir reglas de compatibilidad (como
BACKWARD,FORWARD,FULL) para controlar cómo los esquemas pueden cambiar con el tiempo. Si se intenta registrar una nueva versión de un esquema que rompe la compatibilidad según la regla definida, el Registry lo impide. Esto es crucial para garantizar que los consumidores existentes puedan seguir procesando datos producidos con esquemas nuevos o viceversa, facilitando que las aplicaciones evolucionen de forma independiente.
Ejemplo Práctico de Evolución de Esquemas
Consideremos un esquema inicial para un usuario (User_v1) con campos id (entero) y name (cadena).
{
"type": "record",
"name": "User",
"fields": [
{"name": "id", "type": "int"},
{"name": "name", "type": "string"}
]
}
Si queremos añadir un campo opcional email, creamos User_v2 con la regla BACKWARD. Un consumidor usando User_v1 aún podrá leer mensajes de User_v2 ignorando el nuevo campo email, mientras que los consumidores nuevos podrán usarlo.
{
"fields": [
{"name": "id", "type": "int"},
{"name": "name", "type": "string"},
{"name": "email", "type": ["null", "string"], "default": null}
]
}
Sin embargo, intentar eliminar el campo name en User_v3 con una regla FULL (que requiere compatibilidad bidireccional) sería rechazado por el Schema Registry porque rompería a los consumidores antiguos que esperan el campo name. Esto demuestra cómo el Registry previene errores en producción.
Mejores Prácticas para Schema Registry:
- Es recomendable usar Avro para la serialización debido a su eficiencia binaria y excelente soporte para la evolución de esquemas.
- Define reglas de compatibilidad según el ciclo de vida de tus datos y despliegues.
BACKWARDes ideal si los consumidores se actualizan gradualmente después de los productores. - Valida la compatibilidad de los esquemas en tus procesos de Integración Continua/Despliegue Continuo (CI/CD) para detectar problemas antes de llegar a producción.
- Considera usar "subjects" con sufijos de entorno (ej:
user-dev,user-prod) para aislar versiones de esquemas en diferentes entornos. - Existe una alternativa open-source al Confluent Schema Registry llamada Apicurio Registry.
Caso de Uso Real:
Plataformas de pagos que necesitan evolucionar sus modelos de transacciones añadiendo nuevos campos (ej: tipo de divisa) sin romper los sistemas de conciliación o antifraude que usan esquemas más antiguos.
Kafka Connect: El Puente hacia Otros Sistemas
Kafka Connect es un framework open-source (parte de Apache Kafka) diseñado para conectar Kafka con otros sistemas de datos de forma escalable y fiable. Permite importar datos a Kafka (conectores fuente o Source Connectors) o exportar datos desde Kafka (conectores sumidero o Sink Connectors) sin necesidad de escribir código de integración personalizado.
Kafka Connect se ejecuta como un clúster separado de workers que gestionan el ciclo de vida de los conectores. Cada conector es una instancia de una tarea de integración específica, configurada para leer o escribir datos de un sistema particular.
Modos de Implementación:
- Standalone: Ideal para desarrollo o pruebas. Un solo proceso maneja todas las tareas del conector. La configuración es simple usando un archivo
.properties. - Distribuido: Para entornos de producción. Múltiples workers se coordinan a través de una REST API. Este modo es escalable y tolerante a fallos; si un worker falla, otro retoma sus tareas. Se recomienda usar al menos 3 workers en producción para tolerancia a fallos.
Gestión de Offsets:
Una de las grandes ventajas de Kafka Connect es su gestión automática de offsets. Los conectores fuente almacenan su progreso (el último offset leído del sistema de origen) en topics internos de Kafka (llamados connect-offsets). En caso de fallo o reinicio, el conector puede retomar la ingesta de datos exactamente desde el último offset guardado, garantizando la entrega "at least once" o "exactly once" dependiendo del conector y la configuración.
Ejemplos Populares de Conectores:
- Debezium: Un conjunto de Source Connectors open-source para Change Data Capture (CDC). Debezium monitoriza bases de datos (como MySQL, PostgreSQL, MongoDB) a nivel de log transaccional y publica todos los cambios (inserciones, actualizaciones, eliminaciones) como flujos de eventos en topics de Kafka. Esto permite reaccionar a los cambios en la base de datos en tiempo real y construir arquitecturas basadas en eventos.
- JDBC Connector: Un conector genérico que puede funcionar como Source (lee datos de bases de datos relacionales vía JDBC y los publica en Kafka) o como Sink (lee datos de Kafka y los escribe en bases de datos relacionales).
- Otros conectores populares incluyen los de S3, Elasticsearch, HDFS, GCS, y muchos más. Puedes descubrir y probar cientos de conectores listos para usar en Confluent Hub.
Mejores Prácticas para Kafka Connect:
- Prioriza el uso de conectores oficiales o aquellos mantenidos activamente por comunidades robustas (verifica en Confluent Hub).
- Monitoriza métricas clave por conector, como
source-record-poll-rate(ritmo de lectura del origen) ysink-record-send-rate(ritmo de escritura al destino) para evaluar su rendimiento.
Caso de Uso Real:
Sincronización en tiempo real entre bases de datos transaccionales y data warehouses. Por ejemplo, usando Debezium para capturar cambios en una base de datos MySQL/PostgreSQL y publicarlos en Kafka, y luego un JDBC Sink Connector para exportar esos datos a un data warehouse como Snowflake o BigQuery. Esto moderniza arquitecturas legacy convirtiendo bases de datos en streams de eventos sin código personalizado.
Otras Herramientas de Confluent Platform (Comerciales y Open-Source)
- REST Proxy: Expone la API de Kafka a través de HTTP, lo que puede ser ideal para microservicios ligeros o entornos con restricciones de librerías cliente.
- MirrorMaker 2: Una herramienta para sincronizar topics entre clústeres de Kafka. Es invaluable para replicación multi-datacenter, migraciones o estrategias de recuperación ante desastres (DR - Disaster Recovery).
Monitorización y Gestión: Mantén el Control
Conforme un clúster de Kafka crece en tamaño y complejidad (más topics, particiones, productores, consumidores), monitorizar su salud, rendimiento y el flujo de datos se vuelve absolutamente esencial.
Confluent Control Center:
Control Center es una herramienta de interfaz gráfica que forma parte de la Confluent Platform comercial (no es open-source Apache Kafka). Proporciona una visibilidad integral del clúster. Permite:
- Visualizar la topología del clúster, incluyendo brokers, topics y consumidores.
- Monitorizar métricas clave de rendimiento como throughput, latencia, y tasa de errores para brokers, productores y consumidores.
- Inspeccionar datos dentro de los topics (ver mensajes).
- Gestionar topics (crear, eliminar, modificar).
- Monitorizar y gestionar aplicaciones de Kafka Connect y Kafka Streams.
- Visualizar el flujo de datos de extremo a extremo a través de la función "Data Lineage" (rastreo del origen y destino de los datos). Control Center puede alertar sobre problemas como el consumer lag (retraso de los consumidores).
Alternativas Open-Source para Monitorización:
Existen potentes alternativas open-source para la monitorización y gestión.
- Prometheus + Grafana: Una combinación muy común para el scraping y visualización de métricas. Puedes exportar métricas JMX de Kafka usando herramientas como el JMX Exporter y crear dashboards personalizados en Grafana para métricas clave (throughput, latencia, consumer lag, uso de disco, etc.). Prometheus permite configurar alertas basadas en estas métricas.
- Kafdrop: Una interfaz web ligera y fácil de usar para explorar topics, particiones, líderes y ver mensajes en tiempo real. Es útil para inspecciones rápidas sin configuración compleja. Se puede desplegar fácilmente con Docker.
- Kafka Manager: Una herramienta de gestión de clústeres que permite tareas como la creación y modificación de topics.
- Cruise Control: Desarrollado por LinkedIn, es una herramienta open-source para el balanceo automático de particiones y la optimización de clústeres. Ayuda a optimizar la distribución de réplicas para evitar "nodos calientes" (hotspots) y puede ayudar en la autorrecuperación de brokers.
Operadores Kubernetes para Despliegues Cloud-Native
Para entornos que utilizan Kubernetes (K8s), los operadores simplifican enormemente el despliegue, escalado, y operaciones de Kafka.
- Strimzi: Un operador muy popular para desplegar, escalar y gestionar Kafka sobre K8s.
- Banzaicloud Kafka Operator: Similar a Strimzi, con un enfoque en multitenancy y GitOps.
Estos operadores aseguran alta disponibilidad y portabilidad de tu clúster Kafka en la nube.
Ecosistema Alternativo: Más Allá de Apache Kafka Core
Aunque Apache Kafka es el líder indiscutible en el espacio del streaming de eventos distribuidos open-source, es importante saber que existen otras plataformas con arquitecturas diferentes que podrían ser más adecuadas para casos de uso específicos. Dos alternativas open-source notables son:
- Redpanda: Una plataforma de streaming de datos compatible con la API de Kafka, escrita en C++. Su objetivo es ser más simple de operar, más rápida y sin la dependencia de ZooKeeper (utiliza un motor Raft integrado, similar a KRaft en las versiones recientes de Kafka). Se posiciona como una opción de alto rendimiento y menor latencia (1-10 ms frente a 10-50 ms de Kafka), especialmente atractiva en entornos de edge computing o donde la simplicidad operativa y baja latencia son primordiales. La comunidad es aún más pequeña que la de Kafka.
- Apache Pulsar: Una plataforma de mensajería y streaming distribuida con una arquitectura desacoplada de almacenamiento y servicio. A diferencia de Kafka, donde los brokers almacenan los datos, Pulsar utiliza una capa de almacenamiento separada basada en Apache BookKeeper (un log de commits distribuido). Esta separación permite escalar la capacidad de almacenamiento y servicio de forma independiente y ofrece características avanzadas como "tiered storage" nativo (mover datos antiguos a almacenamiento más barato). Pulsar también soporta múltiples modelos de suscripción (exclusivo, compartido, failover), a diferencia de los Consumer Groups de Kafka. Tiene un concepto nativo de "multi-tenancy". Es una alternativa potente con un conjunto de características diferente, aunque con potencialmente mayor complejidad de operación. Su latencia es baja (5-20 ms).
Comparativa Rápida: Kafka vs Redpanda vs Pulsar
| Característica | Apache Kafka | Redpanda | Apache Pulsar |
|---|---|---|---|
| Arquitectura | Broker + ZooKeeper/KRaft | Single binary, Raft (sin ZK) | Broker + BookKeeper (almac. sep.) |
| Latencia | Moderada (10-50 ms) | Muy baja (1-10 ms) | Baja (5-20 ms) |
| Tiered Storage | Sí (vía extensiones/Confluent) | No | Sí (nativo) |
| Modelos Consumer | Consumer Groups | Consumer Groups | Suscripciones (exclusivo, compartido, failover) |
| Escalabilidad | Alta | Alta | Muy Alta (por desacoplamiento) |
| Caso de Uso Ideal | Ecosistema maduro, procesamiento | Edge computing, baja latencia, simplicidad | Multi-tenancy, escalabilidad extrema |
Es importante notar que las alternativas (Redpanda/Pulsar) pueden no ser 100% compatibles con todas las APIs de Kafka.
Flujo de Datos de Extremo a Extremo (Ejemplo Integrado)
Para ilustrar cómo encajan estas piezas, consideremos un pipeline típico:
┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ ┌─────────────────┐
│ Database │──▶│Debezium (CDC)│──▶│ Kafka Topic (Avro) │──▶│Kafka Streams App│
└─────────────┘ └─────────────┘ └─────────────────────┘ └─────────────────┘
▲ ▲ ▲ │
│ Schema Registry │ │ (Validation) │ (Processing)
▼ │ │ ▼
┌────────────────┐ ┌─────────────────┐ ┌────────────────┐ ┌────────────────┐
│Monitorización │◀──│ Kafka Connect │◀──│ Kafka Topic │◀── │ Kafka Streams │
│(Control Center,│ │ (JDBC Sink) │ │ (Enriched Data)│ │ (Results) │
│Prometheus) │ └─────────────────┘ └────────────────┘ └────────────────┘
└────────────────┘ │
│ (Export)
▼
┌────────────────┐
│Data Warehouse │
└────────────────┘
- Ingesta: Debezium captura cambios de una tabla PostgreSQL (
users) y los publica en un topic de Kafka (postgres.public.users). El Schema Registry valida que los mensajes Avro cumplan con el esquema esperado (User_v2). - Procesamiento: Una aplicación Kafka Streams consume datos del topic de origen, los enriquece (ej: agrega geolocalización) y escribe los resultados en un nuevo topic (
users-enriched). - Exportación: Un JDBC Sink Connector consume los datos enriquecidos del topic
users-enrichedy los inserta en un Data Warehouse como BigQuery. El conector gestiona automáticamente sus offsets. - Monitorización: Confluent Control Center o una combinación de Prometheus + Grafana monitoriza el rendimiento de todo el pipeline. Se pueden configurar alertas si el consumer lag del Sink Connector excede un umbral o si la latencia de los brokers aumenta significativamente.
Este ejemplo demuestra cómo el ecosistema completo transforma una base de datos estática en un flujo de eventos dinámico que alimenta procesamiento en tiempo real y analítica.
Checklist Rápido de Herramientas por Necesidad
| Necesidad | Herramienta Recomendada | Alternativa Open-Source |
|---|---|---|
| Gestión de esquemas | Confluent Schema Registry | Apicurio Registry |
| CDC (Bases de datos) | Debezium | No hay equivalente directo |
| Integración genérica | Kafka Connect (Source/Sink) | - |
| Acceso vía HTTP | Confluent REST Proxy | - |
| Sincronización clúster | MirrorMaker 2 | - |
| Monitorización/Gestión | Confluent Control Center | Prometheus + Grafana, Kafdrop, Kafka Manager |
| Balanceo/Optimización | Cruise Control | - |
| Despliegue en K8s | Strimzi, Banzaicloud Operator | - |
| Plataforma simplificada | Redpanda | - |
| Multi-tenancy, tiered | Apache Pulsar | - |
⚠ Importante (Advertencias Comunes) ⚠
- No intentes usar Schema Registry con formatos como JSON genérico; úsalo con Avro, Protobuf o JSON Schema para beneficiarte de la validación y compatibilidad.
- Kafka Connect requiere tuning de los workers y la configuración de los conectores para lograr un alto throughput y eficiencia.
- Si bien Redpanda y Pulsar son alternativas potentes, no son 100% compatibles con todas las APIs y herramientas del ecosistema de Kafka. Investiga si tus librerías o herramientas específicas son compatibles antes de elegirlas.
Conclusión: El Poder del Ecosistema
Hemos ampliado nuestra perspectiva más allá del núcleo de Apache Kafka para explorar el valioso ecosistema de herramientas y componentes que lo rodean. Vimos cómo Schema Registry resuelve el desafío crítico de la gestión de esquemas en un entorno dinámico, cómo Kafka Connect simplifica enormemente la integración con sistemas externos a través de una rica variedad de conectores (como Debezium para CDC). Exploramos cómo herramientas de monitorización y gestión como Control Center (comercial) o las alternativas open-source como Prometheus+Grafana y Kafdrop proporcionan la visibilidad necesaria para operar Kafka en producción a escala. También echamos un vistazo a alternativas open-source como Redpanda y Apache Pulsar, reconociendo la diversidad en el paisaje del streaming de datos.
El verdadero poder de Kafka emerge cuando se integra con un sólido ecosistema. Schema Registry garantiza la integridad y evolución controlada de tus datos. Kafka Connect y el REST Proxy facilitan la ingesta y exposición de eventos. MirrorMaker 2 y los operadores nativos de Kubernetes aseguran alta disponibilidad y portabilidad. Y un adecuado stack de monitorización te dará la visibilidad total necesaria para operar sistemas de misión crítica.
La selección de herramientas dependerá de las necesidades específicas de tu proyecto. Para entornos cloud o donde buscas reducir la carga operativa, considera Confluent Cloud (que integra Schema Registry, Connect y Control Center) o Redpanda Cloud. Si trabajas con arquitecturas legacy que usan bases de datos, Kafka Connect + Debezium es ideal para modernizar con CDC. Equipos pequeños pueden beneficiarse de la simplicidad operativa de Redpanda o soluciones gestionadas. Y para escenarios de multi-tenancy, Apache Pulsar ofrece capacidades nativas robustas.
Con un conocimiento sólido de Kafka, sus componentes clave, la interacción entre productores/consumidores, las capacidades de procesamiento de stream y las herramientas que lo complementan, estamos listos para abordar aspectos prácticos y críticos de su despliegue y operación.
En el próximo artículo, profundizaremos precisamente en el Despliegue, la Seguridad y la Optimización de un clúster de Kafka. Cubriremos temas como opciones de despliegue (incluyendo Strimzi en K8s), cómo asegurar tu clúster con TLS y ACLs, y técnicas para ajustar su rendimiento (tuning de particiones, GC de JVM). También exploraremos herramientas emergentes como Flink (procesamiento avanzado con estado) o Quarkus (construir aplicaciones Kafka nativas en Kubernetes).
Con estas piezas colocadas, estarás listo para transformar tus pruebas de concepto en pipelines de datos robustos y listos para producción de misión crítica. ¡Nos vemos allí!
Spring WebFlux 1: Fundamentos Reactivos y el Corazón de Reactor
- Mauricio ECR
- Arquitectura
- 08 May, 2025
¡Hola, entusiasta del desarrollo moderno! 👋 En el vertiginoso mundo de las aplicaciones web, donde la escalabilidad y la eficiencia son reyes, ha surgido un paradigma que desafía el modelo tradicion
Spring WebFlux 1: Fundamentos Reactivos y el Corazón de Reactor
- Mauricio ECR
- Arquitectura
- 08 May, 2025
¡Hola, entusiasta del desarrollo moderno! 👋
En el vertiginoso mundo de las aplicaciones web, donde la escalabilidad y la eficiencia son reyes, ha surgido un paradigma que desafía el modelo tradicional de solicitud-respuesta síncrono: la Programación Reactiva. Y si trabajas con Spring, inevitablemente te encontrarás con Spring WebFlux, la respuesta de este popular framework a este emocionante cambio.
Prepararte para una entrevista sobre WebFlux implica comprender no solo cómo usarlo, sino por qué existe y cómo funciona por dentro. En esta primera entrega de nuestra serie, sentaremos las bases, explorando los principios reactivos y conociendo a Project Reactor, la biblioteca que impulsa WebFlux.
1. Fundamentos de Programación Reactiva y el "Por Qué" de WebFlux
Imagínate un restaurante. En el modelo tradicional (síncrono), un camarero toma una orden (petición), va a la cocina y espera a que el plato esté listo para llevarlo a la mesa. Mientras espera, no puede atender a nadie más. Si el restaurante se llena, necesitas más camareros (hilos) esperando. Esto escala, pero llega un punto en que tener demasiados camareros se vuelve ineficiente (consumo de memoria, sobrecarga del planificador de hilos).
Ahora, imagina un modelo diferente. El camarero toma la orden, la lleva a la cocina y, en lugar de esperar, vuelve a tomar más órdenes. Cuando un plato está listo, el cocinero avisa, y el camarero que esté libre lo recoge y lo lleva. Este es el modelo reactivo/asíncrono/no bloqueante. Los camareros (hilos) no se quedan inactivos esperando; están constantemente haciendo algo útil.
Teoría: ¿Qué es la Programación Reactiva?
La Programación Reactiva es un paradigma de programación que se centra en trabajar con flujos de datos asíncronos que reaccionan a cambios. No es solo sobre asincronía; es sobre gestionar la propagación de cambios y el manejo de "eventos" de manera eficiente y no bloqueante.
Aunque existe un "Reactive Manifesto" que define los principios de sistemas reactivos (responsivos, resilientes, elásticos y basados en mensajes), en el contexto de la programación reactiva a nivel de código, nos enfocamos más en cómo manejamos esos flujos de datos asíncronos.
Programación Síncrona vs. Asíncrona vs. No Bloqueante vs. Reactiva
Es crucial entender estas diferencias:
- Síncrona: Las operaciones se ejecutan secuencialmente. Una operación debe completarse antes de que la siguiente pueda comenzar. Un hilo realiza una tarea de principio a fin.
- Asíncrona: Una operación se inicia y el programa continúa ejecutando otras tareas sin esperar a que la primera termine. Cuando la operación asíncrona finaliza, a menudo notifica al programa (por ejemplo, a través de un callback o una promesa).
- No Bloqueante: Un subconjunto importante de la programación asíncrona. Una llamada a una función no bloqueante regresa inmediatamente, incluso si la operación solicitada no se ha completado. Si el resultado no está disponible, a menudo devuelve un valor especial (como
nullo un indicador de "pendiente"). No bloquea el hilo llamador. - Reactiva: Un estilo de programación que utiliza flujos de datos asíncronos y no bloqueantes. Se basa en el patrón Observer, donde un "Publisher" emite elementos y un "Subscriber" los consume reaccionando a ellos. Permite componer operaciones complejas sobre estos flujos de manera declarativa.
El Problema del Bloqueo (Thread per Request):
En las arquitecturas web tradicionales (como Spring MVC sobre Servlet API), el modelo común es "un hilo por petición". Cuando una petición llega, se le asigna un hilo del pool. Si esa petición necesita interactuar con algo lento (una base de datos, un servicio externo, una espera de I/O), el hilo asignado se bloquea esperando. Mientras está bloqueado, no puede atender otras peticiones. En escenarios de alto tráfico o latencia, esto lleva a:
- Agotamiento del pool de hilos.
- Alta demanda de recursos del sistema (memoria, CPU por el cambio de contexto entre muchos hilos).
- Disminución del rendimiento y la capacidad de respuesta.
La programación reactiva y WebFlux resuelven esto utilizando un modelo basado en eventos y no bloqueante. Un pequeño número de hilos (a menudo llamados Event Loop threads) maneja muchas peticiones concurrentemente. Cuando una operación de I/O es necesaria, el hilo no espera; delega la operación al sistema operativo y se libera para manejar otras peticiones. Cuando el resultado de la operación de I/O está listo, el sistema operativo notifica a uno de los hilos del Event Loop, que entonces procesa la respuesta.
Ventajas de Usar WebFlux
- Escalabilidad: Maneja un gran número de conexiones concurrentes con un número reducido de hilos, lo que se traduce en una mejor utilización de recursos y mayor capacidad para escalar horizontalmente.
- Uso Eficiente de Recursos: Menos hilos significan menos consumo de memoria y menos sobrecarga del planificador de hilos.
- Manejo de Latencia: Al no bloquear hilos en operaciones de I/O, la aplicación sigue siendo receptiva incluso cuando depende de servicios lentos o tiene alta latencia.
- Composición de Flujos Asíncronos: El modelo reactivo basado en operadores facilita la construcción de lógica compleja que involucra múltiples operaciones asíncronas.
¿Cuándo NO Usar WebFlux?
WebFlux no es una bala de plata para todos los casos. Hay situaciones donde Spring MVC tradicional puede ser más adecuado:
- Aplicaciones CPU-Bound: Si tu aplicación realiza principalmente cálculos intensivos que consumen mucha CPU, un modelo reactivo no te dará grandes beneficios en términos de escalabilidad, ya que los hilos estarán ocupados computando, no esperando I/O. De hecho, la sobrecarga del modelo reactivo podría ser detrimental.
- Aplicaciones Simples con Bajo Tráfico: Para APIs sencillas o aplicaciones internas con poca carga, la complejidad adicional de la programación reactiva puede no justificarse. El modelo síncrono de Spring MVC es a menudo más rápido de desarrollar en estos casos.
- Ecosistema Bloqueante: Si dependes fuertemente de bibliotecas o tecnologías que son inherentemente bloqueantes y no tienen alternativas reactivas, adoptar WebFlux implicará wrappers o adaptadores que pueden complicar el código.
Casos Típicos/Práctica
Hilo Bloqueado vs. Hilo No Bloqueado:
- Hilo Bloqueado: Imagina un hilo pidiendo datos a una base de datos y esperando pasivamente hasta que todos los datos llegan. Durante ese tiempo, el hilo no puede hacer nada más.
- Hilo No Bloqueado: El hilo pide los datos y, en lugar de esperar, le dice a la base de datos "avísame cuando tengas los datos". Luego, el hilo queda libre para procesar otra petición. Cuando la base de datos termina, notifica a un hilo disponible para que procese los resultados.
Escenario donde WebFlux Brilla: Una API Gateway que recibe miles de peticiones por segundo, cada una de las cuales necesita hacer varias llamadas a microservicios internos (con latencia variable) y a bases de datos antes de agregar y devolver la respuesta. En este escenario, un modelo tradicional agotaría rápidamente los hilos, mientras que WebFlux, al no bloquear, puede manejar la concurrencia eficientemente con muchos menos hilos.
¿Por qué Spring creó WebFlux si ya existía Spring MVC? Spring MVC se basa en la API de Servlets, que es fundamentalmente síncrona y bloqueante en su diseño original (aunque ha evolucionado). Para ofrecer una solución de programación reactiva y no bloqueante de extremo a extremo que pudiera competir con frameworks como Node.js o Vert.x en escenarios de alta concurrencia y I/O-bound, Spring necesitaba una arquitectura desde cero que no dependiera del modelo Servlet. WebFlux nació para llenar ese vacío, proporcionando una pila web completamente reactiva construida sobre bibliotecas como Reactor y servidores no bloqueantes como Netty.
2. Project Reactor: El Corazón de WebFlux
WebFlux no implementa la programación reactiva desde cero; se apoya en una biblioteca especializada para ello: Project Reactor. Reactor es una biblioteca de programación reactiva para JVM, basada en la especificación Reactive Streams, que define un estándar para el procesamiento de flujos de datos asíncronos con "backpressure".
Teoría: Conceptos Clave de Reactor
Reactor proporciona dos tipos principales para representar flujos de datos asíncronos:
- Mono: Representa un flujo reactivo que emite 0 o 1 elemento y luego se completa (o emite un error). Ideal para operaciones que devuelven un único resultado o ninguna (como guardar un registro, buscar por ID si existe, o una operación de borrado).
- Flux: Representa un flujo reactivo que emite 0 a N elementos y luego se completa (o emite un error). Ideal para operaciones que pueden devolver múltiples resultados (como buscar todos los usuarios, un stream de eventos, o resultados de una consulta paginada).
Estos tipos implementan la interfaz Publisher de Reactive Streams.
El modelo de Reactor (y Reactive Streams) se basa en cuatro interfaces principales:
- Publisher: Produce elementos (eventos). Es el origen de la secuencia. Solo tiene un método:
subscribe(Subscriber s). - Subscriber: Consume elementos emitidos por el Publisher. Define métodos de callback:
onSubscribe(Subscription s): Se invoca una vez cuando el Subscriber se suscribe exitosamente al Publisher. Recibe un objetoSubscription.onNext(T t): Se invoca para cada elemento emitido por el Publisher.onError(Throwable t): Se invoca si el Publisher encuentra un error. La secuencia termina.onComplete(): Se invoca cuando el Publisher ha terminado de emitir elementos exitosamente. La secuencia termina.
- Subscription: Representa la relación entre un Publisher y un Subscriber. Permite al Subscriber gestionar el flujo de datos (pedir más elementos - backpressure) o cancelar la suscripción. Métodos clave:
request(long n)ycancel(). - Operator: Son funciones puras que transforman, filtran, combinan o manipulan flujos. Reciben un Publisher como entrada y devuelven un nuevo Publisher. Encadenar operadores crea un pipeline reactivo.
El Ciclo de Vida de un Stream Reactivo
El ciclo de vida es fundamental:
- Un Subscriber se suscribe a un Publisher llamando a
publisher.subscribe(subscriber). - El Publisher, si acepta la suscripción, llama a
subscriber.onSubscribe(subscription), pasándole un objetoSubscription. - El Subscriber utiliza el objeto
Subscriptionpara solicitar elementos llamando asubscription.request(n). Esto es backpressure: el consumidor le dice al productor cuántos elementos está listo para manejar. - El Publisher emite elementos llamando a
subscriber.onNext(element)hasta que se alcanzan losnelementos solicitados o se agotan los elementos disponibles. - Este proceso de
request(n)yonNext(element)se repite. - Eventualmente, el Publisher terminará la secuencia llamando a
subscriber.onComplete()osubscriber.onError(error). Una vez queonCompleteoonErrorson llamados, la secuencia termina y no se emitirán más eventos. El Subscriber también puede cancelar la suscripción prematuramente llamando asubscription.cancel().
Importante: La ejecución real del flujo (el pushing de datos a través del pipeline) solo comienza cuando hay un Subscriber. Esto se conoce como lazy execution.
Operadores: ¿Qué son y por qué son importantes?
Los operadores son el poder de Reactor. Permiten construir lógica compleja sobre flujos de datos de manera declarativa y componible. Cada operador toma un Publisher de entrada y devuelve un nuevo Publisher modificado. Puedes encadenar múltiples operadores para construir una secuencia de procesamiento.
Ejemplos de categorías de operadores:
- Transformación:
map,flatMap,concatMap. - Filtrado:
filter,take,skip. - Combinación:
merge,zip,concat. - Manejo de Errores:
onErrorReturn,onErrorResume,doOnError. - Utilidad:
doOnNext,doOnComplete,delayElements.
Casos Típicos/Práctica
Diferencia entre Mono y Flux con ejemplos:
// Mono: Representa 0 o 1 elemento Mono<String> greeting = Mono.just("Hola Mundo"); // Emite "Hola Mundo" Mono<String> noValue = Mono.empty(); // Emite 0 elementos // Flux: Representa 0 a N elementos Flux<Integer> numbers = Flux.just(1, 2, 3, 4, 5); // Emite 1, 2, 3, 4, 5 Flux<String> greetings = Flux.fromIterable(Arrays.asList("Hello", "World", "Reactor")); // Emite "Hello", "World", "Reactor" Flux<Long> infinite = Flux.interval(Duration.ofSeconds(1)); // Emite un número cada segundo (infinito)- Ejemplo de Uso: Usarías un
Mono<User>para obtener los detalles de un usuario por su ID, y unFlux<Product>para obtener una lista de productos de una categoría.
- Ejemplo de Uso: Usarías un
Demostrar el uso de operadores comunes:
Flux.just(1, 2, 3, 4, 5) .filter(n -> n % 2 == 0) // Filtra solo números pares .map(n -> "Número par: " + n) // Transforma cada número en un String .subscribe(System.out::println); // Suscriptor que imprime cada elemento // Salida: // Número par: 2 // Número par: 4 Mono.just("spring") .map(String::toUpperCase) // Transforma a mayúsculas .subscribe(System.out::println); // Suscriptor // Salida: // SPRINGEntender bien
flatMapvsmap: ¡Crucial!map: Transforma cada elemento emitido por el origen sincrónicamente en otro elemento. Si la función de mapeo devuelve un tipo reactivo (MonooFlux), el resultado será unFluxdeMonos oFluxs anidados (unFlux<Mono<T>>oFlux<Flux<T>>), lo cual rara vez es lo que quieres.flatMap: Transforma cada elemento emitido por el origen en un nuevo Publisher (MonooFlux) y luego aplana (fusiona) los elementos de estos Publishers resultantes en un únicoFlux. Es ideal para operaciones asíncronas. El orden de los elementos resultantes no está garantizado conflatMapsi las operaciones internas tardan tiempos variables.concatMap: Similar aflatMap, pero garantiza que los Publishers internos se suscriban y emitan sus elementos en el mismo orden en que llegaron los elementos originales. Esto es útil cuando el orden es importante, pero puede ser menos eficiente queflatMapya que espera a que cada Publisher interno termine antes de procesar el siguiente.
// Ejemplo flatMap vs map Flux.just("Alpha", "Beta") .flatMap(word -> Mono.just(word.length()) // Crea un Mono con la longitud de la palabra (asíncrono o síncrono envuelto en Mono) .delayElement(Duration.ofMillis(word.length() * 100))) // Simula una operación asíncrona con retraso .subscribe(length -> System.out.println("flatMap - Longitud: " + length)); // Posible salida (el orden puede variar debido a delayElement y flatMap): // flatMap - Longitud: 5 // flatMap - Longitud: 4 Flux.just("Alpha", "Beta") .map(word -> Mono.just(word.length()) // Crea un Mono con la longitud .delayElement(Duration.ofMillis(word.length() * 100))) .subscribe(monoLength -> monoLength.subscribe(length -> System.out.println("map - Longitud: " + length))); // Necesitas suscribirte al Mono interno! // Salida (después de 500ms y 400ms): // map - Longitud: 5 // map - Longitud: 4 // ¡Fíjate que map devolvió un Flux<Mono<Integer>>! Tuvimos que suscribirnos a cada Mono. flatMap lo hizo automáticamente y aplanó el resultado. Flux.just("Alpha", "Beta") .concatMap(word -> Mono.just(word.length()) // Crea un Mono con la longitud .delayElement(Duration.ofMillis(word.length() * 100))) // Simula operación asíncrona con retraso .subscribe(length -> System.out.println("concatMap - Longitud: " + length)); // Salida (el orden está garantizado por concatMap): // concatMap - Longitud: 5 (espera 500ms) // concatMap - Longitud: 4 (luego espera 400ms)Secuencia que emita números y luego los transforme:
Flux.range(1, 10) // Emite números del 1 al 10 .map(n -> n * 2) // Multiplica cada número por 2 .filter(n -> n > 10) // Mantiene solo los resultados mayores que 10 .subscribe(result -> System.out.println("Resultado transformado: " + result), // onNext error -> System.err.println("Ocurrió un error: " + error), // onError () -> System.out.println("Secuencia completada.")); // onComplete // Salida: // Resultado transformado: 12 // Resultado transformado: 14 // Resultado transformado: 16 // Resultado transformado: 18 // Resultado transformado: 20 // Secuencia completada.¿Qué sucede si un Flux emite un error? ¿Cómo lo manejas? Cuando un Publisher emite un error a través de
onError(Throwable t), la secuencia termina inmediatamente. Ningún elemento posterior será emitido. El Subscriber recibe la notificaciónonError, y el flujo se detiene en ese punto. Para manejar errores de forma elegante, se usan operadores de manejo de errores (los veremos en detalle en un artículo posterior), comoonErrorReturn(devuelve un valor por defecto y completa),onErrorResume(cambia a un Publisher alternativo), oretry(intenta la secuencia de nuevo).subscribeOnvspublishOn: ¡Otro concepto fundamental! Controlan la ejecución concurrente.subscribeOn(Scheduler scheduler): Afecta el contexto de ejecución del Publisher original y toda la cadena de operadores subsiguiente hasta que se encuentra otropublishOn. Define en quéScheduler(un ejecutor de tareas, similar a un Thread Pool) se ejecutará el trabajo del Publisher y dónde comenzará el pipeline. Si hay múltiplessubscribeOn, solo el primero (el más cercano al Publisher) tiene efecto.publishOn(Scheduler scheduler): Afecta el contexto de ejecución de los operadores que le siguen en la cadena, no los que están antes o el Publisher original. Es útil para cambiar de contexto de ejecución en medio de un pipeline, por ejemplo, para pasar del hilo rápido de I/O a un pool de hilos de trabajo para una operación intensiva en CPU. Puede haber múltiplespublishOnen una cadena, cada uno afectando a la parte del pipeline que le sigue.
Scheduler ioScheduler = Schedulers.boundedElastic(); // Scheduler adecuado para I/O Scheduler computationScheduler = Schedulers.parallel(); // Scheduler adecuado para CPU-bound Flux.range(1, 5) .map(i -> { System.out.println("Map 1 en hilo: " + Thread.currentThread().getName()); return i * 2; }) .publishOn(computationScheduler) // Los operadores que siguen se ejecutarán aquí .map(i -> { System.out.println("Map 2 en hilo: " + Thread.currentThread().getName()); return i + 1; }) .subscribeOn(ioScheduler) // El Publisher original y todo comienza aquí (si no hay publishOn antes) .subscribe(result -> System.out.println("Subscripción en hilo: " + Thread.currentThread().getName() + " - Resultado: " + result)); // Posible Salida (los nombres de hilos variarán): // Map 1 en hilo: boundedElastic-1 // Map 1 en hilo: boundedElastic-1 // Map 1 en hilo: boundedElastic-1 // Map 1 en hilo: boundedElastic-1 // Map 1 en hilo: boundedElastic-1 // Map 2 en hilo: parallel-1 // Subscripción en hilo: parallel-1 - Resultado: 3 // Map 2 en hilo: parallel-2 // Subscripción en hilo: parallel-2 - Resultado: 5 // Map 2 en hilo: parallel-3 // Subscripción en hilo: parallel-3 - Resultado: 7 // Map 2 en hilo: parallel-4 // Subscripción en hilo: parallel-4 - Resultado: 9 // Map 2 en hilo: parallel-1 // Subscripción en hilo: parallel-1 - Resultado: 11 // Observa cómo el primer map se ejecuta en el scheduler de subscribeOn (boundedElastic), // mientras que el segundo map y la subscripción se ejecutan en el scheduler de publishOn (parallel).- Cuándo usar cada uno:
- Usa
subscribeOncerca del origen de tu stream (elPublisherque quizás interactúa con una API bloqueante envuelta o realiza una operación de I/O inicial) para asegurar que esa parte del trabajo no bloquee tus hilos principales. - Usa
publishOnpara cambiar de contexto de ejecución en medio del pipeline, por ejemplo, si después de una operación de I/O (que se ejecuta en un scheduler de I/O), necesitas realizar cálculos intensivos en CPU y quieres usar un pool de hilos diferente dedicado a la computación para no saturar los hilos de I/O.
- Usa
Conclusión
En esta primera parte, hemos desempacado los conceptos fundamentales que motivaron la creación de Spring WebFlux: los desafíos del bloqueo en arquitecturas tradicionales y cómo la programación reactiva, basada en flujos de datos asíncronos y no bloqueantes, ofrece una solución elegante y escalable. Hemos introducido Project Reactor como la biblioteca clave detrás de WebFlux, explorando sus tipos principales (Mono y Flux), el modelo Publisher/Subscriber/Subscription y la importancia de los operadores. Conceptos como flatMap vs map y subscribeOn vs publishOn son esenciales para dominar la programación reactiva con Reactor.
Comprender estas bases es el primer paso crucial. En la próxima entrega de esta serie, nos adentraremos en la arquitectura específica de Spring WebFlux y cómo se construyen las aplicaciones sobre este modelo reactivo, explorando el EventLoop y las diferencias arquitectónicas con Spring MVC.
¡Mantente reactivo!
Kafka 4: Procesamiento de Datos en Tiempo Real con Kafka Streams y ksqlDB
- Mauricio ECR
- Arquitectura
- 07 May, 2025
En los artículos anteriores, hemos construido una sólida comprensión de Apache Kafka: qué es, por qué es una plataforma líder para streaming de eventos, cómo está estructurado internamente con Topic
Kafka 4: Procesamiento de Datos en Tiempo Real con Kafka Streams y ksqlDB
- Mauricio ECR
- Arquitectura
- 07 May, 2025
En los artículos anteriores, hemos construido una sólida comprensión de Apache Kafka: qué es, por qué es una plataforma líder para streaming de eventos, cómo está estructurado internamente con Topics, Particiones y Brokers, y cómo Productores y Consumidores interactúan con él para enviar y recibir datos. Tenemos nuestra "tubería central de datos" funcionando y los datos fluyendo.
Pero la verdadera potencia de una plataforma de streaming de eventos no reside solo en mover datos de un punto a otro de forma fiable y escalable, sino en la capacidad de procesar esos datos a medida que llegan, es decir, en tiempo real. Aquí es donde entran en juego las herramientas de procesamiento de stream del ecosistema Kafka.
Este artículo se centra en dos componentes clave que facilitan la construcción de aplicaciones de procesamiento de datos directamente sobre Kafka: Kafka Streams, una potente biblioteca cliente para construir aplicaciones de procesamiento de stream en Java/Scala, y ksqlDB, una base de datos de streaming que permite procesar datos en Kafka utilizando una sintaxis SQL familiar. Exploraremos cómo estas herramientas te permiten transformar, agregar, enriquecer y analizar tus flujos de eventos para derivar valor de tus datos en movimiento.
Kafka Streams: Construyendo Aplicaciones de Procesamiento de Stream
Kafka Streams es una biblioteca cliente para Java y Scala que te permite construir aplicaciones que procesan datos almacenados en Kafka. No es un framework de procesamiento distribuido separado (como Spark o Flink, aunque estos también se integran bien con Kafka), sino una API que se integra directamente en tu aplicación Java/Scala estándar. Despliegas tu aplicación de Kafka Streams como cualquier otra aplicación, y se conecta al clúster de Kafka para leer datos de Topics de entrada, aplicar lógica de procesamiento y escribir resultados en Topics de salida.
La potencia de Kafka Streams radica en su capacidad para manejar la complejidad inherente del procesamiento de stream distribuido (gestión de estado, tiempo de procesamiento, tolerancia a fallos) de una manera relativamente sencilla para el desarrollador.
codigo mermaid
graph TD
subgraph Kafka Cluster
B[(Broker 1)]
B2[(Broker 2)]
B3[(Broker 3)]
end
subgraph Producers
P1[Producer App 1]
P2[Producer App 2]
end
subgraph Kafka Streams Application
ST[StreamsBuilder]
KT[KafkaStreams]
P[Processor API]
S[State Stores]
end
subgraph Consumers
C1[Consumer App 1]
C2[Consumer App 2]
end
P1 -->|publica en| B
P2 -->|publica en| B2
B -->|topic1| ST
B2 -->|topic2| ST
ST --> KT
KT -->|procesa| P
P -->|escribe en| S
KT -->|escribe en| B3
B3 -->|topic-output| C1
B3 -->|topic-output| C2
classDef kafka fill:#f9f,stroke:#333;
classDef app fill:#bbf,stroke:#333;
classDef stream fill:#9f9,stroke:#333;
class B,B2,B3,Z kafka;
class P1,P2,C1,C2 app;
class ST,KT,P,S stream;
Topologías: Streams, Tablas y State Stores
Kafka Streams introduce una abstracción fundamental para representar y procesar datos:
- Stream (KStream): Representa un flujo ilimitado de eventos inmutables. Piensa en un KStream como el log de commits de Kafka que has estado leyendo: una secuencia de eventos que ocurren a lo largo del tiempo. Cuando procesas un KStream, la lógica se aplica a cada evento individual a medida que llega.
- Table (KTable): Representa una vista materializada de un KStream o de un Topic. A diferencia de un KStream que representa la historia completa de eventos, una KTable representa el estado actual de la clave en el momento más reciente. Por ejemplo, un KStream podría contener todos los eventos de "actualización de saldo de cuenta", mientras que una KTable derivada de ese stream contendría el saldo actual de cada cuenta. Cuando llega un nuevo evento para una clave en un KTable, actualiza el valor existente para esa clave.
- State Stores: Para realizar operaciones con estado (como agregaciones o joins) que requieren recordar información de eventos pasados, Kafka Streams utiliza State Stores. Son bases de datos clave-valor locales (a menudo RocksDB, aunque configurables) asociadas a cada instancia de la aplicación de Kafka Streams. El estado se gestiona localmente para cada tarea de procesamiento de la aplicación, se mantiene sincronizado con réplicas en Kafka para tolerancia a fallos y se reestablece automáticamente en caso de fallos o rebalanceos.
Esta dualidad Stream/Table es clave. Puedes convertir un KStream en un KTable (por ejemplo, para obtener el último valor por clave) y viceversa (por ejemplo, para ver un stream de cambios en una tabla).
Operaciones: map, filter, aggregate, join y Más
Kafka Streams proporciona una rica API funcional para definir la lógica de procesamiento como una topología de procesadores conectados. Algunas operaciones comunes incluyen:
- Transformaciones sin estado:
map(transforma el valor de cada registro),filter(excluye registros que no cumplen una condición),flatMap(produce cero, uno o más registros de salida por cada registro de entrada), etc. - Transformaciones con estado:
- Agregaciones:
groupByKey,count,reduce,aggregate. Estas operaciones acumulan o combinan valores a lo largo del tiempo para una clave específica, manteniendo el estado en un State Store. - Joins:
join(une dos streams o un stream y una tabla basándose en una clave),leftJoin,outerJoin. Las operaciones de join a menudo requieren que uno o ambos lados del join mantengan estado (en State Stores) para poder encontrar coincidencias.
- Agregaciones:
- Ventanas (Windows): Las agregaciones y joins se realizan a menudo dentro de ventanas de tiempo (por ejemplo, contar eventos por minuto, unir eventos que ocurren en un lapso de 5 segundos). Kafka Streams soporta diferentes tipos de ventanas (ventanas de tiempo fijas, deslizantes, de sesión) y maneja la complejidad del tiempo de evento y tiempo de procesamiento.
Exactly-once Processing
Basándose en las capacidades transaccionales de Kafka (mencionadas en el Artículo 3), Kafka Streams puede ofrecer semántica de procesamiento exactly-once de extremo a extremo. Esto significa que cada evento se procesa exactamente una vez, y las actualizaciones de estado resultantes y los mensajes de salida se publican de forma atómica. Si una instancia de la aplicación falla, se reinicia y reanuda el procesamiento desde donde lo dejó sin perder ni duplicar datos, siempre y cuando los orígenes y destinos sean Topics de Kafka. Esto se habilita configurando processing.guarantee=exactly_once_v2.
KSQL (ahora ksqlDB): Streaming con Sintaxis SQL
ksqlDB (anteriormente KSQL) es una base de datos de streaming distribuida construida sobre Kafka. Permite a los desarrolladores definir aplicaciones de procesamiento de stream de forma interactiva utilizando una sintaxis similar a SQL, eliminando la necesidad de escribir código en Java o Scala para muchos casos de uso comunes.
codigo mermaid
graph TD
subgraph Kafka Cluster
B[(Broker 1)]
B2[(Broker 2)]
B3[(Broker 3)]
end
subgraph Data Sources
DB[(Database)]
API[Rest API]
IoT[IoT Devices]
end
subgraph ksqlDB Server
KSQL[ksqlDB Engine]
KQ[Queries Persistentes]
KS[Streams]
KT[Tables]
end
subgraph Consumers
DASH[Dashboard]
ALERTS[Alert System]
DW[Data Warehouse]
end
DB -->|Debezium CDC| B
API -->|Kafka Connect| B2
IoT -->|MQTT Proxy| B3
B --> KSQL
B2 --> KSQL
B3 --> KSQL
KSQL -->|Crea| KS
KSQL -->|Crea| KT
KSQL -->|Ejecuta| KQ
KQ -->|Escribe| B2
B2 --> DASH
B3 --> ALERTS
B --> DW
classDef kafka fill:#f9f,stroke:#333;
classDef source fill:#f96,stroke:#333;
classDef ksql fill:#6af,stroke:#333;
classDef consumer fill:#6f6,stroke:#333;
class B,B2,B3 kafka;
class DB,API,IoT source;
class KSQL,KQ,KS,KT ksql;
class DASH,ALERTS,DW consumer;
ksqlDB es ideal para:
- Transformación de datos (ETL ligero en tiempo real).
- Enriquecimiento de datos (unir un stream de eventos con datos de referencia en una tabla).
- Filtrado y enrutamiento de datos.
- Agregaciones y análisis en tiempo real.
- Creación de vistas materializadas (tablas) sobre streams de eventos.
Consultas Push/Pull
ksqlDB soporta dos tipos de consultas:
- Consultas Push (Push Queries): Son consultas continuas que se ejecutan indefinidamente. Producen resultados en tiempo real a medida que llegan nuevos eventos a los Topics de entrada. Se usan típicamente para crear nuevos streams o tablas persistentes basadas en transformaciones, filtros o agregaciones de otros streams/tablas.
- Consultas Pull (Pull Queries): Son consultas puntuales que se ejecutan una vez y retornan el estado actual de una tabla hasta el momento en que se ejecutó la consulta. Son útiles para obtener el valor actual de una clave o un agregado de una tabla (vista materializada).
Creación de Streams y Tablas
La sintaxis de ksqlDB es muy intuitiva para cualquiera familiarizado con SQL. Puedes definir STREAMS y TABLES sobre Topics de Kafka existentes y luego usar sentencias CREATE STREAM AS SELECT ... o CREATE TABLE AS SELECT ... para definir transformaciones continuas:
-- Crear un Stream a partir de un Topic existente
CREATE STREAM clicks (user_id VARCHAR, url VARCHAR, timestamp BIGINT)
WITH (kafka_topic='user-clicks', value_format='json', timestamp='timestamp');
-- Filtrar y proyectar datos de un Stream y enviarlos a un nuevo Topic
CREATE STREAM high_value_clicks AS
SELECT user_id, url
FROM clicks
WHERE user_id IN ('user123', 'user456');
-- Crear una Tabla (vista materializada) a partir de un Stream para contar clics por usuario
CREATE TABLE click_counts AS
SELECT user_id, COUNT(*)
FROM clicks
GROUP BY user_id;
-- Realizar una consulta Pull sobre la Tabla
SELECT * FROM click_counts WHERE user_id = 'user789';
Uso en Tiempo Real (ej: Detección de Anomalías)
ksqlDB es excelente para casos de uso de tiempo real relativamente sencillos como la detección de anomalías. Por ejemplo, podrías definir una tabla que cuente el número de eventos sospechosos por usuario en una ventana de 5 minutos, y luego consultar esa tabla para alertar si el recuento excede un umbral. O podrías unir un stream de transacciones con una tabla de información de clientes para identificar transacciones inusualmente grandes para clientes nuevos.
Aunque no es tan flexible o potente como Kafka Streams para lógica de procesamiento muy compleja, ksqlDB permite a los desarrolladores y analistas de datos interactuar con Kafka y procesar streams de forma ágil utilizando una interfaz declarativa.
Conclusión
En este artículo, hemos explorado cómo ir más allá de la simple ingesta y distribución de datos en Kafka para procesarlos activamente en tiempo real. Introducimos Kafka Streams como una biblioteca robusta para construir aplicaciones de procesamiento de stream con manejo de estado y garantías exactly-once, y ksqlDB como una interfaz SQL-like accesible para realizar transformaciones y agregaciones sobre streams de forma interactiva.
Estas herramientas nativas del ecosistema Kafka empoderan a los desarrolladores para construir arquitecturas reactivas y basadas en eventos donde el procesamiento de datos ocurre continuamente a medida que los eventos fluyen, en lugar de depender de procesamiento por lotes retrasado. Ya sea que necesites construir pipelines ETL en tiempo real, aplicaciones de monitoreo o sistemas de detección de fraude, Kafka Streams y ksqlDB ofrecen las capacidades necesarias.
Ahora que tenemos una comprensión sólida de la arquitectura de Kafka, cómo interactuar con ella (Productores/Consumidores) y cómo procesar los datos en tiempo real, es momento de mirar las herramientas y plataformas que complementan a Kafka y amplían sus capacidades, así como algunas alternativas notables en el espacio del streaming de datos. En el próximo artículo, exploraremos Confluent Platform y otras herramientas clave del ecosistema Kafka.
Kafka 3: Productores y Consumidores, Configuración y Buenas Prácticas
- Mauricio ECR
- Arquitectura
- 05 May, 2025
Hemos navegado por los conceptos esenciales de Apache Kafka y desentrañado la arquitectura que reside bajo la superficie, comprendiendo cómo los Topics se dividen en Particiones distribuidas entre Bro
Kafka 3: Productores y Consumidores, Configuración y Buenas Prácticas
- Mauricio ECR
- Arquitectura
- 05 May, 2025
Hemos navegado por los conceptos esenciales de Apache Kafka y desentrañado la arquitectura que reside bajo la superficie, comprendiendo cómo los Topics se dividen en Particiones distribuidas entre Brokers para lograr escalabilidad y tolerancia a fallos. Ahora que sabemos dónde se almacenan los datos y cómo se organizan, es momento de hablar de quién los pone ahí y quién los saca: los Productores y los Consumidores.
Estos dos componentes son la interfaz de interacción con el clúster de Kafka. Un productor es una aplicación que escribe datos en uno o varios Topics. Un consumidor es una aplicación que lee datos de uno o varios Topics. Aunque su función básica parece sencilla, hay matices importantes en su configuración y comportamiento que impactan directamente en la fiabilidad, el rendimiento y la semántica de procesamiento de tus aplicaciones.
En este artículo, nos sumergiremos en el mundo de los Productores y Consumidores, explorando sus configuraciones clave, las decisiones de diseño importantes que debes tomar al implementarlos y cómo garantizar diferentes niveles de garantías de entrega de mensajes. Este conocimiento es esencial para construir aplicaciones cliente de Kafka que sean robustas y eficientes.
Productores (Producers): Enviando Datos a Kafka
El Productor es la aplicación cliente encargada de publicar (escribir) datos en Topics dentro del clúster de Kafka. Su principal tarea es tomar los datos de tu aplicación, serializarlos en un formato de bytes adecuado y enviarlos a la partición correcta del Topic de destino.
Al diseñar e implementar un productor, hay varias configuraciones y consideraciones clave que influyen en el rendimiento y la fiabilidad:
Configuración Clave: acks, retries, linger.ms
Estas configuraciones determinan cómo el productor maneja los envíos de mensajes y las respuestas del broker, impactando directamente en la durabilidad y latencia:
- acks (Acknowledgments): Esta configuración es fundamental para la durabilidad de los datos. Controla el número de réplicas que deben confirmar la recepción de un mensaje antes de que el productor lo considere "escrito con éxito".
acks=0: El productor no espera confirmación del broker. Envía el mensaje y lo considera enviado inmediatamente. Ofrece la menor latencia y el mayor rendimiento, pero hay riesgo de perder mensajes si el broker líder falla justo después de recibir el mensaje.acks=1: El productor espera la confirmación solo del broker líder de la partición. Latencia moderada. Los mensajes son duraderos siempre y cuando el broker líder no falle después de confirmar y antes de que los seguidores repliquen el mensaje.acks=all(o-1): El productor espera la confirmación del broker líder y de todas las réplicas en el ISR (In-Sync Replicas). Es la configuración más fuerte en cuanto a durabilidad, garantizando que un mensaje no se pierda mientras haya al menos una réplica en el ISR disponible. Introduce la mayor latencia, pero es la más segura.
- retries: Especifica cuántas veces el productor intentará reenviar un mensaje temporalmente fallido (por ejemplo, debido a un error transitorio de red o un rebalanceo de líder). Combinado con
acks > 0, esto ayuda a garantizar la entrega. Sin embargo, los reintentos pueden llevar a la duplicación de mensajes en el lado del consumidor si los reintentos ocurren después de que el broker recibió el mensaje pero antes de que pudiera confirmar al productor (at-least-once). Paraexactly-oncese requiere idempotencia y transacciones. - linger.ms: Por defecto (
linger.ms=0), el productor envía los mensajes tan pronto como están listos.linger.msespecifica un tiempo en milisegundos que el productor esperará para acumular más mensajes en un lote antes de enviarlos al broker. Esto puede reducir el número de solicitudes enviadas y aumentar el rendimiento (throughput) general, aunque introduce una pequeña latencia artificial. Es un balance entre latencia y throughput. Un valor típico podría ser 5-100 ms.
Otras configuraciones importantes incluyen batch.size (tamaño máximo del lote a enviar) y buffer.memory (memoria del productor para almacenar mensajes pendientes).
Serialización
Antes de enviar un mensaje a Kafka, los datos de tu aplicación deben ser serializados a un array de bytes. De manera similar, el consumidor necesitará deserializarlos. Kafka es agnóstico al formato de los datos (solo ve bytes), pero elegir un formato de serialización adecuado es vital para la interoperabilidad y la evolución de esquemas. Opciones comunes incluyen:
- JSON: Fácil de usar y leer, pero menos eficiente en tamaño y puede tener problemas de compatibilidad al cambiar el esquema sin un registro de esquemas.
- Avro: Formato basado en esquema. Los esquemas se definen por separado y a menudo se gestionan con un Schema Registry. Ofrece compresión eficiente y compatibilidad de esquemas robusta. Es una elección muy popular en el ecosistema Kafka.
- Protobuf (Protocol Buffers) / Thrift: Formatos serialización eficientes y basados en esquema, desarrollados por Google y Apache respectivamente. Similares a Avro en sus ventajas.
Particionamiento Personalizado
Aunque el particionamiento por clave (hash) o round-robin son las estrategias por defecto y las más comunes, los productores pueden implementar una lógica de particionamiento personalizada si las necesidades lo requieren. Esto implica escribir una clase que implemente la interfaz Partitioner de Kafka y configurarla en el productor. Esto podría ser útil para dirigir mensajes a particiones específicas basándose en lógica de negocio compleja.
Consumidores (Consumers): Leyendo Datos de Kafka
El Consumidor es la aplicación cliente que lee mensajes de uno o varios Topics. A diferencia de muchos sistemas de mensajería donde el broker empuja mensajes al consumidor, en Kafka, el consumidor jala (pulls) mensajes de los brokers. Esta es una diferencia fundamental que le da al consumidor control sobre su ritmo de procesamiento.
Consumer Groups y Paralelismo
Para permitir que múltiples instancias de tu aplicación consuman los mismos datos de un Topic de forma concurrente y escalable, Kafka introduce el concepto de Consumer Groups. Un Consumer Group es un conjunto de uno o más consumidores que comparten una misma identidad (un group.id).
La clave del Consumer Group es cómo maneja las Particiones:
- Dentro de un Consumer Group, cada partición de un Topic es asignada a exactamente un consumidor dentro de ese grupo.
- Si hay más consumidores en el grupo que particiones en el Topic, algunos consumidores estarán inactivos (no se les asignará ninguna partición).
- Si hay menos consumidores que particiones, a algunos consumidores se les asignarán múltiples particiones.
Esto significa que el paralelismo de consumo está limitado por el número de particiones en el Topic. Si tienes 10 particiones, puedes tener hasta 10 consumidores activos en un Consumer Group leyendo en paralelo. Si añades más consumidores (hasta el número de particiones), el trabajo se distribuye, escalando la capacidad de procesamiento. Si un consumidor falla, Kafka reasigna automáticamente sus particiones a otros consumidores activos en el mismo grupo.
Estrategias de Commit: Automático vs. Manual
Dado que los consumidores jalan datos y mantienen su propio progreso, necesitan decirle a Kafka hasta dónde han leído en cada partición. A esto se le llama commit del offset. El offset es simplemente la posición del último mensaje procesado en el log de la partición.
Hay dos estrategias principales para gestionar los commits:
- Commit Automático: (
enable.auto.commit=true) El consumidor automáticamente commitea los offsets periódicamente (controlado porauto.commit.interval.ms). Es más simple de implementar, pero tiene el riesgo de procesar mensajes duplicados o perder mensajes.- Riesgo de Duplicados: Si el consumidor commitea un offset X pero falla antes de terminar de procesar el mensaje en ese offset X, al reiniciarse comenzará a leer desde X+1 (si el commit ya se envió) o desde el último offset commiteado Y < X, re-procesando los mensajes entre Y y X.
- Riesgo de Pérdida: Si el consumidor falla después de procesar un mensaje pero antes de que se realice el commit automático, al reiniciarse leerá desde el último offset commiteado, perdiendo los mensajes que procesó pero no commiteó.
- Commit Manual: (
enable.auto.commit=false) El consumidor es responsable de commitear explícitamente los offsets utilizando los métodoscommitSync()ocommitAsync().commitSync(): Bloquea hasta que el broker confirma el commit del offset. Más seguro contra pérdida de mensajes, pero puede reducir el rendimiento del consumidor.commitAsync(): No bloquea. Envía la solicitud de commit y continúa procesando. Es más rápido, pero el commit puede fallar después de que el método retorna, por lo que puede ser necesario manejar errores o usar un patrón de commit asíncrono con commit síncrono final.
Generalmente, el commit manual es la opción preferida para la mayoría de las aplicaciones críticas porque permite commitear el offset después de que el mensaje ha sido completamente procesado (por ejemplo, escrito en una base de datos), minimizando el riesgo de pérdida o duplicación de datos.
Rebalanceo y Cómo Evitarlo (static.membership)
Cuando un consumidor se une o sale de un Consumer Group (ya sea intencionalmente o por un fallo), o cuando se añaden o eliminan particiones de un Topic, Kafka desencadena un rebalanceo. Durante un rebalanceo, las particiones asignadas a los consumidores en el grupo se redistribuyen. Esto implica que los consumidores deben dejar de leer de sus particiones actuales, commitear sus offsets y empezar a leer de las nuevas particiones asignadas.
El rebalanceo es una característica esencial para la alta disponibilidad y escalabilidad, pero puede introducir pausas en el procesamiento y complejidad. Tradicionalmente, el rebalanceo puede ser lento en grupos grandes y causar lo que se conoce como "rebalanceo tempestuoso" (lively rebalances).
Para mitigar algunos de estos problemas, Kafka 2.3 introdujo el concepto de Static Membership. Un consumidor puede configurar un group.instance.id único y persistente. Si un consumidor con un group.instance.id configurado se desconecta temporalmente (por ejemplo, por un reinicio programado o un fallo transitorio), Kafka espera un tiempo configurable (group.instance.id.lease.ms) antes de reasignar sus particiones a otro consumidor. Si el consumidor original vuelve a conectarse con el mismo group.instance.id dentro de ese tiempo, se le reasignan sus particiones sin que ocurra un rebalanceo completo del grupo. Esto es muy útil para despliegues orquestados y para manejar reinicios de aplicaciones sin impactar a todo el grupo.
Semánticas de Entrega: Garantizando la Fiabilidad
Uno de los aspectos más desafiantes del procesamiento de datos distribuidos es garantizar que los mensajes se procesen exactamente una vez. En el contexto de Kafka, podemos hablar de diferentes semánticas de entrega entre el productor y el consumidor:
- At-Most-Once: Los mensajes se pueden perder, pero nunca se duplican. Esto se logra típicamente con
acks=0en el productor (alto riesgo de pérdida pero no duplica por reintentos) o commiteando offsets del consumidor antes de procesar el mensaje (riesgo de pérdida si falla antes de procesar). Adecuado para datos donde la pérdida ocasional es aceptable (ej: métricas agregadas). - At-Least-Once: Los mensajes no se pierden, pero pueden procesarse más de una vez (duplicados). Esta es la semántica por defecto y más fácil de lograr con Kafka. Se consigue con
acks=allen el productor yretries > 0, y commiteando offsets del consumidor después de procesar el mensaje. Es segura contra la pérdida, pero requiere que la aplicación consumidora sea idempotente; es decir, procesar el mismo mensaje varias veces no debe causar efectos secundarios no deseados (ej: incrementar un contador puede ser un problema, pero escribir en una base de datos usando la clave del mensaje como ID y sobrescribiendo la entrada es idempotente). - Exactly-Once: Cada mensaje se procesa exactamente una vez, sin pérdida ni duplicación. Lograr esto en un sistema distribuido es complejo. Kafka lo posibilita a través de la combinación de dos características:
- Idempotencia del Productor: Garantiza que el envío repetido del mismo mensaje por un único productor a una única partición no resulte en duplicados. Esto se logra asignando un ID de Productor (Producer ID - PID) y un número de secuencia a cada mensaje enviado. El broker detecta y descarta duplicados. Se habilita configurando
enable.idempotence=trueen el productor. Esto garantiza "exactly-once" dentro de una única sesión de productor y para envíos a una única partición. - Transacciones: Para lograr "exactly-once" al enviar mensajes a múltiples particiones (incluso en diferentes topics) y/o al commitear offsets de consumidor junto con la producción de nuevos mensajes (patrón Consume-Transform-Produce), Kafka ofrece una API de Transacciones. Esto permite que un conjunto de operaciones (envío de varios mensajes, commit de offsets) se realicen de forma atómica. Si la transacción falla, todas las operaciones se abortan. Esto se habilita configurando un
transactional.iden el productor y utilizando la API transaccional. La semántica "exactly-once" del consumidor requiere que el consumidor esté configurado para leer solo mensajes que forman parte de transacciones completadas (isolation.level=read_committed).
- Idempotencia del Productor: Garantiza que el envío repetido del mismo mensaje por un único productor a una única partición no resulte en duplicados. Esto se logra asignando un ID de Productor (Producer ID - PID) y un número de secuencia a cada mensaje enviado. El broker detecta y descarta duplicados. Se habilita configurando
La semántica "exactly-once" es potente pero añade complejidad. A menudo, lograr "at-least-once" y asegurar que tu aplicación sea idempotente es una solución más simple y suficiente.
Conclusión
Hemos explorado en detalle a los Productores y Consumidores, los componentes esenciales para interactuar con Apache Kafka. Comprendimos cómo los productores configuran garantías de entrega y rendimiento a través de parámetros como acks y retries, y la importancia de la serialización. Vimos cómo los consumidores utilizan los Consumer Groups para paralelizar el procesamiento de particiones, la diferencia crítica entre el commit automático y manual de offsets, y cómo el Static Membership mejora la resiliencia al rebalanceo. Finalmente, desglosamos las diferentes semánticas de entrega (at-most-once, at-least-once, exactly-once) y cómo Kafka ofrece herramientas (idempotencia y transacciones) para lograr la semántica más fuerte.
Dominar la configuración y el comportamiento de Productores y Consumidores es fundamental para construir aplicaciones fiables que se integren eficazmente con Kafka. Ahora que sabemos cómo poner y sacar datos del clúster, la siguiente pregunta natural es: ¿qué podemos hacer con esos datos una vez que están fluyendo? En el próximo artículo, nos adentraremos en las capacidades de procesamiento de datos en tiempo real que ofrece Kafka, explorando las APIs Kafka Streams y la herramienta interactiva ksqlDB, que nos permiten construir aplicaciones de procesamiento de stream directamente sobre Kafka.
Kafka 2: Arquitectura Profunda de Kafka, Topics, Particiones y Brokers
- Mauricio ECR
- Arquitectura
- 04 May, 2025
En nuestro primer artículo, despegamos en el mundo de Apache Kafka, sentando las bases de lo que es esta potente plataforma de streaming de eventos y diferenciándola de los sistemas de mensajería trad
Kafka 2: Arquitectura Profunda de Kafka, Topics, Particiones y Brokers
- Mauricio ECR
- Arquitectura
- 04 May, 2025
En nuestro primer artículo, despegamos en el mundo de Apache Kafka, sentando las bases de lo que es esta potente plataforma de streaming de eventos y diferenciándola de los sistemas de mensajería tradicionales. Comprendimos su propósito fundamental como una “tubería central de datos” que permite desacoplar productores y consumidores, manejando flujos de eventos a gran escala con alta disponibilidad.
Ahora que tenemos esa visión general, es momento de adentrarnos en el corazón de la bestia. ¿Cómo logra Kafka esa escalabilidad masiva, esa tolerancia a fallos y ese alto rendimiento? La respuesta reside en su arquitectura interna distribuida. Este segundo artículo nos llevará a través de los componentes fundamentales que dan vida a un clúster de Kafka: los Topics donde se organizan los datos, las Particiones que permiten paralelizar la lectura y escritura, y los Brokers, los nodos servidores que almacenan y gestionan los datos. También exploraremos la evolución reciente en la gestión del clúster con la llegada de KRaft, la alternativa nativa que busca reemplazar a ZooKeeper.
Comprender la interacción entre estos elementos es crucial no solo para entender cómo funciona Kafka a bajo nivel, sino también para diseñar sistemas que lo aprovechen de manera eficiente, optimizar su rendimiento y resolver problemas comunes. Prepárate para desmontar la “tubería” y ver sus engranajes internos.
codigo mermaid
graph TD
%% Elementos principales con agrupaciones
Producer[Productor] -->|envía mensajes| Cluster
subgraph Cluster[Cluster Kafka]
subgraph Broker1[Broker 1]
subgraph TopicA1[Tópico A]
PA0[Partición 0]
PA1[Partición 1]
end
subgraph TopicB1[Tópico B]
PB0[Partición 0]
end
end
subgraph Broker2[Broker 2]
subgraph TopicA2[Tópico A]
PA2[Partición 2]
end
subgraph TopicB2[Tópico B]
PB1[Partición 1]
PB2[Partición 2]
end
end
subgraph Broker3[Broker 3]
subgraph TopicA3[Tópico A]
PA3[Partición 3]
end
end
end
subgraph Grupo B[Topic B: Grupo 2]
PB0 --> Consumer5[Consumidor 5]
PB1 --> Consumer6[Consumidor 6]
PB2 --> Consumer7[Consumidor 7]
end
subgraph Grupo A[Topic A: Grupo 1]
%% Conexiones de consumidores
PA0 --> Consumer1[Consumidor 1]
PA1 --> Consumer2[Consumidor 2]
PA2 --> Consumer3[Consumidor 3]
PA3 --> Consumer4[Consumidor 4]
end
%% Estilos mejorados
style Producer fill:#4CAF50,stroke:#333,color:white
style Cluster fill:#f5f5f5,stroke:#333,stroke-width:2px
style Broker1 fill:#E1F5FE,stroke:#0288D1
style Broker2 fill:#E1F5FE,stroke:#0288D1
style Broker3 fill:#E1F5FE,stroke:#0288D1
style TopicA1 fill:#B3E5FC,stroke:#0288D1
style TopicB1 fill:#B3E5FC,stroke:#0288D1
style PA0 fill:#FFECB3,stroke:#FFA000
Topics y Particiones: La Organización y Paralelismo de Datos
En Kafka, los eventos no se lanzan a un pozo sin fondo. Se organizan en categorías lógicas llamadas Topics. Piensa en un Topic como una fuente de datos particular, por ejemplo, ordenes-de-compra, clicks-web o lecturas-sensores. Los productores escriben eventos en Topics específicos, y los consumidores leen eventos de Topics a los que se han suscrito.
La magia para la escalabilidad y el paralelismo ocurre dentro de cada Topic. Un Topic se divide en una o más Particiones. Cada Partición es un log de eventos secuencial, inmutable y ordenado. Cuando un productor escribe un evento en un Topic, este se añade a una de las Particiones de ese Topic.
El uso de Particiones tiene implicaciones fundamentales:
- Paralelismo: Las Particiones son la unidad de paralelismo tanto para productores como para consumidores. Múltiples productores pueden escribir en diferentes particiones de un mismo Topic simultáneamente. Más importante aún, múltiples consumidores dentro de un mismo Consumer Group (que veremos en detalle en el próximo artículo) pueden leer datos de diferentes particiones en paralelo, escalando así la capacidad de consumo.
- Orden: Dentro de una misma Partición, Kafka garantiza que los eventos se almacenan y se entregan a los consumidores en el orden en que fueron escritos. Sin embargo, el orden no está garantizado a través de diferentes Particiones de un Topic. Si el orden global es crítico (por ejemplo, para eventos relacionados con una misma cuenta de usuario), debes asegurarte de que todos esos eventos vayan a la misma partición.
- Escalabilidad Horizontal: A medida que el volumen de datos de un Topic crece o necesitas más consumidores para procesar los datos más rápido, puedes aumentar el número de Particiones (aunque reconfigurar particiones existentes en producción puede ser complejo). Un mayor número de particiones permite que más consumidores en paralelo procesen datos.
Configuración Clave: num.partitions y replication.factor
Al crear un Topic, hay dos configuraciones esenciales que debes definir:
num.partitions: El número inicial de particiones para el Topic. Elegir el número correcto es importante; pocas particiones limitan el paralelismo, mientras que demasiadas pueden aumentar la sobrecarga de gestión tanto para Kafka como para los clientes.replication.factor: El número de copias de cada partición que Kafka mantendrá a través de diferentes brokers. Un factor de replicación de 3 significa que cada partición tendrá 3 copias (una copia original y dos réplicas) distribuidas en el clúster. Esto es crucial para la tolerancia a fallos. Si un broker que contiene una réplica falla, las otras réplicas garantizan que los datos no se pierdan y sigan estando disponibles.
Estrategias de Particionamiento
Cuando un productor envía un mensaje a un Topic, Kafka debe decidir a qué Partición enviarlo. La estrategia de particionamiento se define en el productor. Las estrategias más comunes son:
- Por Clave (Key-based): Si el mensaje incluye una clave (
key), el productor por defecto utiliza un hash de esa clave para determinar la partición. Esto asegura que todos los mensajes con la misma clave (ej: un ID de usuario, un ID de producto) siempre irán a la misma partición. Esto es fundamental si necesitas procesar eventos relacionados con una entidad específica en orden. - Round-Robin: Si el mensaje no tiene clave, o si se configura explícitamente, el productor distribuirá los mensajes de forma equitativa entre todas las particiones disponibles del Topic. Esto ayuda a distribuir la carga de escritura de manera uniforme.
- Personalizado: Puedes implementar tu propia lógica de particionamiento si las estrategias por defecto no se ajustan a tus necesidades.
Replicación (ISR - In-Sync Replicas)
Como mencionamos, la replicación es clave para la tolerancia a fallos. Cada partición tiene una Réplica Líder (Leader Replica) y cero o más Réplicas Seguidoras (Follower Replicas). Todas las escrituras y lecturas para una partición específica siempre pasan por la Réplica Líder. Las Réplicas Seguidoras simplemente copian los datos del Líder de forma asíncrona pero continua.
Kafka utiliza el concepto de In-Sync Replicas (ISR). El ISR es el conjunto de réplicas (incluyendo la líder) que están completamente sincronizadas con la Réplica Líder de una partición. Es decir, han replicado todos los mensajes que han sido confirmados (committed) por la líder hasta un cierto punto. Kafka garantiza que un mensaje sólo se considera “committed” (es decir, no se perderá) si ha sido replicado por todas las réplicas en el ISR.
Si la Réplica Líder falla, Kafka elegirá automáticamente una nueva Réplica Líder de entre las Réplicas que están en el ISR. Esto garantiza que la nueva líder tiene todos los datos confirmados, evitando la pérdida de datos. Si una réplica seguidora se retrasa demasiado o falla, es eliminada temporalmente del ISR hasta que se ponga al día o se recupere. Configurar adecuadamente el factor de replicación y monitorizar el estado del ISR es vital para la durabilidad de los datos y la disponibilidad del clúster.
Brokers y Clúster: Los Servidores de Kafka
Un clúster de Kafka se compone de uno o más servidores, conocidos como Brokers. Cada Broker es una instancia de la aplicación Kafka que se ejecuta en una máquina física o virtual.
Los Brokers son los nodos de almacenamiento y servicio del clúster. Cada Broker:
- Almacena una o más Particiones de diferentes Topics.
- Responde a las solicitudes de productores para escribir datos en particiones de las que es líder.
- Responde a las solicitudes de consumidores para leer datos de particiones de las que es líder.
- Sincroniza datos entre las réplicas líderes y seguidoras que aloja.
Roles: Líder y Seguidor (Leader/Follower)
Como vimos con las Particiones, los Brokers asumen roles de Líder o Seguidor para las réplicas de las particiones que albergan. Un Broker puede ser el líder para algunas particiones y el seguidor para otras. Esta distribución de liderazgo entre los brokers es lo que permite el balanceo de carga; la carga de trabajo de escritura y lectura para un Topic dado se distribuye entre los Brokers que son líderes para sus particiones.
Balanceo de Carga y Escalabilidad
La escalabilidad horizontal del clúster se logra añadiendo o eliminando Brokers. Cuando añades un nuevo Broker, Kafka puede (con ayuda de herramientas de administración o manualmente) redistribuir réplicas de particiones existentes al nuevo Broker. También puede transferir el liderazgo de algunas particiones al nuevo Broker. Esto equilibra la carga de trabajo de escritura y lectura entre los Brokers y aumenta la capacidad total del clúster.
ZooKeeper vs. KRaft (Kafka Raft): El Cerebro del Clúster
Hasta hace poco, Kafka dependía externamente de Apache ZooKeeper para gestionar el estado del clúster. ZooKeeper es un servicio de coordinación distribuida que Kafka utilizaba para:
- Mantener la lista de brokers activos en el clúster.
- Manejar la elección del controlador (un broker especial que gestiona el estado de particiones y réplicas).
- Almacenar metadatos sobre Topics, Particiones y la asignación de réplicas a brokers.
- Gestionar la elección de líderes de partición.
Sin embargo, la dependencia de ZooKeeper presentaba algunos desafíos:
- Complejidad Operacional: Requería desplegar y gestionar un clúster de ZooKeeper separado, añadiendo una capa de complejidad.
- Escalabilidad Limitada: ZooKeeper puede convertirse en un cuello de botella en clústeres muy grandes (miles de particiones).
- Versiones Acopladas: La compatibilidad entre versiones de Kafka y ZooKeeper a veces era un problema.
Para abordar estos problemas, la comunidad de Kafka ha estado trabajando en la eliminación de la dependencia de ZooKeeper, introduciendo un nuevo modo de consenso nativo llamado KRaft (Kafka Raft).
Introducción a KRaft (modo consensus nativo)
KRaft implementa un protocolo de consenso basado en Raft (similar al que usan sistemas como etcd o Consul) directamente dentro de los brokers de Kafka. En un clúster KRaft, un subconjunto de brokers asume el rol de Controlador (Controller) y gestiona el estado del clúster utilizando el protocolo Raft. Estos brokers controladores forman un quorum. El líder del quorum se encarga de tomar decisiones sobre la gestión del clúster (elección de líderes de partición, gestión de brokers, etc.).
Los beneficios de KRaft incluyen:
- Simplificación: Elimina la necesidad de un clúster de ZooKeeper separado, reduciendo la complejidad de despliegue y operación.
- Mejor Escalabilidad: Diseñado para escalar a clústeres de Kafka mucho más grandes.
- Arranque Más Rápido: Los clústeres KRaft generalmente se inician más rápido.
- Arquitectura Unificada: La lógica de gestión del clúster reside ahora dentro de los propios brokers de Kafka.
Aunque Kafka aún soporta el modo basado en ZooKeeper por compatibilidad, KRaft es el futuro y el modo recomendado para nuevas instalaciones.
Conclusión
Hemos realizado una inmersión profunda en la arquitectura interna de Apache Kafka, explorando los conceptos fundamentales de Topics, Particiones y Brokers que son la columna vertebral de su capacidad de procesamiento de datos a gran escala. Entendimos cómo las Particiones permiten el paralelismo y la ordenación dentro de un log inmutable, cómo la replicación y el concepto de ISR garantizan la durabilidad y disponibilidad de los datos, y cómo los Brokers actúan como los servidores que alojan y gestionan estos componentes distribuidos. Finalmente, vimos la importante transición hacia KRaft, que simplifica la arquitectura al integrar la gestión del clúster dentro de los propios brokers.
Comprender esta arquitectura es fundamental para cualquier persona que trabaje con Kafka, ya que influye directamente en cómo se diseñan los sistemas, cómo se optimiza el rendimiento y cómo se garantiza la resiliencia. Con estos conocimientos arquitectónicos en mente, estamos listos para pasar al siguiente nivel: interactuar con el clúster. En el próximo artículo, exploraremos en detalle a los actores principales que se conectan a Kafka: los Productores que escriben datos y los Consumidores que los leen, así como sus configuraciones clave y buenas prácticas.
Kafka 1: Introducción a Apache Kafka, fundamentos y Casos de Uso
- Mauricio ECR
- Arquitectura
- 03 May, 2025
En el panorama tecnológico actual, los datos son el motor que impulsa la innovación. La capacidad de procesar, reaccionar y mover grandes volúmenes de datos en tiempo real se ha convertido en una nece
Kafka 1: Introducción a Apache Kafka, fundamentos y Casos de Uso
- Mauricio ECR
- Arquitectura
- 03 May, 2025
En el panorama tecnológico actual, los datos son el motor que impulsa la innovación. La capacidad de procesar, reaccionar y mover grandes volúmenes de datos en tiempo real se ha convertido en una necesidad para empresas de todos los tamaños. Aquí es donde Apache Kafka brilla con luz propia.
Nacido en LinkedIn para manejar su creciente volumen de datos de actividad de usuario, Kafka ha evolucionado hasta convertirse en la plataforma de streaming de eventos distribuida líder en el mundo. No es simplemente un sistema de mensajería tradicional; es una columna vertebral de datos robusta que permite construir arquitecturas escalables, resilientes y, fundamentalmente, basadas en eventos.
Este artículo es el primero de una serie dedicada a explorar Apache Kafka en profundidad. En esta entrega inicial, sentaremos las bases sólidas: entenderemos qué es Kafka realmente, cómo se diferencia de otros sistemas de manejo de mensajes, cuáles son sus características clave que lo hacen único y, quizás lo más importante para la práctica, en qué escenarios es una herramienta indispensable (y en cuáles quizás no sea la opción más óptima). Nuestro objetivo es proporcionar una comprensión fundamental y accesible que sirva como punto de partida para los artículos más técnicos y detallados que explorarán la arquitectura interna y aspectos operativos en el futuro.
¿Qué es Apache Kafka?
En su esencia más pura, Apache Kafka es una plataforma distribuida de streaming de eventos. Su propósito principal y razón de ser es manejar flujos de datos en tiempo real con una capacidad de procesamiento extraordinariamente alta (throughput) y una latencia predecible y generalmente baja. Piensa en un "evento" como cualquier cosa que suceda en tu sistema o negocio y que sea relevante registrar y potencialmente reaccionar: puede ser una orden de compra en un e-commerce, una lectura de temperatura de un sensor IoT, un clic de un usuario en una página web, una entrada en un archivo de log de una aplicación, o el cambio de estado de un pedido. Kafka está meticulosamente diseñado para capturar estos eventos tan pronto como ocurren, almacenarlos de forma duradera y segura, y ponerlos a disposición de múltiples aplicaciones para que los procesen de forma completamente independiente y asíncrona.
Aquí radica una de las diferencias conceptuales clave con muchos sistemas de mensajería tradicionales: mientras que en esos sistemas los mensajes a menudo se consideran consumidos una vez y luego desaparecen de la cola, Kafka almacena los eventos de forma persistente en lo que se conoce como un log de commits distribuido y tolerante a fallos. Esto significa que los datos no son efímeros; persisten por un período configurable (horas, días, semanas o incluso permanentemente) y pueden ser leídos no solo por un consumidor, sino por múltiples consumidores, cada uno manteniendo su propio registro de progreso en el log.
Analogía de la "Tubería Central de Datos" o "Bus de Eventos"
Para visualizar su funcionamiento de una manera más intuitiva, puedes pensar en Kafka como una gran "tubería central de datos" o un "bus de eventos" que atraviesa toda tu organización o arquitectura de software. En lugar de que cada aplicación o servicio que genera datos (llamados productores en la jerga de Kafka) tenga que saber y conectarse directamente con cada aplicación o servicio que necesita esos datos (llamados consumidores), creando una compleja, frágil y difícil de mantener red de conexiones punto a punto (el famoso "spaghetti integration"), todas las aplicaciones se conectan únicamente a Kafka.
- Las aplicaciones que generan datos simplemente escriben (publican) sus eventos en esta tubería central.
- Las aplicaciones que necesitan consumir datos simplemente leen (se suscriben) a los eventos relevantes de esta tubería.
La "tubería" (Kafka) se encarga de la parte difícil: recibir los datos de todos los productores, almacenarlos de manera confiable y escalable, y entregarlos a todos los consumidores interesados. Esta arquitectura centralizada desacopla radicalmente a los productores de los consumidores. Un productor no necesita saber quién (o cuántos) consumidores leerán sus datos, y un consumidor no necesita saber de dónde vienen exactamente los datos; solo necesitan conocer a Kafka. Esto permite que los diferentes componentes de un sistema evolucionen, se desplieguen o fallen de forma independiente sin afectar a los demás, promoviendo una mayor resiliencia y agilidad en el desarrollo. Imagina que necesitas añadir una nueva aplicación de análisis que procese los datos de un sistema legacy; con Kafka en medio, la nueva aplicación simplemente se conecta a Kafka y comienza a leer los eventos que ya están fluyendo, sin necesidad de modificar el sistema legacy original.
Diferencias Clave con Brokers de Mensajería Tradicionales
Aunque en la superficie Kafka comparte algunas similitudes con sistemas de mensajería tradicionales como RabbitMQ, ActiveMQ o IBM MQ, es crucial entender que su diseño y propósito fundamental son distintos. No es un reemplazo directo para estos sistemas en todos los casos, y su fortaleza reside en manejar patrones de datos específicos a escala. Las diferencias fundamentales radican en su modelo de almacenamiento, modelo de consumo y enfoque en la escalabilidad/rendimiento para streaming:
Modelo de Almacenamiento:
- Tradicional: Principalmente basado en colas (queues) o modelos de publicación/suscripción efímeros. Los mensajes suelen ser transitorios y se eliminan de la cola una vez que son consumidos por uno o más suscriptores. El broker es el responsable de gestionar el estado de entrega de cada mensaje a cada consumidor.
- Kafka: Basado en un log distribuido y particionado. Los eventos (mensajes) se añaden de forma inmutable al final de un log secuencial dentro de una "partición" de un "topic". Los eventos no se eliminan automáticamente tras ser consumidos; se retienen en el log por un período configurable (basado en tiempo o tamaño). Cada consumidor o grupo de consumidores mantiene su propio "offset" (puntero) dentro del log, indicando hasta dónde ha leído. Esto permite que múltiples consumidores lean los mismos datos sin interferirse, y que un consumidor pueda "rebobinar" y releer datos históricos si es necesario.
Modelo de Consumo:
- Tradicional: Mayormente "push". El broker de mensajería empuja los mensajes a los consumidores tan pronto como llegan o tan rápido como el consumidor puede manejarlos.
- Kafka: Modelo "pull". Los consumidores jalan (pull) los mensajes de los brokers a su propio ritmo. Esto da un control mucho mayor al consumidor sobre cuántos datos quiere procesar a la vez (batching) y cuándo, evitando que se sature y permitiendo una mayor eficiencia en el procesamiento por lotes. El consumidor es responsable de gestionar su propio progreso (su offset en el log).
Escalabilidad y Rendimiento:
- Tradicional: Pueden ser escalables, pero a menudo están optimizados para patrones de mensajería de bajo volumen/baja latencia por mensaje individual, o para la gestión precisa de colas de trabajo donde el broker administra estrictamente quién recibe qué mensaje.
- Kafka: Diseñado desde cero con la escalabilidad masiva y el alto rendimiento (high throughput) como objetivos principales para manejar flujos de datos continuos y voluminosos. Escala horizontalmente de manera muy eficiente simplemente añadiendo más máquinas (brokers) al clúster. Su diseño basado en log permite escrituras secuenciales muy rápidas en disco y lecturas eficientes en lotes.
Propósito Principal:
- Tradicional: A menudo se usan para comunicación punto a punto confiable, sistemas de colas de trabajo (donde cada tarea es procesada por un único worker), o patrones de publicación/suscripción donde la preocupación principal es la entrega garantizada a un conjunto definido de receptores y la gestión del estado de entrega por parte del broker.
- Kafka: Su propósito principal es ser una plataforma de streaming de eventos duradera, escalable y de alto rendimiento para la ingesta centralizada, el procesamiento (a menudo con procesamiento de stream) y la entrega de flujos continuos de datos a múltiples consumidores independientes y desacoplados. Es la base ideal para construir arquitecturas reactivas, basadas en eventos y de procesamiento de datos en tiempo real a escala.
Características Principales
La robustez y popularidad de Kafka derivan de un conjunto de características fundamentales que lo diferencian y lo hacen especialmente adecuado para cargas de trabajo de streaming de datos:
- Escalabilidad Horizontal: La capacidad de escalar tu clúster Kafka es lineal y sencilla. Puedes aumentar significativamente la capacidad de procesamiento y almacenamiento simplemente añadiendo más máquinas ("brokers") al clúster. Kafka se encarga de distribuir automáticamente los datos y equilibrar la carga de trabajo entre los brokers disponibles.
- Tolerancia a Fallos: Los datos en Kafka están distribuidos y replicados automáticamente a través de múltiples brokers (puedes configurar cuántas réplicas quieres). Esto significa que si un broker falla (una máquina se cae, por ejemplo), las réplicas de los datos que contenía en otros brokers garantizan que esos datos sigan estando disponibles para productores y consumidores, minimizando el tiempo de inactividad y la pérdida de datos.
- Alto Rendimiento (High Throughput): Kafka puede manejar tasas de ingesta y consumo de datos extremadamente altas, a menudo millones de mensajes por segundo con hardware modesto. Esto se debe a su diseño optimizado que favorece escrituras secuenciales rápidas en disco y el procesamiento de datos en lotes (batching).
- Modelo de Consumo Pull: Como ya mencionamos, el hecho de que los consumidores "jalan" datos les otorga un control significativo sobre su propio ritmo de procesamiento. Esto es crucial para evitar la sobrecarga del consumidor y permite optimizaciones como el procesamiento por lotes eficiente.
- Almacenamiento Persistente y Retención Configurable: A diferencia de los sistemas que eliminan mensajes tras el consumo, Kafka almacena los eventos de forma duradera en disco. Puedes configurar por cuánto tiempo (tiempo) o hasta qué cantidad de datos (tamaño) se retienen los eventos en cada "topic". Esta persistencia permite a los consumidores ponerse al día después de un fallo, o que nuevas aplicaciones empiecen a consumir datos históricos que ya habían sido procesados por otras.
- Log Distribuido, Inmutable y Ordenado: El corazón conceptual de Kafka es este log. Cada "topic" (una categoría o feed de eventos) se divide en "particiones", y cada partición es un log ordenado e inmutable de eventos. Una vez que un evento se escribe en una partición, su posición (offset) y el evento en sí no cambian. Este log proporciona una "fuente de verdad" fiable y reproducible de la secuencia de eventos que han ocurrido en el sistema.
Casos de Uso Clave
Dadas sus poderosas características y su enfoque en el streaming de eventos a escala, Kafka se ha convertido en la elección preferida para una amplia gama de aplicaciones en diversas industrias:
- Streaming en Tiempo Real: El caso de uso más obvio. Procesar datos a medida que se generan para reaccionar instantáneamente. Ejemplos incluyen análisis de clics y comportamiento de usuarios en sitios web (clickstream analysis), detección y monitorización de fraudes en tiempo real, seguimiento de activos (vehículos, paquetes), procesamiento de datos de sensores en entornos IoT, etc.
- Ingesta Centralizada de Logs y Métricas: Recopilar logs de múltiples servidores, aplicaciones y servicios en un único punto centralizado. Sistemas como ELK stack (Elasticsearch, Logstash, Kibana) o Splunk a menudo usan Kafka como un buffer robusto y escalable para ingestar datos antes de su indexación y análisis. Similarmente, se usa para agregar métricas de rendimiento.
- Event-Driven Architectures (EDA): Construir arquitecturas de software donde los diferentes componentes (servicios, microservicios) no se comunican directamente, sino que reaccionan a eventos publicados en un bus de eventos central (Kafka). Esto promueve un fuerte desacoplamiento, flexibilidad y escalabilidad, ya que los servicios solo necesitan saber cómo interactuar con Kafka, no con cada otro servicio.
- Integración de Microservicios: Kafka sirve como un bus de comunicación asíncrono ideal para entornos de microservicios. Los microservicios pueden publicar eventos relevantes (ej:
OrdenCreada,UsuarioActualizado) en Kafka, y otros microservicios interesados pueden suscribirse a esos eventos para reaccionar, sin necesidad de que los servicios se llamen directamente o conozcan la topología de la red. Esto simplifica la comunicación y mejora la resiliencia. - Commit Log para Sistemas Distribuidos: Dada su durabilidad y la naturaleza inmutable del log, Kafka puede ser utilizado como una capa de persistencia distribuida para otros sistemas. Por ejemplo, bases de datos de series temporales o sistemas de procesamiento de stream pueden usar Kafka como el log primario para replicación, recuperación de fallos o para mantener un historial completo de cambios.
¿Cuándo NO usar Kafka?
A pesar de sus muchas fortalezas y su idoneidad para el streaming de eventos a gran escala, es importante reconocer que Kafka no es una solución mágica universal para todos los problemas de comunicación entre sistemas. Hay escenarios específicos donde otras tecnologías pueden ser más apropiadas:
- Mensajería Transaccional con ACID Estricto: Si tu caso de uso requiere una secuencia compleja de operaciones de mensajería que deben ejecutarse como una única transacción atómica con garantías ACID (Atomicidad, Consistencia, Aislamiento, Durabilidad) similares a las de una base de datos relacional, Kafka por sí solo no es la opción ideal. Si bien Kafka ofrece garantías de "exactly-once processing" a nivel de procesamiento de stream (particularmente con las Kafka Streams API o Flink/Spark sobre Kafka, y usando transacciones de productor/consumidor), no reemplaza la necesidad de transacciones de base de datos tradicionales para operaciones complejas que modifican el estado de múltiples recursos externos de manera coordinada.
- Sistemas con Latencia Ultra-Baja por Mensaje Individual: Si tu aplicación opera en un dominio donde la latencia garantizada por cada mensaje individual debe ser extremadamente baja, del orden de pocos microsegundos o milisegundos (por ejemplo, ciertos sistemas de trading de alta frecuencia en el núcleo de la ejecución de órdenes), la latencia inherente introducida por el batching y la persistencia en disco en Kafka podría ser un factor limitante. Sistemas de mensajería especializados de latencia ultra-baja o protocolos de red punto a punto finamente optimizados podrían ser más adecuados. Sin embargo, para la gran mayoría de los casos de uso de "tiempo real" donde una latencia de decenas o incluso pocos cientos de milisegundos es aceptable, Kafka funciona excepcionalmente bien.
Conclusión
En este primer artículo de nuestra serie, hemos dado los pasos iniciales para desmitificar Apache Kafka, presentándolo no simplemente como un sistema de mensajería, sino como una potente, escalable y resiliente plataforma de streaming de eventos. Hemos entendido cómo su diseño fundamental, centrado en un log distribuido, lo diferencia radicalmente de los brokers tradicionales, ofreciendo capacidades únicas para el manejo de flujos de datos continuos a gran escala con alta disponibilidad y rendimiento. Exploramos sus características clave que lo hacen tan valioso y destacamos los escenarios más comunes donde Kafka se convierte en una herramienta indispensable para la construcción de arquitecturas modernas, desacopladas y reactivas.
Comprender estos fundamentos sólidos es el primer paso esencial en el viaje hacia el dominio de Kafka y su aprovechamiento para resolver problemas complejos de datos en el mundo real. Es la base sobre la que construiremos nuestro conocimiento. En el próximo artículo de la serie, profundizaremos significativamente en la arquitectura interna de Kafka, explorando conceptos cruciales y tangibles como Topics, Particiones, Brokers, Réplicas y Controladores, y cómo interactúan en conjunto para formar un clúster robusto, escalable y tolerante a fallos. ¡Prepárate para adentrarnos en el corazón de Kafka!
RabbitMQ 6: Alta Disponibilidad y Escalabilidad con Clustering en RabbitMQ
- Mauricio ECR
- Arquitectura
- 01 May, 2025
Hasta ahora, hemos hablado de cómo un nodo individual de RabbitMQ maneja mensajes, gestiona colas, y cómo monitorizar su rendimiento y seguridad. Sin embargo, para aplicaciones críticas que no pueden
RabbitMQ 6: Alta Disponibilidad y Escalabilidad con Clustering en RabbitMQ
- Mauricio ECR
- Arquitectura
- 01 May, 2025
Hasta ahora, hemos hablado de cómo un nodo individual de RabbitMQ maneja mensajes, gestiona colas, y cómo monitorizar su rendimiento y seguridad. Sin embargo, para aplicaciones críticas que no pueden permitirse tiempo de inactividad y necesitan procesar volúmenes de mensajes que superan la capacidad de un solo servidor, un solo nodo de RabbitMQ representa un punto único de fallo y un límite de escalabilidad inherente.
Aquí es donde entra en juego el clustering. Agrupar varios nodos de RabbitMQ para que trabajen juntos nos permite lograr alta disponibilidad (HA) y escalabilidad horizontal. Este artículo se sumergirá en el concepto de clustering, las arquitecturas comunes para HA de mensajes (Mirroring Clásico y Quorum Queues), cómo escalar añadiendo más nodos y algunas consideraciones avanzadas cruciales para la gestión de despliegues distribuidos y de misión crítica.
Concepto de Clustering en RabbitMQ
Para entender el clustering, primero debemos definir qué es un nodo en el contexto de RabbitMQ. Un nodo de RabbitMQ es, simplemente, una instancia individual del broker RabbitMQ ejecutándose en un servidor (físico o virtual). Es la unidad básica que inicia el servicio, maneja conexiones, gestiona recursos y procesa mensajes. Un despliegue de RabbitMQ con un solo servidor es un "clúster" de un solo nodo.
Un clúster de RabbitMQ es, por lo tanto, un grupo de dos o más nodos de RabbitMQ interconectados. Estos nodos trabajan conjuntamente y se comunican entre sí para compartir información vital sobre el estado y la topología del broker. Esta información compartida, conocida como metadatos, incluye detalles sobre usuarios, virtual hosts (vhosts), exchanges, colas, bindings y parámetros de configuración como políticas. Al compartir estos metadatos, el clúster presenta una vista unificada y consistente de la topología del sistema de mensajería a todas las aplicaciones cliente conectadas, sin importar a qué nodo específico se conecten inicialmente.
Sin embargo, y este es un punto crucial para entender la alta disponibilidad de los mensajes, aunque los metadatos de la configuración se replican automáticamente en todos los nodos del clúster, por defecto y en las arquitecturas clásicas (anteriores a Quorum Queues), los mensajes en sí mismos residían únicamente en el nodo donde la cola fue declarada o donde se estableció su nodo "master". Esto significaba que si ese nodo específico fallaba, los mensajes en esa cola se volvían inaccesibles o incluso se perdían si la cola no era persistente. Esta limitación convertía a un nodo individual en un punto único de fallo (Single Point Of Failure - SPOF) para los mensajes que gestionaba. Para superar esta restricción fundamental y asegurar que los mensajes permanecieran disponibles y duraderos incluso ante la caída de un nodo, se hizo necesario desarrollar mecanismos específicos dentro del framework de clustering orientados a la alta disponibilidad de los datos de los mensajes, dando origen a soluciones como el mirroring de colas y, más recientemente y de forma más robusta, las quorum queues.
Arquitecturas para Alta Disponibilidad de Mensajes: Asegurando la Resiliencia de Tus Datos
Como mencionamos, mientras que los metadatos del clúster se replican en todos los nodos, la alta disponibilidad de los mensajes mismos no es inherente al simple hecho de tener un clúster. Los mensajes residen físicamente en un nodo específico. Para asegurar que tus mensajes sobrevivan a la caída de un nodo y permanezcan accesibles, RabbitMQ ha desarrollado arquitecturas de réplica de datos. Históricamente, esto se abordó con el Mirroring de Colas Clásicas, y la solución moderna y recomendada son las Quorum Queues.
1. Mirroring de Colas (Classic Mirrored Queues): El Enfoque Tradicional
- Concepto: El mirroring fue la primera solución de RabbitMQ para lograr la alta disponibilidad de los mensajes en colas clásicas. Su propósito es replicar activamente el flujo de mensajes de una cola desde un nodo "maestro" designado para esa cola a uno o más nodos "espejo" dentro del mismo clúster. La idea es que, si el nodo maestro original falla, uno de los nodos espejo promocione a maestro, permitiendo que productores y consumidores continúen operando con la cola sin perder mensajes.
- Mecanismo: Cuando un mensaje es publicado en una cola que está configurada para mirroring, es enviado primero al nodo maestro de esa cola. El nodo maestro se encarga de escribir el mensaje localmente y luego replicarlo a sus nodos espejo configurados. La confirmación al productor puede ocurrir en diferentes momentos, dependiendo de la configuración de sincronización (
ha-sync-mode):- Sincronización Síncrona (
ha-sync-mode: exactlyoautomatic): El nodo maestro espera a que todos (o un número específico) de los espejos hayan recibido y persistido el mensaje antes de enviar la confirmación (ack) de vuelta al productor. Esto ofrece la garantía más fuerte contra la pérdida de datos en caso de fallo del maestro, pero introduce latencia adicional debido a la espera de la replicación. - Sincronización Asíncrona (
ha-sync-mode: manualyautomaticpost-sync): El nodo maestro confirma al productor tan pronto como ha procesado el mensaje localmente, replicándolo a los espejos en segundo plano. Esto ofrece un mayor throughput ya que no hay espera por la replicación, pero existe una pequeña ventana de riesgo donde un mensaje podría confirmarse al productor pero perderse si el nodo maestro falla antes de que el mensaje se replique a un espejo. Los consumidores siempre se conectan y operan con el nodo que es el maestro actual de la cola. El failover (promoción de un espejo a maestro) es un proceso automático gestionado por el clúster.
- Sincronización Síncrona (
- Configuración: El mirroring se define mediante Políticas. Una política especifica un patrón para los nombres de las colas y las propiedades de HA a aplicar, como el número de espejos (
ha-count: 3para 3 réplicas en total: 1 maestro + 2 espejos), o si se espeja en todos los nodos del clúster (ha-mode: all). - Consideraciones: Aunque fue la solución estándar durante años, el mirroring clásico es ahora considerado un enfoque heredado y se desaconseja para nuevas implementaciones de alta disponibilidad en favor de las Quorum Queues. Su principal desventaja radica en su complejidad operativa y de gestión. La distinción explícita entre maestro y espejos puede ser confusa, la gestión de la sincronización inicial de grandes colas a nuevos espejos puede ser costosa en tiempo y recursos, y el manejo de escenarios de partición de red es propenso a complicaciones ("split-brain") que requieren configuración y entendimiento cuidadosos. Su modelo de consistencia y failover es menos predecible que el de Quorum Queues.
2. Quorum Queues: La Solución Moderna Basada en Consenso Fuerte
- Concepto: Introducidas en RabbitMQ 3.8, las Quorum Queues representan la arquitectura recomendada y predeterminada para la alta disponibilidad de mensajes en RabbitMQ moderno. Están diseñadas desde cero para ofrecer consistencia fuerte y durabilidad utilizando una implementación integrada del probado algoritmo de consenso Raft. La clave de Quorum Queues es que operan como un conjunto de réplicas que colaboran activamente, eliminando la distinción rígida maestro/espejo del mirroring clásico (aunque internamente Raft elige un "líder").
- Mecanismo: Una Quorum Queue existe como un conjunto de réplicas distribuidas en diferentes nodos del clúster. Cualquier operación que altere el estado de la cola (como publicar un mensaje, reconocer una entrega) debe ser acordada por una mayoría (un quórum) de las réplicas antes de ser considerada exitosa y confirmada al cliente. Por ejemplo, en un conjunto de 3 réplicas, se necesitan al menos 2 réplicas para confirmar una operación. Este mecanismo basado en consenso garantiza la consistencia y previene la pérdida de datos en caso de fallos de nodo, siempre que la mayoría de las réplicas permanezcan disponibles.
- Publicación: Un productor envía un mensaje a la cola. Internamente, la solicitud es gestionada por el líder Raft actual. El mensaje se replica a las otras réplicas. La confirmación al productor solo se envía una vez que el líder ha confirmado que la mayoría de las réplicas han recibido y persistido el mensaje.
- Consumo: Un consumidor puede conectarse a cualquier nodo que albergue una réplica de la Quorum Queue. La operación de consumo (obtener un mensaje, enviar un ack/nack) también pasa por el líder Raft, y la confirmación del procesamiento también requiere consenso.
- Failover: Si el nodo que alberga el líder Raft actual falla, las réplicas restantes utilizan el algoritmo Raft para elegir automáticamente un nuevo líder entre ellas, siempre y cuando haya un quórum disponible. El proceso de failover es rápido y transparente para los clientes (aunque una reconexión puede ser necesaria si el nodo al que estaban conectados falla).
- Configuración: Las Quorum Queues se declaran explícitamente estableciendo el argumento
x-queue-typeaquorumal declarar la cola, ya sea directamente desde el cliente o, más comúnmente, mediante una Política. La política también se usa para definir el número deseado de réplicas (x-queue-replicas), que debe ser un número impar (típicamente 3 o 5) para garantizar que siempre pueda haber un quórum (N/2 + 1 réplicas necesarias para el quórum, donde N es el número total de réplicas). - Consideraciones: Las Quorum Queues son la opción preferida para la alta disponibilidad de mensajes en RabbitMQ debido a su simplicidad operativa en comparación con el mirroring, sus garantías de consistencia fuerte (basadas en Raft) y su robusto manejo de fallos. Su principal (ligero) trade-off puede ser un throughput de publicación potencialmente un poco menor en algunos escenarios en comparación con colas clásicas no mirrored, debido al overhead inherente del protocolo de consenso. Sin embargo, los beneficios de durabilidad y disponibilidad superiores generalmente superan esta posible diferencia. Requieren un número impar de réplicas para funcionar correctamente y evitar problemas de quórum.
En resumen, mientras que el clustering es la base para la HA y escalabilidad de los metadatos y la gestión de conexiones, son arquitecturas específicas como el mirroring (legado) y las Quorum Queues (recomendado) las que extienden la alta disponibilidad a los datos de los mensajes mismos, asegurando que tu sistema de mensajería pueda soportar fallos de nodo sin perder información crítica. Las Quorum Queues, con su enfoque basado en consenso, representan el estado del arte en la garantía de durabilidad y consistencia para tus mensajes en un clúster de RabbitMQ.
Beneficios de la Alta Disponibilidad en un Clúster
La implementación adecuada de HA para las colas dentro de un clúster de RabbitMQ ofrece beneficios cruciales para aplicaciones de misión crítica:
- Tolerancia a Fallos: Si un nodo individual en el clúster falla inesperadamente (debido a problemas de hardware, red o software), el clúster en su conjunto puede seguir operando. Para colas configuradas con mirroring o quorum queues, la pérdida del nodo que era primario/líder para esa cola no resulta en la pérdida de mensajes ni en la interrupción del servicio para productores y consumidores (tras un breve período de failover automático).
- Continuidad del Servicio: Minimiza o elimina el tiempo de inactividad no planificado. Las aplicaciones pueden seguir enviando y recibiendo mensajes sin una interrupción significativa, lo cual es vital para sistemas que deben estar siempre disponibles (ej. procesamiento de pagos, logs de auditoría, microservicios críticos).
Consideraciones al Implementar un Clúster de RabbitMQ
Implementar un clúster requiere atención a varios factores para asegurar su estabilidad y rendimiento óptimo:
- Particiones de Red (Split-Brain): Un clúster es susceptible a particiones de red donde los nodos se dividen en dos o más grupos que pierden la comunicación entre sí. Sin una estrategia de manejo adecuada, esto puede llevar a una situación de "split-brain" donde ambos grupos creen ser el estado correcto del clúster, causando inconsistencias y posible pérdida de datos cuando la red se restablece. RabbitMQ ofrece estrategias de manejo de particiones (configuradas por la política
network_partition_handling) que típicamente implican pausar al grupo minoritario (pause_minority) o requerir intervención manual (autoheal,ignore). La configuraciónpause_minorityes generalmente la más segura para evitar split-brain, pero requiere un número impar de nodos para funcionar correctamente en escenarios de partición en dos grupos. - Consistencia vs. Rendimiento: La elección entre mirroring (síncrono/asíncrono) y quorum queues implica un compromiso fundamental entre la fuerza de la garantía de consistencia y el rendimiento (throughput y latencia). Las Quorum Queues, al basarse en Raft y requerir quórum para las operaciones, ofrecen una consistencia mucho más fuerte y predecible que el mirroring clásico, especialmente en condiciones de fallo. Sin embargo, el overhead del consenso puede resultar en un throughput marginalmente menor en comparación con colas clásicas no mirrored o con mirroring asíncrono en condiciones ideales. Para HA y durabilidad garantizada, Quorum Queues son el camino a seguir.
- Descubrimiento de Nodos: Los nodos que van a formar un clúster necesitan poder encontrarse y comunicarse entre sí. Esto se puede configurar de varias maneras: manualmente (usando
rabbitmqctl join_cluster), mediante archivos de configuración (comocluster_formation.classic_config), o a través de mecanismos de descubrimiento automático, especialmente en entornos de nube o contenedores (plugins comorabbitmq_peer_discovery_consul,rabbitmq_peer_discovery_k8s, etc.). - Diseño de Aplicaciones Cliente: Las librerías cliente oficiales de RabbitMQ para la mayoría de los lenguajes de programación están diseñadas para ser "cluster-aware" hasta cierto punto. Generalmente soportan la especificación de una lista de nodos del clúster o la dirección de un balanceador de carga. Si un nodo al que la aplicación está conectada falla, la librería intentará reconectarse automáticamente a otro nodo disponible en la lista. Es crucial que las aplicaciones implementen mecanismos de reintento de conexión robustos.
Escalabilidad Horizontal de RabbitMQ
El clustering no solo proporciona HA, sino que también es la base fundamental para la escalabilidad horizontal en RabbitMQ.
Cómo Añadir Más Nodos al Clúster para Aumentar la Capacidad:
La capacidad de procesamiento de mensajes y conexiones de RabbitMQ se puede aumentar significativamente añadiendo más nodos al clúster existente. Esto distribuye la carga de trabajo a través de los nodos:
- Gestión de Metadatos: La carga de gestionar y replicar los metadatos (declaraciones de exchanges, colas, etc.) se distribuye entre los nodos.
- Gestión de Conexiones: Las conexiones entrantes de productores y consumidores pueden balancearse entre los nodos del clúster. Cada conexión consume recursos (CPU, memoria) en el nodo al que está conectada. Añadir nodos permite manejar un mayor número total de conexiones concurrentes.
- Throughput General: Al distribuir las colas (en el caso de colas clásicas no mirrored, que no son HA pero ilustran el punto) o las réplicas de colas (para Quorum Queues) a través de diferentes nodos, la carga de procesamiento de mensajes (publicación, enrutamiento, entrega) se distribuye. Incluso con colas mirrored o Quorum Queues, añadir nodos puede ayudar a distribuir la carga de replicación y el procesamiento total de mensajes, especialmente si el número de colas es grande o si diferentes conjuntos de colas son muy activos.
Añadir un nodo a un clúster existente es un proceso relativamente directo que implica instalar RabbitMQ en el nuevo servidor, asegurar la comunicación entre nodos y usar el comando rabbitmqctl join_cluster <nombre_del_nodo_existente> (o su equivalente en la configuración de descubrimiento automático). El nuevo nodo descargará los metadatos del clúster existente.
Consideraciones sobre el Balanceo de Carga entre Nodos:
Aunque un clúster comparte metadatos, RabbitMQ no actúa internamente como un balanceador de carga de red para las conexiones de clientes entrantes (a excepción del plugin de gestión que tiene un balanceador rudimentario para la UI web). Por lo tanto, para distribuir de manera uniforme las conexiones de productores y consumidores entre los nodos del clúster y evitar que un solo nodo se convierta en un cuello de botella, generalmente necesitas una capa de balanceo de carga externa:
- Un Balanceador de Carga Externo Dedicado: Esta es la estrategia más común y recomendada para despliegues en producción. Se coloca un balanceador de carga (como HAProxy, Nginx con
streammodule, un Load Balancer de la nube como AWS ELB/ALB, GCP Load Balancer, Azure Load Balancer) frente al clúster de RabbitMQ. El balanceador recibe todas las conexiones entrantes en una única dirección IP o nombre de host y las distribuye a los nodos del clúster según un algoritmo de balanceo (ej. round-robin, least-connection). Los balanceadores de carga pueden también realizar health checks a los nodos de RabbitMQ para enviar tráfico solo a los nodos saludables. - DNS Round-Robin: Una alternativa más simple es configurar una entrada DNS que resuelva el nombre de host del broker a las múltiples direcciones IP de los nodos del clúster. Las aplicaciones cliente intentarán conectarse a las IPs en el orden que les devuelve el DNS. Esta estrategia es menos sofisticada que un balanceador dedicado, ya que no realiza health checks y la distribución de carga depende de cómo los clientes resuelven y cachean las entradas DNS.
- Configuración en el Cliente: Algunas librerías cliente de RabbitMQ permiten especificar una lista de nodos del clúster (
amqp://user:pass@host1:port1,host2:port2,...). La librería intentará conectarse secuencialmente a los nodos de la lista hasta que una conexión tenga éxito. Esto proporciona una forma básica de failover a nivel de cliente pero no balancea activamente la carga entre conexiones concurrentes de múltiples instancias de la aplicación cliente.
Un balanceo de carga efectivo es crucial para la escalabilidad, ya que asegura que el tráfico de la aplicación se distribuya de manera uniforme, maximizando el uso de los recursos de cada nodo y aumentando el throughput total del clúster.
Implicaciones para Productores y Consumidores
Es importante entender cómo las aplicaciones cliente (productores y consumidores) interactúan con un clúster de RabbitMQ:
- Agentes Externos: Las aplicaciones cliente no son nodos del clúster; son agentes externos que se conectan a él.
- Conexión a un Punto del Clúster: Una aplicación cliente se conecta a uno de los nodos del clúster (o, idealmente, a la dirección IP/nombre del balanceador de carga que está frente al clúster). Una vez conectada, la conexión es gestionada por ese nodo específico.
- Transparencia (en su mayoría): Para los productores, una vez conectados, pueden publicar mensajes a exchanges que existen en el clúster. Los exchanges y bindings se replican en todos los nodos, por lo que el mensaje se enrutará correctamente a la(s) cola(s) destino, independientemente de en qué nodo residan primariamente o de qué nodo sea el líder Raft de la cola. Para los consumidores de colas mirrored o Quorum Queues, si el nodo al que están conectados falla, una librería cliente bien configurada intentará reconectarse a otro nodo disponible, y la cola (si está configurada para HA) seguirá disponible en otro nodo del clúster.
- Consideraciones de Topología: Las aplicaciones no necesitan saber en qué nodo reside primariamente una cola (a menos que usen colas clásicas no mirrored, lo cual no es una configuración de HA). El clúster maneja internamente el enrutamiento y la gestión de las colas distribuidas.
Consideraciones Avanzadas: Gestión y Conectividad Distribuida
Más allá del clustering básico para HA y escalabilidad dentro de un único grupo de nodos, RabbitMQ ofrece herramientas adicionales para gestionar configuraciones a gran escala y conectar brokers o clústeres distribuidos geográficamente o lógicamente.
Políticas (Policies):
- Concepto: Las políticas son un mecanismo poderoso para aplicar configuraciones a múltiples exchanges y/o colas en un clúster basándose en patrones de nombres. Se definen de forma centralizada en el clúster y se aplican dinámicamente a los recursos existentes o recién declarados que coincidan con el patrón.
- Uso: Permiten definir propiedades de recursos de manera uniforme sin que las aplicaciones tengan que declararlas explícitamente o si se necesita cambiar una configuración (ej. número de espejos,
x-queue-type,x-message-ttl,max-length) en runtime para muchas colas/exchanges a la vez. Son la forma principal de configurar el mirroring clásico (ha-mode,ha-params) y de definir el tipo de cola por defecto (x-queue-type) o el número de réplicas para Quorum Queues (x-queue-replicas) para colas coincidentes. - Beneficio: Simplifican enormemente la gestión de configuraciones uniformes, la aplicación de reglas de HA (espejado, Quorum) y la gestión de otras propiedades en un clúster con muchas colas y exchanges, reduciendo el riesgo de errores de configuración a nivel de aplicación.
Shovel y Federation para la Interconexión entre Brokers:
- Concepto: A diferencia del clustering que une nodos para formar un único broker lógico, Shovel y Federation son mecanismos para mover mensajes entre diferentes brokers, que pueden ser nodos independientes, clústeres separados o incluso brokers en diferentes centros de datos o regiones de la nube. No forman parte del clúster interno de RabbitMQ.
- Shovel: Es un plugin que define una tarea de copia de mensajes configurable. Típicamente, mueve mensajes de una cola en un broker ("broker de origen") a un exchange en otro broker ("broker de destino"). Es útil para escenarios de re-enrutamiento de mensajes entre sistemas o dominios de aplicación que están lógicamente separados o para migración/backhaul de mensajes. El Shovel es unidireccional y puede configurarse como dinámico (declarado y gestionado en runtime) o estático (configurado en el archivo de configuración de RabbitMQ).
- Federation: Es otro plugin diseñado para enlazar exchanges o colas entre brokers remotos de una manera más dinámica y distribuida que Shovel. La idea principal es que un exchange o cola en un broker puede "federar" con uno remoto, de modo que los mensajes publicados en el exchange/cola remoto aparezcan como si hubieran sido publicados localmente, o que un consumidor local pueda consumir de una cola remota. Federation es útil para escenarios de distribución de topología y mensajes a través de WANs o entre clústeres geográficamente dispersos, permitiendo que los mensajes "fluyan" entre ellos de manera transparente para las aplicaciones locales.
- Uso: Shovel y Federation son esenciales para arquitecturas más complejas que implican la conexión de múltiples brokers o clústeres, ya sea por razones organizacionales, geográficas o de aislamiento lógico.
Plugins y Extensiones para Funcionalidades Adicionales:
RabbitMQ es altamente extensible a través de su sistema de plugins. Más allá del esencial plugin de gestión (rabbitmq_management), existen muchos otros plugins que añaden funcionalidades:
- Soporte de Protocolos: Plugins para soportar otros protocolos de mensajería además de AMQP 0-9-1 (MQTT, STOMP).
- Autenticación/Autorización: Plugins para integrarse con sistemas de autenticación y autorización externos (LDAP, OAuth 2.0, etc.).
- Funcionalidades de Colas: Plugins que modifican o añaden comportamiento a las colas (ej.
rabbitmq_delayed_message_exchangepara colas de retraso). - Integración: Plugins para integrar con sistemas externos (ej.
rabbitmq_web_stomp,rabbitmq_web_mqtt).
Estas características avanzadas son cruciales para administrar despliegues de RabbitMQ complejos, conectar sistemas distribuidos, extender la funcionalidad base del broker y adaptarse a los requisitos específicos de las aplicaciones y la infraestructura.
Conclusión
La alta disponibilidad y la escalabilidad horizontal son requisitos fundamentales para la inmensa mayoría de las aplicaciones modernas, y RabbitMQ aborda estos desafíos de manera eficaz a través de su capacidad de clustering. Hemos visto cómo los nodos pueden agruparse no solo para compartir metadatos, sino también, y crucialmente, cómo arquitecturas como el mirroring de colas (un enfoque clásico, ahora en desuso para HA) y las modernas y robustas Quorum Queues (basadas en Raft) aseguran que los mensajes no se pierdan y que las colas permanezcan disponibles incluso si un nodo individual falla.
Comprendimos que la escalabilidad horizontal se logra fundamentalmente añadiendo más nodos al clúster para distribuir la carga de conexiones y procesamiento, y que una estrategia efectiva de balanceo de carga externo es esencial para maximizar el throughput y la utilización de recursos del clúster. Discutimos las implicaciones para productores y consumidores, que interactúan con el clúster como un único broker lógico.
Finalmente, exploramos herramientas avanzadas como las Políticas para la gestión centralizada y dinámica de la configuración de recursos, y los mecanismos de Shovel y Federation como soluciones para conectar brokers o clústeres distribuidos, así como la importancia del ecosistema de plugins para extender la funcionalidad base de RabbitMQ.
Con esta comprensión de la HA, el clustering y las herramientas avanzadas, tienes una visión completa de cómo diseñar, desplegar y gestionar RabbitMQ para escenarios de misión crítica, alta carga y distribuidos.
RabbitMQ 5: Consumo de Recursos, Latencia y Monitorización de RabbitMQ
- Mauricio ECR
- Arquitectura
- 29 Apr, 2025
Hemos explorado la teoría detrás de RabbitMQ, su arquitectura, cómo enruta mensajes y cómo podemos construir sistemas robustos y seguros. Sin embargo, para operar RabbitMQ de manera efectiva en produc
RabbitMQ 5: Consumo de Recursos, Latencia y Monitorización de RabbitMQ
- Mauricio ECR
- Arquitectura
- 29 Apr, 2025
Hemos explorado la teoría detrás de RabbitMQ, su arquitectura, cómo enruta mensajes y cómo podemos construir sistemas robustos y seguros. Sin embargo, para operar RabbitMQ de manera efectiva en producción, necesitamos entender cuántos recursos consume, qué esperar en términos de latencia de los mensajes y, lo más importante, cómo vigilarlo y gestionarlo activamente.
Este artículo se sumerge en estos aspectos prácticos, brindándote la información necesaria para dimensionar tu infraestructura, gestionar expectativas sobre el rendimiento y mantener tu broker funcionando sin problemas.
Consumo de Recursos e Implementación
El "costo" de ejecutar RabbitMQ, principalmente en términos de CPU, memoria RAM y espacio en disco, no es fijo. Varía significativamente en función de varios factores clave:
Factores que Influyen en el Consumo
- Carga (Throughput): El factor más obvio. Un alto volumen de mensajes publicados y entregados por segundo requiere más CPU y red para procesar las operaciones.
- Número de Colas y Exchanges: Aunque los recursos por cola/exchange inactivos son bajos, un gran número de ellos (miles o decenas de miles) puede aumentar la carga de gestión interna del broker y el consumo de memoria. Esto es especialmente cierto si hay muchas conexiones y bindings activos asociados a estos elementos.
- Persistencia de Mensajes y Durabilidad de Colas:
- Los mensajes persistentes (en colas duraderas) requieren escrituras a disco para asegurar que no se pierdan en caso de fallo del broker. Esto consume I/O de disco y puede ser un cuello de botella importante si el volumen es alto y el disco subyacente es lento.
- Las colas duraderas requieren que su estado (configuración, mensajes en cola, estado de consumidores) sea guardado persistentemente.
- Usar mensajes persistentes aumenta significativamente la demanda de recursos de disco y puede reducir el throughput máximo comparado con mensajes no persistentes.
- Número de Conexiones y Canales: Cada conexión de cliente TCP y cada canal AMQP asociado consumen memoria en el broker para mantener su estado. Un gran número de clientes conectados (cientos o miles) puede sumar un consumo notable de RAM, incluso si la tasa de mensajes no es extremadamente alta.
- Tamaño de los Mensajes: Mensajes más grandes consumen más ancho de banda de red al ser transferidos entre productores/consumidores y el broker. También consumen más memoria y/o disco al ser transferidos y almacenados temporalmente en el broker o en las colas.
- Uso de Características Avanzadas: Características como prioridades de mensajes (
x-max-priority), TTLs (x-message-ttl), o Dead-Lettering añaden algo de overhead de procesamiento interno en el broker para gestionar la lógica asociada.
Ejemplos de Carga y Consideraciones de Dimensionamiento
No hay una "calculadora" única y precisa para dimensionar RabbitMQ que funcione en todos los casos, ya que depende mucho de los factores anteriores y del hardware subyacente. Sin embargo, aquí hay algunas pautas generales basadas en la experiencia común:
| Mensajes/s | Tamaño Msg (bytes) | CPU Cores | RAM (GB) | Disco (IOPS / MB/s) | Ancho de Banda |
|---|---|---|---|---|---|
| 100 | 512 | 1 | 1 | 50 IOPS / 1 MB/s | ~0.4 Mbps |
| 1,000 | 1,024 (1 KB) | 2 | 2 | 100 IOPS / 5 MB/s | ~8 Mbps |
| 5,000 | 2,048 (2 KB) | 4 | 4 | 200 IOPS / 20 MB/s | ~80 Mbps |
| 10,000 | 4,096 (4 KB) | 6 | 6 | 400 IOPS / 40 MB/s | ~320 Mbps |
| 50,000 | 4,096 (4 KB) | 8–12 | 12–16 | 1,000 IOPS / 200 MB/s | ~1.6 Gbps |
| 100,000 | 8,192 (8 KB) | 16+ | 32+ | 2,000+ IOPS / 800 MB/s | ~6.4 Gbps |
🔍 Notas / Supuestos:
- Se asume que la persistencia está activada (uso típico).
- Uso de colas clásicas (classic queues) sin clustering.
- No se consideran configuraciones con replicación (HA) o federation.
- Basado en RabbitMQ 3.11+.
- Los mensajes tienen TTL o se procesan rápidamente (sin grandes acumulaciones).
- Red de baja latencia.
Estrategias para Optimizar el Uso de Recursos
- Minimizar la Persistencia: Usa persistencia solo para los mensajes y colas donde la pérdida sea inaceptable. Los mensajes no persistentes son mucho más rápidos y consumen significativamente menos recursos de disco y CPU asociados al I/O.
- Mantener las Colas Cortas: Idealmente, los consumidores deben procesar mensajes tan rápido como llegan. Colas que crecen indefinidamente son un signo de contrapresión (la tasa de producción excede la de consumo) y consumen progresivamente más RAM y eventualmente pagan a disco. Usa límites de cola (
x-max-length,x-max-length-bytes) para proteger el broker de crecimientos descontrolados. - Optimizar el Prefetch (QoS): Ajusta el prefetch de los consumidores. Un prefetch muy alto puede hacer que un consumidor acapare muchos mensajes en memoria, potencialmente agotando la RAM del consumidor y reduciendo el throughput si no puede procesarlos rápido. Un prefetch muy bajo (ej. 1) puede reducir el throughput total al esperar la confirmación de cada mensaje. Encuentra un balance óptimo para tu carga, el rendimiento de tus consumidores y la latencia deseada.
- Limitar Conexiones/Canales: Si es posible, reutiliza conexiones y canales en tus aplicaciones cliente en lugar de crear nuevos para cada operación. Mantener miles de conexiones efímeras puede ser costoso en recursos para el broker.
- Dimensionar el Disco Correctamente: Si usas persistencia o esperas colas largas (aunque esto último es una señal de alerta), invierte en discos SSD rápidos y con suficiente espacio para manejar tanto los mensajes en cola como los archivos de paginación y logs. La velocidad del disco impacta directamente el throughput y la latencia con persistencia.
- Monitorizar y Ajustar: Usa las métricas de RabbitMQ de forma constante para identificar cuellos de botella (I/O de disco alto, uso de CPU elevado, crecimiento persistente de colas, alta latencia, número excesivo de conexiones/canales) y ajusta tu configuración o escala tu infraestructura en consecuencia.
Latencia Esperada en los Mensajes
La latencia de un mensaje es el tiempo que tarda desde que un productor lo publica hasta que un consumidor lo recibe (y potencialmente lo procesa). No es cero, ya que implica varias etapas y tránsitos por la red y el broker.
La latencia total se puede conceptualizar aproximadamente como la suma de los tiempos en cada etapa:
$ Latencia_{Total} = Latencia_{Red} (Productor => Broker) + Tiempo_{Broker} (Enrutamiento, Encolamiento, Persistencia) + Latencia_{Red} (Broker => Consumidor) + Tiempo_{Procesamiento} (Consumidor) $
Nos centraremos en los factores que afectan el tiempo que el mensaje pasa dentro o viajando hacia/desde el broker. Varios factores afectan esta latencia:
Factores que Afectan la Latencia
- Carga del Broker: Un broker bajo alta carga (muchos mensajes, muchas conexiones, I/O de disco saturado) tardará más en procesar mensajes y entregarlos. Las operaciones se encolan internamente.
- Tamaño del Mensaje: Mensajes más grandes tardan más en viajar por la red y ser procesados por el broker y los clientes (serialización/deserialización).
- Persistencia: Publicar y encolar mensajes persistentes es significativamente más lento que con mensajes no persistentes, ya que requiere que el broker espere la confirmación de escritura a disco antes de reconocer la publicación al productor (si se usan confirmaciones de editor) y antes de considerarlo seguro en la cola. Esto añade latencia.
- Red: La latencia intrínseca de la red entre productores, el broker y consumidores es un componente directo y, a menudo, incontrolable si los componentes están distribuidos geográficamente. Brokers y aplicaciones en diferentes centros de datos o regiones tendrán mayor latencia de red.
- QoS (Prefetch): Un prefetch bajo (ej. 1) puede aumentar la latencia percibida entre mensajes para un mismo consumidor, ya que debe confirmar cada mensaje antes de recibir el siguiente. Un prefetch más alto reduce esta latencia inter-mensaje, pero puede aumentar el tiempo que un mensaje espera en el buffer del consumidor antes de ser procesado, aumentando su latencia dentro del consumidor.
- Número de Hops (Enrutamiento): Mensajes que son enrutados a través de múltiples Exchanges encadenados (topologías avanzadas con
shovelofederationo simples bindings secuenciales) pueden experimentar latencia adicional en cada paso de enrutamiento interno o entre brokers. - Paginación a Disco: Si las colas crecen y los mensajes que no caben en RAM se paginan a disco, la latencia para acceder a ellos cuando un consumidor los solicita aumenta drásticamente. Acceder a disco es mucho más lento que acceder a RAM.
Latencias Típicas Esperadas
Las latencias varían ampliamente, pero aquí hay un rango esperado bajo diferentes escenarios:
- Configuración Optimizada, Baja Carga, Mensajes Pequeños y No Persistentes, Red Rápida: Latencia muy baja, a menudo en el rango de pocos milisegundos. Este es el mejor escenario.
- Configuración Típica, Carga Moderada, Mensajes Persistentes, Red Estándar: Latencia moderada, probablemente en el rango de decenas o cientos de milisegundos. La persistencia suele ser el factor dominante aquí.
- Alta Carga, I/O de Disco Saturado, Colas Largas, Paginación Activa: Latencia puede dispararse a varios segundos o incluso más. Esto indica un problema grave de rendimiento o dimensionamiento.
Estrategias para Minimizar la Latencia Cuando es Crítico
- Usar Mensajes No Persistentes: Si la pérdida ocasional de mensajes es aceptable y la latencia es crítica (ej. datos de monitorización no vitales, notificaciones efímeras), usa mensajes no persistentes. Son significativamente más rápidos.
- Mantener Colas Cortas: Asegura que los consumidores procesen mensajes rápidamente para evitar que las colas se alarguen y los mensajes se paginen. Escalar el número de consumidores es la estrategia clave contra la contrapresión.
- Optimizar el Prefetch: Experimenta con el prefetch para encontrar el equilibrio adecuado que mantenga a tus consumidores ocupados sin sobrecargarlos ni acaparar mensajes innecesariamente.
- Hardware Rápido: Usa servidores con CPU potente (para el procesamiento interno), mucha RAM (para colas en memoria) y discos SSD muy rápidos (crítico si usas persistencia) para minimizar los tiempos de procesamiento del broker.
- Red de Baja Latencia: Coloca el broker y los consumidores/productores en la misma red o lo más cerca posible geográficamente para minimizar la latencia de red.
- Evitar Enrutamiento Complejo Innecesario: Si una topología de enrutamiento simple es suficiente, úsala en lugar de cadenas complejas de Exchanges, ya que cada paso añade una pequeña sobrecarga.
- Monitorizar la Latencia: Mide la latencia de extremo a extremo de tus mensajes en tus aplicaciones (productor -> broker -> consumidor -> procesamiento) para identificar cuellos de botella reales. Las métricas del broker te dirán cuánto tiempo pasa dentro de RabbitMQ.
Es importante recordar que RabbitMQ está diseñado principalmente para la comunicación asíncrona, el desacoplamiento de servicios y la entrega confiable (especialmente con persistencia), optimizando el throughput (mensajes por segundo) sobre la latencia ultrabaja. Si necesitas latencias de microsegundos y comunicación estrictamente síncrona, RabbitMQ podría no ser la herramienta adecuada; la comunicación directa via RPC o protocolos especializados de baja latencia serían más apropiados.
Monitorización y Gestión de RabbitMQ
Una vez que RabbitMQ está funcionando, la monitorización activa y la gestión son vitales para asegurar su salud, rendimiento, prever problemas y solucionarlos rápidamente cuando ocurren.
Uso Exhaustivo de la Interfaz de Administración Web
La interfaz web de gestión (Management Plugin) es una herramienta invaluable para la inspección manual del estado del broker. Se accede típicamente a través del puerto 15672 (asegúrate de que esté accesible solo desde redes de administración seguras). Permite visualizar:
- Overview: Métricas generales y gráficos de alto nivel como mensajes publicados/entregados por segundo, uso de memoria/disco, número de conexiones/canales, y estado del cluster.
- Connections: Listado de todas las conexiones activas, desde qué host se originan, qué usuario las estableció, qué vhost están usando, etc. Puedes cerrarlas forzadamente si es necesario.
- Channels: Detalles de los canales AMQP dentro de cada conexión, incluyendo el prefetch configurado y los mensajes no confirmados.
- Exchanges: Listado de todos los Exchanges declarados, su tipo (direct, fanout, topic, headers), durabilidad, y a qué colas están vinculados (
bindings). Puedes declarar/eliminar Exchanges. - Queues: La vista más importante para diagnosticar problemas. Muestra todas las colas, cuántos mensajes tienen listos para entregar (
messages ready), cuántos mensajes están entregados pero sin confirmar (messages unacknowledged), la tasa de entrada (incoming), la tasa de salida (outgoing), el número de consumidores activos, sus propiedades (durabilidad, auto-delete, argumentos), etc. Puedes declarar/eliminar colas, purgar mensajes (vaciar la cola), publicar mensajes de prueba. - Admin: Gestionar usuarios, vhosts (entornos virtuales), permisos de usuario en vhosts, políticas (para configuración a gran escala), y el estado del cluster (si aplica).
Aprender a navegar por esta interfaz e interpretar sus métricas es fundamental para diagnosticar problemas rápidamente (ej. colas creciendo consistentemente -> contrapresión, los consumidores no dan abasto; tasas de entrega bajas pero mensajes en cola -> problemas en los consumidores; muchas conexiones -> posible fuga de recursos en una aplicación cliente; uso de disco alto -> persistencia o paginación).
Herramientas de Línea de Comandos (rabbitmqctl)
rabbitmqctl es la herramienta de línea de comandos para interactuar con RabbitMQ. Es esencial para tareas de automatización, scripting, y gestión de bajo nivel, especialmente en servidores donde no tienes acceso fácil a una UI gráfica o para realizar operaciones repetitivas. Algunos comandos esenciales incluyen (siempre especificando el vhost con -p <vhost> si no es el / por defecto):
rabbitmqctl status
Muestra el estado general del nodo RabbitMQ, versión, estado de los procesos, uso de memoria, etc.
rabbitmqctl list_queues name messages_ready messages_unacknowledged consumers memory
Lista las colas y métricas clave como el número de mensajes listos y sin confirmar, consumidores activos y uso de memoria por cola. Puedes listar otras columnas según necesites (message_stats.publish, message_stats.deliver_get, etc.).
rabbitmqctl list_exchanges name type durable
Lista los Exchanges, su tipo y si son duraderos.
rabbitmqctl list_bindings source destination destination_type routing_key arguments
Lista todas las vinculaciones (bindings) entre Exchanges y Colas/Exchanges.
rabbitmqctl add_user <username> <password>
rabbitmqctl set_permissions -p <vhost> <user> "<configure>" "<write>" "<read>"
rabbitmqctl delete_user <username>
Gestión básica de usuarios y permisos. Los permisos son expresiones regulares. "<configure>" afecta a exchanges/colas, "<write>" a la publicación, "<read>" al consumo.
rabbitmqctl delete_queue [-p <vhost>] <name>
rabbitmqctl purge_queue [-p <vhost>] <name>
Elimina una cola o elimina todos los mensajes de una cola sin borrarla. ¡Usar con precaución en producción!
rabbitmqctl tiene muchos más comandos para gestionar el cluster, políticas, parámetros de vhost, etc., siendo una herramienta muy potente para la administración avanzada.
Integración con Sistemas de Monitorización Externos
Si bien la UI web es útil para la inspección manual y rabbitmqctl para la gestión puntual, para la monitorización proactiva, la visualización histórica y las alertas necesitas integrar RabbitMQ con sistemas de monitorización centralizados.
- Prometheus + Grafana: Una combinación muy popular en entornos de microservicios. El plugin de gestión de RabbitMQ expone métricas en un formato que Prometheus puede scrapear (
/metricsendpoint si el pluginprometheusestá habilitado, o via API si solo el pluginmanagementestá habilitado). Grafana se utiliza luego para crear dashboards visuales con estas métricas (uso de CPU/RAM, I/O de disco, longitud de colas a lo largo del tiempo, tasas de mensajes public/deliver/ack, latencia de confirmación, número de conexiones/canales, estado de health checks, etc.). - Otras Herramientas: RabbitMQ también puede integrarse con otras herramientas de monitorización como Nagios, Zabbix, Datadog, New Relic, etc., a menudo a través de plugins específicos, exportando métricas vía API de gestión o analizando sus logs.
Configurar alertas basadas en umbrales (ej. cola excede cierto número de mensajes, uso de CPU/RAM demasiado alto, espacio en disco casi lleno, nodo de cluster caído, tasa de mensajes entregados cae drásticamente) es vital para ser notificado y responder rápidamente a los problemas antes de que afecten significativamente a tus aplicaciones.
Logging y Alertas
Además de las métricas, los logs de RabbitMQ (generalmente ubicados en /var/log/rabbitmq/ en sistemas tipo Linux) contienen información valiosa sobre eventos, errores, advertencias, intentos de conexión, desconexiones, cambios de estado del cluster, etc. Es crucial tener un sistema centralizado de gestión de logs (ej. ELK Stack - Elasticsearch, Logstash, Kibana; Splunk; Loki+Grafana) para agregarlos desde todos tus nodos RabbitMQ, buscar patrones, diagnosticar fallos específicos y configurar alertas basadas en la aparición de eventos o errores particulares en los logs (ej. errores al escribir a disco, fallos de autenticación repetidos que podrían indicar un ataque, errores de sincronización de cluster).
La combinación de métricas en tiempo real (para el qué está pasando con el rendimiento y los recursos) y análisis de logs históricos (para el por qué algo falló o un evento ocurrió) te dará una visibilidad completa sobre la salud y el comportamiento de tu broker RabbitMQ.
Conclusión
Operar RabbitMQ en producción va mucho más allá de entender cómo enviar y recibir mensajes; implica comprender su apetito por los recursos, gestionar las expectativas de latencia y, sobre todo, tener herramientas robustas de monitorización y gestión para asegurar su estabilidad y rendimiento continuo.
Hemos visto los múltiples factores que influyen en el consumo de CPU, RAM y disco, y cómo la persistencia de mensajes y colas es un gran determinante del rendimiento de I/O, a menudo siendo el principal cuello de botella. Discutimos la latencia, sus componentes y cómo optimizarla según tus necesidades, recordando que RabbitMQ está optimizado para el throughput y el desacoplamiento más que para la latencia ultrabaja, un diseño fundamental para sistemas asíncronos robustos.
Finalmente, exploramos las herramientas esenciales para mantener el control: la indispensable interfaz de administración web para la inspección visual y diagnósticos puntuales, rabbitmqctl para la gestión por línea de comandos y automatización, y la integración con sistemas de monitorización externos como Prometheus y Grafana (junto con un buen sistema de logs) para tener visibilidad proactiva, análisis histórico y alertas cruciales.
Con estos conocimientos sobre el consumo de recursos, las expectativas de latencia y las herramientas de monitorización y gestión, estás mucho mejor equipado para desplegar, dimensionar y mantener una infraestructura de RabbitMQ saludable y resiliente. Un tema que complementa directamente la robustez en producción, especialmente a cargas altas, es la Alta Disponibilidad y el Clustering, que abordaremos en un futuro artículo.
RabbitMQ 4: Robustez y Seguridad en RabbitMQ
- Mauricio ECR
- Arquitectura
- 27 Apr, 2025
Hemos recorrido el camino desde la introducción a RabbitMQ y su papel en la mensajería asíncrona, pasando por su arquitectura, componentes de enrutamiento (Exchanges y Bindings), y la gestión detallad
RabbitMQ 4: Robustez y Seguridad en RabbitMQ
- Mauricio ECR
- Arquitectura
- 27 Apr, 2025
Hemos recorrido el camino desde la introducción a RabbitMQ y su papel en la mensajería asíncrona, pasando por su arquitectura, componentes de enrutamiento (Exchanges y Bindings), y la gestión detallada de las Colas. Ahora es momento de abordar cómo hacer que nuestro sistema de mensajería sea verdaderamente robusto y seguro.
La robustez implica asegurar que los mensajes no se pierdan y que el broker pueda manejar situaciones de estrés. La seguridad es fundamental para proteger tus datos y recursos. Este artículo profundiza en la retención de mensajes, cómo gestionar la contrapresión (cuando los productores envían mensajes más rápido de lo que los consumidores pueden procesar) y los aspectos clave de la seguridad.
Retención de Mensajes en RabbitMQ
La retención de mensajes se refiere a cuánto tiempo y bajo qué condiciones un mensaje permanece en RabbitMQ antes de ser entregado o, potencialmente, descartado. Esto depende de una combinación de factores:
Comportamiento de Mensajes Persistentes vs No Persistentes
La persistencia de los mensajes se define en el momento en que el productor los publica, marcándolos como persistentes. Esto indica al broker que debe intentar escribir esos mensajes en disco tan pronto como llegan a una cola. Por el contrario, los mensajes que no se marcan como persistentes permanecen únicamente en memoria, siendo más vulnerables a pérdidas en caso de fallos.
En escenarios como el reinicio del broker, solo los mensajes persistentes almacenados en colas duraderas sobrevivirán; los mensajes no persistentes —incluso si estaban en colas duraderas— y todos los mensajes en colas no duraderas se perderán.
En el caso de fallos de consumidores, si un consumidor falla antes de confirmar (ACK) un mensaje, este puede ser re-enviado. Sin embargo, su estado de persistencia no cambia: sigue siendo el mismo con el que fue originalmente publicado. En estas situaciones, la confiabilidad en la reentrega depende principalmente de la durabilidad de la cola y del correcto manejo de los ACKs
Durabilidad de Colas y su Impacto en la Retención
La durabilidad de la cola (establecida mediante la propiedad durable=true) es independiente de la persistencia de los mensajes, aunque ambas características trabajan en conjunto para garantizar la retención de información a largo plazo. Una cola duradera asegura que su definición no se pierda incluso si el broker se reinicia. Sin embargo, para que un mensaje específico sobreviva a un reinicio, no basta con que la cola sea duradera: el propio mensaje también debe marcarse como persistente. Si cualquiera de estas condiciones falta —ya sea que la cola no sea duradera o el mensaje no sea persistente—, el mensaje se perderá tras el reinicio del broke
Time-To-Live (TTL) de Mensajes y Colas
El tiempo de vida de los mensajes puede controlarse de diferentes maneras. La propiedad x-message-ttl permite definir un tiempo de vida (TTL) predeterminado para todos los mensajes de una cola, asegurando que cualquier mensaje que exceda ese tiempo sea descartado automáticamente. Además, es posible establecer un TTL individual al momento de publicar un mensaje; en caso de que tanto el mensaje como la cola tengan valores TTL definidos, se aplicará siempre el menor de los dos. Por otro lado, la propiedad x-expires determina cuánto tiempo puede permanecer una cola sin actividad antes de ser eliminada automáticamente por el broker.
Dead-Letter Exchanges (DLX) y Dead-Letter Queues (DLQ)
los mensajes que expiran, los que son rechazados sin reenvío (requeue=false) o los descartados debido al desbordamiento de una cola pueden ser redirigidos a una Dead-Letter Exchange (DLX). Esta funcionalidad permite capturar mensajes no procesados para realizar análisis de errores y, si es necesario, reenviarlos o reprocesarlos posteriormente.
Problemas de Contrapresión (Backpressure)
La contrapresión ocurre cuando el ritmo de llegada de mensajes a una cola excede consistentemente el ritmo al que los consumidores pueden procesarlos. Si no se maneja, esto puede llevar a que las colas crezcan sin control, consuman la memoria y el disco del broker, y eventualmente afecten el rendimiento o incluso hagan que el broker colapse. RabbitMQ tiene varios mecanismos incorporados para manejar la contrapresión:
Mecanismos de Contrapresión en RabbitMQ:
- Límites de Prefetch (QoS): Vimos en el artículo anterior que QoS limita cuántos mensajes no confirmados puede tener un consumidor a la vez. Al establecer un prefetch bajo (ej. 10-100), evitas que un consumidor "acapare" mensajes, permitiendo una mejor distribución entre múltiples consumidores y limitando cuántos mensajes pendientes de ACK existen en tránsito. Si los consumidores son lentos, un prefetch bajo hace que RabbitMQ deje de enviarles mensajes, forzando a los mensajes a esperar en la cola.
- Límites de Longitud de Cola: Limitan explícitamente el tamaño de la cola. Cuando se alcanza el límite, la política de desbordamiento (x-overflow) entra en juego (drop-head descarta los mensajes más viejos, reject-publish detiene a los productores). Esto protege al broker de quedarse sin recursos, pero puede resultar en pérdida de mensajes si se usa drop-head y los mensajes no se consumen a tiempo.
- Políticas de Almacenamiento: RabbitMQ puede paginar mensajes fuera de la memoria al disco si la cola crece mucho, para liberar RAM. Esto añade latencia al acceder a esos mensajes, pero previene fallos por falta de memoria. Puedes configurar umbrales de memoria para activar la paginación.
- Control de Flujo TCP: A un nivel más bajo, si el broker detecta que no puede escribir datos salientes tan rápido como llegan los entrantes (ej. porque los consumidores no están recibiendo mensajes lo suficientemente rápido), puede activar el control de flujo a nivel de conexión TCP con los productores. Esto pausa temporalmente a los productores, forzándolos a esperar antes de enviar más mensajes. Este es el mecanismo de última instancia para proteger al broker de ser abrumado.
Estrategias para Diseñar Sistemas Resilientes:
- Monitorización: Monitorea activamente la longitud de las colas, el uso de memoria/disco del broker, y la tasa de entrega/ACKs de los consumidores. Esto te alerta antes de que la situación se vuelva crítica.
- Dimensionamiento Adecuado: Asegúrate de que tu broker y tus consumidores tengan suficientes recursos (CPU, RAM, disco) para manejar la carga esperada y picos.
- Escalabilidad de Consumidores: La forma principal de mitigar la contrapresión es escalar el número de consumidores para igualar o superar la tasa de llegada de mensajes. Asegúrate de que sea fácil desplegar más instancias de tus consumidores.
- Diseño Idempotente y Robusto:Consumidores que fallan a menudo o son lentos contribuyen a la contrapresión. Diseña consumidores eficientes y que puedan manejar fallos sin colapsar.
- Uso Cauteloso de Mensajes Persistentes: Los mensajes persistentes requieren escrituras a disco, lo que es más lento que escribir en memoria y puede limitar el throughput máximo si la carga es muy alta y el disco lento. Úsalos solo donde la pérdida de mensajes sea inaceptable.
- Límites de Cola y DLX: Decide qué es preferible: descartar mensajes viejos (drop-head) o detener a los productores (reject-publish) cuando la cola se llena. En muchos casos, usar drop-head junto con un DLX para capturar los mensajes descartados es una buena estrategia para auditar la pérdida sin detener a todo el sistema.
Seguridad en RabbitMQ en Detalle
Asegurar tu broker de mensajes es tan importante como asegurar tus bases de datos o APIs. Un broker comprometido puede ser utilizado para interceptar datos sensibles, inyectar mensajes maliciosos o interrumpir el servicio.
Autenticación de Usuarios y Hosts: RabbitMQ admite múltiples mecanismos de autenticación para verificar la identidad de las aplicaciones que intentan conectarse. El método más común es la autenticación basada en usuario y contraseña, donde RabbitMQ almacena las credenciales de forma segura (hashed) y las valida al momento de la conexión. Además, ofrece soporte para esquemas de autenticación más avanzados mediante Pluggable Authentication, permitiendo integrar mecanismos como LDAP, certificados X.509 (TLS) u OAuth 2.0 a través de plugins. También es posible configurar restricciones a nivel de red, limitando las conexiones únicamente a hosts específicos para reforzar la seguridad.
Autorización y Control de Acceso: Una vez que un usuario se autentica, necesita contar con los permisos adecuados para realizar operaciones dentro de un vhost. Los permisos se asignan específicamente a nivel de vhost, lo que significa que un mismo usuario puede tener distintos permisos en diferentes vhosts. Dentro de cada vhost, los permisos se otorgan sobre tres tipos de operaciones principales: Configure, que permite crear o eliminar exchanges y colas; Write, que permite publicar mensajes en exchanges; y Read, que permite consumir mensajes de colas. Es una buena práctica de seguridad aplicar el Principio del Mínimo Privilegio, creando usuarios separados para cada aplicación o servicio y otorgándoles únicamente los permisos estrictamente necesarios. Por ejemplo, un productor solo debería tener permisos de escritura (write) sobre determinados exchanges, mientras que un consumidor únicamente debería tener permisos de lectura (read) sobre las colas pertinentes.
Comunicación Segura con TLS/SSL: Por defecto, la comunicación entre los clientes y el broker de RabbitMQ no está cifrada, lo que representa un riesgo de seguridad si los mensajes contienen información sensible o si la red no es confiable. Para mitigar este riesgo, RabbitMQ soporta TLS/SSL, que permite cifrar las conexiones TCP entre clientes y el broker. La configuración de TLS implica obtener o generar certificados SSL/TLS tanto para el broker como, opcionalmente, para la autenticación del cliente, configurar el listener de RabbitMQ para aceptar conexiones TLS y ajustar los clientes para que utilicen TLS y validen el certificado del broker. Como mejor práctica, se recomienda utilizar TLS para todas las conexiones en entornos de producción, validar los certificados del broker desde el cliente y considerar la autenticación mutua, donde el cliente también presenta un certificado, para agregar una capa extra de seguridad.
Consideraciones de Seguridad en Entornos de Red: En cuanto a las consideraciones de seguridad en entornos de red para RabbitMQ, es fundamental tomar medidas para proteger el acceso al broker. Uno de los aspectos clave es configurar firewalls, restringiendo el acceso a los puertos predeterminados de RabbitMQ (5672 para AMQP sin cifrar, 5671 para AMQP con TLS y 15672 para la interfaz de administración web) solo a los servidores y redes que realmente lo necesiten. Además, se recomienda ejecutar RabbitMQ dentro de redes privadas o VPNs, lo que reduce su exposición a la red pública de Internet y mejora la seguridad. Por último, si gestionas múltiples aplicaciones, es aconsejable emplear segmentación lógica mediante el uso de diferentes vhosts y usuarios con permisos específicos, lo que facilita el aislamiento de los recursos y minimiza el riesgo de accesos no autorizados.
Auditoría y Registro de Eventos de Seguridad: En cuanto a la auditoría y registro de eventos de seguridad, RabbitMQ permite configurar el registro de eventos importantes, como conexiones, intentos de autenticación fallidos, operaciones de declaración o eliminación de recursos, entre otros. Estos registros son fundamentales para monitorear la seguridad del sistema, detectar actividades sospechosas y llevar a cabo auditorías para identificar quién realizó qué acciones dentro del entorno.
Implementar una estrategia de seguridad sólida es un paso no negociable antes de desplegar RabbitMQ en un entorno de producción.
Conclusión
Este artículo nos ha equipado con conocimientos esenciales para construir y operar sistemas de mensajería confiables y seguros con RabbitMQ. Hemos explorado a fondo cómo se retienen los mensajes, combinando la persistencia de mensajes con la durabilidad de colas, y cómo los mecanismos como TTL y DLX/DLQ nos permiten gestionar el ciclo de vida de los mensajes y manejar fallos de manera elegante.
Abordamos el desafío de la contrapresión, entendiendo los mecanismos de defensa de RabbitMQ (prefetch, límites de cola, control de flujo) y las estrategias de diseño para construir sistemas escalables que puedan absorber picos de carga sin colapsar o perder datos indiscriminadamente.
Finalmente, destacamos la importancia crítica de la seguridad, cubriendo la autenticación y autorización de usuarios, la protección de las comunicaciones con TLS/SSL y consideraciones de seguridad en la red.
Con una comprensión sólida de la arquitectura, la gestión de colas, la robustez y la seguridad, estás bien preparado para diseñar e implementar soluciones de mensajería con RabbitMQ. El próximo paso lógico en esta serie es ver todos estos conceptos en acción a través de un ejemplo práctico en código.
RabbitMQ 3: Configuración y Gestión de Colas en RabbitMQ
- Mauricio ECR
- Arquitectura
- 26 Apr, 2025
Después de entender qué es RabbitMQ y cómo sus Exchanges y Bindings dirigen los mensajes, llegamos a la Cola. La cola es fundamentalmente un buffer confiable: es el lugar donde los mensajes esperan su
RabbitMQ 3: Configuración y Gestión de Colas en RabbitMQ
- Mauricio ECR
- Arquitectura
- 26 Apr, 2025
Después de entender qué es RabbitMQ y cómo sus Exchanges y Bindings dirigen los mensajes, llegamos a la Cola. La cola es fundamentalmente un buffer confiable: es el lugar donde los mensajes esperan su turno para ser procesados por un consumidor. Aunque parecen simples contenedores, las colas en RabbitMQ tienen una serie de propiedades y argumentos avanzados que son cruciales para definir su comportamiento, rendimiento y fiabilidad.
En este tercer artículo, exploraremos en detalle la estructura de las colas, cómo múltiples consumidores trabajan con ellas, profundizaremos en el patrón Pub/Sub desde la perspectiva de la cola, y abordaremos uno de los temas más importantes para la resiliencia: el manejo de errores y reintentos.
Estructura y Propiedades de las Colas
Cada cola en RabbitMQ se define con un conjunto de propiedades que determinan cómo se comporta:
Nombre:
- Puede ser especificado por la aplicación que declara la cola. Si varias aplicaciones declaran la misma cola con el mismo nombre y propiedades, todas interactuarán con la misma cola.
- Puede ser generado automáticamente por RabbitMQ (lo que ocurre si no especificas un nombre al declarar la cola). Estas colas generadas suelen ser no duraderas, exclusivas y auto-eliminables, ideales para respuestas temporales o escenarios de "reply-to".
Durabilidad (durable):
- Si es true, la declaración de la cola sobrevivirá a un reinicio del broker. Esto es crucial si quieres que tu sistema sea resiliente y no pierda la definición de sus colas principales ante una caída del servidor. Los mensajes persistentes en una cola duradera también sobrevivirán.
- Si es false (colas transitorias), la cola se perderá si el broker se reinicia. Útil para colas temporales.
Exclusividad (exclusive):
- Si es true, la cola solo puede ser utilizada por la conexión que la declaró y se eliminará cuando esa conexión se cierre. Son útiles para colas de respuesta temporales y privadas entre dos procesos.
- Si es false, la cola puede ser utilizada por múltiples conexiones.
Auto-Delete (auto-delete):
- Si es true, la cola se eliminará automáticamente cuando el último consumidor se desconecte de ella. Es útil para colas temporales usadas solo mientras haya un consumidor activo.
- Si es false, la cola persistirá incluso si no hay consumidores activos. Es el comportamiento típico para colas de tareas o eventos que deben esperar.
Declarar una cola con propiedades que no coinciden con una cola existente con el mismo nombre resultará en un error. Por eso, es una buena práctica que todas las aplicaciones que interactúen con una cola la declaren con las mismas propiedades esperadas.
Argumentos Avanzados de las Colas
Las colas pueden aceptar argumentos adicionales durante su declaración para configurar comportamientos más complejos:
x-message-ttl (Time-To-Live por Mensaje):
- Define por cuánto tiempo (en milisegundos) un mensaje puede permanecer en la cola antes de expirar.
- Si un mensaje expira, puede ser descartado o enviado a un Dead-Letter Exchange (si está configurado).
- Útil para mensajes con validez limitada.
x-expires (TTL de la Cola):
- Define por cuánto tiempo (en milisegundos) una cola puede existir sin actividad (sin consumidores, sin mensajes publicados). Después de este tiempo, la cola se elimina automáticamente.
- Útil para colas temporales que no son exclusivas pero que deseas que se limpien solas.
x-dead-letter-exchange (DLX) y x-dead-letter-routing-key:
- Permiten configurar el Dead-Lettering. Si un mensaje muere en esta cola (expira por TTL, es rechazado sin posibilidad de reencolar, o la cola alcanza su límite de longitud), en lugar de ser descartado, se publica en el Exchange especificado por
x-dead-letter-exchange, opcionalmente con larouting keyespecificada porx-dead-letter-routing-key. - Fundamental para implementar manejo de mensajes fallidos, reintentos con retraso o auditoría de mensajes perdidos.
- Permiten configurar el Dead-Lettering. Si un mensaje muere en esta cola (expira por TTL, es rechazado sin posibilidad de reencolar, o la cola alcanza su límite de longitud), en lugar de ser descartado, se publica en el Exchange especificado por
x-max-length y x-max-length-bytes:
- Establecen límites máximos en el número de mensajes (
x-max-length) o el tamaño total en bytes (x-max-length-bytes) que una cola puede contener. - Útil para proteger el broker de colas que crecen indefinidamente y consumen demasiada memoria o disco.
- Establecen límites máximos en el número de mensajes (
x-overflow (Política de Desbordamiento):
- Define qué sucede si la cola alcanza su límite (
x-max-lengthox-max-length-bytes). - Las políticas comunes son
drop-head(eliminar los mensajes más viejos) oreject-publish(rechazar nuevas publicaciones al Exchange asociado con la cola, notificando al productor).drop-heades el valor por defecto.
- Define qué sucede si la cola alcanza su límite (
x-queue-type (Tipos de Cola):
- Permite elegir el tipo de implementación de la cola. Los tipos comunes son
classic(el tipo histórico, con variantesmirroredpara HA) yquorum(un tipo más reciente, recomendado para alta disponibilidad y durabilidad, basado en Raft). - La elección depende de los requisitos de HA y consistencia.
- Permite elegir el tipo de implementación de la cola. Los tipos comunes son
x-max-priority (Prioridades de Mensajes):
- Si se configura, la cola puede manejar mensajes con diferentes niveles de prioridad. Los consumidores recibirán los mensajes de mayor prioridad antes que los de menor prioridad.
- Requiere que los mensajes también se publiquen con una propiedad
priority.
Procesamiento Paralelo con Múltiples Consumidores
Una de las grandes ventajas de usar colas de mensajes es la capacidad de escalar el procesamiento simplemente añadiendo más consumidores a la misma cola.
Cuando múltiples consumidores se conectan a la misma cola, RabbitMQ distribuye los mensajes entre ellos en un esquema de round-robin por defecto. Cada mensaje enviado a esa cola será entregado a uno solo de los consumidores activos conectados a ella. Esto permite que el trabajo (procesar mensajes) se paralelice. Si un consumidor está ocupado, el mensaje se enviará al siguiente consumidor disponible.
Consideraciones Importantes:
Idempotencia: Dado que los mensajes se distribuyen y un consumidor podría fallar después de recibir el mensaje pero antes de confirmarlo (ACK), el mismo mensaje podría ser reentregado a otro consumidor. Tus operaciones de procesamiento deben ser idempotentes, es decir, poder ejecutarse múltiples veces con el mismo resultado que si se ejecutaran una sola vez, para evitar efectos secundarios no deseados.
Concurrencia: Tus consumidores deben estar diseñados para manejar la concurrencia si procesan múltiples mensajes simultáneamente (controlado por el prefetch).
Orden de Procesamiento: RabbitMQ garantiza el orden de los mensajes dentro de una sola cola. Sin embargo, con múltiples consumidores procesando mensajes en paralelo, el orden en que los mensajes terminan de procesarse puede no ser el mismo que el orden en que llegaron a la cola, debido a las diferentes velocidades de procesamiento de los consumidores. Si el orden global es estrictamente necesario, necesitas una estrategia diferente (ej: usar un solo consumidor por cola, o particionar la cola lógicamente).
QoS (Quality of Service) / Prefetch: Esta es una configuración crucial. El
prefetch counten el consumidor le dice a RabbitMQ cuántos mensajes puede enviar a ese consumidor antes de que reciba un acknowledgement (ACK). Un prefetch de 1 significa que RabbitMQ no enviará el siguiente mensaje a ese consumidor hasta que haya confirmado el anterior. Un prefetch más alto permite al consumidor tener un buffer de mensajes y mantener ocupados a los workers internos, pero si el consumidor falla, todos esos mensajes "prefecheados" pero no confirmados serán re-enviados. Ajustar el prefetch es clave para balancear el rendimiento y la distribución de carga.
Patrón de Publicación/Suscripción Detallado
Mientras que el patrón Pub/Sub a menudo se asocia con el Fanout Exchange, es importante entender que la suscripción en RabbitMQ implica que cada suscriptor tiene su propia cola.
Cuando se utiliza un Fanout Exchange (o incluso Direct/Topic con múltiples bindings a diferentes colas), el mensaje que llega al Exchange se copia a cada cola vinculada a ese Exchange. Los consumidores se conectan individualmente a sus propias colas para recibir los mensajes.
Uso del Fanout Exchange: Ideal cuando un evento debe ser notificado a múltiples sistemas independientes, y cada sistema necesita procesar todos los eventos de ese tipo. Cada sistema se conecta a su propia cola, y esta cola se vincula al Fanout Exchange.
Consideraciones:
- Acoplamiento Laxo: Los publicadores no necesitan saber cuántos o quiénes son los suscriptores.
- Escalabilidad: Cada suscriptor puede escalar el procesamiento de su copia de los mensajes añadiendo más consumidores a su cola.
- Garantía de Entrega a Cada Cola: Si un mensaje llega a un Fanout Exchange, RabbitMQ garantiza que intentará entregarlo a todas las colas vinculadas (asumiendo que las colas existan y no estén llenas). Si una cola no existe o tiene problemas, eso no afecta la entrega a las otras colas.
Manejo de Errores y Reintentos
La comunicación asíncrona implica que el productor envía un mensaje y asume que será procesado. ¿Pero qué pasa si el consumidor falla al procesarlo? RabbitMQ ofrece mecanismos robustos para manejar estos escenarios y evitar la pérdida de mensajes.
Acknowledgements (Confirmaciones):
- Auto-ACK: (No recomendado para procesamiento crítico) El broker elimina el mensaje de la cola inmediatamente después de enviarlo al consumidor. Si el consumidor falla antes de procesar el mensaje, este se pierde.
- Manual-ACK: El consumidor debe enviar explícitamente una confirmación (basic.ack) al broker después de haber procesado exitosamente el mensaje. Solo entonces el broker eliminará el mensaje de la cola. Si el consumidor falla antes de enviar el ACK, o si la conexión se cierra, el broker detectará que el mensaje no fue confirmado y lo re-enviará (a la misma cola, posiblemente a otro consumidor). Este es el modo preferido para la fiabilidad.
Qué sucede si un consumidor lanza un error (con Manual-ACK): Si un consumidor encuentra un error al procesar un mensaje y no envía un ACK, RabbitMQ (por defecto o si la conexión se cierra) re-enviará el mensaje. Esto puede llevar a un bucle infinito de fallos si el error es persistente para ese mensaje particular.
Estrategias de Reintento en el Consumidor: El consumidor debe implementar lógica para manejar fallos transitorios (ej: base de datos caída temporalmente) y permanentes (ej: mensaje mal formado). Para fallos transitorios, puede intentar re-procesar el mensaje (posiblemente con un retraso usando una cola de retardo o DLX). Para fallos permanentes, debe rechazar el mensaje de forma que no vuelva a ser re-enviado inmediatamente a la misma cola, sino que se envíe a una cola de "mensajes muertos".
Uso de Dead-Letter Exchanges (DLX) y Dead-Letter Queues (DLQ):
- Configuras tu cola principal (la que consume tu aplicación) con
x-dead-letter-exchangey opcionalmentex-dead-letter-routing-key. - Declaras una cola separada, la Dead-Letter Queue (DLQ), y la vinculas al DLX configurado en el paso 1.
- Cuando un mensaje en la cola principal:
- Expira (TTL).
- Es rechazado por el consumidor usando
basic.rejectobasic.nackconrequeue=false. - La cola principal alcanza su límite de longitud y mensajes viejos son descartados (
x-overflow: drop-head).
- Ese mensaje es enviado al DLX y enrutado a la DLQ.
- Configuras tu cola principal (la que consume tu aplicación) con
Puedes tener un consumidor separado escuchando en la DLQ para inspeccionar los mensajes fallidos, registrarlos, alertar a un operador, o intentar un reintento manual/diferido.
- Rechazo de Mensajes (
basic.rejectybasic.nack):basic.rejectes para rechazar un solo mensaje.basic.nack(Negative Acknowledgement) es similar a reject pero puede rechazar múltiples mensajes a la vez (los mensajes anteriores al delivery_tag especificado que aún no han sido confirmados).- Ambos métodos aceptan un argumento
requeue:requeue=true: El mensaje se re-enviará a la misma cola (posiblemente al mismo o a otro consumidor). Útil para fallos transitorios donde quieres reintentar inmediatamente.requeue=false: El mensaje no se re-enviará a la cola de origen. Si la cola tiene un DLX configurado, el mensaje irá allí. Si no, el mensaje se descarta. Útil para fallos permanentes.
codigo mermaid
graph LR
P[Productor] --> B(Broker);
B --> Q1[Cola Principal];
Q1 --> C1{Consumidor Principal};
C1 -- Procesamiento Exitoso --> OK[Éxito];
C1 -- Error --> B;
B -- DLX --> QD[(Cola de Mensajes Fallidos 'DLQ')];
QD --> C2{Consumidor de Errores};
C2 --> RE[Registro de Error/Análisis];
Conclusión
Las colas son más que simples contenedores; son componentes configurables que determinan la durabilidad, la capacidad y el comportamiento de los mensajes en reposo. Hemos explorado sus propiedades básicas (durabilidad, exclusividad, auto-delete) y, de manera más importante, los argumentos avanzados como TTL, DLX y límites de tamaño, que nos dan control granular sobre el ciclo de vida del mensaje y la gestión de la cola.
Entendimos cómo RabbitMQ distribuye mensajes a múltiples consumidores para lograr procesamiento paralelo y las consideraciones (como la idempotencia y QoS) que esto implica. Finalmente, abordamos el crítico tema del manejo de errores mediante acknowledgements manuales y la implementación de estrategias de reintento y gestión de mensajes fallidos utilizando Dead-Letter Exchanges y Dead-Letter Queues.
Con una comprensión sólida de los Exchanges y las Colas, sus propiedades y cómo interactúan, tenemos la base teórica completa. El siguiente paso lógico es llevar esta teoría a la práctica. En el próximo artículo, construiremos un ejemplo funcional simple usando Java (o un lenguaje de tu elección, especificaremos Java como ejemplo) para conectar un productor y un consumidor a RabbitMQ y ver la mensajería en acción.
RabbitMQ 2: Arquitectura y Enrutamiento Avanzado en RabbitMQ
- Mauricio ECR
- Arquitectura
- 25 Apr, 2025
En nuestro primer artículo, exploramos qué es RabbitMQ, por qué es fundamental para la comunicación asíncrona en sistemas distribuidos y cuáles son sus casos de uso típicos. Lo comparamos con una "ofi
RabbitMQ 2: Arquitectura y Enrutamiento Avanzado en RabbitMQ
- Mauricio ECR
- Arquitectura
- 25 Apr, 2025
En nuestro primer artículo, exploramos qué es RabbitMQ, por qué es fundamental para la comunicación asíncrona en sistemas distribuidos y cuáles son sus casos de uso típicos. Lo comparamos con una "oficina de correos inteligente" que recibe, clasifica y entrega mensajes. Ahora, es momento de abrir las puertas de esa oficina de correos y ver qué hay dentro. Entender los componentes clave de RabbitMQ y cómo interactúan es esencial para diseñar sistemas de mensajería eficientes y robustos. Este artículo se sumergirá en la arquitectura interna y, lo que es más importante, en cómo RabbitMQ decide a dónde enviar cada mensaje, es decir, su sofisticado enrutamiento.
Componentes Clave de RabbitMQ
Para entender cómo funciona RabbitMQ, primero debemos conocer a los actores principales en su arquitectura:
- Productor (Producer): Es la aplicación que crea y envía mensajes a RabbitMQ. En nuestra analogía, es quien escribe y deposita la carta en el buzón. El productor no necesita saber quién consumirá el mensaje, solo sabe a qué tipo de destinatario (Exchange) quiere enviárselo.
- Consumidor (Consumer): Es la aplicación que se conecta a RabbitMQ para recibir y procesar mensajes. Es la persona que recibe la carta en su casa. Los consumidores se registran en las colas y esperan a que lleguen los mensajes.
- Broker (RabbitMQ Server): Es el propio servicio de RabbitMQ, la "oficina de correos" en sí. Recibe mensajes de los productores y los enruta a las colas donde los consumidores están escuchando.
- Cola (Queue): Es un buffer donde los mensajes residen temporalmente hasta que un consumidor esté listo para procesarlos. Es el buzón específico de cada destinatario donde se acumulan sus cartas. Las colas están definidas por nombres.
- Exchange: Es la primera parada para un mensaje enviado por un productor al broker. El Exchange no almacena mensajes; su única función es recibir mensajes y determinar a qué colas debe enrutarlos. Piensa en el Exchange como el empleado de correos que lee la dirección (o el tipo de servicio solicitado) en la carta y la coloca en la pila correcta para su distribución a los buzones (colas).
- Binding: Es la "regla" o "conexión" que le dice a un Exchange cómo enrutar mensajes a una cola específica. Es como decirle al empleado del Exchange: "Las cartas con esta dirección [Routing Key] deben ir a este buzón [Queue]". Un Binding es una conexión entre un Exchange y una Queue.
- Vhost (Virtual Host): Un Vhost es un entorno virtual completamente aislado dentro de un solo servidor de RabbitMQ. Es como tener múltiples oficinas de correos separadas dentro del mismo edificio. Cada Vhost tiene sus propios Exchanges, Queues, Bindings, permisos, etc., lo que permite aislar diferentes aplicaciones o entornos multi-tenant dentro del mismo broker físico o cluster. Se identifica con un nombre (por defecto, /).
- Channel: Dentro de una conexión TCP entre una aplicación (productor o consumidor) y RabbitMQ, se pueden crear uno o más canales virtuales. Una conexión puede tener múltiples canales. Esto reduce el overhead de abrir/cerrar múltiples conexiones TCP. Las operaciones de envío y recepción de mensajes se realizan sobre un canal. Piensa en una conexión como la tubería principal y los canales como "sub-tuberías" multiplexadas dentro de ella.
(Diagrama simplificado de la interacción entre componentes)
codigo mermaid
flowchart TD
%% Definición de los componentes
subgraph Producers
P1[Productor 1]
P2[Productor 2]
P3[Productor 3]
end
subgraph Broker["Broker RabbitMQ (Vhost)"]
subgraph Exchanges
EX1[Exchange]
end
subgraph Queues
Q1[Cola 1]
Q2[Cola 2]
end
EX1 -->|Binding 1| Q1
EX1 -->|Binding 2| Q2
end
subgraph Consumers
C1[Consumidor 1]
C2[Consumidor 2]
C3[Consumidor 3]
end
%% Conexiones
P1 -->|Mensaje| EX1
P2 -->|Mensaje| EX1
P3 -->|Mensaje| EX1
Q1 --> C1
Q1 --> C2
Q2 --> C3
%% Leyenda/Notas
note[Nota: Las conexiones entre clientes y broker \nse realizan a través de Channels\ndentro de una conexión TCP]
style note fill:#fff,stroke:#666,stroke-width:1px
Exchanges en Detalle
El Exchange es el corazón del sistema de enrutamiento de RabbitMQ. Un productor nunca envía un mensaje directamente a una cola; siempre lo envía a un Exchange.
¿Qué es un Exchange y su función principal?
Como mencionamos, un Exchange recibe mensajes del productor y, basándose en su tipo y en las reglas de Binding, decide a qué cola(s) enviar ese mensaje. El Exchange es la lógica de enrutamiento central.
Tipos de Exchanges
RabbitMQ soporta varios tipos de Exchanges, cada uno con una lógica de enrutamiento diferente:
- Direct Exchange:
- Lógica: Enruta mensajes a colas basándose en una coincidencia exacta entre la routing key del mensaje y la binding key del binding.
- Uso típico: Comunicación uno-a-uno o uno-a-varios si múltiples colas tienen la misma binding key. Ideal para enviar un mensaje a una cola específica identificada por un nombre o un código.
- Analogía: Envías una carta con una dirección exacta ("Calle Sol, 123"). El Exchange (empleado) busca bindings que coincidan exactamente con "Calle Sol, 123" y envía la carta a los buzones (colas) vinculados con esa dirección.
- Topic Exchange:
- Lógica: Enruta mensajes a colas basándose en patrones en la routing key. La routing key es una lista de palabras separadas por puntos (ej: logs.error.critical). Los bindings usan patrones con comodines:
*(asterisco) coincide con exactamente una palabra.#(almohadilla) coincide con cero o más palabras.
- Uso típico: Sistemas de logging, eventos que tienen jerarquías. Permite a los consumidores suscribirse a categorías amplias o muy específicas de mensajes.
- Analogía: Envías una carta con un tema categorizado (ej: Reportes.Financieros.Mensual). El Exchange busca bindings que coincidan con patrones como
Reportes.#(cualquier reporte) o*.Financieros.*(cualquier cosa financiera) oReportes.Financieros.Mensual(reportes financieros mensuales específicos).
- Lógica: Enruta mensajes a colas basándose en patrones en la routing key. La routing key es una lista de palabras separadas por puntos (ej: logs.error.critical). Los bindings usan patrones con comodines:
- Fanout Exchange:
- Lógica: Enruta el mensaje a todas las colas que están vinculadas a él, ignorando por completo la routing key. Es una transmisión (broadcast).
- Uso típico: Patrón Publicación/Suscripción (Pub/Sub). Ideal cuando quieres enviar una copia del mismo mensaje a múltiples consumidores que están escuchando en diferentes colas.
- Analogía: Anuncias algo por un megáfono en el centro de la oficina. Todos los que estén escuchando (colas vinculadas) reciben el mismo mensaje.
- Headers Exchange:
- Lógica: Enruta mensajes basándose en los encabezados (headers) del mensaje en lugar de la routing key. Los bindings especifican qué headers deben coincidir. Soporta coincidencias
any(cualquiera de los headers debe coincidir) oall(todos los headers deben coincidir). - Uso típico: Enrutamiento más complejo basado en múltiples atributos del mensaje, cuando la estructura jerárquica de Topic no es suficiente.
- Analogía: Envías una carta con varias etiquetas (headers) como
Departamento: Ventas,Prioridad: Alta. El Exchange busca bindings que requieran queDepartamentoseaVentasyPrioridadseaAlta(coincidenciaall), o quizás que soloPrioridadseaAlta(coincidenciaany).
- Lógica: Enruta mensajes basándose en los encabezados (headers) del mensaje en lugar de la routing key. Los bindings especifican qué headers deben coincidir. Soporta coincidencias
Declaración de Exchanges
Para usar un Exchange, primero debe ser declarado en el broker. Al declararlo, especificas:
- Nombre: Un identificador único.
- Tipo:
direct,topic,fanout,headers. - Durabilidad: Si el Exchange sobrevive a un reinicio del broker (
true) o no (false). Los Exchanges declarados por el sistema (sin nombre oamq.fanout,amq.direct, etc.) suelen ser duraderos. - Auto-Delete: Si el Exchange se elimina automáticamente cuando no hay más colas vinculadas a él (
true) o no (false). - Argumentos: Parámetros adicionales para configuraciones más avanzadas.
La declaración puede hacerla tanto un productor como un consumidor; si ya existe un Exchange con el mismo nombre y propiedades, no pasa nada. Si no existe, se crea.
Routing Keys y Bindings
Estos dos elementos trabajan juntos para definir cómo los mensajes fluyen desde un Exchange a una o varias Colas.
¿Qué es una Routing Key?
La routing key es un atributo que el productor incluye con cada mensaje que envía a un Exchange. Es como la "dirección" o "categoría" del mensaje. La interpretación de la routing key depende del tipo de Exchange al que se envía el mensaje:
- Direct Exchange: La routing key es una cadena exacta.
- Topic Exchange: La routing key es una cadena jerárquica separada por puntos (ej:
stock.usd.nyse,stock.eur.london). - Fanout Exchange: La routing key del mensaje se ignora.
- Headers Exchange: La routing key del mensaje se ignora, el enrutamiento se basa en los headers del mensaje.
¿Qué es un Binding?
Un Binding es una conexión entre un Exchange y una Cola. Define la regla por la cual los mensajes que llegan al Exchange serán copiados a esa Cola particular. Cuando declaras un Binding, también especificas:
- El Exchange de origen.
- La Cola de destino.
- Una Binding Key (excepto para Fanout Exchanges). Esta clave es la que se compara con la routing key del mensaje o los headers del mensaje, dependiendo del tipo de Exchange.
Cómo trabajan juntos Routing Keys y Bindings
La magia ocurre cuando un mensaje llega a un Exchange:
- El Exchange recibe el mensaje y su routing key (y possibly headers).
- El Exchange mira su lista de Bindings.
- Para cada Binding conectado a ese Exchange, el Exchange compara la routing key del mensaje (o los headers) con la binding key (o las reglas de headers) del Binding, según el tipo de Exchange.
- Si la routing key (o headers) coincide con la binding key del Binding, el Exchange copia el mensaje a la Cola asociada con ese Binding.
Un mismo mensaje puede ser copiado a múltiples colas si coincide con varios bindings del Exchange. Si un mensaje llega a un Exchange y no coincide con ningún binding, el mensaje se descarta (a menos que el Exchange esté configurado para enviar mensajes "unroutable" de vuelta al productor o a un Alternate Exchange).
Ejemplos de Bindings por tipo de Exchange
- Direct Exchange:
- Binding: Exchange
mi_directo-> Queuecola_acon Binding Keyclave.exacta - Mensaje con Routing Key
clave.exactaenviado ami_directo-> Va acola_a. - Mensaje con Routing Key
otra.claveenviado ami_directo-> Se descarta (si no hay otros bindings).
- Binding: Exchange
- Topic Exchange:
- Binding 1: Exchange
mi_topico-> Queuelogs_errorescon Binding Keylogs.error.# - Binding 2: Exchange
mi_topico-> Queuelogs_criticos_prodcon Binding Keylogs.*.critical.production - Mensaje con Routing Key
logs.error.databaseenviado ami_topico-> Va alogs_errores. - Mensaje con Routing Key
logs.warningenviado ami_topico-> Va alogs_errores. - Mensaje con Routing Key
logs.error.critical.productionenviado ami_topico-> Va alogs_erroresYlogs_criticos_prod.
- Binding 1: Exchange
- Fanout Exchange:
- Binding 1: Exchange
mi_fanout-> Queuecola_sub1(Binding Key se ignora) - Binding 2: Exchange
mi_fanout-> Queuecola_sub2(Binding Key se ignora) - Mensaje enviado a
mi_fanoutcon cualquier Routing Key -> Va acola_sub1Ycola_sub2.
- Binding 1: Exchange
- Headers Exchange:
- Binding: Exchange
mi_headers-> Queuecola_reportescon Headers{"formato": "pdf", "tipo": "mensual"}y argumentox-match: all. - Mensaje con Headers
{"formato": "pdf", "tipo": "mensual", "departamento": "ventas"}enviado ami_headers-> Va acola_reportes(cumple la reglaall). - Mensaje con Headers
{"formato": "pdf", "tipo": "anual"}enviado ami_headers-> No va acola_reportes(no cumple la reglaall).
- Binding: Exchange
Topologías Típicas de Enrutamiento
La combinación de diferentes tipos de Exchanges, Routing Keys y Bindings permite crear diversas topologías de mensajería para satisfacer distintas necesidades:
- Uno-a-Uno (Generalmente con Direct Exchange): Un productor envía mensajes que van a una única cola específica (o un grupo reducido de colas que procesan el mismo tipo de tarea). Se logra con un Direct Exchange y bindings exactos entre la routing key y la binding key de la cola.
codigo mermaid
graph LR
Producer --> DirectEx[Direct Exchange]
DirectEx -->|routing_key = 'task_a'| QueueA[Queue A]
DirectEx -->|routing_key = 'task_b'| QueueB[Queue B]
QueueA --> ConsumerA[Consumer A]
QueueB --> ConsumerB[Consumer B]
- Publicación/Suscripción (Generalmente con Fanout Exchange): Un productor envía un mensaje que es recibido por todos los consumidores que están suscritos a ese "tema" (Exchange). Cada suscriptor suele tener su propia cola. Se logra con un Fanout Exchange. Todos los mensajes enviados al Fanout Exchange se copian a todas las colas vinculadas a él.
codigo mermaid
graph LR
Publisher[Publisher] --> FanoutEx[Fanout Exchange]
FanoutEx --> Queue1[(Sub 1)]
FanoutEx --> Queue2[(Sub 2)]
FanoutEx --> Queue3[(Sub 3)]
Queue1 --> Subscriber1[Subscriber 1]
Queue2 --> Subscriber2[Subscriber 2]
Queue3 --> Subscriber3[Subscriber 3]
- Enrutamiento Selectivo (Generalmente con Direct o Topic Exchanges): Los mensajes se enrutan a colas específicas basándose en el contenido de la routing key. Esto permite que diferentes grupos de consumidores reciban solo los mensajes que les interesan. Direct Exchange se usa para selección exacta, Topic Exchange para selección basada en patrones jerárquicos.
codigo mermaid
graph LR
Logger[Logger] --> TopicEx[Topic Exchange]
TopicEx -->|routing_key = 'logs.error.#'| ErrorQueue[Error Queue]
TopicEx -->|routing_key = '*.critical'| CriticalQueue[Critical Queue]
TopicEx -->|routing_key = 'logs.#'| AllLogsQueue[All Logs Queue]
ErrorQueue --> ErrorConsumer[Error Consumer]
CriticalQueue --> CriticalConsumer[Critical Consumer]
AllLogsQueue --> AnalyticsConsumer[Analytics Consumer]
Entender estos componentes y cómo interactúan es el primer paso para diseñar tu sistema de mensajería con RabbitMQ. La flexibilidad del sistema de Exchanges y Bindings es lo que permite a RabbitMQ adaptarse a una amplia gama de patrones de comunicación.
Conclusión
En este artículo, hemos desglosado la arquitectura fundamental de RabbitMQ, conociendo a sus protagonistas: productores, consumidores, colas, exchanges y bindings. Hemos visto que el Exchange es el cerebro del enrutamiento, dirigiendo los mensajes a las colas basándose en el tipo de Exchange y las reglas definidas por los Bindings y las Routing Keys. Exploramos los diferentes tipos de Exchanges (Direct, Topic, Fanout, Headers) y cómo sus lógicas de enrutamiento permiten construir topologías desde la simple comunicación uno-a-uno hasta complejos sistemas de publicación/suscripción y enrutamiento selectivo. Con una comprensión clara de estos componentes y cómo se enrutan los mensajes, estamos listos para el siguiente paso crucial: la configuración y gestión detallada de las Colas, que es donde los mensajes esperan pacientemente a ser procesados. En el próximo artículo, profundizaremos en las propiedades de las colas, cómo gestionar su durabilidad, tamaño y características avanzadas como los Dead-Letter Exchanges.
RabbitMQ 1: Introducción a RabbitMQ, El Corazón de la Mensajería Asíncrona
- Mauricio ECR
- Arquitectura
- 24 Apr, 2025
En el mundo del desarrollo de software moderno, especialmente con el auge de los microservicios y los sistemas distribuidos, la forma en que las diferentes partes de una aplicación se comunican es fun
RabbitMQ 1: Introducción a RabbitMQ, El Corazón de la Mensajería Asíncrona
- Mauricio ECR
- Arquitectura
- 24 Apr, 2025
En el mundo del desarrollo de software moderno, especialmente con el auge de los microservicios y los sistemas distribuidos, la forma en que las diferentes partes de una aplicación se comunican es fundamental. La comunicación directa y síncrona (donde una aplicación llama a otra y espera una respuesta inmediata) puede volverse rápidamente un cuello de botella, crear dependencias rígidas y dificultar la escalabilidad y la resiliencia.
Aquí es donde entra en juego la mensajería asíncrona, y RabbitMQ es uno de los actores más populares y robustos en este espacio. En este artículo, desmitificaremos qué es RabbitMQ, por qué es tan útil, y cuándo es la herramienta adecuada (o no) para tu proyecto.
Introducción y Descripción General
¿Qué es RabbitMQ? Una analogía sencilla
Imagina que tienes un montón de cartas (mensajes) que necesitas enviar a diferentes personas (aplicaciones o servicios). En lugar de ir tú mismo a entregar cada carta, o de que cada persona venga a buscar la suya en un punto fijo, utilizas una oficina de correos inteligente.
Esta oficina de correos, que es nuestro RabbitMQ, no solo recibe tus cartas, sino que también sabe cómo clasificarlas, a quién van dirigidas basándose en la dirección (reglas de enrutamiento), las guarda de forma segura hasta que el destinatario esté listo para recibirlas, y se asegura de que lleguen a su destino. Además, puede manejar muchísimas cartas a la vez y enviarlas a diferentes destinatarios interesados en el mismo tipo de carta.
En términos técnicos, RabbitMQ es un broker de mensajes o agente de mensajes. Actúa como intermediario: recibe mensajes de las aplicaciones que los envían (productores) y los reenvía a las aplicaciones que los quieren recibir (consumidores). Su función principal es desacoplar a los productores de los consumidores, permitiendo que operen de forma independiente.
El protocolo AMQP y su importancia
RabbitMQ implementa principalmente el protocolo AMQP (Advanced Message Queuing Protocol). Piensa en AMQP como el "idioma" estándar que las aplicaciones usan para hablar con el broker de mensajes. Define las reglas, los comandos y la estructura de los mensajes para operaciones como publicar, suscribir, enrutar y almacenar mensajes de manera confiable. La ventaja de usar un protocolo estándar como AMQP es que fomenta la interoperabilidad; aunque RabbitMQ es el broker más conocido que lo implementa, no es el único, y las librerías cliente que usan AMQP pueden (en teoría) comunicarse con cualquier broker compatible.
Comunicación síncrona vs. asíncrona y dónde encaja RabbitMQ
- Comunicación Síncrona: Un emisor envía una solicitud y espera una respuesta inmediata del receptor. Ejemplo: Una llamada a una API REST donde el cliente espera la respuesta HTTP. Es directa y simple para interacciones uno a uno, pero el emisor queda bloqueado y muy acoplado al receptor. Si el receptor falla o está lento, el emisor también se ve afectado.
- Comunicación Asíncrona: Un emisor envía un mensaje y no espera una respuesta inmediata. Continúa con otras tareas. El mensaje es recibido y procesado por el receptor en algún momento posterior. RabbitMQ facilita este modelo. El emisor envía el mensaje al broker, y el broker se encarga de entregarlo al receptor (o receptores) cuando estén disponibles. Esto desacopla a las partes: el emisor no necesita saber quién es el receptor ni si está activo, y el receptor puede procesar los mensajes a su propio ritmo.
RabbitMQ encaja perfectamente en el modelo asíncrono, actuando como el buffer y enrutador que permite a las aplicaciones comunicarse sin estar directamente conectadas o tener que responder al instante.
Características Clave de RabbitMQ
RabbitMQ no se ha vuelto popular por casualidad. Sus características principales lo hacen una opción robusta para diversas necesidades de mensajería:
- Confiabilidad: Garantiza que los mensajes no se pierdan. Esto lo logra a través de:
- Persistencia: Los mensajes y las colas pueden configurarse para sobrevivir a reinicios del broker.
- Confirmaciones del Productor: El productor puede recibir una confirmación del broker cuando el mensaje ha sido recibido y manejado (por ejemplo, escrito a disco si es persistente).
- Acknowledgements del Consumidor: El consumidor notifica al broker cuando ha terminado de procesar un mensaje. Si no lo hace (por un fallo), el broker puede reentregarlo a otro consumidor.
- Enrutamiento Robusto: Mecanismos flexibles para asegurar que los mensajes lleguen a las colas correctas.
- Escalabilidad: Puede manejar un alto volumen de mensajes y conexiones. Permite escalar horizontalmente añadiendo más nodos a un cluster de RabbitMQ.
- Flexibilidad de Enrutamiento: A través de sus conceptos de Exchanges (intercambios) y Bindings (enlaces), ofrece potentes opciones para decidir a qué colas debe ir un mensaje, basándose en reglas complejas si es necesario. Esto lo diferencia de brokers más simples.
- Soporte para Múltiples Protocolos: Aunque AMQP es el principal, RabbitMQ soporta otros protocolos populares como MQTT y STOMP a través de plugins, facilitando la integración con una gama más amplia de aplicaciones y dispositivos (especialmente útil para IoT).
- Interfaz de Administración Web: Proporciona una UI muy útil para monitorear el estado del broker, ver colas, exchanges, conexiones, mensajes en cola y realizar tareas de gestión.
- Gran Ecosistema y Comunidad: Al ser tan popular, existe una gran cantidad de librerías cliente para casi cualquier lenguaje de programación, mucha documentación, tutoriales y una comunidad activa para resolver dudas.
- Durabilidad de Colas y Mensajes: Como mencionamos en confiabilidad, puedes elegir si una cola sobrevive o no a un reinicio del broker, y si los mensajes dentro de ella también lo hacen.
- Manejo de Entrega (Acknowledgements): El control explícito que tiene el consumidor para indicar cuándo un mensaje ha sido exitosamente procesado es vital para la fiabilidad, evitando pérdidas de mensajes si un consumidor falla a mitad de procesamiento.
Debilidades a Considerar
Como cualquier tecnología, RabbitMQ no es una solución mágica para todos los problemas:
- Complejidad de Configuración: Para entornos de producción, especialmente aquellos que requieren alta disponibilidad y rendimiento, la configuración de RabbitMQ puede ser compleja. Requiere entender sus componentes y cómo configurarlos correctamente.
- Dependencia de un Broker: Tus aplicaciones ahora dependen de que el broker esté operativo. Si el broker falla (y no tienes un setup de alta disponibilidad), la comunicación asíncrona se detiene.
- Posible Cuello de Botella: Si el broker no se dimensiona correctamente, o si hay un uso intensivo de características que consumen muchos recursos (como mensajes persistentes o colas muy grandes), RabbitMQ mismo puede convertirse en el cuello de botella del sistema.
- Latencia: Introducir un broker en el camino de la comunicación añade una pequeña latencia inherente en comparación con la comunicación punto a punto directa. Aunque a menudo es despreciable para tareas asíncronas, es un factor a considerar.
Casos de Uso Típicos
RabbitMQ brilla en escenarios que requieren comunicación desacoplada, confiable y escalable:
- Procesamiento en Segundo Plano (Background Jobs): Enviar tareas largas y no críticas (como enviar emails, procesar imágenes, generar reportes) a una cola para que workers las procesen sin bloquear la interfaz de usuario.
- Integración de Microservicios: Permitir que microservicios se comuniquen entre sí sin conocer la ubicación o estado de los otros. Un servicio publica un evento, y otros servicios interesados lo consumen.
- Patrón de Publicación/Suscripción (Pub/Sub): Un editor envía un mensaje sobre un tema, y múltiples suscriptores que están interesados en ese tema reciben una copia del mensaje.
- Orquestación de Tareas: Coordinar flujos de trabajo donde la finalización de una tarea desencadena el inicio de otra, posiblemente en otro servicio.
- Sistemas de Logging y Monitorización: Centralizar logs o métricas de múltiples fuentes en una cola para ser procesados por sistemas de análisis o almacenamiento.
- Procesamiento de Streams de Datos: Aunque otras herramientas como Kafka son más populares para streaming puro de alto throughput, RabbitMQ puede usarse para procesar flujos de datos con ciertas características, especialmente donde la flexibilidad de enrutamiento es clave.
Problema que Resuelve RabbitMQ
En esencia, RabbitMQ resuelve el problema del acoplamiento rígido entre los componentes de un sistema. Al actuar como intermediario, permite que las aplicaciones:
- Envien mensajes sin saber quién los recibirá (desacoplamiento del productor).
- Reciban mensajes sin que el emisor sepa de su existencia o estado (desacoplamiento del consumidor).
- Manejen la necesidad de comunicación confiable (garantizando la entrega incluso si las partes fallan temporalmente).
- Escalabilidad de forma independiente (puedes añadir más productores o más consumidores según la carga).
- Aumenten la resiliencia (si un consumidor falla, el mensaje espera en la cola; si el productor está temporalmente inactivo, el consumidor puede seguir procesando mensajes viejos).
- Mejoras en el rendimiento general al permitir procesamiento asíncrono y paralelo.
Cuándo No Usar RabbitMQ (Casos Menos Ideales)
Si bien es potente, RabbitMQ no es la mejor opción para todo:
- Comunicación en Tiempo Real de Baja Latencia Extrema: Para aplicaciones que requieren latencia de microsegundos (ej. sistemas de trading de alta frecuencia, algunas aplicaciones de gaming), el overhead de pasar por un broker puede ser demasiado alto.
- Transferencia de Grandes Bloques de Datos: RabbitMQ está diseñado para manejar mensajes relativamente pequeños (metadatos, comandos, payloads de unos pocos KB o MB). No es eficiente para transferir archivos grandes (GBs). En estos casos, es mejor usar RabbitMQ para enviar un mensaje notificando que un archivo está listo y dónde descargarlo (ej. en S3), y que el consumidor lo descargue directamente.
- Almacenamiento de Datos a Largo Plazo: RabbitMQ es un buffer transitorio. Los mensajes están destinados a ser consumidos y luego eliminados de la cola. No es una base de datos ni un sistema de almacenamiento persistente a largo plazo.
- Sistemas Síncronos Simples: Si tienes dos componentes que simplemente necesitan hacer una llamada request/response directa sin necesidad de desacoplamiento, reintentos gestionados por el broker, o escalabilidad independiente a través de colas, una llamada API síncrona directa es más sencilla y con menor latencia.
Conclusión
RabbitMQ es una herramienta esencial en el arsenal de cualquier arquitecto o desarrollador que trabaje con sistemas distribuidos. Al proporcionar un mecanismo robusto y flexible para la mensajería asíncrona, resuelve problemas críticos de acoplamiento, escalabilidad y confiabilidad.
Hemos visto que actúa como una "oficina de correos inteligente", facilitando la comunicación entre aplicaciones mediante el protocolo AMQP, y permitiendo que productores y consumidores operen de forma independiente. Conocimos sus principales fortalezas, como la confiabilidad y la flexibilidad de enrutamiento, pero también sus puntos débiles, como la complejidad inicial. Finalmente, exploramos escenarios donde brilla (procesamiento en segundo plano, microservicios) y donde quizás no es la mejor elección (latencia extrema, transferencia de datos masivos).
Cuándo Usar Colas de Mensajes en el Desarrollo de Software
- Mauricio ECR
- Arquitectura
- 18 Apr, 2025
Las colas de mensajes son herramientas clave para construir sistemas distribuidos, escalables y tolerantes a fallos. En este artículo te comparto una guía con situaciones comunes donde su uso es altam
Cuándo Usar Colas de Mensajes en el Desarrollo de Software
- Mauricio ECR
- Arquitectura
- 18 Apr, 2025
Las colas de mensajes son herramientas clave para construir sistemas distribuidos, escalables y tolerantes a fallos. En este artículo te comparto una guía con situaciones comunes donde su uso es altamente recomendable. Esto puede servirte como referencia rápida para decidir si una cola puede ser útil en tu arquitectura.
1. Procesamiento Asíncrono de Tareas Pesadas
Descripción de la situación
Una aplicación web necesita procesar tareas pesadas (como enviar correos, generar PDFs o hacer procesamiento de imágenes) después de una solicitud del usuario.
Dificultades
- Alta latencia si se procesa todo en la misma petición HTTP.
- Posibles timeouts en el servidor.
- Experiencia de usuario lenta y frustrante.
Por qué se solucionaría con colas de mensajes
Separar el procesamiento de la respuesta al usuario permite responder rápido y delegar la tarea a un worker. La cola actúa como puente entre el sistema que genera la tarea y el que la ejecuta.
Características típicas de la cola
- Persistencia para no perder mensajes si algo falla.
- Retries automáticos para tareas fallidas.
- Delay opcional para tareas programadas.
- Visibilidad de mensajes en procesamiento.
2. Comunicación Entre Microservicios
Descripción de la situación
Una arquitectura basada en microservicios donde varios servicios necesitan intercambiar información o coordinar acciones.
Dificultades
- El acoplamiento entre servicios crece si se comunican de forma directa (HTTP sincrónico).
- Si un servicio está caído, puede romper toda la cadena.
- Difícil escalar servicios de forma independiente.
Por qué se solucionaría con colas de mensajes
Las colas desacoplan los servicios, permitiendo que uno publique mensajes sin depender del estado del consumidor. Esto permite una comunicación más resiliente y escalable.
Características típicas de la cola
- Entrega garantizada (at-least-once).
- Soporte para múltiples consumidores.
- Escalabilidad horizontal.
- Opcional: orden garantizado de mensajes.
3. Picos de Carga Temporales
Descripción de la situación
Una aplicación recibe picos de tráfico (por ejemplo, durante una campaña de marketing o un evento en vivo).
Dificultades
- El sistema puede saturarse si intenta procesar todo al instante.
- Riesgo de perder solicitudes o fallar por falta de recursos.
Por qué se solucionaría con colas de mensajes
Las colas permiten "almacenar" las tareas y procesarlas a medida que los workers tienen capacidad. Se convierte una carga variable en una carga continua.
Características típicas de la cola
- Alta capacidad de buffer.
- Procesamiento en paralelo (workers escalables).
- Métricas para monitorear backlog.
- Integración con sistemas de auto-escalado.
4. Integración con Sistemas Externos o APIs Lentas
Descripción de la situación
Tu sistema necesita integrarse con APIs de terceros (por ejemplo, pasarelas de pago, servicios de envío, etc.) que pueden ser lentas o poco confiables.
Dificultades
- Timeouts frecuentes.
- Limitaciones de tasa (rate limiting).
- Caídas del servicio externo afectan el sistema completo.
Por qué se solucionaría con colas de mensajes
Poner las llamadas a servicios externos en una cola permite controlar el ritmo, manejar reintentos, y evitar sobrecargar al proveedor.
Características típicas de la cola
- Retries con backoff.
- Soporte para Dead Letter Queues (DLQ).
- Capacidad de definir prioridades o tasa de procesamiento.
- Persistencia y durabilidad.
5. Auditoría y Logging Centralizado
Descripción de la situación
Se requiere capturar eventos del sistema (como accesos, cambios de estado, errores) en un sistema central para auditoría o análisis.
Dificultades
- El logeo en tiempo real puede bloquear procesos principales.
- Si el sistema de auditoría cae, se pierden los eventos.
Por qué se solucionaría con colas de mensajes
Las colas permiten enviar eventos de forma asincrónica y confiable a un sistema de almacenamiento o procesamiento.
Características típicas de la cola
- Alta velocidad de escritura.
- Orden garantizado (opcional, según la necesidad).
- Múltiples consumidores (ej. para alertas, dashboards).
- Baja latencia.
6. Workflows Distribuidos (Orquestación de Procesos)
Descripción de la situación
Un proceso complejo requiere que varias acciones ocurran en orden y/o condicionalmente, como un onboarding de usuario o procesamiento de pagos.
Dificultades
- Difícil mantener el estado y coordinación entre servicios.
- Problemas de sincronización y gestión de errores.
Por qué se solucionaría con colas de mensajes
Las colas permiten implementar orquestadores que gestionan los pasos del workflow como eventos, con flexibilidad para manejar errores y lógica condicional.
Características típicas de la cola
- Soporte para enrutamiento de mensajes.
- Integración con motores de orquestación.
- Baja latencia y confiabilidad.
- Opcional: soporte para eventos tipo pub/sub.
🧪 Ejemplo: Generación Asíncrona de PDF usando una Cola
Este ejemplo representa un caso real y común: un usuario solicita la generación de un PDF. En lugar de procesarlo en la misma solicitud (lo cual puede tardar), se encola la tarea y un worker la procesa de forma asíncrona.
🧍♂️ Usuario solicita un PDF desde el Frontend
El usuario hace una solicitud para generar un PDF. Este proceso es controlado desde el frontend, donde el usuario envía su solicitud.
// Envío de solicitud desde el cliente (frontend)
// Este llamado puede estar en un botón: "Generar PDF"
fetch('/generate-pdf', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: 123 })
})
.then(res => res.json())
.then(data => {
// El usuario recibe un mensaje indicando que la tarea ha sido encolada.
console.log(data.status); // "Tarea encolada correctamente"
console.log("ID de la tarea:", data.jobId); // El ID para consultar el estado
});
🧠 Backend (API) recibe la solicitud y encola la tarea
El backend recibe la solicitud del frontend y encola la tarea en una cola de trabajo para ser procesada en segundo plano. La API responde inmediatamente al usuario con la confirmación de que la tarea se ha encolado.
# Supongamos un backend en Flask (Python)
@app.route('/generate-pdf', methods=['POST'])
def generate_pdf():
data = request.get_json()
user_id = data['userId']
# Genera un identificador único para la tarea
job_id = str(uuid.uuid4())
# Se encola una tarea para procesar luego
enqueue_task('generate_pdf', {'user_id': user_id, 'job_id': job_id})
# Responde al usuario con la confirmación de la tarea encolada
return jsonify({
'status': 'Tarea encolada correctamente',
'jobId': job_id, # ID de la tarea para que el usuario pueda consultar el estado
'message': 'Te notificaremos cuando tu PDF esté listo para descargar.'
})
¿Qué hace enqueue_task?
La función enqueue_task empuja la tarea a una cola (como Redis, RabbitMQ, AWS SQS, etc.). El jobId se guarda para poder referenciar la tarea.
def enqueue_task(task_name, data):
task = {
'name': task_name,
'data': data
}
redis.rpush('pdf_tasks', json.dumps(task)) # Ejemplo con Redis
⚙️ Worker que consume tareas y las ejecuta
El worker es un proceso que corre en segundo plano y escucha la cola para procesar las tareas en el momento adecuado. Una vez que el PDF esté generado, puede guardarlo o enviarlo al usuario.
# Un worker que corre en segundo plano y escucha la cola
def worker():
while True:
raw_task = redis.blpop('pdf_tasks', timeout=0) # Espera indefinidamente
if raw_task:
task = json.loads(raw_task[1])
handle_task(task)
def handle_task(task):
if task['name'] == 'generate_pdf':
user_id = task['data']['user_id']
job_id = task['data']['job_id']
generate_pdf_for_user(user_id, job_id)
def generate_pdf_for_user(user_id, job_id):
# Aquí iría la lógica real de generación del PDF
print(f"Generando PDF para el usuario {user_id}")
# Simulación: se genera el PDF y se guarda con el ID de tarea
filename = f"{job_id}.pdf"
with open(filename, "w") as f:
f.write(f"PDF generado para usuario {user_id}")
# Aquí podrías guardar el resultado en una BD o subirlo a un almacenamiento
# Además, actualizamos el estado de la tarea en la base de datos o en el sistema de colas
redis.set(f"job:{job_id}:status", "completado")
📥 Consulta del estado de la tarea (opcional)
El usuario puede consultar el estado de la tarea en cualquier momento utilizando el jobId que se le proporcionó cuando la tarea fue encolada. Esto permite saber si la tarea está aún en proceso o si ya ha sido completada.
@app.route('/job-status/<job_id>', methods=['GET'])
def job_status(job_id):
status = redis.get(f"job:{job_id}:status") # Recupera el estado desde Redis
return jsonify({'jobId': job_id, 'status': status or 'pendiente'})
En este ejemplo, si el jobId existe en el sistema, el usuario recibirá el estado de la tarea. De lo contrario, puede devolver el estado como "pendiente" si la tarea aún no se ha completado.
📧 Notificación cuando la tarea se complete (opcional)
Además de permitir que el usuario consulte el estado, puedes configurar una notificación para cuando el trabajo esté listo. Esto podría ser una notificación en la web, un correo electrónico, o incluso un SMS.
Ejemplo de función de notificación:
def notify_user(user_id, job_id):
# Esta función podría enviar un email, SMS o una notificación web
# Aquí simplemente imprimimos un mensaje de ejemplo
print(f"Notificando al usuario {user_id} que su PDF con jobId {job_id} está listo para descargar.")
Puedes llamar a esta función después de que la tarea haya sido procesada y el PDF esté disponible.
💡 Ventajas de este enfoque
- ✅ Respuesta inmediata: El usuario no espera bloqueado mientras se genera el PDF.
- 🕐 Asincronía: El trabajo pesado se maneja en segundo plano, sin afectar la experiencia del usuario.
- 🔔 Notificación opcional: El usuario puede ser notificado cuando la tarea esté lista.
- 🧱 Escalabilidad: Puedes agregar más workers si la carga aumenta, o priorizar tareas según la necesidad.
- 🔗 Desacoplamiento: El frontend no está directamente vinculado al procesamiento pesado.
Conclusión
Las colas no son solo una herramienta de "alto nivel empresarial", sino una solución práctica para muchos retos comunes en el desarrollo moderno. Identificar los síntomas típicos —como latencia, acoplamiento, o pérdida de datos— puede ayudarte a decidir cuándo usarlas.
Transformando Colecciones con Java Streams: 15 Métodos Esenciales
- Mauricio ECR
- Arquitectura
- 29 Mar, 2025
Introducción En el mundo de Java, trabajar con colecciones de datos solía ser sinónimo de bucles interminables, condicionales anidados y código repetitivo. Pero con la llegada de Java Streams
Transformando Colecciones con Java Streams: 15 Métodos Esenciales
- Mauricio ECR
- Arquitectura
- 29 Mar, 2025
Introducción
En el mundo de Java, trabajar con colecciones de datos solía ser sinónimo de bucles interminables, condicionales anidados y código repetitivo. Pero con la llegada de Java Streams (desde Java 8), todo cambió. Los Streams introdujeron un paradigma funcional y declarativo que permite manipular datos de manera eficiente, legible y elegante.
¿Imaginas poder filtrar, transformar, agrupar o reducir elementos con solo unas líneas de código? Los métodos de los Streams hacen esto posible, convirtiendo operaciones complejas en secuencias intuitivas. Pero para aprovecharlos al máximo, es clave conocer sus herramientas principales.
Aquí te presentamos un listado detallado de los métodos más poderosos de los Streams, divididos en dos categorías: métodos generales y métodos de agrupación. Descubre cómo dominarlos puede simplificar tu código, potenciar tu productividad y desbloquear nuevas posibilidades en el manejo de datos.
Listado de Métodos de Java Streams
Métodos Generales
- filter(Predicate<? super T> predicate)
Filtra los elementos que cumplen con una condición.List<Integer> numeros = Arrays.asList(5, 12, 3, 20); List<Integer> mayoresA10 = numeros.stream() .filter(x -> x > 10) .collect(Collectors.toList()); // Resultado: [12, 20] - map(Function<? super T, ? extends R> mapper)
Transforma cada elemento aplicando una función.List<String> palabras = Arrays.asList("java", "streams"); List<Integer> longitudes = palabras.stream() .map(String::length) .collect(Collectors.toList()); // Resultado: [4, 7] - flatMap(Function<? super T, ? extends Stream<? extends R>> mapper)
Aplana múltiples Streams en uno solo (útil para listas anidadas).List<List<Integer>> listaAnidada = Arrays.asList( Arrays.asList(1, 2), Arrays.asList(3, 4) ); List<Integer> listaPlana = listaAnidada.stream() .flatMap(List::stream) .collect(Collectors.toList()); // Resultado: [1, 2, 3, 4] - reduce(BinaryOperator
accumulator)
Reduce los elementos a un único valor mediante una operación (ej: suma).List<Integer> numeros = Arrays.asList(1, 2, 3, 4); Optional<Integer> suma = numeros.stream() .reduce((a, b) -> a + b); // Resultado: 10 - collect(Collector<? super T, A, R> collector)
Transforma el Stream en una colección o estructura de datos.List<String> palabras = Arrays.asList("a", "b", "c"); Set<String> set = palabras.stream() .collect(Collectors.toSet()); // Resultado: [a, b, c] (como Set) - forEach(Consumer<? super T> action)
Ejecuta una acción en cada elemento (como imprimirlo).List<String> frutas = Arrays.asList("Manzana", "Pera"); frutas.stream() .forEach(fruta -> System.out.print(fruta + " ")); // Resultado: "Manzana Pera " - sorted()
Ordena los elementos (requiere que sean comparables).List<Integer> numeros = Arrays.asList(3, 1, 4, 2); List<Integer> ordenados = numeros.stream() .sorted() .collect(Collectors.toList()); // Resultado: [1, 2, 3, 4] - distinct()
Elimina duplicados, retornando elementos únicos.List<Integer> numeros = Arrays.asList(2, 2, 5, 5); List<Integer> unicos = numeros.stream() .distinct() .collect(Collectors.toList()); // Resultado: [2, 5] - limit(long maxSize)
Limita el Stream a un número máximo de elementos.List<Integer> numeros = Arrays.asList(1, 2, 3, 4, 5); List<Integer> primeros3 = numeros.stream() .limit(3) .collect(Collectors.toList()); // Resultado: [1, 2, 3] - skip(long n)
Omite los primeros n elementos del Stream.List<Integer> numeros = Arrays.asList(1, 2, 3, 4, 5); List<Integer> sinPrimeros2 = numeros.stream() .skip(2) .collect(Collectors.toList()); // Resultado: [3, 4, 5]
Métodos para Agrupar Elementos
- Collectors.groupingBy(Function<? super T, ? extends K> classifier)
Agrupa elementos por una clave (ej: edad de una persona).List<Persona> personas = Arrays.asList( new Persona("Ana", 25), new Persona("Luis", 25) ); Map<Integer, List<Persona>> porEdad = personas.stream() .collect(Collectors.groupingBy(Persona::getEdad)); // Resultado: {25=[Ana, Luis]} - Collectors.partitioningBy(Predicate<? super T> predicate)
Divide el Stream en dos grupos: los que cumplen y no cumplen un predicado.// Agrupar por categoría + stock mayor a 5 Map<String, List<Producto>> porCategoriaYStock = productos.stream() .collect(Collectors.groupingBy(p -> p.getCategoria() + "-" + (p.getStock() > 5 ? "AltoStock" : "BajoStock") )); /* Resultado: { "Electrónica-AltoStock": [Laptop, Smartphone], "Ropa-AltoStock": [Camisa] } */ - Collectors.groupingBy(classifier, downstream)
Agrupa y luego aplica un segundo colector a cada grupo (ej: contar elementos).// Precio promedio por categoría Map<String, Double> precioPromedio = productos.stream() .collect(Collectors.groupingBy( Producto::getCategoria, Collectors.averagingDouble(Producto::getPrecio) )); /* Resultado: { "Electrónica": 1000.0, "Ropa": 40.0 } */ - Collectors.groupingBy(classifier, mapFactory, downstream)
Agrupa usando un tipo de mapa específico (ej: TreeMap).// Agrupar por rangos de precios Map<String, List<Producto>> porRangoPrecio = productos.stream() .collect(Collectors.groupingBy(p -> { if (p.getPrecio() < 100) return "Económico"; else if (p.getPrecio() < 1000) return "Medio"; else return "Premium"; })); /* Resultado: { "Premium": [Laptop], "Medio": [Smartphone], "Económico": [Camisa] } */ - Agrupar con downstream complejo (ej: contar y sumar stock)
// Por categoría: cantidad de productos y stock total Map<String, Map<String, Object>> estadisticas = productos.stream() .collect(Collectors.groupingBy( Producto::getCategoria, Collectors.collectingAndThen( Collectors.toList(), lista -> { int cantidad = lista.size(); int stockTotal = lista.stream().mapToInt(Producto::getStock).sum(); return Map.of("Cantidad", cantidad, "Stock Total", stockTotal); } ) )); /* Resultado: { "Electrónica": {"Cantidad": 2, "Stock Total": 15}, "Ropa": {"Cantidad": 1, "Stock Total": 20} } */
Conclusión
Dominar los métodos de Java Streams no solo simplifica tu código, sino que también mejora su legibilidad y eficiencia. Al explorar y aplicar estos métodos, descubrirás nuevas formas de manipular colecciones de datos que pueden transformar tu enfoque en el desarrollo de software. ¡Sigue profundizando y experimentando con Java Streams para desbloquear todo su potencial!