Type something to search...
Observabilidad sin Ruido: Diseñando un Sistema de Logs con AOP en Arquitecturas DDD — Parte III

Observabilidad sin Ruido: Diseñando un Sistema de Logs con AOP en Arquitecturas DDD — Parte III

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.

Related Posts

Cuándo Usar Colas de Mensajes en el Desarrollo de Software

Cuándo Usar Colas de Mensajes en el Desarrollo de Software

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

Leer más
RabbitMQ 1: Introducción a RabbitMQ, El Corazón de la Mensajería Asíncrona

RabbitMQ 1: Introducción a RabbitMQ, El Corazón de la Mensajería Asíncrona

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

Leer más
RabbitMQ 3: Configuración y Gestión de Colas en RabbitMQ

RabbitMQ 3: Configuración y Gestión de Colas en RabbitMQ

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

Leer más
RabbitMQ 2: Arquitectura y Enrutamiento Avanzado en RabbitMQ

RabbitMQ 2: Arquitectura y Enrutamiento Avanzado en RabbitMQ

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

Leer más
RabbitMQ 4: Robustez y Seguridad en RabbitMQ

RabbitMQ 4: Robustez y Seguridad en RabbitMQ

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

Leer más
RabbitMQ 5: Consumo de Recursos, Latencia y Monitorización de RabbitMQ

RabbitMQ 5: Consumo de Recursos, Latencia y Monitorización de RabbitMQ

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

Leer más
RabbitMQ 6: Alta Disponibilidad y Escalabilidad con Clustering en RabbitMQ

RabbitMQ 6: Alta Disponibilidad y Escalabilidad con Clustering en RabbitMQ

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

Leer más
Kafka 1: Introducción a Apache Kafka, fundamentos y Casos de Uso

Kafka 1: Introducción a Apache Kafka, fundamentos y Casos de Uso

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

Leer más
Kafka 2: Arquitectura Profunda de Kafka, Topics, Particiones y Brokers

Kafka 2: Arquitectura Profunda de Kafka, Topics, Particiones y Brokers

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

Leer más
Kafka 3: Productores y Consumidores, Configuración y Buenas Prácticas

Kafka 3: Productores y Consumidores, Configuración y Buenas Prácticas

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

Leer más
Kafka 4: Procesamiento de Datos en Tiempo Real con Kafka Streams y ksqlDB

Kafka 4: Procesamiento de Datos en Tiempo Real con Kafka Streams y ksqlDB

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

Leer más
Spring WebFlux 1: Fundamentos Reactivos y el Corazón de Reactor

Spring WebFlux 1: Fundamentos Reactivos y el Corazón de Reactor

¡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

Leer más
Spring WebFlux 2: Alta Concurrencia sin Más Hilos

Spring WebFlux 2: Alta Concurrencia sin Más Hilos

¡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

Leer más
Kafka 6: Despliegue, Seguridad y Optimización

Kafka 6: Despliegue, Seguridad y Optimización

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

Leer más
Spring WebFlux 3: Comunicación, Datos y Errores Reactivos

Spring WebFlux 3: Comunicación, Datos y Errores Reactivos

¡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

Leer más
Kafka 7: Patrones Avanzados y Anti-Patrones con Kafka

Kafka 7: Patrones Avanzados y Anti-Patrones con Kafka

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

Leer más
Spring WebFlux 4: Comunicación Avanzada, Pruebas y Producción

Spring WebFlux 4: Comunicación Avanzada, Pruebas y Producción

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

Leer más
Kafka 5: Más Allá del Core, Explorando el Ecosistema de Apache Kafka

Kafka 5: Más Allá del Core, Explorando el Ecosistema de Apache Kafka

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

Leer más
Arquitectura DDD y Hexagonal: Construyendo Software para el Futuro

Arquitectura DDD y Hexagonal: Construyendo Software para el Futuro

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

Leer más
Arquitectura de Base de Datos para Identidad, Autenticación y Autorización (IAM)

Arquitectura de Base de Datos para Identidad, Autenticación y Autorización (IAM)

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

Leer más
Arquitectura distribuida y el abandono consciente de ACID

Arquitectura distribuida y el abandono consciente de ACID

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

Leer más
Estándar de Arquitectura: Transacciones Distribuidas (Patrón Saga)

Estándar de Arquitectura: Transacciones Distribuidas (Patrón Saga)

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

Leer más
Arquitectura Modular por Contexto: Cuando la Teoría se Encuentra con la Realidad

Arquitectura Modular por Contexto: Cuando la Teoría se Encuentra con la Realidad

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

Leer más
Observabilidad sin Ruido: Diseñando un Sistema de Logs con AOP en Arquitecturas DDD

Observabilidad sin Ruido: Diseñando un Sistema de Logs con AOP en Arquitecturas DDD

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

Leer más
Observabilidad sin Ruido: Diseñando un Sistema de Logs con AOP en Arquitecturas DDD — Parte II

Observabilidad sin Ruido: Diseñando un Sistema de Logs con AOP en Arquitecturas DDD — Parte II

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

Leer más
Cuando un sistema debe ejecutar lo mismo siempre y algo distinto cada vez

Cuando un sistema debe ejecutar lo mismo siempre y algo distinto cada vez

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

Leer más
Cuando el sistema nuevo tiene que hablarle al sistema viejo en su idioma

Cuando el sistema nuevo tiene que hablarle al sistema viejo en su idioma

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

Leer más
Descubre el Poder del SemVer: Optimiza el Versionado de tu Software y Mantén un CHANGELOG Excepcional

Descubre el Poder del SemVer: Optimiza el Versionado de tu Software y Mantén un CHANGELOG Excepcional

El Versionado Semántico (SemVer) es una herramienta fundamental para comunicar de forma precisa los cambios en el software, facilitando el mantenimiento y la colaboración. Complementarlo con un **

Leer más
Observabilidad de Servidores y Contenedores Docker: Una Mirada Práctica con Prometheus, Grafana y cAdvisor

Observabilidad de Servidores y Contenedores Docker: Una Mirada Práctica con Prometheus, Grafana y cAdvisor

En el mundo de la infraestructura moderna, especialmente con la creciente adopción de contenedores y arquitecturas distribuidas, entender qué está sucediendo dentro de nuestros sistemas en tiempo real

Leer más