- Agile 2
- Alta disponibilidad 1
- Alternativas cloud 1
- Aop 1
- Arquitectura 3
- Arquitectura distribuida 2
- Automatizacion 3
- Azure devops 1
- Base de datos 1
- Buenas practicas 19
- Cloud 1
- Colas 7
- Competing consumers 1
- Convenciones 11
- Copilot 1
- Diseno 6
- Docker 2
- Docker compose 1
- Documentacion 1
- Eda 11
- Equipos 1
- Escalabilidad 1
- Flujo de negocio 1
- Flujo de trabajo 3
- Flyway 1
- Git 4
- Gradle 3
- Herramientas digitales 1
- Ia 1
- Iam 1
- Infraestructura 2
- Java 14
- Jerarquia tecnica 1
- Jpa 1
- Jsonb 1
- Kafka 7
- Kubernetes 1
- Liderazgo en software 1
- Lineamientos 1
- Log 1
- Logging 3
- Microservicios 3
- Mongodb 1
- Monitoreo 1
- Nosql 3
- Observabilidad 4
- Open source 1
- Plugins 3
- Postgresql 1
- Privacidad 1
- Programacion funcional 1
- Programacion reactiva 4
- Rabbitmq 6
- Rotacion de talento 1
- Saga 2
- Scrum 2
- Security 1
- Seguridad 1
- Self hosting 1
- Sistemas legados 1
- Spring boot 3
- Spring mvc 2
- Sql 3
- Streams 1
- Threadlocal 1
- Trazabilidad 2
- Versionado 2
- Web 1
- Webflux 2
- Websockets 1
- Zero trust 1
Buenas practicas
19 artículos
El tramo en cascada que todo proyecto ágil necesita (y casi ninguno tiene)
- Mauricio ECR
- Gestion
- 02 Aug, 2026
Llevas un tiempo trabajando en equipos que dicen practicar Scrum, y algo no termina de cuadrarte. Los sprints avanzan, la demo sale en tiempo, el tablero se vacía cada dos semanas. Y aun así, al cabo
El tramo en cascada que todo proyecto ágil necesita (y casi ninguno tiene)
- Mauricio ECR
- Gestion
- 02 Aug, 2026
Llevas un tiempo trabajando en equipos que dicen practicar Scrum, y algo no termina de cuadrarte. Los sprints avanzan, la demo sale en tiempo, el tablero se vacía cada dos semanas. Y aun así, al cabo de varios meses, el sistema tiene algo raro: funciona por partes, pero no tiene columna vertebral. Cada módulo fue una decisión tomada en el momento, sin conversación con las anteriores. Se ve ágil desde afuera. Se siente frágil desde adentro.
No es que el equipo sea poco disciplinado. Los rituales se hacen, el backlog está priorizado, el Product Owner participa. El problema es más silencioso que eso: nadie definió, antes de arrancar, qué parte del proyecto no puede improvisarse sprint a sprint. Y esa omisión, que parece menor al principio, se cobra con intereses compuestos.
Lo que nadie dice en la retrospectiva
El síntoma más claro aparece cuando alguien intenta cambiar algo que parecía simple. Un ajuste en el modelo de datos toca cuatro historias ya entregadas. Un nuevo servicio necesita un contrato que nadie documentó porque se asumió. Una integración que "ya estaba resuelta" resulta que cada equipo la resolvió a su manera. Cada uno de esos problemas tiene la misma raíz: una decisión estructural que se tomó con la misma liviandad que una tarea de sprint.
La fragilidad no nace de hacer sprints cortos. Nace de confundir dos tipos de decisiones que tienen costos de reversión completamente distintos, y tratarlas como si fueran iguales.
Hay decisiones de implementación —cómo se resuelve una historia de usuario específica, en qué orden se atacan las funcionalidades dentro de una misma capa, qué tan grande es cada tarea— que son baratas de revertir. Si una no funciona, la corriges en el siguiente sprint sin que nada estructural se rompa. Esas son exactamente las decisiones que se benefician de la flexibilidad ágil: se ajustan con la información más fresca posible, sprint a sprint, sin necesidad de comité.
Y hay decisiones estructurales —el modelo de datos central, los contratos entre servicios, los patrones de comunicación del sistema— que son caras de cambiar una vez que varios equipos ya construyeron sobre ellas. Cada sprint que pasa sin que esas decisiones estén tomadas es un sprint que agrega capas sobre cimientos que nadie inspeccionó. El costo de revertirlas no crece linealmente: crece con cada historia que asumió que esa decisión ya estaba resuelta.
El error que produce proyectos frágiles no es aplicar demasiado ágil. Es aplicar la flexibilidad de la capa barata en la capa cara.
La solución incómoda
La respuesta es que esa capa cara necesita el tratamiento opuesto: rigidez deliberada antes de que comience el primer sprint. Se define una vez, con la misma seriedad que un plano estructural, y cambiarla requiere un proceso formal, no una conversación de pasillo. Es, en ese tramo específico, exactamente lo que hace la cascada.
Decirlo así genera resistencia inmediata. "Eso es cascada" es la objeción más rápida, y es comprensible: el malestar con la rigidez de los proyectos tradicionales es real y justificado. Pero la objeción parte de una confusión sobre qué es lo que realmente distingue cascada de ágil, y vale la pena deshacerla antes de continuar.
Lo que define a la cascada no es tener fases, ni tener diseño antes de construcción, ni tener decisiones tomadas de antemano. Lo que la define es que el mapa completo de trabajo —qué se construye, en qué orden, con qué criterios— se cierra una sola vez al principio y no se vuelve a abrir con lo que se aprende en el camino. Un proyecto en cascada puede tener veinte subproyectos y entregas parciales. Sigue siendo cascada porque el subproyecto quince ya estaba escrito en el mes uno, aunque se ejecute en el mes diez, y lo que se aprendió construyendo el subproyecto tres no tuvo ningún efecto sobre él.
Lo que distingue a ágil es una sola cosa: cuando terminas de ejecutar una unidad de trabajo, lo que aprendiste ahí puede reescribir el contenido, el orden o la existencia de la unidad que sigue. Es un bit de información viajando en dirección contraria al plan. Ese bit es lo que mantiene vivo el aprendizaje. Y ese bit puede existir perfectamente en un proyecto que tiene una arquitectura base cerrada, contratos entre servicios definidos antes del primer sprint, y una Definition of Ready rigurosa. Nada de eso impide que lo aprendido en el sprint tres cambie el alcance del sprint seis. Solo impide que lo aprendido en el sprint tres destruya los cimientos sobre los que ya construyó el resto del equipo.
Aplicar rigidez en la capa cara no es cascada. Es reconocer que no todas las decisiones tienen el mismo costo de reversión, y tratarlas en consecuencia.
Dónde vive esa rigidez dentro de Scrum
Lo interesante es que no necesitas inventar nada por fuera del framework. Ágil ya tiene nombre para cada capa de este sistema de gobernanza, y los tres elementos son igual de obligatorios.
El primero es el walking skeleton: antes de que arranque el primer sprint de construcción, el equipo define un esqueleto funcional y liviano que recorre el sistema de punta a punta. No es el diseño completo — es lo mínimo necesario para que todos los equipos construyan sobre la misma base sin pisarse. Incluye las decisiones que son caras de revertir: el modelo de datos central, los contratos entre servicios, los patrones de comunicación, las convenciones técnicas que el resto del proyecto va a asumir como dadas. Quién lo construye es el equipo técnico completo, antes del sprint uno, con la misma formalidad con que un arquitecto firma un plano estructural. Lo que viene después puede crecer y cambiar libremente — precisamente porque ese esqueleto existe.
El segundo y el tercero viven dentro de cada sprint y protegen ese esqueleto historia por historia: la Definition of Ready es la puerta de entrada —ninguna historia entra a un sprint sin demostrar que respeta los contratos que el walking skeleton estableció—, y la Definition of Done es la puerta de salida —ninguna historia se declara terminada sin haber verificado que sigue encajando con el sistema completo, no solo con el módulo recién construido.
Dicho así suena razonable. El problema es que la mayoría de los equipos tratan esa puerta de entrada como un trámite: "la historia tiene criterios de aceptación, ya está lista". Eso responde apenas una parte de la pregunta. Antes de que una historia entre a un sprint, alguien tiene que haber respondido, con la misma seriedad con que un ingeniero civil revisa un plano antes de excavar, qué depende de qué.
No basta con saber que la historia es viable en abstracto. Hay que identificar explícitamente qué habilitadores necesita para poder construirse: un endpoint que todavía no existe, un cambio en el modelo de datos que otra historia debe entregar primero, un permiso o una integración externa que tarda en aprobarse. Y aquí está el punto que casi siempre se salta: no puedes nombrar esas dependencias con honestidad si nadie diseñó, aunque sea a nivel de solución técnica, cómo se va a construir esa historia en concreto.
Decir "esto depende de X" sin haber bajado al menos un boceto de la solución —qué componentes toca, qué contrato de datos necesita, por dónde entra y por dónde sale la información— no es identificar una dependencia, es adivinarla. Y las dependencias adivinadas son exactamente las que aparecen a mitad del sprint disfrazadas de sorpresa.
Lo que una DoR seria realmente exige
Por eso la rigidez concreta no está en agregar más preguntas a un checklist de planning. Está en aceptar que ninguna historia entra a un sprint sin haber pasado antes por un ejercicio de diseño de solución propio, específico para esa actividad. No el diseño de arquitectura completo del sistema —eso vive en el walking skeleton y rara vez se toca. Es un diseño más modesto pero igual de innegociable: la DoR deja de ser una intención difusa en el momento en que se convierte en un entregable verificable con campos obligatorios.
Ninguna historia entra a Sprint Planning sin esos campos completos, y no admite medias respuestas. Como mínimo:
- El diseño de solución específico de esa actividad: qué componentes toca, por dónde entra y sale la información, qué contratos consume o expone.
- La lista explícita de dependencias y habilitadores identificados a partir de ese diseño, no adivinados desde la descripción funcional.
- La estimación de esfuerzo apoyada en esa lista. Construir algo que necesita tres piezas ajenas no cuesta lo mismo que construir algo autocontenido, aunque la descripción funcional suene igual de simple.
- Los criterios de aceptación funcionales: lo que ve el usuario.
- Los criterios no funcionales: rendimiento, seguridad, manejo de errores, mantenibilidad. Una historia puede cumplir su criterio funcional y aun así ser un desastre para el sistema si nadie exigió que respetara los estándares que el resto del proyecto ya adoptó.
- Dos validaciones distintas: la del dueño de producto, que confirma que resuelve el problema del usuario; y la del responsable técnico, que confirma que la solución respeta la arquitectura y los lineamientos que el walking skeleton estableció.
Si a la historia le falta cualquiera de esos ítems, no está lista, sin importar qué tan urgente parezca meterla al sprint. No es un principio abstracto: es un documento con campos obligatorios que nadie puede saltarse por falta de tiempo, exactamente igual que un plano estructural no se salta porque la obra vaya con retraso.
Esa segunda validación —la del responsable técnico— es la que casi nunca se sienta en la conversación. El usuario final valida si la historia resuelve su problema, pero eso es necesario y no suficiente. Falta quien evalúe si el modo en que se va a construir mantiene viva la coherencia del sistema completo. Puedes tener una historia perfectamente aprobada por el producto y absolutamente inaceptable para quien tiene que mantener esa base de código dentro de un año. Si tu Definition of Ready solo mira la primera validación, ya sembraste el mismo desacople que estás intentando evitar.
La Definition of Done cierra el ciclo: exigir pruebas de integración contra el sistema completo —y no solo contra el módulo recién construido— garantiza que lo que se declara terminado realmente encaja con todo lo demás. Ninguna de estas tres piezas rompe el Scrum Guide. Todas simplemente convierten en obligatorio, para ese proyecto específico, algo que el framework siempre dejó como decisión de cultura de equipo —y que la mayoría, por prisa o por comodidad, nunca llegó a decidir en serio.
De vuelta al tablero
Lo que hace funcionar este sistema no es la cantidad de rigor que aplicas, sino dónde lo aplicas. La mayor parte del proyecto —reglas de negocio, funcionalidades, flujos de usuario— se beneficia de decidirse tarde, con la información más fresca posible. Solo una fracción pequeña necesita el tratamiento opuesto: definirse antes, con formalidad, y no tocarse sin proceso. El walking skeleton, la DoR y la DoD son exactamente los tres puntos donde esa fracción vive.
Cuando vuelves al tablero de ese sprint en el que todo se veía bien —los puntos avanzaban, la demo salía en tiempo, nadie levantaba la mano— y lo comparas con el sistema que quedó meses después, frágil, sin columna vertebral, con cada módulo viviendo en su propia realidad, la distancia entre los dos momentos ya tiene explicación. No fueron los rituales, ni la disciplina del equipo, ni el framework. Fue que nadie definió los tres puntos donde el rigor no es opcional: el walking skeleton que estableciera la base común, la DoR que obligara a diseñar antes de construir, y la DoD que verificara que cada pieza encajaba con el resto.
Eso no es traicionar el manifiesto ágil. Es leerlo con más cuidado.
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.
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.
Respuestas API Consistentes: Un Wrapper Transversal con Spring WebFlux y `WebFilter`
- Mauricio ECR
- Snippets
- 30 Aug, 2025
En entornos modernos de microservicios, la consistencia en las respuestas de una API es más que una cuestión de estética: es un factor crítico para la mantenibilidad, observabilidad y experiencia del
Respuestas API Consistentes: Un Wrapper Transversal con Spring WebFlux y `WebFilter`
- Mauricio ECR
- Snippets
- 30 Aug, 2025
En entornos modernos de microservicios, la consistencia en las respuestas de una API es más que una cuestión de estética: es un factor crítico para la mantenibilidad, observabilidad y experiencia del consumidor. Aplicaciones frontend, integraciones con terceros, herramientas de monitoreo y otros microservicios esperan estructuras de respuesta predecibles. Cada variación no planificada introduce fricción: más lógica en los clientes, validaciones dispersas y puntos ciegos en trazabilidad.
En este contexto, estandarizar las respuestas de manera transversal —sin ensuciar cada controlador con lógica repetitiva— no solo simplifica el desarrollo, también abre la puerta a métricas uniformes, trazabilidad distribuida y soporte para nuevas funcionalidades sin tocar el código de negocio.
Este artículo explica cómo lograrlo en aplicaciones reactivas con Spring WebFlux, donde la naturaleza streaming de la respuesta introduce desafíos distintos a los de un stack imperativo como Spring MVC.
El Contrato de Respuesta: Mucho más que Datos
Antes de modificar nada, debemos definir el destino. Una respuesta estándar debe separar claramente los datos de negocio de la información contextual que permite entender la petición en su conjunto.
Un diseño común y extensible puede lucir así:
package com.app247.api.shared.response_wrapper.model;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class ApiResponse<T> {
private Meta meta;
private T data;
@Data
@Builder
public static class Meta {
private String timestamp;
private String path;
private int status;
private String requestId;
}
}
Este contrato permite:
- Consistencia: cada respuesta, sin importar el endpoint, sigue la misma forma.
- Trazabilidad: con
requestIdytimestamppodemos correlacionar logs, métricas y reportes. - Extensibilidad: podemos agregar campos en
meta(e.g., tiempos de respuesta, versión del servicio) sin afectar al cliente.
En entornos con OpenAPI/Swagger, este modelo puede documentarse fácilmente para que los consumidores conozcan el formato exacto de las respuestas.
WebFlux y el Desafío del Streaming
En aplicaciones no reactivas, ResponseBodyAdvice permite interceptar y modificar respuestas antes de serializarse. Pero en WebFlux, las respuestas son streams (Publisher<DataBuffer>), no objetos finales en memoria.
Esto implica dos retos:
- Respetar el modelo reactivo: no bloquear el flujo ni forzar materializaciones tempranas.
- Actuar en el punto correcto: cuando la respuesta está completa, pero antes de enviarla al cliente.
Aquí entra en juego el dúo WebFilter + ServerHttpResponseDecorator. El filtro decide si aplicar la transformación; el decorador define cómo hacerlo.
El Filtro: Decidiendo Cuándo Intervenir
Nuestro WebFilter actúa como middleware, excluyendo rutas (por ejemplo, Swagger o Actuator) y habilitando/deshabilitando la lógica según configuración externa:
package com.app247.api.shared.response_wrapper.filter;
import com.app247.api.shared.response_wrapper.config.ResponseWrapperProperties;
import com.app247.api.shared.response_wrapper.decorator.ResponseWrapperDecorator;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order;
import org.springframework.http.server.reactive.ServerHttpResponseDecorator;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;
@Slf4j
@Component
@Order(-2)
@RequiredArgsConstructor
public class ResponseWrapperFilter implements WebFilter {
private final ResponseWrapperProperties properties;
private final ObjectMapper objectMapper;
private final AntPathMatcher pathMatcher = new AntPathMatcher();
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
String path = exchange.getRequest().getURI().getPath();
// Verificamos si la ruta está excluida
boolean isExcluded = !properties.isEnabled() || properties.getExcludedPaths().stream()
.anyMatch(pattern -> pathMatcher.match(pattern, path));
if (isExcluded) {
return chain.filter(exchange);
}
// Creamos una instancia de nuestro nuevo decorador
ServerHttpResponseDecorator decoratedResponse = new ResponseWrapperDecorator(
exchange.getResponse(),
path,
objectMapper
);
// Pasamos el exchange con la respuesta decorada al siguiente filtro en la cadena
return chain.filter(exchange.mutate().response(decoratedResponse).build());
}
}
Las rutas excluidas y la activación del wrapper se controlan con propiedades externas, evitando recompilar para cambios operativos:
package com.app247.api.shared.response_wrapper.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
/*
Ejemplo:
api:
response:
wrapper:
enabled: true
# Patrones de URL para excluir. Usa el formato Ant.
excluded-paths:
- "/v3/api-docs/**"
- "/swagger-ui/**"
- "/webjars/**"
- "/swagger-resources/**"
- "/actuator/**"
*/
@Data
@Component
@ConfigurationProperties(prefix = "api.response.wrapper")
public class ResponseWrapperProperties {
private boolean enabled = true;
private List<String> excludedPaths = new ArrayList<>();
}
El Decorador: Interviniendo sin Romper el Flujo
ServerHttpResponseDecorator nos da acceso al cuerpo de la respuesta. El método clave es writeWith, que recibe el stream de datos antes de enviarlo al cliente.
package com.app247.api.shared.response_wrapper.decorator;
import com.app247.api.shared.response_wrapper.model.ApiResponse;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.reactivestreams.Publisher;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.http.server.reactive.ServerHttpResponseDecorator;
import reactor.core.publisher.Mono;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.UUID;
/**
* Decorador para ServerHttpResponse que intercepta las respuestas exitosas
* y las envuelve en una estructura estandarizada de ApiResponse (meta y data).
*/
@Slf4j
public class ResponseWrapperDecorator extends ServerHttpResponseDecorator {
private final ObjectMapper objectMapper;
private final String path;
public ResponseWrapperDecorator(ServerHttpResponse delegate, String path, ObjectMapper objectMapper) {
super(delegate);
this.path = path;
this.objectMapper = objectMapper;
}
/**
* Sobrescribe el método que escribe el cuerpo de la respuesta en el flujo de salida.
* Aquí es donde ocurre toda la magia de la intercepción y transformación.
* @param body El publicador original del cuerpo de la respuesta.
* @return Un Mono<Void> que representa la finalización de la operación de escritura.
*/
@Override
public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {
// PASO 1: Almacenar el cuerpo completo en un búfer.
// DataBufferUtils.join() consume tod_o el flujo del 'body' y lo une en un solo DataBuffer.
// Esto es CRUCIAL porque crea un punto de sincronización. La lógica siguiente
// no se ejecutará hasta que el controlador haya terminado y el cuerpo completo esté disponible.
Mono<DataBuffer> bufferedBody = DataBufferUtils.join(body)
.defaultIfEmpty(new DefaultDataBufferFactory().wrap(new byte[0])); // Maneja cuerpos vacíos (ej: 204 No Content)
// PASO 2: Usar flatMap para transformar el cuerpo almacenado en búfer.
// El código dentro de flatMap está garantizado a ejecutarse DESPUÉS de que 'bufferedBody' se complete.
return bufferedBody.flatMap(originalBuffer -> {
// PASO 3: Obtener el código de estado.
// En este punto, la llamada a getStatusCode() es 100% fiable porque el controlador
// ya ha finalizado y el framework ha establecido el estado final de la respuesta.
HttpStatusCode statusCode = getStatusCode();
// PASO 4: Decidir si se debe envolver la respuesta.
// Si el estado es un error explícito (4xx o 5xx), no hacemos nada y devolvemos el cuerpo original.
if (statusCode != null && !statusCode.is2xxSuccessful()) {
// Se escribe el buffer original en la respuesta real.
return getDelegate().writeWith(Mono.just(originalBuffer));
}
// PASO 5: Manejar el caso del entorno de pruebas.
// En WebFluxTest, un 200 OK por defecto puede resultar en un statusCode 'null'.
// Asumimos HttpStatus.OK si el estado es null para que las pruebas pasen.
HttpStatusCode statusToUse = (statusCode != null) ? statusCode : HttpStatus.OK;
// PASO 6: Procesar y envolver el cuerpo de la respuesta.
byte[] bytes = new byte[originalBuffer.readableByteCount()];
originalBuffer.read(bytes);
DataBufferUtils.release(originalBuffer); // Liberar memoria del buffer original.
String originalBodyJson = new String(bytes, StandardCharsets.UTF_8);
// Evitar envolver una respuesta que ya tiene nuestro formato.
if (originalBodyJson.contains("\"meta\"")) {
return getDelegate().writeWith(Mono.just(new DefaultDataBufferFactory().wrap(bytes)));
}
try {
// Deserializar el cuerpo original para poder ponerlo dentro del campo 'data'.
// Si el cuerpo está vacío, se asigna 'null' a los datos.
Object originalBodyObject = originalBodyJson.isEmpty() ? null : objectMapper.readValue(originalBodyJson, Object.class);
// Construir la nueva respuesta envuelta.
ApiResponse<?> apiResponse = buildSuccessResponse(originalBodyObject, path, statusToUse);
// Serializar la respuesta envuelta a bytes.
byte[] responseBytes = objectMapper.writeValueAsBytes(apiResponse);
// Actualizar las cabeceras HTTP con la nueva longitud y tipo de contenido.
getHeaders().setContentLength(responseBytes.length);
getHeaders().setContentType(MediaType.APPLICATION_JSON);
// Crear un nuevo buffer con la respuesta envuelta.
DataBuffer wrappedBuffer = new DefaultDataBufferFactory().wrap(responseBytes);
// Escribir el nuevo cuerpo en la respuesta real. Esta es la llamada final y única
// que envía los datos al cliente, siguiendo las buenas prácticas reactivas.
return getDelegate().writeWith(Mono.just(wrappedBuffer));
} catch (Exception e) {
log.error("Error al envolver la respuesta para la ruta {}: {}", path, e.getMessage(), e);
return getDelegate().writeWith(Mono.just(new DefaultDataBufferFactory().wrap(bytes)));
}
});
}
/**
* Método de ayuda para construir la estructura estandarizada de ApiResponse.
* @param data El objeto de datos original que se incluirá en el campo 'data'.
* @param path La ruta de la petición actual.
* @param status El código de estado HTTP final.
* @return Una instancia de ApiResponse.
*/
private ApiResponse<?> buildSuccessResponse(Object data, String path, HttpStatusCode status) {
ApiResponse.Meta meta = ApiResponse.Meta.builder()
.timestamp(Instant.now().toString())
.path(path)
.requestId(UUID.randomUUID().toString().substring(0, 10))
.status(status.value())
.build();
return ApiResponse.builder()
.meta(meta)
.data(data)
.build();
}
}
Consideraciones Técnicas
- Performance:
DataBufferUtils.join()carga todo en memoria; para respuestas muy grandes, conviene evaluar streaming JSON. - Idempotencia: el filtro detecta si ya existe
"meta"para evitar doble envoltura. - Trazabilidad distribuida:
requestIdpuede integrarse con Spring Cloud Sleuth o MDC para correlacionar logs entre microservicios.
Pruebas: Validando Comportamiento y Robustez
Con @WebFluxTest podemos probar controladores y filtros en un entorno aislado.
package com.app247.api.shared.response_wrapper.filter;
import com.app247.api.shared.response_wrapper.config.ResponseWrapperProperties;
import com.app247.api.shared.response_wrapper.model.ApiResponse;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
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.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.Instant;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import static org.mockito.Mockito.when;
/**
* Pruebas de integración para el ResponseWrapperFilter.
* Valida que las respuestas exitosas se envuelvan y que las de error o excluidas se ignoren.
*/
@WebFluxTest
@Import(ResponseWrapperFilterTest.TestConfig.class)
class ResponseWrapperFilterTest {
@Autowired
private WebTestClient webTestClient;
@MockitoBean
private ResponseWrapperProperties responseWrapperProperties;
@BeforeEach
void setUp() {
when(responseWrapperProperties.isEnabled()).thenReturn(true);
when(responseWrapperProperties.getExcludedPaths()).thenReturn(List.of("/excluded/**"));
}
@Test
@DisplayName("Debería envolver una respuesta Mono exitosa en ApiResponse")
void shouldWrapSuccessfulMonoResponse() {
webTestClient.get().uri("/test/mono")
.exchange()
.expectStatus().isOk()
.expectHeader().contentType(MediaType.APPLICATION_JSON)
.expectBody()
.jsonPath("$.meta").exists()
.jsonPath("$.meta.status").isEqualTo(200)
.jsonPath("$.meta.path").isEqualTo("/test/mono")
.jsonPath("$.data").exists()
.jsonPath("$.data.id").isEqualTo(1)
.jsonPath("$.data.name").isEqualTo("Test Mono");
}
@Test
@DisplayName("Debería envolver una respuesta Flux exitosa en ApiResponse con una lista")
void shouldWrapSuccessfulFluxResponse() {
webTestClient.get().uri("/test/flux")
.exchange()
.expectStatus().isOk()
.expectHeader().contentType(MediaType.APPLICATION_JSON)
.expectBody()
.jsonPath("$.meta").exists()
.jsonPath("$.meta.status").isEqualTo(200)
.jsonPath("$.data").isArray()
.jsonPath("$.data[0].id").isEqualTo(1)
.jsonPath("$.data[0].name").isEqualTo("Test Flux 1")
.jsonPath("$.data[1].id").isEqualTo(2)
.jsonPath("$.data[1].name").isEqualTo("Test Flux 2");
}
@Test
@DisplayName("No debería envolver una respuesta de una ruta excluida")
void shouldNotWrapExcludedPath() {
webTestClient.get().uri("/excluded/path")
.exchange()
.expectStatus().isOk()
.expectHeader().contentType(MediaType.APPLICATION_JSON)
.expectBody()
.jsonPath("$.meta").doesNotExist()
.jsonPath("$.data").doesNotExist()
.jsonPath("$.id").isEqualTo(99)
.jsonPath("$.name").isEqualTo("Excluded");
}
@Test
@DisplayName("No debería envolver una respuesta de error (ej: 400 Bad Request)")
void shouldNotWrapErrorResponse() {
webTestClient.get().uri("/test/error")
.exchange()
.expectStatus().isBadRequest()
.expectHeader().contentType(MediaType.APPLICATION_JSON)
.expectBody()
.jsonPath("$.meta").exists()
.jsonPath("$.errors").exists()
.jsonPath("$.errors[0].code").isEqualTo("400-CUSTOM-ERROR")
.jsonPath("$.data").doesNotExist();
}
@Test
@DisplayName("No debería envolver una respuesta que ya tiene el formato ApiResponse")
void shouldNotDoubleWrapAlreadyFormattedResponse() {
webTestClient.get().uri("/test/pre-wrapped")
.exchange()
.expectStatus().isOk()
.expectHeader().contentType(MediaType.APPLICATION_JSON)
.expectBody()
.jsonPath("$.meta").exists()
.jsonPath("$.meta.status").isEqualTo(200)
.jsonPath("$.data.message").isEqualTo("This is already wrapped")
.jsonPath("$.data.meta").doesNotExist(); // La comprobación clave: no hay un 'meta' dentro del 'data'.
}
@Test
@DisplayName("Debería devolver un error 500 estándar si la serialización del framework falla")
void shouldReturnStandard500ErrorOnFrameworkSerializationFailure() {
webTestClient.get().uri("/test/unserializable")
.exchange()
// 1. Aserción clave: el estado DEBE ser 500 Internal Server Error.
.expectHeader().contentType(MediaType.APPLICATION_JSON)
.expectBody()
// 2. Aserciones sobre el cuerpo de error estándar de Spring Boot.
// Este cuerpo NO es el original, sino el generado por el manejador de errores de Spring.
.jsonPath("$.status").isEqualTo(500)
.jsonPath("$.error").isEqualTo("Internal Server Error")
.jsonPath("$.path").isEqualTo("/test/unserializable")
// 3. Confirmamos que no hay rastro del cuerpo original ni de nuestra envoltura personalizada.
.jsonPath("$.meta").doesNotExist()
.jsonPath("$.data").doesNotExist()
.jsonPath("$.id").doesNotExist();
}
// --- CONFIGURACIÓN INTERNA Y COMPONENTES DE PRUEBA ---
@Data
@NoArgsConstructor
@AllArgsConstructor
static class TestDto {
private int id;
private String name;
}
// DTO diseñado para fallar durante la serialización de Jackson debido a una referencia circular.
@Data
static class UnserializableDto {
private int id = 123;
private Object problematicField = this;
}
@RestController
static class TestController {
@GetMapping("/test/mono")
Mono<TestDto> getMono() {
return Mono.just(new TestDto(1, "Test Mono"));
}
@GetMapping("/test/flux")
Flux<TestDto> getFlux() {
return Flux.just(new TestDto(1, "Test Flux 1"), new TestDto(2, "Test Flux 2"));
}
@GetMapping("/excluded/path")
Mono<TestDto> getExcluded() {
return Mono.just(new TestDto(99, "Excluded"));
}
@GetMapping("/test/error")
Mono<TestDto> getError() {
return Mono.error(new BusinessException("Error forzado", "400-CUSTOM-ERROR"));
}
@GetMapping("/test/pre-wrapped")
Mono<ApiResponse<Map<String, String>>> getPreWrappedResponse() {
ApiResponse.Meta meta = ApiResponse.Meta.builder().status(200).build();
Map<String, String> data = Collections.singletonMap("message", "This is already wrapped");
return Mono.just(ApiResponse.<Map<String, String>>builder().meta(meta).data(data).build());
}
@GetMapping("/test/unserializable")
Mono<UnserializableDto> getUnserializableObject() {
return Mono.just(new UnserializableDto());
}
}
static class BusinessException extends RuntimeException {
private final String errorCode;
public BusinessException(String message, String errorCode) {
super(message);
this.errorCode = errorCode;
}
public String getErrorCode() {
return errorCode;
}
}
@org.springframework.web.bind.annotation.RestControllerAdvice
static class TestGlobalExceptionHandler {
@org.springframework.web.bind.annotation.ExceptionHandler(BusinessException.class)
@org.springframework.web.bind.annotation.ResponseStatus(HttpStatus.BAD_REQUEST)
public Mono<Map<String, Object>> handleBusinessException(BusinessException ex) {
Map<String, String> error = Map.of("code", ex.getErrorCode(), "message", ex.getMessage());
Map<String, Object> meta = Map.of("timestamp", Instant.now().toString());
return Mono.just(Map.of("meta", meta, "errors", List.of(error)));
}
}
@Configuration
static class TestConfig {
@Bean
public ObjectMapper objectMapper() {
return new ObjectMapper();
}
@Bean
public AntPathMatcher antPathMatcher() {
return new AntPathMatcher();
}
@Bean
public TestGlobalExceptionHandler testGlobalExceptionHandler() {
return new TestGlobalExceptionHandler();
}
@Bean
public ResponseWrapperFilter responseWrapperFilter(
ResponseWrapperProperties properties, ObjectMapper objectMapper
) {
return new ResponseWrapperFilter(properties, objectMapper);
}
@Bean
public TestController testController() {
return new TestController();
}
}
}
Podemos extender las pruebas con StepVerifier para validar que el flujo sigue siendo reactivo y no introduce bloqueos inesperados.
Próximos Pasos y Extensiones
La solución presentada puede evolucionar hacia:
- Trazabilidad distribuida: Propagando
requestIdcon Spring Cloud Sleuth, Zipkin o Jaeger. - Internacionalización: Soporte para mensajes localizados en errores o advertencias.
- Observabilidad avanzada: Tiempo de procesamiento en
meta, integración con Prometheus o Grafana. - Functional Endpoints: Adaptando la solución a APIs basadas en
RouterFunctionen lugar de anotaciones tradicionales.
Con esta base, la envoltura de respuestas deja de ser solo un detalle de formato y se convierte en una capa estratégica para consistencia, trazabilidad y mantenimiento a largo plazo.
Más Allá del `try-catch`: Diseñando un Sistema de Excepciones Robusto y Escalable 🎯
- Mauricio ECR
- Snippets
- 28 Aug, 2025
En el desarrollo de APIs, el manejo de errores suele ser un ciudadano de segunda clase. Con frecuencia, nos conformamos con respuestas de error genéricas, inconsistentes o, en el peor de los casos, co
Más Allá del `try-catch`: Diseñando un Sistema de Excepciones Robusto y Escalable 🎯
- Mauricio ECR
- Snippets
- 28 Aug, 2025
En el desarrollo de APIs, el manejo de errores suele ser un ciudadano de segunda clase. Con frecuencia, nos conformamos con respuestas de error genéricas, inconsistentes o, en el peor de los casos, con trazas de stack trace en HTML que no aportan valor al consumidor de la API. Un buen contrato de API no solo define las rutas de éxito, sino que también establece un lenguaje claro y predecible para cuando las cosas van mal.
El objetivo de este artículo es construir, paso a paso, una estrategia de manejo de excepciones que sea robusta, escalable y centralizada. Dejaremos atrás los bloques try-catch dispersos por el código de negocio para dar paso a un sistema que produce respuestas JSON consistentes y enriquecidas para cualquier tipo de error, ya sea una validación de negocio, un recurso no encontrado o un fallo inesperado del sistema. Para ello, nos apoyaremos en principios de diseño sólidos como el Patrón Strategy, el Principio de Abierto/Cerrado y un enfoque que mantiene nuestro dominio limpio de preocupaciones de infraestructura.
Definiendo un Lenguaje Común para el Error
Antes de manejar cualquier error, debemos definir cómo queremos comunicarlo. En lugar de depender de estructuras volátiles como Map<String, Object>, estableceremos un contrato sólido mediante Data Transfer Objects (DTOs). Esto nos proporciona seguridad de tipos, autocompletado en el IDE y una excelente base para la documentación automática con herramientas como OpenAPI.
Nuestra estructura de respuesta de error estándar será la siguiente:
{
"meta": {
"timestamp": "2025-08-28T19:12:58.123Z",
"path": "/api/users",
"status": 409,
"requestId": "a1b2c3d4e5"
},
"errors": [
{
"code": "409-001",
"message": "EMAIL ALREADY EXISTS",
"payload": {
"email": "[email protected]"
}
}
]
}
Para modelar esto, definimos tres clases principales. ApiErrorResponse es el contenedor principal, que incluye una sección de metadatos (Meta) y una lista de errores. El ApiError en sí mismo es flexible, con un código, un mensaje y un payload opcional para datos contextuales.
// Modelo de respuesta genérico y estandarizado
@Data
@Builder
public class ApiErrorResponse<T> {
private Meta meta;
private List<T> errors;
@Data
@Builder
public static class Meta {
private String timestamp;
private String path;
private int status;
private String requestId;
}
}
// DTO que representa un único error de la API
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ApiError(String code, String message, Object payload) {
public ApiError(String code, String message) {
this(code, message, null);
}
}
Finalmente, un pequeño DTO para encapsular el contexto de la petición que se pasará a través de nuestro sistema.
// Encapsula la información del contexto de la request
@Data
@Builder
public class RequestContextApi {
private String path;
private String requestId;
}
Manteniendo el Dominio Puro: La BusinessException
Una de las claves de una buena arquitectura es la separación de conceptos. La lógica de negocio no debería saber nada sobre códigos de estado HTTP o la estructura de una respuesta JSON. Para lograrlo, definimos una excepción base para nuestro dominio, BusinessException.
Esta clase abstracta es simple pero poderosa. Contiene un errorCode único para la aplicación y un payload opcional. Cualquier excepción de negocio específica (ej. InsufficientFundsException) heredará de ella, manteniendo el dominio completamente agnóstico a la tecnología.
@Getter
public abstract class BusinessException extends RuntimeException {
private final String errorCode;
private final Object payload;
protected BusinessException(String message, String errorCode, Object payload) {
super((message != null) ? message.toUpperCase() : "");
this.errorCode = errorCode;
this.payload = payload;
}
protected BusinessException(String message, String errorCode) {
super((message != null) ? message.toUpperCase() : "");
this.errorCode = errorCode;
this.payload = null;
}
}
El Cerebro de la Operación: El Patrón Strategy
Con los modelos definidos, es hora de diseñar el mecanismo central. En lugar de un gran bloque if-else o un switch para manejar diferentes tipos de excepciones, utilizaremos el Patrón Strategy. Esto nos permitirá encapsular la lógica para manejar cada tipo de excepción en su propia clase, haciendo el sistema increíblemente fácil de extender.
La piedra angular es la interfaz ExceptionHandlerStrategy. Define un contrato que cada manejador debe cumplir:
supports(Class<? extends Throwable> exceptionType): Determina si el manejador es capaz de procesar un tipo de excepción dado.getStatus(Throwable ex): Define elHttpStatusque corresponde a la excepción. Esto nos permite devolver códigos más precisos que un simple 400 o 500.handle(Throwable ex, ...): El método que procesa la excepción. Lo interesante aquí es que proveemos una implementacióndefaultque cubre los casos más comunes, de modo que muchos de nuestros manejadores serán puramente declarativos.
public interface ExceptionHandlerStrategy {
boolean supports(Class<? extends Throwable> exceptionType);
default HttpStatus getStatus(Throwable ex) {
return HttpStatus.INTERNAL_SERVER_ERROR;
}
default ResponseEntity<ApiErrorResponse<?>> handle(Throwable ex, RequestContextApi context) {
HttpStatus status = getStatus(ex);
Object error = new ApiError(String.valueOf(status.value()), "INTERNAL_SERVER_ERROR");
return buildErrorResponse(status, context, (error instanceof List<?>) ? (List<?>) error : List.of(error));
}
static ResponseEntity<ApiErrorResponse<?>> buildErrorResponse(
HttpStatus status, RequestContextApi context, List<?> errors) {
// ... Lógica para construir la respuesta final ...
}
}
Estrategias en Acción: El Manejador Específico y el Genérico
Con la interfaz lista, crear manejadores es trivial. Para nuestras BusinessException, creamos un BusinessExceptionHandler. Este manejador sobreescribe getStatus para implementar una lógica ingeniosa que deriva el código de estado HTTP a partir del errorCode de la excepción (ej. "409-001" se convierte en HttpStatus.CONFLICT). También sobreescribe handle para asegurarse de que el payload se incluya en la respuesta.
@Component
public class BusinessExceptionHandler implements ExceptionHandlerStrategy {
@Override
public boolean supports(Class<? extends Throwable> exceptionType) {
return BusinessException.class.isAssignableFrom(exceptionType);
}
@Override
public HttpStatus getStatus(Throwable ex) {
BusinessException businessException = (BusinessException) ex;
String codeHttp = businessException.getErrorCode().split("-")[0];
try {
int codigo = Integer.parseInt(codeHttp);
return HttpStatus.valueOf(codigo);
} catch (Exception e) {
return HttpStatus.BAD_REQUEST;
}
}
@Override
public ResponseEntity<ApiErrorResponse<?>> handle(Throwable ex, RequestContextApi context) {
BusinessException exception = (BusinessException) ex;
HttpStatus status = getStatus(exception);
Object error = new ApiError(exception.getErrorCode(), exception.getMessage(), exception.getPayload());
return ExceptionHandlerStrategy.buildErrorResponse(status, context, (error instanceof List<?>) ? (List<?>) error : List.of(error));
}
}
Para cualquier otra excepción no controlada, tenemos el GenericExceptionStrategyHandler. Gracias a la anotación @Order(Ordered.LOWEST_PRECEDENCE) de Spring, esta estrategia solo se ejecutará si ninguna otra más específica puede manejar la excepción. Es nuestra red de seguridad, y gracias a la implementación default de la interfaz, su código es mínimo.
@Component
@Order(Ordered.LOWEST_PRECEDENCE)
public class GenericExceptionStrategyHandler implements ExceptionHandlerStrategy {
@Override
public boolean supports(Class<? extends Throwable> exceptionType) {
return true;
}
}
El Orquestador: Poniendo Todo en Marcha
Las estrategias individuales son útiles, pero necesitamos un director de orquesta. Aquí es donde entran el GlobalExceptionHandlerStrategyRegistry y el GlobalExceptionTranslator.
El Registry es una clase simple que se inyecta con una lista de todas las implementaciones de ExceptionHandlerStrategy disponibles en el contexto de Spring. Su única misión es iterar sobre ellas (respetando el @Order) y delegar el control a la primera que declare que puede manejar la excepción.
@Component
public class GlobalExceptionHandlerStrategyRegistry {
private final List<ExceptionHandlerStrategy> strategies;
public GlobalExceptionHandlerStrategyRegistry(List<ExceptionHandlerStrategy> strategies) {
this.strategies = strategies;
}
public ResponseEntity<ApiErrorResponse<?>> handle(Throwable ex, RequestContextApi context) {
return strategies.stream()
.filter(s -> s.supports(ex.getClass()))
.findFirst()
.map(s -> s.handle(ex, context))
.orElseThrow(() -> new IllegalStateException("No suitable exception handler found.", ex));
}
}
Finalmente, el Translator es el punto de entrada. Es una clase anotada con @RestControllerAdvice que captura cualquier Throwable que escape de nuestros controladores. Su responsabilidad es mínima y crucial: crear el RequestContextApi y pasarle la excepción al Registry. No contiene ninguna lógica de negocio, lo que lo mantiene limpio y enfocado.
@RestControllerAdvice
@RequiredArgsConstructor
public class GlobalExceptionTranslator {
private final GlobalExceptionHandlerStrategyRegistry registry;
@ExceptionHandler(Throwable.class)
public final ResponseEntity<ApiErrorResponse<?>> handleAnyException(
Throwable ex, ServerWebExchange exchange) {
RequestContextApi context = RequestContextApi.builder()
.path(exchange.getRequest().getURI().getPath())
.requestId(UUID.randomUUID().toString().substring(0, 10))
.build();
return registry.handle(ex, context);
}
}
Con todas las piezas en su lugar, podemos visualizar la arquitectura completa y el flujo de una excepción a través de nuestro sistema. El siguiente diagrama ilustra cómo estos componentes colaboran, desde la captura inicial hasta la selección de la estrategia adecuada y la construcción de la respuesta final.
codigo mermaid
classDiagram
class DatabaseConnectionPool {
-DatabaseEngineFactory engineFactory
+dataSourceFromSecret(String, DatabaseConnectionPropertiesFactory) DataSource
}
class DatabaseConnectionPropertiesFactory {
-GenericManager secretsManager
+getDatabaseConnectionProperties(String) DatabaseConnectionProperties
}
class DatabaseConnectionProperties {
-String dbname
-String username
-String password
-String engine
...
}
class DatabaseEngineFactory {
-Map~String, DatabaseEngineStrategy~ strategies
+getStrategy(String) DatabaseEngineStrategy
}
class DatabaseEngineStrategy {
<>
+getName() String
+buildJdbcUrl(...) String
+getValidationQuery() String
}
class PostgresqlStrategy {
+getName() String
...
}
class MysqlStrategy {
+getName() String
...
}
DatabaseConnectionPool ..> DatabaseConnectionPropertiesFactory : uses
DatabaseConnectionPool ..> DatabaseEngineFactory : uses
DatabaseConnectionPropertiesFactory ..> DatabaseConnectionProperties : creates
DatabaseEngineFactory ..> DatabaseEngineStrategy : uses
PostgresqlStrategy --|> DatabaseEngineStrategy : implements
MysqlStrategy --|> DatabaseEngineStrategy : implements
Conclusión y Futuras Mejoras
Hemos construido un sistema de manejo de excepciones que es a la vez potente y elegante. Todas las respuestas de error de nuestra API son ahora consistentes, informativas y se generan a través de un flujo centralizado y predecible. La belleza de este diseño radica en su escalabilidad: añadir soporte para un nuevo tipo de excepción es tan simple como crear una nueva clase Strategy, sin necesidad de modificar el código existente, adhiriéndonos así al Principio de Abierto/Cerrado.
Este sistema, sin embargo, es una base sólida sobre la cual se puede seguir construyendo. Algunas líneas futuras de mejora podrían incluir:
- Integración con Logging: Centralizar el registro de las excepciones completas dentro de los manejadores para un monitoreo más efectivo.
- Internacionalización (i18n): Modificar el
ApiErrory los manejadores para que puedan devolver mensajes de error en diferentes idiomas según las cabeceras de la petición. - Manejadores Específicos de Framework: Crear estrategias para excepciones comunes de frameworks como Spring Security (ej.
AccessDeniedException) para traducirlas a respuestas403 Forbiddencon un formato consistente.
Transacciones Declarativas en Arquitecturas Hexagonales con Spring WebFlux y AOP
- Mauricio ECR
- Snippets
- 27 Aug, 2025
En el desarrollo de aplicaciones reactivas modernas, especialmente bajo paradigmas como la Arquitectura Hexagonal, surgen desafíos que nos obligan a repensar cómo aplicamos conceptos transversales. Un
Transacciones Declarativas en Arquitecturas Hexagonales con Spring WebFlux y AOP
- Mauricio ECR
- Snippets
- 27 Aug, 2025
En el desarrollo de aplicaciones reactivas modernas, especialmente bajo paradigmas como la Arquitectura Hexagonal, surgen desafíos que nos obligan a repensar cómo aplicamos conceptos transversales. Uno de los interrogantes más comunes es: ¿dónde y cómo gestionamos las transacciones de base de datos sin contaminar nuestra lógica de negocio? Este artículo documenta un viaje desde esa pregunta inicial hasta una solución robusta y elegante, utilizando el poder de la Programación Orientada a Aspectos (AOP) en un entorno Spring WebFlux con R2DBC.
La Arquitectura como Punto de Partida
Antes de sumergirnos en el código, es fundamental visualizar la estructura del proyecto. Una organización clara de paquetes, que refleje las capas de la Arquitectura Hexagonal, es la base sobre la que construiremos nuestra solución. El dominio permanece en el centro, puro y sin dependencias externas, mientras que la aplicación y la infraestructura se organizan a su alrededor.
ms_auth/
├── applications/app-service/ # Módulo principal de la aplicación Spring Boot
│ ├── build.gradle
│ └── src/
│ ├── main/java/com/app247/
│ │ ├── MainApplication.java
│ │ └── config/aop/
│ │ └── TransactionalUseCaseAspect.java # Nuestro Aspecto AOP
│ └── test/java/com/app247/config/aop/
│ ├── TransactionalUseCaseAspectTest.java # Test unitario del Aspecto
│ └── TransactionalRollbackSelfContainedTest.java # Test de Integración
│
├── domain/
│ ├── model/
│ └── usecase/ # Módulo de la lógica de negocio pura
│ └── src/main/java/com/app247/usecase/shared/core/usecase/
│ ├── TransactionalWrapperUseCase.java # Anotación personalizada
│ └── UseCase.java # Interfaz genérica
│
└── infrastructure/
├── r2dbc-postgresql/ # Módulo adaptador para la base de datos
└── reactive-web/ # Módulo adaptador para los controladores REST
El Dilema Inicial: La Transacción y la Unidad de Trabajo
Todo comienza con una necesidad fundamental: asegurar la atomicidad de las operaciones. Imaginemos un caso de uso de negocio, como procesar una compra, que implica modificar el inventario de productos y crear un registro de orden. Ambas acciones deben tener éxito, o ninguna debe persistir. Esta es la definición de una unidad de trabajo, y la herramienta para garantizarla es la transacción.
La primera intuición podría ser colocar la anotación @Transactional de Spring en los métodos del repositorio. Sin embargo, esto es incorrecto. Una transacción en el repositorio solo cubriría una única operación de base de datos, rompiendo la unidad de trabajo del negocio. La transacción debe envolver la ejecución completa del caso de uso.
Esto nos lleva a la capa de servicio o caso de uso. Pero aquí nos encontramos con el primer gran obstáculo arquitectónico. En una Arquitectura Hexagonal, la capa de dominio (donde residen los casos de uso) debe ser pura. No puede, ni debe, tener dependencias de frameworks externos como Spring. Anotar un caso de uso del dominio con @Transactional viola este principio fundamental, acoplando nuestra lógica de negocio más preciada a un detalle de infraestructura.
La Solución Emerge: Programación Orientada a Aspectos
Si no podemos modificar el dominio, debemos aplicar el comportamiento transaccional desde afuera, de una manera no invasiva. Aquí es donde la Programación Orientada a Aspectos (AOP) brilla. AOP nos permite interceptar la ejecución de nuestros métodos para añadir funcionalidades transversales (como transacciones, seguridad o logging) sin alterar el código original.
La estrategia que emerge es crear un mecanismo declarativo y reutilizable que nos permita "marcar" qué casos de uso deben ser transaccionales, dejando que la magia de AOP haga el resto.
Una Anotación para Declarar la Intención
El primer paso es crear una anotación personalizada. Su único propósito es servir como una señal o marcador. Al ser parte de nuestro código de dominio (usecase), no introduce una dependencia directa de Spring, sino que define un contrato interno.
package com.app247.usecase.shared.core.usecase;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Anotación para marcar clases de Casos de Uso que deben ser
* envueltas en una transacción reactiva de forma automática.
*/
@Target(ElementType.TYPE) // Se aplica a nivel de clase
@Retention(RetentionPolicy.RUNTIME) // Disponible en tiempo de ejecución para que Spring la lea
public @interface TransactionalWrapperUseCase {
}
Junto a esta, podemos definir una interfaz genérica para estandarizar nuestros casos de uso, promoviendo un diseño limpio y consistente.
package com.app247.usecase.shared.core.usecase;
// Interfaz genérica (opcional pero recomendada)
public interface UseCase<Request, Response> {
Response execute(Request request);
}
El Aspecto: El Motor de la Transacción
Con la anotación en su lugar, construimos el componente que buscará esta marca y aplicará la lógica transaccional. Este es nuestro Aspecto, una clase de infraestructura que vive en la capa de aplicación.
Este Aspecto tiene dos partes clave:
- Pointcut: Una expresión que actúa como un selector. Le dice a Spring: "Encuentra todos los métodos públicos en cualquier clase que esté anotada con
@TransactionalWrapperUseCase". - Advice: La lógica que se ejecuta cuando el Pointcut encuentra una coincidencia. Usaremos un
advicede tipo@Around, que nos permite envolver completamente la ejecución del método original.
La lógica del advice es simple pero poderosa: toma el Mono o Flux devuelto por el caso de uso y lo compone con el TransactionalOperator reactivo de Spring. Este operador se encarga de iniciar la transacción antes de la suscripción y de realizar commit o rollback al finalizar.
package com.app247.config.aop;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;
import org.springframework.transaction.reactive.TransactionalOperator;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@Aspect
@Component
public class TransactionalUseCaseAspect {
private final TransactionalOperator transactionalOperator;
public TransactionalUseCaseAspect(TransactionalOperator transactionalOperator) {
this.transactionalOperator = transactionalOperator;
}
@Pointcut("@within(com.app247.usecase.shared.core.usecase.TransactionalWrapperUseCase) && execution(public * *(..))")
public void transactionalUseCase() {
// Método vacío para nombrar el pointcut.
}
@Around("transactionalUseCase()")
public Object wrapInTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
Object result = joinPoint.proceed();
if (result instanceof Mono) {
return ((Mono<?>) result).as(transactionalOperator::transactional);
} else if (result instanceof Flux) {
return ((Flux<?>) result).as(transactionalOperator::transactional);
}
return result;
}
}
Con estos dos elementos, hemos creado un sistema donde simplemente anotando una clase de caso de uso con @TransactionalWrapperUseCase, garantizamos que su ejecución será atómica, sin haber escrito una sola línea de código transaccional dentro del propio caso de uso.
Probando la Solución: De la Confianza a la Certeza
Una solución no está completa hasta que se prueba rigurosamente. Para este mecanismo, necesitamos dos niveles de prueba para tener una confianza total.
Nivel 1: El Test de Cableado (Unitario)
El primer test debe responder a la pregunta: ¿Nuestro aspecto AOP está correctamente configurado para interceptar la llamada y usar el TransactionalOperator? Este test valida tanto respuestas Mono como Flux.
Este test no necesita una base de datos. Utiliza un contexto de Spring para activar el mecanismo AOP, pero reemplaza todas las dependencias externas (TransactionalOperator, repositorios) con Mocks. El objetivo no es probar el rollback, sino verificar la interacción: que el método transactional() del operador sea invocado.
package com.app247.config.aop;
import com.app247.usecase.shared.core.usecase.TransactionalWrapperUseCase;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.transaction.reactive.TransactionalOperator;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@SpringBootTest(classes = TransactionalUseCaseAspectTest.TestConfig.class)
class TransactionalUseCaseAspectTest {
@Autowired
private PurchaseProductUseCasePort purchaseUseCase;
@Autowired
private FindProductsUseCasePort findProductsUseCase; // Caso de uso que devuelve Flux
@Autowired
private NonReactiveUseCasePort nonReactiveUseCase;
@MockitoBean
private ProductRepository productRepository;
@MockitoBean
private OrderRepository orderRepository;
@MockitoBean
private TransactionalOperator transactionalOperator;
@InjectMocks
private TransactionalUseCaseAspect transactionalUseCaseAspect;
@Test
void whenUseCaseReturnsMono_thenItShouldBeWrappedInTransaction() {
// ARRANGE
Product fakeProduct = new Product("prod-123", 10);
Order fakeOrder = new Order("user-007", "prod-123");
when(productRepository.findById(any())).thenReturn(Mono.just(fakeProduct));
when(orderRepository.save(any())).thenReturn(Mono.just(fakeOrder));
when(productRepository.updateStock(any(), any(Integer.class))).thenReturn(Mono.empty());
when(transactionalOperator.transactional(any(Mono.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
// ACT
Mono<Order> result = purchaseUseCase.execute("user-007", "prod-123");
// ASSERT
StepVerifier.create(result).expectNext(fakeOrder).verifyComplete();
verify(transactionalOperator).transactional(any(Mono.class));
verify(productRepository).updateStock("prod-123", 9);
}
@Test
void whenUseCaseReturnsFlux_thenItShouldBeWrappedInTransaction() {
// ARRANGE
Product fakeProduct1 = new Product("prod-001", 5);
Product fakeProduct2 = new Product("prod-002", 3);
when(productRepository.findAll()).thenReturn(Flux.just(fakeProduct1, fakeProduct2));
// Configuramos el mock para que el operador transaccional simplemente devuelva el Flux original
when(transactionalOperator.transactional(any(Flux.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
// ACT
Flux<Product> result = findProductsUseCase.execute(null); // `null` porque no requiere parámetros
// ASSERT
StepVerifier.create(result)
.expectNext(fakeProduct1)
.expectNext(fakeProduct2)
.verifyComplete();
// La verificación clave: ¿Se llamó al operador con un Flux?
verify(transactionalOperator).transactional(any(Flux.class));
}
/**
* Test para el caso no reactivo.
*/
@Test
void whenUseCaseIsNotReactive_thenItShouldNotBeWrappedInTransaction() {
// --- ARRANGE (Preparar) ---
String expectedResult = "Este es un resultado síncrono";
// --- ACT (Actuar) ---
// Ejecutamos el caso de uso que devuelve un String simple.
String actualResult = nonReactiveUseCase.execute(null);
// --- ASSERT (Verificar) ---
// 1. Verificamos que el resultado devuelto es el original, sin cambios.
assertThat(actualResult).isEqualTo(expectedResult);
// 2. La verificación MÁS IMPORTANTE: nos aseguramos de que el operador transaccional
// NUNCA fue invocado, ya que la respuesta no era ni Mono ni Flux.
verify(transactionalOperator, never()).transactional(any(Mono.class));
verify(transactionalOperator, never()).transactional(any(Flux.class));
}
@Test
void transactionalUseCasePointcut_shouldExecuteForCoverage() {
// --- ACT ---
// Simplemente llamamos al método vacío.
// La herramienta de cobertura registrará que se ha entrado en este método.
// --- ASSERT ---
// Como el método no hace nada, la única aserción posible es
// que la llamada no lance ninguna excepción.
assertDoesNotThrow(() -> {
transactionalUseCaseAspect.transactionalUseCase();
});
}
@Configuration
@EnableAspectJAutoProxy
@Import(TransactionalUseCaseAspect.class)
static class TestConfig {
@Bean
public PurchaseProductUseCasePort purchaseProductUseCase(ProductRepository productRepo, OrderRepository orderRepo) {
return new PurchaseProductUseCase(productRepo, orderRepo);
}
@Bean
public FindProductsUseCasePort findProductsUseCase(ProductRepository productRepo) {
return new FindProductsUseCase(productRepo);
}
@Bean
public NonReactiveUseCasePort nonReactiveUseCase() {
return new NonReactiveUseCase();
}
}
// --- Definiciones Fakes ---
record Product(String id, int stock) {}
record Order(String userId, String productId) {}
interface ProductRepository {
Mono<Product> findById(String productId);
Flux<Product> findAll(); // Añadido para el test de Flux
Mono<Void> updateStock(String productId, int newStock);
}
interface OrderRepository { Mono<Order> save(Order order); }
interface PurchaseProductUseCasePort { Mono<Order> execute(String userId, String productId); }
interface FindProductsUseCasePort { Flux<Product> execute(Void request); } // Nuevo caso de uso para Flux
interface NonReactiveUseCasePort { String execute(Void request); }
@TransactionalWrapperUseCase
static class PurchaseProductUseCase implements PurchaseProductUseCasePort {
private final ProductRepository productRepository;
private final OrderRepository orderRepository;
public PurchaseProductUseCase(ProductRepository p, OrderRepository o) { this.productRepository = p; this.orderRepository = o; }
public Mono<Order> execute(String userId, String productId) {
return productRepository.findById(productId)
.flatMap(product -> productRepository.updateStock(product.id(), product.stock() - 1)
.then(orderRepository.save(new Order(userId, productId))));
}
}
@TransactionalWrapperUseCase
static class FindProductsUseCase implements FindProductsUseCasePort {
private final ProductRepository productRepository;
public FindProductsUseCase(ProductRepository p) { this.productRepository = p; }
public Flux<Product> execute(Void request) {
return productRepository.findAll();
}
}
@TransactionalWrapperUseCase
static class NonReactiveUseCase implements NonReactiveUseCasePort {
@Override
public String execute(Void request) {
return "Este es un resultado síncrono";
}
}
}
Nivel 2: El Test de Comportamiento (Integración)
El segundo test debe responder a una pregunta más importante: si una operación falla, ¿la transacción realmente hace rollback?
Para esto, necesitamos un test de integración que utilice una base de datos real (en memoria, como H2, para velocidad y aislamiento) y el TransactionalOperator real de Spring. La clave aquí es usar @SpyBean para envolver un repositorio real y forzar un fallo en una de sus operaciones. La validación final consiste en consultar la base de datos después del fallo y verificar que el estado de los datos ha sido revertido a su estado original.
Este test es completamente autocontenido: define su propia configuración, su esquema de base de datos y sus implementaciones de dominio e infraestructura, pero lo más importante es que importa y prueba el Aspecto de AOP de producción real.
package com.app247.config.aop;
import com.app247.usecase.shared.core.usecase.TransactionalWrapperUseCase;
import io.r2dbc.spi.ConnectionFactory;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration;
import org.springframework.boot.autoconfigure.r2dbc.R2dbcAutoConfiguration;
import org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
import org.springframework.context.annotation.Import;
import org.springframework.data.annotation.Id;
import org.springframework.data.r2dbc.core.R2dbcEntityTemplate;
import org.springframework.data.relational.core.mapping.Table;
import org.springframework.r2dbc.connection.R2dbcTransactionManager;
import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.stereotype.Repository;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doReturn;
import static org.springframework.data.relational.core.query.Criteria.where;
import static org.springframework.data.relational.core.query.Query.query;
@SpringBootTest(classes = TransactionalRollbackSelfContainedTest.TestConfig.class)
@ImportAutoConfiguration({
R2dbcAutoConfiguration.class,
TransactionAutoConfiguration.class,
PropertyPlaceholderAutoConfiguration.class
})
@TestPropertySource(properties = {
"spring.r2dbc.url=r2dbc:h2:mem:///finaltestdb;DB_CLOSE_DELAY=-1;",
"spring.r2dbc.username=sa",
"spring.r2dbc.password=",
"spring.sql.init.mode=never"
})
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class TransactionalRollbackSelfContainedTest {
@Autowired private PurchaseProductUseCasePort purchaseUseCase;
@Autowired private DatabaseClient databaseClient;
@Autowired private R2dbcEntityTemplate template;
@MockitoSpyBean
private OrderRepository orderRepository;
private final String PRODUCT_ID = "prod-123";
private final int INITIAL_STOCK = 10;
@BeforeAll
void setupDatabaseSchema() {
String createProductsTable = "CREATE TABLE PRODUCTS (id VARCHAR(255) PRIMARY KEY, name VARCHAR(255), stock INT);";
String createOrdersTable = "CREATE TABLE ORDERS (id INT AUTO_INCREMENT PRIMARY KEY, user_id VARCHAR(255), product_id VARCHAR(255));";
databaseClient.sql(createProductsTable).then().block();
databaseClient.sql(createOrdersTable).then().block();
}
@BeforeEach
void setupTestData() {
databaseClient.sql("DELETE FROM PRODUCTS").then().block();
template.insert(new ProductEntity(PRODUCT_ID, "Test Product", INITIAL_STOCK)).block();
}
@Test
void whenSecondOperationFails_thenRealAspectRollsBackTransaction() {
doReturn(Mono.error(new RuntimeException("DB Error"))).when(orderRepository).save(any());
Mono<Void> result = purchaseUseCase.execute("user-007", PRODUCT_ID);
StepVerifier.create(result).expectError(RuntimeException.class).verify();
ProductEntity productAfter = template.selectOne(query(where("id").is(PRODUCT_ID)), ProductEntity.class).block();
assertThat(productAfter.stock()).isEqualTo(INITIAL_STOCK);
}
@Configuration
@EnableAspectJAutoProxy
@Import(TransactionalUseCaseAspect.class)
static class TestConfig {
@Bean
public R2dbcEntityTemplate r2dbcEntityTemplate(ConnectionFactory connectionFactory) {
return new R2dbcEntityTemplate(connectionFactory);
}
@Bean
public R2dbcTransactionManager transactionManager(ConnectionFactory connectionFactory) {
return new R2dbcTransactionManager(connectionFactory);
}
@Bean
public PurchaseProductUseCasePort purchaseProductUseCase(ProductRepository productRepo, OrderRepository orderRepo) {
return new PurchaseProductUseCase(productRepo, orderRepo);
}
@Bean
public ProductRepository productRepository(R2dbcEntityTemplate template) {
return new R2dbcProductRepositoryAdapter(template);
}
@Bean
public OrderRepository orderRepository(R2dbcEntityTemplate template) {
return new R2dbcOrderRepositoryAdapter(template);
}
}
record Product(String id, int stock) {}
record Order(String userId, String productId) {}
interface ProductRepository { Mono<Product> findById(String id); Mono<Void> updateStock(String id, int stock); }
interface OrderRepository { Mono<Order> save(Order order); }
interface PurchaseProductUseCasePort { Mono<Void> execute(String userId, String productId); }
@Table("PRODUCTS")
record ProductEntity(@Id String id, String name, int stock) {}
@Table("ORDERS")
record OrderEntity(@Id Integer id, String userId, String productId) {}
@Repository
static class R2dbcProductRepositoryAdapter implements ProductRepository {
private final R2dbcEntityTemplate template;
public R2dbcProductRepositoryAdapter(R2dbcEntityTemplate t) { this.template = t; }
public Mono<Product> findById(String id) { return template.selectOne(query(where("id").is(id)),ProductEntity.class).map(e -> new Product(e.id(), e.stock())); }
public Mono<Void> updateStock(String id, int stock) { return template.getDatabaseClient().sql("UPDATE PRODUCTS SET stock = :s WHERE id = :i").bind("s", stock).bind("i", id).fetch().rowsUpdated().then(); }
}
@Repository
static class R2dbcOrderRepositoryAdapter implements OrderRepository {
private final R2dbcEntityTemplate template;
public R2dbcOrderRepositoryAdapter(R2dbcEntityTemplate t) { this.template = t; }
public Mono<Order> save(Order o) { return template.insert(new OrderEntity(null, o.userId(), o.productId())).map(e -> o); }
}
@TransactionalWrapperUseCase
static class PurchaseProductUseCase implements PurchaseProductUseCasePort {
private final ProductRepository pRepo;
private final OrderRepository oRepo;
public PurchaseProductUseCase(ProductRepository p, OrderRepository o) { this.pRepo = p; this.oRepo = o; }
public Mono<Void> execute(String userId, String productId) {
return pRepo.findById(productId).flatMap(p -> pRepo.updateStock(p.id(), p.stock() - 1)).then(oRepo.save(new Order(userId, productId))).then();
}
}
}
Conclusión y Próximos Pasos
Hemos construido una solución completa, limpia y robusta para un problema complejo. Al mantener nuestro dominio puro y delegar las responsabilidades transversales a la capa de aplicación mediante AOP, logramos un código desacoplado, mantenible y altamente testeable. Las dependencias del proyecto reflejan esta arquitectura limpia, utilizando starters de Spring Boot para AOP y R2DBC, y librerías de prueba para H2 y ArchUnit.
// build.gradle
dependencies {
implementation 'org.reactivecommons.utils:object-mapper:0.1.0'
implementation project(':r2dbc-postgresql')
implementation project(':reactive-web')
implementation project(':model')
implementation project(':usecase')
implementation 'org.springframework.boot:spring-boot-starter'
implementation 'org.springframework.boot:spring-boot-starter-aop'
implementation 'org.springframework.boot:spring-boot-starter-data-r2dbc'
runtimeOnly('org.springframework.boot:spring-boot-devtools')
testImplementation 'com.tngtech.archunit:archunit:1.4.1'
testImplementation 'com.fasterxml.jackson.core:jackson-databind'
testImplementation 'com.h2database:h2'
testImplementation 'io.r2dbc:r2dbc-h2'
}
Este patrón no se limita a las transacciones. El mismo mecanismo de anotación y aspecto puede extenderse para manejar otras responsabilidades, como la autorización de seguridad, la auditoría o el registro de métricas, consolidándose como una base sólida para el desarrollo de futuras funcionalidades en cualquier aplicación reactiva que aspire a una arquitectura limpia y escalable.
El Arte de Conectar: Forjando un DataSource Dinámico en Spring Boot
- Mauricio ECR
- Snippets
- 23 Aug, 2025
En el vertiginoso universo del desarrollo de software, donde la seguridad es un pilar innegociable y la agilidad es la moneda de cambio, nos enfrentamos a desafíos que van más allá de la simple lógica
El Arte de Conectar: Forjando un DataSource Dinámico en Spring Boot
- Mauricio ECR
- Snippets
- 23 Aug, 2025
En el vertiginoso universo del desarrollo de software, donde la seguridad es un pilar innegociable y la agilidad es la moneda de cambio, nos enfrentamos a desafíos que van más allá de la simple lógica de negocio. Uno de los más recurrentes y críticos es la gestión de las conexiones a bases de datos. ¿Cómo construimos un sistema que no solo proteja sus credenciales como si fueran las joyas de la corona, sino que también sea lo suficientemente flexible para "hablar" con distintos motores de bases de datos sin despeinarse?
La respuesta no reside en un truco de magia, sino en la elegancia de la buena arquitectura. Este artículo te llevará en un viaje a través de una solución sofisticada en Spring Boot, donde desvelaremos cómo obtener credenciales de forma segura desde un gestor de secretos y, a la vez, emplear el ingenioso patrón de diseño Strategy para crear DataSources que se adaptan dinámicamente a su entorno. Prepárate para transformar una tarea mundana en una pieza de ingeniería de software.
El Mapa de la Arquitectura
Toda gran solución comienza con un plan. Antes de sumergirnos en el código, visualicemos nuestro ecosistema. No se trata de un monolito de lógica enrevesada, sino de un conjunto de componentes especializados que colaboran en perfecta armonía, como una orquesta bien afinada.
Antes de desgranar el código, un buen mapa visual nos ayudará a navegar la solución. El siguiente diagrama de clases ilustra las relaciones y dependencias entre nuestros componentes clave. Observa cómo las fábricas (Factory) orquestan la creación de objetos, mientras que la interfaz DatabaseEngineStrategy actúa como un contrato para sus diferentes implementaciones.
codigo mermaid
classDiagram
class DatabaseConnectionPool {
-DatabaseEngineFactory engineFactory
+dataSourceFromSecret(String, DatabaseConnectionPropertiesFactory) DataSource
}
class DatabaseConnectionPropertiesFactory {
-GenericManager secretsManager
+getDatabaseConnectionProperties(String) DatabaseConnectionProperties
}
class DatabaseConnectionProperties {
-String dbname
-String username
-String password
-String engine
...
}
class DatabaseEngineFactory {
-Map~String, DatabaseEngineStrategy~ strategies
+getStrategy(String) DatabaseEngineStrategy
}
class DatabaseEngineStrategy {
<>
+getName() String
+buildJdbcUrl(...) String
+getValidationQuery() String
}
class PostgresqlStrategy {
+getName() String
...
}
class MysqlStrategy {
+getName() String
...
}
DatabaseConnectionPool ..> DatabaseConnectionPropertiesFactory : uses
DatabaseConnectionPool ..> DatabaseEngineFactory : uses
DatabaseConnectionPropertiesFactory ..> DatabaseConnectionProperties : creates
DatabaseEngineFactory ..> DatabaseEngineStrategy : uses
PostgresqlStrategy --|> DatabaseEngineStrategy : implements
MysqlStrategy --|> DatabaseEngineStrategy : implements
El flujo de nuestra sinfonía es el siguiente:
- El director de orquesta (
DatabaseConnectionPool) necesita la partitura: las propiedades de conexión. Para ello, acude a nuestro "bibliotecario" (DatabaseConnectionPropertiesFactory). - El bibliotecario viaja a una bóveda segura (el gestor de secretos) para recuperar la partitura (
DatabaseConnectionProperties). - La partitura indica qué tipo de instrumento principal se necesita (el
engine, ej. "postgres"). Con esta clave, el director consulta a un "maestro de instrumentos" (DatabaseEngineFactory). - Este maestro selecciona al músico virtuoso adecuado (
DatabaseEngineStrategy) para ese instrumento. - El músico, con su maestría, interpreta la partitura y genera la melodía única: la URL JDBC.
- Finalmente, con todos los elementos en su lugar, el director da la señal y se forma la orquesta completa: un pool de conexiones
HikariDataSourcelisto para actuar.
Ahora, conozcamos a cada uno de los protagonistas de esta obra.
1. El Molde de Nuestros Secretos: DatabaseConnectionProperties
Todo sistema necesita un lenguaje común. Antes de poder manejar nuestros secretos, debemos definir su forma. Aquí es donde entra en juego DatabaseConnectionProperties, nuestro DTO (Data Transfer Object). No es más que el plano que define qué información esperamos encontrar en esa bóveda segura. Con la ayuda de Lombok, su definición es pura simpleza y elegancia.
package com.app247.mecrblog.jpa.config.datasource;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class DatabaseConnectionProperties {
private String dbname;
private String schema;
private String username;
private String password;
private String host;
private Integer port;
private String engine; // La pieza clave que define nuestra estrategia.
private String dbClusterIdentifier;
}
Esta clase es nuestro contrato: cualquier secreto que recuperemos deberá poder amoldarse a esta estructura.
2. El Guardián de los Secretos: DatabaseConnectionPropertiesFactory
La misión de esta fábrica es simple pero crucial: aventurarse en el mundo exterior, dialogar con el gestor de secretos y volver con el botín, ya transformado en nuestro DatabaseConnectionProperties.
package com.app247.mecrblog.jpa.config.datasource;
import org.springframework.stereotype.Component;
// ... (otros imports)
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@RequiredArgsConstructor
@Component
public class DatabaseConnectionPropertiesFactory {
private static final String DATABASE_SCHEMA = "schema";
// Un detalle brillante: no depende de un cliente de AWS o Vault,
// sino de nuestra propia interfaz 'GenericManager'. Pura abstracción.
private final GenericManager secretsManager;
public DatabaseConnectionProperties getDatabaseConnectionProperties(String key) throws SecretException {
var props = secretsManager.getSecret(key, DatabaseConnectionProperties.class);
props.setSchema(DATABASE_SCHEMA);
log.info("Creando DataSource para el motor={} en host: {}...",
props.getEngine(), props.getHost());
return props;
}
}
La verdadera magia aquí es la dependencia de GenericManager. Esta interfaz es nuestro pasaporte universal, permitiéndonos cambiar de proveedor de secretos (de AWS a HashiCorp Vault, por ejemplo) con solo cambiar una implementación, sin que el resto de nuestra aplicación se inmute. Es el arte del desacoplamiento en su máxima expresión.
3. El Arte de la Poliglotía: Adaptabilidad con el Patrón Strategy
Aquí es donde la trama se pone interesante. ¿Qué sucede cuando nuestra aplicación necesita conversar fluidamente con PostgreSQL y, mañana, con MySQL? Podríamos caer en la tentación de un pantanoso bloque if-else o switch, un camino seguro hacia un código frágil y una deuda técnica creciente.
Pero nosotros elegimos un camino más elegante: el patrón Strategy.
El Contrato del Traductor: DatabaseEngineStrategy
Primero, definimos un contrato, una serie de reglas que cualquier "traductor" de dialectos de bases de datos debe seguir.
package com.app247.mecrblog.jpa.config.datasource;
public interface DatabaseEngineStrategy {
// ¿Cómo te llamas? (ej: "postgres", "mysql")
String getName();
// ¿Cómo construyes una URL de conexión en tu idioma?
String buildJdbcUrl(String host, int port, String dbname, String schema);
// ¿Cuál es tu forma de verificar que estás vivo? (Validation Query)
String getValidationQuery();
}
Los Especialistas en Dialectos
Con el contrato en mano, contratamos a nuestros especialistas. Cada uno es un maestro en su propio idioma y se registra como un bean de Spring (@Component).
El experto en PostgreSQL:
package com.app247.mecrblog.jpa.config.datasource.strategy;
import org.springframework.stereotype.Component;
// ...
@Component
public class PostgresqlStrategy implements DatabaseEngineStrategy {
@Override
public String getName() { return "postgres"; }
@Override
public String buildJdbcUrl(String host, int port, String dbname, String schema) {
return String.format("jdbc:postgresql://%s:%d/%s%s", host, port, dbname,
((schema != null) && (!schema.isEmpty())) ? ("?currentSchema=" + schema) : "");
}
@Override
public String getValidationQuery() { return "SELECT 1"; }
}
El experto en MySQL:
package com.app247.mecrblog.jpa.config.datasource.strategy;
import org.springframework.stereotype.Component;
// ...
@Component
public class MysqlStrategy implements DatabaseEngineStrategy {
@Override
public String getName() { return "mysql"; }
@Override
public String buildJdbcUrl(String host, int port, String dbname, String schema) {
return String.format("jdbc:mysql://%s:%d/%s?useSSL=false&serverTimezone=UTC", host, port, dbname);
}
@Override
public String getValidationQuery() { return "SELECT 1"; }
}
Esta estructura es liberadora. ¿Necesitamos soportar Oracle mañana? Simplemente creamos un OracleStrategy sin tocar una sola línea del código existente. Nuestro sistema ha aprendido a crecer.
4. El Maestro de Ceremonias: DatabaseEngineFactory
Ya tenemos a nuestros músicos especialistas, pero necesitamos a alguien que sepa a quién llamar en cada momento. Ese es el rol de DatabaseEngineFactory, nuestro maestro de ceremonias.
package com.app247.mecrblog.jpa.config.datasource;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
// ...
@Slf4j
@Service
public class DatabaseEngineFactory {
private final Map<String, DatabaseEngineStrategy> strategies;
// Gracias a la magia de Spring, el constructor recibe un mapa con todos
// nuestros especialistas (beans de DatabaseEngineStrategy) disponibles.
public DatabaseEngineFactory(Map<String, DatabaseEngineStrategy> strategiesMap) {
this.strategies = strategiesMap.values().stream()
.collect(Collectors.toMap(DatabaseEngineStrategy::getName, Function.identity()));
log.info("Motores de base de datos soportados: {}", this.strategies.keySet());
}
// Dada una clave ("postgres"), devuelve al especialista correcto.
public DatabaseEngineStrategy getStrategy(String engineName) {
DatabaseEngineStrategy strategy = strategies.get(engineName.toLowerCase());
if (strategy == null) {
throw new IllegalArgumentException("Motor de BD no soportado: " + engineName);
}
return strategy;
}
}
Esta fábrica es un ejemplo sublime de cómo el framework Spring puede simplificar nuestro código. En lugar de registrar manualmente cada estrategia, Spring las descubre y nos las entrega listas para usar. La fábrica simplemente las organiza en un mapa para un acceso instantáneo.
5. La Gran Orquesta: Sincronizando Todo en DatabaseConnectionPool
Hemos llegado al acto final. Es hora de que el director suba al podio y una todas las piezas en una sinfonía funcional. La clase DatabaseConnectionPool es nuestro @Configuration principal, el lugar donde la magia realmente ocurre.
package com.app247.mecrblog.jpa.config.datasource;
import javax.sql.DataSource;
// ...
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
// ...
@Slf4j
@Configuration
@RequiredArgsConstructor
@Profile({ "local", "qa", "dev", "pdn" }) // Actuamos solo en los escenarios indicados
public class DatabaseConnectionPool {
// ... (Constantes de configuración de HikariCP)
private final DatabaseEngineFactory engineFactory;
@Bean
public DataSource dataSourceFromSecret(
@Value("${aws.secrets.credentials.db-write}") String secretName,
DatabaseConnectionPropertiesFactory connectionPropertiesFactory) throws SecretException {
// Acto 1: El bibliotecario trae la partitura.
var props = connectionPropertiesFactory.getDatabaseConnectionProperties(secretName);
// Acto 2: El maestro de ceremonias elige al virtuoso.
DatabaseEngineStrategy engine = engineFactory.getStrategy(props.getEngine());
// Acto 3: El virtuoso crea la melodía (la URL JDBC).
String jdbcUrl = engine.buildJdbcUrl(props.getHost(), props.getPort(), props.getDbname(), props.getSchema());
log.info("Creando DataSource con URL: {}", jdbcUrl);
// Gran final: Se forma la orquesta (el pool de conexiones).
return buildHikariDataSource(jdbcUrl, props.getUsername(), props.getPassword(), engine);
}
private DataSource buildHikariDataSource(String jdbcUrl, String username, String password,
DatabaseEngineStrategy engine) {
var config = new HikariConfig();
config.setJdbcUrl(jdbcUrl);
config.setUsername(username);
config.setPassword(password);
config.setPoolName("jpa-" + engine.getName() + "-hikari-pool");
config.setConnectionTestQuery(engine.getValidationQuery()); // Usamos la frase del especialista
// ... (resto de la configuración del pool)
return new HikariDataSource(config);
}
}
El método dataSourceFromSecret es el corazón palpitante de nuestra aplicación. Orquesta la secuencia de llamadas de una manera tan limpia y declarativa que su lógica se lee casi como prosa.
Telón Final y Futuras Funciones
Lo que hemos creado es más que un simple configurador de DataSource. Es un testimonio de cómo los buenos principios de diseño pueden dar como resultado un sistema que respira:
- Seguro: Las credenciales viven en su fortaleza, lejos de miradas indiscretas.
- Adaptable: Es un políglota de bases de datos, listo para aprender nuevos dialectos en cualquier momento.
- Robusto y Mantenible: Cada componente tiene su lugar y su propósito, haciendo que el sistema sea un placer de mantener y extender.
¿Y qué nos depara el futuro? Esta arquitectura no es un final, sino un punto de partida para nuevas aventuras:
- Mundos Paralelos: Extender la lógica para manejar réplicas de lectura, creando un
DataSourcepara escritura y otro para lectura. - Nuevos Talentos: Incorporar estrategias para Oracle, SQL Server o incluso bases de datos NoSQL con drivers JDBC.
- Inteligencia Dinámica: Hacer que la selección del
schemasea tan dinámica como el resto de la configuración.
Hemos transformado un requisito técnico en una solución elegante, demostrando que el código, en sus mejores momentos, se acerca más al arte que a la ciencia.
Manejando el Caos: Una Guía Definitiva sobre Excepciones de Dominio 🎯
- Mauricio ECR
- Snippets
- 20 Aug, 2025
En el desarrollo de software moderno, escribir código que funciona perfectamente en escenarios ideales es solo el primer paso. La verdadera fortaleza de un sistema se manifiesta cuando debe enfrentar
Manejando el Caos: Una Guía Definitiva sobre Excepciones de Dominio 🎯
- Mauricio ECR
- Snippets
- 20 Aug, 2025
En el desarrollo de software moderno, escribir código que funciona perfectamente en escenarios ideales es solo el primer paso. La verdadera fortaleza de un sistema se manifiesta cuando debe enfrentar situaciones imprevistas, errores y desviaciones del comportamiento esperado. En este contexto, el manejo adecuado de excepciones se convierte en una disciplina fundamental.
Sin embargo, no todas las excepciones son iguales. Mientras que errores como NullPointerException o IOException señalan problemas técnicos o de infraestructura, existe una categoría más rica y expresiva: las Excepciones de Dominio. Estas no representan fallos técnicos, sino violaciones específicas de las reglas, políticas y restricciones del negocio.
Las excepciones de dominio son especialmente valiosas en arquitecturas que siguen los principios de Domain-Driven Design (DDD), ya que permiten que nuestro código comunique directamente en el lenguaje del negocio, encapsulando la lógica de forma explícita y comprensible.
Este artículo establece una base documental completa sobre el tema, explorando un catálogo exhaustivo de excepciones de dominio y presentando un patrón de implementación que mantiene la separación de responsabilidades entre las capas de dominio e infraestructura.
El Corazón del Asunto: Un Catálogo de Excepciones de Dominio
Una arquitectura robusta requiere identificar y nombrar los conceptos con precisión. Para los errores de negocio, esto significa crear una jerarquía de excepciones que comunique exactamente qué regla específica ha sido violada. El siguiente catálogo cubre una amplia gama de escenarios de negocio comunes:
| Excepción | Descripción | Ejemplo de Uso | Código HTTP | DEFAULT_MESSAGE | DEFAULT_CODE |
|---|---|---|---|---|---|
| EntityNotFoundException | La entidad o recurso solicitado no existe | Buscar un usuario con id=99 que no está en la base de datos |
404 Not Found | ENTITY_NOT_FOUND |
404-001 |
| DuplicateEntityException | Ya existe una entidad con un identificador único | Crear un usuario con un email ya registrado | 409 Conflict | DUPLICATE_ENTITY |
409-001 |
| InvalidIdentifierException | El identificador no cumple con el formato requerido | Consultar un producto con ID esperado como UUID usando valor "ABC-###" |
400 Bad Request | INVALID_IDENTIFIER |
400-001 |
| BusinessRuleViolationException | Violación de una regla de negocio fundamental | Retiro bancario que excede el saldo disponible | 422 Unprocessable Entity | BUSINESS_RULE_VIOLATION |
422-001 |
| OperationNotAllowedException | Operación no permitida en el estado actual del recurso | Intentar cancelar un pedido ya entregado | 403 Forbidden | OPERATION_NOT_ALLOWED |
403-001 |
| InconsistentStateException | Estado internamente incoherente en el modelo de dominio | Pedido marcado como "pagado" sin transacciones asociadas | 500 Internal Server Error | INCONSISTENT_STATE |
500-001 |
| ValidationException | Error genérico de validación de datos de entrada | Petición a la API sin campo obligatorio | 400 Bad Request | VALIDATION_FAILED |
400-002 |
| InvalidValueException | Valor de campo fuera de rango o inválido | Crear usuario con edad = -5 |
400 Bad Request | INVALID_VALUE |
400-003 |
| MissingMandatoryValueException | Ausencia de valor obligatorio para la operación | Crear factura sin número de serie | 400 Bad Request | MISSING_MANDATORY_VALUE |
400-004 |
| ConcurrencyException | Conflicto al modificar un recurso en paralelo | Dos usuarios editando el mismo producto simultáneamente | 409 Conflict | CONCURRENCY_CONFLICT |
409-002 |
| OptimisticLockingException | Discordancia en versión de entidad (bloqueo optimista) | Guardar cliente con version=2 cuando la BD tiene version=3 |
409 Conflict | OPTIMISTIC_LOCK_ERROR |
409-003 |
| ReferentialIntegrityException | Violación de restricción de integridad referencial | Eliminar cliente que tiene facturas asociadas | 409 Conflict | REFERENTIAL_INTEGRITY_VIOLATION |
409-004 |
| AuthenticationException | Fallo en proceso de autenticación | Iniciar sesión con contraseña incorrecta | 401 Unauthorized | AUTHENTICATION_FAILED |
401-001 |
| AuthorizationException | Usuario sin permisos necesarios para la operación | Usuario "cliente" intentando acceder a panel de administración | 403 Forbidden | AUTHORIZATION_FAILED |
403-002 |
| SessionExpiredException | Sesión expirada o token inválido | Petición con JWT expirado a endpoint protegido | 401 Unauthorized | SESSION_EXPIRED |
401-002 |
| WorkflowViolationException | Transición inválida en flujo o proceso | Intentar "aprobar" orden de compra no "validada" | 422 Unprocessable Entity | WORKFLOW_VIOLATION |
422-002 |
| TimeoutException | Operación excedió tiempo de espera máximo | Pago en pasarela externa sin respuesta a tiempo | 504 Gateway Timeout | OPERATION_TIMEOUT |
504-001 |
| ExternalSystemUnavailableException | Sistema externo dependiente no disponible | Servicio de inventario caído durante procesamiento de venta | 503 Service Unavailable | EXTERNAL_SYSTEM_UNAVAILABLE |
503-001 |
| InsufficientBalanceException | Fondos o saldo insuficientes | Pagar compra de 200€ con saldo de 100€ | 422 Unprocessable Entity | INSUFFICIENT_BALANCE |
422-003 |
| CurrencyMismatchException | Mezcla de monedas incompatibles | Pagar en USD desde cuenta que opera solo en EUR | 400 Bad Request | CURRENCY_MISMATCH |
400-005 |
| LimitExceededException | Superación de límite definido | Transferir 10.000€ con límite diario de 5.000€ | 429 Too Many Requests | LIMIT_EXCEEDED |
429-001 |
| ConfigurationException | Error o falta de configuración en el dominio | Sistema sin tipo de IVA definido para país específico | 500 Internal Server Error | CONFIGURATION_ERROR |
500-002 |
| UnsupportedOperationException | Operación no soportada o implementada | Exportar reporte a formato obsoleto no desarrollado | 501 Not Implemented | UNSUPPORTED_OPERATION |
501-001 |
Patrón de Implementación: De la Pureza del Dominio a la Realidad de la Infraestructura 💡
Tener una rica jerarquía de excepciones es valioso, pero el verdadero desafío está en manejarlas de forma elegante. El objetivo es que nuestra capa de dominio lance una InsufficientBalanceException sin conocimiento alguno sobre HTTP, mientras que nuestra capa de API REST la traduzca apropiadamente a una respuesta 422 Unprocessable Entity con formato JSON.
La solución combina el Principio de Inversión de Dependencias con el patrón Strategy, creando un sistema flexible y escalable.
1. La Base de Todo: DomainException
Creamos una clase base abstracta de la que heredarán todas nuestras excepciones de dominio. Es un POJO puro, sin dependencias de frameworks:
package com.tuempresa.dominio.excepciones;
/**
* Excepción base del dominio.
*
* Todas las excepciones específicas del dominio deben heredar de esta clase.
* No contiene ninguna referencia a frameworks ni tecnologías (HTTP, DB, etc.)
*
* Permite mantener un "errorCode" que facilita el mapeo en las capas de
* aplicación/infraestructura.
*/
public abstract class DomainException extends RuntimeException {
private final String errorCode;
protected DomainException(String message, String errorCode) {
super(message);
this.errorCode = errorCode;
}
public String getErrorCode() {
return errorCode;
}
}
2. Una Excepción Concreta: EntityNotFoundException
Cada excepción de dominio es una clase simple que extiende DomainException, proporcionando sus propios códigos y mensajes por defecto:
package com.tuempresa.dominio.excepciones;
/**
* Se lanza cuando una entidad no puede ser encontrada en el dominio.
*/
public class EntityNotFoundException extends DomainException {
private static final String DEFAULT_MESSAGE = "ENTITY_NOT_FOUND";
private static final String DEFAULT_CODE = "404-001";
public EntityNotFoundException() {
super(DEFAULT_MESSAGE, DEFAULT_CODE);
}
// Constructor opcional para mayor flexibilidad
public EntityNotFoundException(String message, String errorCode) {
super(message, errorCode);
}
}
3. El Traductor: Patrón Strategy para el Manejo de Excepciones
En lugar de un gigantesco bloque if-else o switch, creamos una "estrategia" de manejo para cada excepción. Esto respeta el Principio de Abierto/Cerrado: podemos añadir nuevos manejadores sin modificar código existente.
3.1. La Interfaz Común (DomainExceptionHandlerStrategy)
Define el contrato que todos nuestros manejadores deben cumplir:
package com.tuempresa.infraestructura.excepciones;
import com.tuempresa.dominio.excepciones.DomainException;
import org.springframework.http.ResponseEntity;
public interface DomainExceptionHandlerStrategy<T extends DomainException> {
/**
* Devuelve el tipo de excepción que este manejador puede procesar.
*/
Class<T> getExceptionType();
/**
* Procesa la excepción y la convierte en una respuesta HTTP.
*/
ResponseEntity<ApiError> handle(T ex);
}
3.2. Un Manejador Específico (EntityNotFoundHandler)
Implementación concreta para EntityNotFoundException. Su única responsabilidad es traducir esta excepción de dominio en un HTTP 404 Not Found:
package com.tuempresa.infraestructura.excepciones;
import com.tuempresa.dominio.excepciones.EntityNotFoundException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
@Component
public class EntityNotFoundHandler
implements DomainExceptionHandlerStrategy<EntityNotFoundException> {
@Override
public Class<EntityNotFoundException> getExceptionType() {
return EntityNotFoundException.class;
}
@Override
public ResponseEntity<ApiError> handle(EntityNotFoundException ex) {
ApiError error = new ApiError(ex.getErrorCode(), ex.getMessage());
return new ResponseEntity<>(error, HttpStatus.NOT_FOUND);
}
}
4. El Orquestador: DomainExceptionHandlerRegistry 🔨
Este componente central actúa como director de orquesta. Mediante inyección de dependencias de Spring, recibe un Map donde las claves son tipos de excepción y los valores son las estrategias correspondientes:
package com.tuempresa.infraestructura.excepciones;
import com.tuempresa.dominio.excepciones.DomainException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
@Component
public class DomainExceptionHandlerRegistry {
private final Map<Class<? extends DomainException>, DomainExceptionHandlerStrategy> strategies;
// Spring inyectará una lista de todos los beans que implementen la interfaz
// y nosotros la convertimos en un Map para un acceso rápido.
public DomainExceptionHandlerRegistry(
java.util.List<DomainExceptionHandlerStrategy> strategyList) {
this.strategies = strategyList.stream()
.collect(Collectors.toMap(
DomainExceptionHandlerStrategy::getExceptionType,
Function.identity()
));
}
@SuppressWarnings("unchecked")
public ResponseEntity<ApiError> handle(DomainException ex) {
// Buscamos la estrategia específica para el tipo de excepción
DomainExceptionHandlerStrategy<DomainException> strategy =
strategies.get(ex.getClass());
if (strategy != null) {
return strategy.handle(ex);
}
// Fallback para excepciones de dominio no mapeadas explícitamente
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ApiError("500-000", "UNEXPECTED_DOMAIN_ERROR"));
}
}
5. La Estructura de Respuesta: ApiError DTO
Un record de Java para estandarizar el formato de nuestras respuestas de error:
package com.tuempresa.infraestructura.excepciones;
// Usamos un record de Java para una clase de datos inmutable y concisa.
public record ApiError(String code, String message) {}
6. La Puerta de Entrada: @RestControllerAdvice
Finalmente, usamos @RestControllerAdvice de Spring para crear un traductor global que intercepta cualquier DomainException no capturada anteriormente:
package com.tuempresa.infraestructura.excepciones;
import com.tuempresa.dominio.excepciones.DomainException;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
@RestControllerAdvice
public class GlobalExceptionTranslator extends ResponseEntityExceptionHandler {
private final DomainExceptionHandlerRegistry registry;
public GlobalExceptionTranslator(DomainExceptionHandlerRegistry registry) {
this.registry = registry;
}
@ExceptionHandler(DomainException.class)
public final ResponseEntity<ApiError> handleDomainException(DomainException ex) {
// Toda la lógica compleja está en el registry, aquí solo delegamos.
return registry.handle(ex);
}
}
Beneficios del Patrón Implementado
Este enfoque proporciona múltiples ventajas significativas:
Separación de Responsabilidades: La capa de dominio permanece completamente aislada de las preocupaciones de infraestructura como códigos HTTP o formatos de respuesta.
Extensibilidad: Añadir nuevas excepciones de dominio requiere únicamente crear la excepción y su manejador correspondiente, sin modificar código existente.
Testabilidad: Cada componente puede ser probado independientemente, facilitando la escritura de pruebas unitarias y de integración.
Mantenibilidad: La lógica de manejo de errores está centralizada pero distribuida de forma lógica, evitando el antipatrón de "God Objects".
Reutilización: El mismo patrón puede adaptarse a diferentes protocolos y tecnologías más allá de HTTP/REST.
Conclusión y Futuras Líneas de Trabajo
Hemos establecido una base sólida y documentada para el manejo de errores de negocio. Las excepciones de dominio trascienden la simple gestión de errores para convertirse en una herramienta de modelado que enriquece nuestro código, haciéndolo más expresivo y alineado con las reglas del negocio.
El patrón presentado, fundamentado en Strategy y un Registro central, ofrece una solución elegante que mantiene la pureza de la capa de dominio mientras proporciona un mecanismo extensible para traducir errores de negocio en respuestas concretas de infraestructura.
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.
Estándar de Codificación y Gestión de Errores en Arquitecturas de Microservicios
- Mauricio ECR
- Convenciones
- 18 Jun, 2025
En el ecosistema de aplicaciones web modernas, la arquitectura de microservicios distribuidos se ha consolidado como un paradigma dominante. Su flexibilidad, escalabilidad y resiliencia son innegables
Estándar de Codificación y Gestión de Errores en Arquitecturas de Microservicios
- Mauricio ECR
- Convenciones
- 18 Jun, 2025
En el ecosistema de aplicaciones web modernas, la arquitectura de microservicios distribuidos se ha consolidado como un paradigma dominante. Su flexibilidad, escalabilidad y resiliencia son innegables. Sin embargo, esta distribución introduce una complejidad significativa en la gestión de estados y, especialmente, en el manejo de errores. Cuando una operación involucra a múltiples servicios, identificar la causa raíz de un fallo puede convertirse en una tarea titánica, afectando la experiencia del usuario, los tiempos de resolución y la mantenibilidad del sistema.
Este documento establece un estándar completo y directamente aplicable para la codificación, documentación y gestión de errores visibles al usuario final. El objetivo es crear un marco de trabajo robusto que, aunque inicialmente se implementará en un entorno de backend con Java (Spring Boot) y frontend con Angular, ha sido diseñado con un enfoque agnóstico al lenguaje para garantizar su longevidad y adaptabilidad a futuras tecnologías.
Adoptar este estándar permitirá no solo presentar mensajes de error claros y útiles al usuario, sino también establecer una base documental sólida que facilite la observabilidad, la trazabilidad y la colaboración entre equipos de desarrollo, QA y soporte técnico.
El Estándar Detallado
1. Filosofía y Principios Fundamentales
Antes de detallar la implementación, es crucial entender los principios que guían este estándar:
- El Error como Ciudadano de Primera Clase: Los errores no son un caso excepcional, sino una parte inherente del flujo de una aplicación. Deben ser diseñados, documentados y probados con el mismo rigor que las funcionalidades exitosas.
- Claridad para el Usuario, Detalle para el Desarrollador: La información presentada al usuario final debe ser concisa, clara, traducible y orientada a la acción. Por el contrario, la información registrada para los equipos técnicos debe ser rica en detalles para permitir un diagnóstico rápido y preciso.
- Seguridad por Opacidad: Los códigos y mensajes de error públicos nunca deben revelar detalles de la infraestructura interna, nombres de clases, trazas de pila (stack traces) o cualquier información que pueda ser explotada por un actor malicioso.
- Trazabilidad Extrema: Cada error debe ser unívocamente identificable y correlacionable a través de los distintos servicios y sistemas de observabilidad (logs, métricas, trazas distribuidas).
2. Formato del Código de Error
Todo error visible al usuario final o que cruce los límites de un microservicio deberá ser identificado por un código único y opaco. Este código actúa como una clave inmutable que desacopla el error en sí de su representación (el mensaje).
La estructura propuesta es: MSS-ECNNN[-EXTCODE]
MSS(MicroService Short-identifier): Identificador numérico único de 3 dígitos asignado a cada microservicio. Esta asignación debe ser gestionada en un registro centralizado para evitar colisiones.- Ejemplo:
101para "Servicio de Autenticación",205para "Servicio de Pedidos".
- Ejemplo:
EC(Error Category): Código alfabético de 2 letras que clasifica la naturaleza del error. Esto permite un filtrado y análisis rápido. (Ver sección 3 para el catálogo de categorías).- Ejemplo:
IVpara "Validación de Entrada",BRpara "Regla de Negocio".
- Ejemplo:
NNN(Numeric Sequence): Secuencia numérica de 3 dígitos, única dentro del microservicio para esa categoría. Se recomienda iniciar en001y aumentar de forma secuencial.- Ejemplo:
001,002,047.
- Ejemplo:
[EXTCODE](External Code - Opcional): Componente opcional para encapsular errores originados en servicios de terceros. Proporciona una correlación directa sin exponer el formato original del tercero.- Formato:
EXT_<ID_SERVICIO>_<CODIGO_ERROR_ORIGINAL> ID_SERVICIO: Un identificador corto y predefinido para el servicio externo (ej.STRIPE,SENDGRID,AWS_S3).CODIGO_ERROR_ORIGINAL: El código de error devuelto por el servicio externo, sanitizado para ser compatible con la URL (ej. reemplazando espacios o caracteres especiales por_).- Ejemplo:
205-DP003-EXT_STRIPE_card_declined
- Formato:
Ejemplo completo: 205-BR001 representa el primer error de "Regla de Negocio" definido en el microservicio "Pedidos" (ID 205).
3. Clasificación de Errores Comunes
La estandarización de categorías (EC) es fundamental para la observabilidad y la generación de métricas. El catálogo inicial es el siguiente:
| Código | Categoría | Descripción |
|---|---|---|
IV |
Input Validation | Errores relacionados con la validación de datos de entrada (formato, rango, campos obligatorios). Suelen ser errores del tipo 400 Bad Request. |
AU |
Authentication & Authorization | Fallos de autenticación (token inválido, credenciales incorrectas) o autorización (sin permisos para la acción). Errores 401 Unauthorized o 403 Forbidden. |
BR |
Business Rule | Violación de una regla de negocio específica. La solicitud es sintácticamente correcta pero semánticamente inválida en el contexto actual (ej. stock insuficiente). Suele ser un 409 Conflict o 422 Unprocessable Entity. |
DP |
Dependency Failure | Un servicio externo del que depende el microservicio no está disponible o ha devuelto un error (otra API interna, base de datos, sistema de colas). Errores del tipo 502 Bad Gateway o 503 Service Unavailable. |
SY |
System Error | Errores inesperados o no controlados en el servidor (excepciones RuntimeException, NullPointerException, etc.). Siempre deben ser investigados. Corresponden a un 500 Internal Server Error. |
NT |
Network & Timeout | Errores de comunicación, ya sea porque una dependencia no respondió a tiempo o por problemas en la red. Corresponde a un 504 Gateway Timeout. |
CF |
Configuration Error | El servicio no puede iniciarse o funcionar correctamente debido a una configuración faltante o incorrecta (variables de entorno, secretos, etc.). |
NF |
Not Found | El recurso solicitado no existe. Corresponde a un 440 Not Found. |
4. Catálogo Inicial de Códigos de Error
A continuación, se presenta un catálogo de ejemplo con 10 códigos para dos microservicios hipotéticos: Gestión de Usuarios (ID: 101) y Procesamiento de Pedidos (ID: 205).
| Código | Mensaje usuario | Descripción técnica | Severidad | Estado | Acción esperada | Servicio externo (si aplica) |
|---|---|---|---|---|---|---|
| 101-IV001 | El correo electrónico proporcionado no tiene un formato válido. Por favor, revísalo. | El campo email en el DTO de registro de usuario no supera la validación de la expresión regular ^(.+)@(.+)$. |
Baja | Activo | Usuario: Corregir el formato del email. Frontend: Realizar validación en cliente para prevenir el error. | N/A |
| 101-AU001 | Tu sesión ha expirado. Por favor, inicia sesión de nuevo. | El JWT recibido en la cabecera Authorization ha caducado. La fecha de expiración (exp) es anterior a la fecha actual. |
Media | Activo | Sistema: Redirigir al usuario a la página de login. Usuario: Volver a introducir sus credenciales. | N/A |
| 101-AU002 | No tienes permisos para realizar esta acción. Contacta al administrador si crees que es un error. | El usuario autenticado, extraído del token, no posee el rol requerido (ROLE_ADMIN) para acceder al endpoint. |
Media | Activo | Frontend: Ocultar o deshabilitar la opción que provoca el error. Soporte: Verificar los roles del usuario si este levanta un ticket. | N/A |
| 101-BR001 | El correo electrónico ya está registrado en nuestra plataforma. Intenta iniciar sesión. | Se intentó crear un usuario con un email que ya existe en la tabla users de la base de datos (violación de UNIQUE constraint). |
Baja | Activo | Usuario: Usar la opción "recuperar contraseña" o iniciar sesión. Frontend: Ofrecer un enlace directo a la página de login. | N/A |
| 205-IV001 | El identificador del producto no es válido. Debe ser un número positivo. | El productId recibido en la línea de un pedido es nulo, cero o negativo. La validación @Positive falló en el DTO. |
Baja | Activo | Usuario: Informar del error. Es un caso raro que indica un bug en el frontend. Desarrollo: Investigar cómo se pudo enviar un ID inválido desde el cliente. | N/A |
| 205-BR001 | No hay suficiente stock para el producto 'Nombre del Producto'. Solo quedan X unidades. | La cantidad solicitada de un producto es mayor que el stock disponible registrado en la base de datos para ese productId. |
Media | Activo | Usuario: Reducir la cantidad del producto o eliminarlo del carrito. Sistema: El mensaje debe incluir el stock actual para ser útil. | N/A |
| 205-BR002 | No se pueden añadir productos de diferentes vendedores en un mismo pedido. | La lógica de negocio impide mezclar productos de sellerId distintos en una sola transacción para simplificar la logística. |
Media | Activo | Usuario: Finalizar la compra actual y crear un nuevo pedido para los otros productos. Frontend: Mostrar una advertencia al intentar añadir un producto incompatible. | N/A |
| 205-DP001 | El servicio de inventario no está respondiendo. Por favor, inténtalo de nuevo en unos minutos. | La llamada HTTP al microservicio de Inventario (ID 310) para verificar el stock ha resultado en un timeout o error 5xx. |
Alta | Activo | Sistema: Implementar un reintento con exponential backoff. Si persiste, activar un circuit breaker. Soporte: Revisar el estado del servicio de Inventario. | N/A |
| 205-DP002 | Tu pago no pudo ser procesado por el proveedor. Razón: Fondos insuficientes. | La pasarela de pagos (Stripe) devolvió un error 402 Request Failed con el código card_declined y el motivo insufficient_funds. |
Media | Activo | Usuario: Intentar con otro método de pago o verificar los fondos de su tarjeta. Sistema: No reintentar automáticamente. Invalidar el pedido. | EXT_STRIPE_insufficient_funds |
| 205-SY001 | Ha ocurrido un error inesperado al procesar tu pedido. Nuestro equipo ya ha sido notificado. | Se ha capturado una NullPointerException no esperada en la clase OrderProcessingService al calcular los impuestos. Se ha creado una alerta en Sentry/Datadog. |
Alta | Activo | Sistema: Registrar el trace_id y toda la información de la petición. Desarrollo: Priorizar la corrección de este bug. Usuario: Reintentar más tarde o contactar a soporte con el trace_id si se le muestra. |
N/A |
5. Documentación y Centralización
La documentación es lo que transforma este estándar de una idea a una herramienta útil.
- Estructura de Archivos: Cada microservicio debe incluir en su repositorio un archivo
docs/errors/errors.md. Este archivo contendrá la tabla completa de errores que el servicio puede generar. - Control de Versiones: El archivo
errors.mddebe ser versionado junto con el código fuente del microservicio. Cualquier adición o modificación de un código de error debe formar parte de un Pull Request, permitiendo su revisión. - Formato de Documentación: Se utilizará la tabla Markdown definida en la sección anterior. Este formato es fácil de leer para humanos y de parsear por máquinas.
Ejemplo de microservice-orders/docs/errors/errors.md:
Catálogo de Errores - Servicio de Pedidos
- Nombre Lógico: Servicio de Procesamiento de Pedidos
- ID del Microservicio: 205
| Código | Mensaje usuario | Descripción técnica | Severidad | Estado | Acción esperada | Servicio externo (si aplica) |
|---|---|---|---|---|---|---|
| 205-IV001 | El identificador del producto no es válido. Debe ser un número positivo. | El productId recibido en la línea de un pedido es nulo, cero o negativo. La validación @Positive falló en el DTO. |
Baja | Activo | Usuario: Informar del error. Desarrollo: Investigar cómo se pudo enviar un ID inválido. | N/A |
| ... | ... | ... | ... | ... | ... | ... |
6. Recomendaciones para Centralización y Escalabilidad
Para que el estándar sea efectivo en una organización grande, se recomienda:
- Repositorio Central de Errores: Un pipeline de CI/CD debería agregar los archivos
errors.mdde todos los microservicios tras cada release exitosa en un repositorio central o una página de Confluence/Wiki. Esto crea un único punto de verdad para que los equipos de QA, Soporte y Frontend puedan consultar cualquier código de error sin necesidad de acceder a los repositorios individuales. - Automatización y Linters: El pipeline de CI/CD debe incluir pasos para:
- Validar la Unicidad: Asegurar que no existan códigos
MSS-ECNNNduplicados dentro del mismo microservicio. - Validar el Formato: Comprobar que todos los nuevos códigos sigan la estructura definida.
- Detectar Nuevos Servicios Externos: Lanzar una advertencia si se añade un
EXTCODEcon un<ID_SERVICIO>no registrado previamente, para asegurar que se documente centralmente.
- Validar la Unicidad: Asegurar que no existan códigos
- Generación de Artefactos: Se pueden crear scripts que lean estos archivos
.mdpara generar automáticamente artefactos útiles, como:- Enums de TypeScript para el frontend, permitiendo un manejo de errores tipado (
case ErrorCodes.USER_EMAIL_EXISTS:). - Clases de constantes en Java para el backend.
- Plantillas para sistemas de tickets (Jira, Zendesk).
- Enums de TypeScript para el frontend, permitiendo un manejo de errores tipado (
Conclusión
Este estándar de codificación y gestión de errores proporciona un marco de trabajo unificado y resiliente, diseñado para escalar con la complejidad de una arquitectura de microservicios. Al tratar los errores como un componente central del diseño de software, logramos múltiples beneficios:
- Mejora la Experiencia del Usuario (UX): Los mensajes son claros, consistentes y orientados a la acción.
- Agiliza la Resolución de Incidencias: Los códigos únicos y la documentación detallada permiten a los equipos de soporte y desarrollo identificar y solucionar problemas rápidamente.
- Potencia la Observabilidad: La estructura de códigos y categorías facilita la creación de dashboards, alertas y métricas significativas sobre la salud del sistema.
- Aumenta la Seguridad: La opacidad de los códigos previene la fuga de información sensible de la infraestructura.
Futuras Líneas de Aplicación:
- Integración con Plataformas de Observabilidad: Enriquecer automáticamente las trazas distribuidas (ej. en Jaeger o Datadog) con la "Descripción Técnica" del error a partir de su código.
- Desarrollo de un "Servicio de Errores": Una pequeña API central que, dado un código de error, devuelva su documentación completa, incluyendo el mensaje de usuario localizado en diferentes idiomas.
- Generación de SDKs de Cliente: Automatizar la creación de librerías para diferentes lenguajes (JS/TS, Python, Go) que encapsulen la lógica de manejo de estos códigos de error estandarizados.
La adopción disciplinada de este estándar no es una carga adicional, sino una inversión estratégica en la calidad, mantenibilidad y escalabilidad a largo plazo de la plataforma.
Implementando Trunk Based Development con Ramas de Vida Corta en Tu Equipo: Una Guía Completa
- Mauricio ECR
- DevOps
- 28 Apr, 2025
En el ámbito del desarrollo de software, la elección de una estrategia de versionamiento eficiente y estable es fundamental para el éxito de un equipo. El Trunk Based Development (TBD) tradicional, do
Implementando Trunk Based Development con Ramas de Vida Corta en Tu Equipo: Una Guía Completa
- Mauricio ECR
- DevOps
- 28 Apr, 2025
En el ámbito del desarrollo de software, la elección de una estrategia de versionamiento eficiente y estable es fundamental para el éxito de un equipo. El Trunk Based Development (TBD) tradicional, donde los desarrolladores trabajan directamente sobre una única rama principal (master o main), es ideal para equipos pequeños de 1 a 3 desarrolladores. Para equipos de 4 a 5 desarrolladores, aunque sigue siendo una opción viable, requiere indispensablemente la implementación de pruebas automáticas. Sin embargo, para equipos más grandes, con seis o más desarrolladores, una variante del TBD se presenta como una solución escalable: el Trunk Based Development con Ramas de Vida Corta (TBD con SLB). Esta estrategia, utilizada por empresas como Google, utiliza ramas de vida muy corta para gestionar los cambios en lugar de que los desarrolladores hagan commits directamente en el tronco.
¿En qué Consiste TBD con SLB?
En TBD con SLB, la práctica central es que los desarrolladores no hacen commits directos sobre la rama principal o tronco. En su lugar, trabajan en ramas dedicadas y de muy corta duración. Estas ramas, llamadas ramas de vida corta (short-lived branches), duran muy poco tiempo, idealmente menos de 3 días, y como máximo 3 días. Incluso, pueden contener un solo commit. Una vez que la tarea o funcionalidad de la rama está terminada y lista, se integra de nuevo al tronco mediante un merge back, y la rama de vida corta se elimina.
Implementación Paso a Paso (Desarrollo de Nuevas Funcionalidades)
La implementación de TBD con SLB para añadir o modificar funcionalidades sigue un ciclo bien definido. Suponiendo que la rama principal se llama main (puede ser master en repositorios antiguos).
1. Creación de Ramas de Vida Corta
Cada vez que un desarrollador inicia una nueva tarea, debe crear una nueva rama de desarrollo de vida corta. Esta rama se crea a partir de la última versión del tronco (main). Es crucial asegurarse de que tu copia local del tronco esté actualizada antes de crear la rama.
Comandos Git:
# Asegúrate de estar en la rama principal (trunk)
git checkout main
# Descarga los últimos cambios del tronco remoto
git pull origin main
# Crea una nueva rama de vida corta basada en el tronco actual
# Reemplaza 'feature/nombre-tarea' con un nombre descriptivo para tu tarea
git checkout -b feature/nombre-tarea
Ilustración: La rama feature/nombre-tarea se crea a partir del último commit en main (representado por los commits existentes C1 y C2 en main).
codigo mermaid
gitGraph
commit id: "C1"
commit id: "C2"
branch feature/nombre-tarea
checkout feature/nombre-tarea
2. Desarrollo y Commits Locales
El desarrollador trabaja sobre su rama recién creada y realiza los commits necesarios con sus cambios, trabajando a su propio ritmo.
Comandos Git:
# Realiza cambios en tus archivos...
# Por ejemplo, crea o modifica un archivo: touch nuevo-archivo.txt
# Agrega los cambios al área de staging
git add .
# Realiza un commit con un mensaje descriptivo
git commit -m "Implementa la funcionalidad X"
# Repite los pasos add/commit según sea necesario mientras trabajas en la tarea
Ilustración: Nuevos commits (F1, F2) se añaden a la rama feature/nombre-tarea, mientras main permanece inalterada.
codigo mermaid
gitGraph
commit id: "C1"
commit id: "C2"
branch feature/nombre-tarea
checkout feature/nombre-tarea
commit id: "F1"
commit id: "F2"
3. Sincronización con el Tronco (Previo al Merge)
Antes de preparar la integración de los cambios, es esencial que la rama local del desarrollador esté actualizada con los últimos cambios que ya se encuentran en el tronco. Esto se logra descargando los cambios del tronco y aplicándolos a tu rama, típicamente usando merge o rebase. rebase es a menudo preferido en TBD para mantener un historial más lineal.
Comandos Git (Usando Rebase - Preferido en TBD para historial limpio):
# Asegúrate de que tu tronco local esté actualizado
git checkout main
git pull origin main
# Vuelve a tu rama de trabajo
git checkout feature/nombre-tarea
# Rebase tu rama sobre el tronco actualizado
# Esto reescribe el historial de tu rama para que parezca que tus cambios
# se hicieron después de los últimos cambios del tronco
git rebase main
Comandos Git (Usando Merge - Alternativa):
# Asegúrate de que tu tronco local esté actualizado
git checkout main
git pull origin main
# Vuelve a tu rama de trabajo
git checkout feature/nombre-tarea
# Fusiona los cambios del tronco en tu rama
# Esto crea un commit de merge si hay cambios en el tronco
git merge main
Ilustración: main ha avanzado con C3. El merge crea un nuevo commit (M) en feature/nombre-tarea que combina los cambios de main y los de la rama feature.
codigo mermaid
gitGraph
commit id: "C1"
commit id: "C2"
branch feature/nombre-tarea
commit id: "F1"
commit id: "F2"
checkout main
commit id: "C3"
checkout feature/nombre-tarea
merge main id: "M"
4. Creación de un Pull Request (PR)
Una vez que los cambios están completos, la rama está sincronizada y lista para integrarse, el desarrollador crea un Pull Request (PR) dirigido al tronco (main). Este paso generalmente se realiza a través de la interfaz web de la plataforma de gestión de código (GitHub, GitLab, Bitbucket, Azure DevOps, etc.), después de haber subido la rama local al repositorio remoto.
Comandos Git (para subir la rama antes del PR):
# Sube tu rama de vida corta al repositorio remoto
# La opción -u (o --set-upstream-to) configura el seguimiento remoto
git push -u origin feature/nombre-tarea
5. Revisión de Código y Pruebas Automáticas Pre-Merge
El uso de PRs es una característica distintiva y ventajosa del TBD con SLB. Permite la revisión de código por otros miembros del equipo y, crucialmente, habilita la ejecución de pruebas automáticas antes de que se realice el merge. Esto es una diferencia significativa con el TBD tradicional, donde las pruebas automáticas a menudo se realizan después del merge. La ventaja clave es que evita que commits con errores lleguen al tronco. Este paso es un proceso que ocurre en la plataforma de código, no un comando Git ejecutado por el usuario para realizar la revisión o las pruebas, aunque los revisores pueden descargar la rama si necesitan probar localmente.
6. Merge al Tronco y Eliminación de la Rama
Si el PR es aprobado (por revisores) y las pruebas automáticas pasan, los cambios de la rama de vida corta se integran al tronco (main) mediante un merge (generalmente completando el PR en la plataforma). Posteriormente, la rama de vida corta es eliminada. Todo desarrollo futuro para nuevas tareas siempre empieza creando una nueva rama de vida corta desde el tronco.
codigo mermaid
gitGraph
commit id: "C1"
commit id: "C2"
branch feature/nombre-tarea
commit id: "F1"
commit id: "F2"
checkout main
commit id: "C3"
checkout feature/nombre-tarea
merge main id: "M"
checkout main
merge feature/nombre-tarea
Comandos Git (Después de completar el PR en la plataforma):
# Vuelve al tronco local
git checkout main
# Asegúrate de tener el último estado del tronco (que ahora incluye el merge)
git pull origin main
# Elimina la rama de vida corta localmente (usa -D para forzar si no se ha mergeado, -d es seguro)
git branch -d feature/nombre-tarea
# Elimina la rama de vida corta del repositorio remoto
git push origin --delete feature/nombre-tarea
codigo mermaid
gitGraph
commit id: "C1"
commit id: "C2"
commit id: "C3"
commit id: "T1" tag: "merge tarea 1"
Cómo se Gestionan los Releases (Versiones para Producción)
Una parte fundamental del versionamiento es la gestión de las liberaciones de software (releases) a entornos productivos. En el TBD con SLB, el manejo de las ramas de release es directo y se deriva del tronco:
- Creación de la Rama de Release: Cuando el estado actual del tronco (
main) se considera listo para ser liberado como una nueva versión, simplemente se crea una nueva rama a partir de ese punto. - Denominación y Uso: Esta nueva rama se nombra típicamente con el número de la versión que se va a lanzar (por ejemplo,
release/1.0.0). Esta rama de versión es la que se utiliza para ser desplegada en producción. Es la línea base a partir de la cual se gestionarán los despliegues y, si es necesario, las correcciones de bugs específicos de esa versión. El tronco (main) sigue siendo la rama activa para el desarrollo de nuevas funcionalidades.
Comandos Git:
# Asegúrate de estar en el tronco y que esté actualizado
git checkout main
git pull origin main
# Crea la rama de release a partir del tronco actual
# Reemplaza 'release/1.0.0' con el nombre de tu versión
git checkout -b release/1.0.0
# Opcional pero recomendado: Etiqueta este punto para una referencia clara de la versión
git tag v1.0.0 release/1.0.0
# Sube la rama de release y la etiqueta al remoto
git push -u origin release/1.0.0
git push origin v1.0.0
Ilustración: Se crea la rama release/1.0.0 (y potencialmente se etiqueta v1.0.0) a partir de un commit específico en main (C5). main continúa para el desarrollo futuro.
codigo mermaid
gitGraph
commit id: "C1"
commit id: "C2"
commit id: "C3"
commit id: "T1" tag: "merge tarea 1"
Escenarios para Solucionar Bugs en Producción
Los bugs que se descubren en una versión ya desplegada en producción (es decir, en la rama de Release) deben manejarse cuidadosamente para mantener la coherencia con la metodología TBD y la integridad del tronco. La forma de proceder depende de si el error aún se puede reproducir en la versión actual del tronco.
Escenario 1: El Bug Sigue Existiendo en el Tronco (Preferido)
Este es el escenario recomendado y se alinea con la práctica de empresas como Google. Se corrige el bug en el tronco y luego se "trasplanta" esa corrección a la rama de Release.
Verificar el Bug en el Tronco: Se confirma que el error se puede replicar en la versión actual del tronco (
main). (Esto es una verificación manual o automatizada, no un comando Git).Crear Rama de Vida Corta desde el Tronco: Se crea una nueva rama de vida corta específicamente para el bug fix, partiendo directamente del tronco.
Comandos Git:
# Asegúrate de estar en el tronco y actualizado
git checkout main
git pull origin main
# Crea la rama para el bug fix desde el tronco
git checkout -b bugfix/nombre-bug-en-produccion
- Solucionar el Bug: El bug se corrige en esta nueva rama de vida corta.
Comandos Git:
# Realiza los cambios para corregir el bug...
# Agrega y realiza el commit
git add .
git commit -m "Fix: Corrige el bug X reportado en v1.0.0 (también presente en main)"
- Merge del Bug Fix al Tronco: El commit que contiene la solución del bug se integra de vuelta al tronco (
main). Esto se hace mediante el proceso habitual de TBD con SLB (usando un PR).
Comandos Git (Después de completar el PR en la plataforma):
# Sube la rama con el bug fix
git push -u origin bugfix/nombre-bug-en-produccion
# Crea un PR en la plataforma de 'bugfix/nombre-bug-en-produccion' a 'main'
# ... (Una vez aprobado y mergeado) ...
# Vuelve al tronco local y actalízalo
git checkout main
git pull origin main
- Cherry Pick a la Rama de Release: Finalmente, se realiza un Cherry Pick del commit específico que contiene el bug fix (el commit
B1original o el merge commitM_B1, aunqueB1es más limpio para cherry-pick) desde el tronco (main) hacia la rama de la versión en producción (la ramarelease/1.0.0donde se encontró el bug).
Comandos Git:
# Identifica el hash del commit de bug fix en el tronco (ej: el commit B1 original o M_B1)
# Puedes usar 'git log main' para encontrarlo
# Vuelve a la rama de release
git checkout release/1.0.0
# Aplica el commit de bug fix desde el tronco a esta rama
# Reemplaza <commit-hash-del-bugfix> con el hash real
git cherry-pick <commit-hash-del-bugfix>
# Sube la rama de release actualizada al remoto
git push origin release/1.0.0
codigo mermaid
gitGraph
commit id: "C1"
commit id: "C2"
commit id: "C3"
commit id: "T1"
branch release/1.0.0
checkout release/1.0.0
commit id: "R1" tag: "v1.0.0"
checkout main
commit id: "C4"
commit id: "C5"
branch bugfix/nombre-bug-en-produccion
checkout bugfix/nombre-bug-en-produccion
commit id: "B1" tag: "Bugfix Commit"
checkout main
merge bugfix/nombre-bug-en-produccion id: "M_B1" tag: "Bugfix on Main"
checkout release/1.0.0
cherry-pick id:"M_B1" parent:"B1" tag:"v1.0.1"
Este proceso, aunque involucra más pasos (crear rama, merge al tronco, cherry pick a la rama de Release), garantiza que solo el bug fix se incorpore a la versión de producción. Es la forma de respetar el contrato del Trunk Based Development y asegurar que el tronco sigue siendo la fuente principal de verdad. El desarrollo normal de nuevas funcionalidades continúa siempre en nuevas ramas de vida corta creadas a partir del tronco. Google utiliza este enfoque: las soluciones de bugs para un release se desarrollan en la línea principal y luego se hace un cherry pick a la rama de release.
Escenario 2: El Bug NO Sigue Existiendo en el Tronco (Menos Sugerido)
Este escenario es menos común y no es el recomendado por los expertos en TBD. Ocurre si el bug ya fue corregido en el tronco por un commit que, sin embargo, incluye otras funcionalidades que no deben ir a la rama de Release actual, lo que impide crear la rama de bug fix desde el tronco.
Verificar el Bug y el Tronco: Se confirma que el bug no es replicable en el tronco, pero la solución en el tronco está ligada a otros cambios no deseados en la versión de producción. (Verificación manual/automatizada).
Crear Rama de Vida Corta desde la Rama de Release: En esta situación excepcional, se crea una rama de vida corta desde la rama de la versión en producción (la rama
release/1.0.0).
Comandos Git:
# Asegúrate de estar en la rama de release y actualizada
git checkout release/1.0.0
git pull origin release/1.0.0
# Crea la rama para el hotfix directamente desde la rama de release
git checkout -b hotfix/nombre-bug-solo-en-v1.0.0
- Solucionar el Bug: El bug se corrige en esta rama creada directamente desde la rama de Release.
Comandos Git:
# Realiza los cambios para corregir el bug...
# Agrega y realiza el commit
git add .
git commit -m "Hotfix: Corrige el bug Y específicamente en v1.0.0"
- Merge del Bug Fix a la Rama de Release: Se integra el bug fix de vuelta a la rama de la versión en producción (
release/1.0.0) mediante un merge (idealmente vía PR). La solución está ahora en la versión que la necesita.
Comandos Git (Después de completar el PR en la plataforma):
# Sube la rama con el hotfix
git push -u origin hotfix/nombre-bug-solo-en-v1.0.0
# Crea un PR en la plataforma de 'hotfix/nombre-bug-solo-en-v1.0.0' a 'release/1.0.0'
# ... (Una vez aprobado y mergeado) ...
# Vuelve a la rama de release local y actualízala
git checkout release/1.0.0
git pull origin release/1.0.0
# La rama hotfix/nombre-bug-solo-en-v1.0.0 ahora se eliminaría
- Merge de la Rama de Release al Tronco: Por último, se integra la rama de la versión en producción actualizada (
release/1.0.0) de vuelta al tronco (main). Esto es crucial para asegurar que la corrección hecha en la rama de Release también se refleje en el tronco, evitando la divergencia y que el mismo bug reaparezca en futuras versiones creadas desdemain.
Comandos Git:
# Asegúrate de estar en el tronco y actualizado
git checkout main
git pull origin main
# Fusiona la rama de release (ahora con el hotfix) en el tronco
git merge release/1.0.0
# Sube el tronco actualizado al remoto
git push origin main
codigo mermaid
gitGraph
commit id: "C1"
commit id: "C2"
commit id: "C3"
commit id: "T1"
branch release/1.0.0
checkout release/1.0.0
commit id: "R1" tag: "v1.0.0"
checkout main
commit id: "C4"
commit id: "C5"
checkout release/1.0.0
branch bugfix/nombre-bug-en-produccion
checkout bugfix/nombre-bug-en-produccion
commit id: "B1" tag: "Bugfix Commit"
checkout release/1.0.0
merge bugfix/nombre-bug-en-produccion id:"M_B1" tag: "v1.0.1"
checkout main
cherry-pick id:"M_B1" parent:"B1"
Este escenario invierte el flujo habitual de los cambios. Una vez completado este proceso, el desarrollo continúa siempre en nuevas ramas de vida corta creadas desde el tronco.
Características Clave y Beneficios del TBD con SLB
El TBD con SLB se define por las siguientes características y aporta importantes beneficios:
- Ramas de Vida Corta: Las ramas de desarrollo duran muy poco, idealmente menos de 4 horas y un máximo de 3 días.
- Uso de Pull Requests: La integración de cambios al tronco se realiza exclusivamente a través de PRs, lo que trae consigo sus beneficios.
- Pruebas Automáticas Pre-Merge: Permite la ejecución de pruebas automatizadas antes de fusionar los cambios al tronco, lo que mejora significativamente la estabilidad del tronco al prevenir que lleguen commits con errores.
- Evita "Merge Hell": Al trabajar con ramas pequeñas e integrar cambios con mucha frecuencia, se minimizan los conflictos de merge dolorosos que son comunes con ramas de vida larga.
- Soporte para Versionamiento: Se gestionan versiones creando ramas de Release a partir del tronco.
- Escalabilidad: Es una variante escalable del TBD, siendo muy adecuada para equipos grandes con más de seis desarrolladores.
- Alineación con Prácticas de Grandes Empresas: Es una estrategia utilizada por compañías como Google, donde el desarrollo en ramas (excepto para releases) es inusual y las ramas de vida larga son extremadamente raras.
Conclusión
Implementar Trunk Based Development con Ramas de Vida Corta es una estrategia de versionamiento robusta, profesional y altamente escalable que se adapta bien a equipos de desarrollo grandes. Al basarse en la creación, el trabajo y la rápida fusión (vía PR y con pruebas pre-merge) de ramas muy pequeñas, esta metodología mantiene el tronco (master o main) en un estado más estable y listo para ser desplegado en cualquier momento. Aunque la gestión de bug fixes para versiones en producción requiere un proceso específico (preferiblemente el escenario 1, con Cherry Pick desde el tronco a la rama de Release), el flujo de trabajo general simplifica la integración continua, reduce drásticamente los conflictos de fusión (merge hell) y facilita un ritmo de desarrollo ágil y confiable. Su adopción por grandes empresas como Google subraya su eficacia y solidez como una práctica de versionamiento moderna y eficiente.
Asegurando el Tejido Distribuido: Una Guía para la Seguridad en Arquitecturas de Microservicios
- Mauricio ECR
- Seguridad
- 24 Apr, 2025
El viaje hacia arquitecturas de microservicios ha transformado la forma en que construimos y desplegamos software. La agilidad, escalabilidad y resiliencia que ofrecen son innegables. Sin embargo, est
Asegurando el Tejido Distribuido: Una Guía para la Seguridad en Arquitecturas de Microservicios
- Mauricio ECR
- Seguridad
- 24 Apr, 2025
El viaje hacia arquitecturas de microservicios ha transformado la forma en que construimos y desplegamos software. La agilidad, escalabilidad y resiliencia que ofrecen son innegables. Sin embargo, esta evolución no está exenta de desafíos, y quizás el más crítico en el panorama tecnológico actual sea el de la seguridad. Al pasar de monolitos robustos y relativamente cerrados a un ecosistema dinámico de servicios interconectados, multiplicamos la superficie de ataque y la complejidad de gestionar el riesgo.
Este artículo busca ser una referencia práctica. No se trata de una receta única, sino de una exploración de los enfoques, topologías y consideraciones clave para que, como arquitectos, desarrolladores y profesionales de la seguridad, puedan tomar decisiones informadas al implementar o mejorar la seguridad en su propio ecosistema de microservicios. Porque en un mundo donde cada conexión es un punto potencial de compromiso, la seguridad debe ser, por diseño, la columna vertebral de nuestra arquitectura distribuida.
El Desafío Inherente: Más Servicios, Más Vectores de Ataque
En una arquitectura monolítica tradicional, la seguridad se centraba a menudo en proteger el perímetro de la aplicación y gestionar el acceso interno. Con los microservicios, cada servicio se convierte en un punto de entrada potencial (incluso si solo es interno), y las comunicaciones entre ellos (tráfico Este-Oeste) se vuelven tan críticas como las comunicaciones externas (tráfico Norte-Sur). Esto introduce nuevos desafíos:
- Mayor Superficie de Ataque: Cada nuevo servicio, cada nueva API interna o externa, es un vector potencial.
- Complejidad en la Gestión de Identidades y Accesos: Gestionar quién o qué (usuario, servicio) puede acceder a qué recurso en un ecosistema de decenas o cientos de servicios es exponencialmente más difícil.
- Comunicación Segura entre Servicios: Asegurar que solo los servicios legítimos puedan comunicarse entre sí y que los datos en tránsito estén protegidos.
- Visibilidad Distribuida: Monitorear y auditar eventos de seguridad a través de múltiples servicios independientes.
- Consistencia de la Seguridad: Aplicar políticas de seguridad uniformes a través de servicios desarrollados por diferentes equipos, con diferentes tecnologías.
Abordar estos desafíos requiere un enfoque proactivo y multifacético, integrado desde las primeras etapas del ciclo de vida del desarrollo, lo que conocemos como "Security by Design" y "Shift Left" (mover la seguridad a etapas tempranas).
Enfoques Fundamentales para Blindar tus Microservicios
La seguridad en un entorno de microservicios no es una característica que se añade al final, sino un tejido que se teje en cada capa. Aquí detallamos los enfoques clave, con una perspectiva actualizada a las prácticas modernas:
Seguridad a nivel de API Gateway: Sigue siendo la primera línea de defensa crucial para el tráfico externo. Un API Gateway moderno no solo enruta peticiones, sino que centraliza la autenticación y autorización inicial (integrándose con Identity Providers - IdP), aplica políticas de rate limiting para mitigar DoS, y realiza validación de entrada y saneamiento. Actualmente, vemos una mayor integración de capacidades WAF (Web Application Firewall) avanzadas y detección de anomalías basada en IA/ML directamente en el Gateway o en componentes adyacentes.
Seguridad de Servicio a Servicio (Este-Oeste) - Zero Trust Interno: La comunicación interna ya no puede ser confiada implícitamente (principio de Confianza Cero). Asegurar esta capa es vital.
- mTLS (mutual TLS): Es el estándar de facto para cifrar y autenticar la comunicación entre servicios. Cada servicio verifica la identidad del otro mediante certificados, garantizando tanto la privacidad del dato en tránsito como la autenticidad del emisor y receptor.
- Autorización Granular: Más allá de saber quién se comunica, es crucial controlar qué acciones puede realizar un servicio sobre otro. Esto implica políticas de autorización finas, a menudo basadas en la identidad verificada por mTLS.
Autenticación y Autorización Robusta:
- Autenticación: Verificar la identidad. Para usuarios, estándares como OAuth2 y OpenID Connect son omnipresentes. Para servicios, mTLS proporciona una base sólida, complementada a menudo con tokens de corta duración obtenidos de un servicio de identidad interno.
- Autorización: Definir y aplicar permisos. El Control de Acceso Basado en Roles (RBAC) es un modelo común, pero para la complejidad de microservicios, el Control de Acceso Basado en Políticas (PBAC) gana terreno. Soluciones de "Policy as Code" (como Open Policy Agent - OPA) permiten gestionar políticas de autorización de forma centralizada y desacoplada de la lógica del servicio, facilitando su auditoría y consistencia.
Gestión Segura de Secretos: Hardcodear credenciales o claves es una vulnerabilidad grave. Las soluciones dedicadas (HashiCorp Vault, AWS Secrets Manager, Azure Key Vault, Google Secret Manager) son esenciales. Permiten almacenar, versionar y rotar secretos de forma segura, inyectándolos en los servicios en tiempo de ejecución sin exponerlos en código o configuraciones estáticas. Las capacidades de secretos dinámicos (generar credenciales temporales para bases de datos, por ejemplo) son cada vez más importantes.
Seguridad en Contenedores y Orquestadores: Dado que la mayoría de los microservicios corren en contenedores (Docker, containerd) orquestados por plataformas como Kubernetes, asegurar esta capa es fundamental.
- Seguridad de Imágenes: Escaneo automatizado de vulnerabilidades en el pipeline de CI/CD y uso exclusivo de imágenes base de confianza. Implementación de firmas de imágenes para garantizar su integridad.
- Seguridad en Tiempo de Ejecución: Monitorización y aplicación de políticas a nivel de syscall (uso de herramientas como Falco o capacidades basadas en eBPF) para detectar y prevenir comportamientos anómalos dentro del contenedor.
- Políticas de Red de Contenedores: Utilizar las capacidades de la plataforma de orquestación (como Kubernetes Network Policies) para aplicar micro-segmentación y controlar qué pods pueden comunicarse entre sí.
- Seguridad del Orquestador: Configuración segura del clúster (RBAC estricto, seguridad de etcd, auditoría) y gestión de nodos subyacentes. La gestión de la cadena de suministro de software (SLSA - Supply-chain Levels for Software Artifacts) para imágenes y dependencias es un área de foco creciente en el panorama actual.
Registro, Monitorización y Observabilidad Centralizados: Recopilar logs de seguridad, métricas y trazas de todos los microservicios en una plataforma centralizada (SIEM, plataformas de observabilidad) es vital. Permite tener visibilidad del flujo de peticiones, detectar patrones sospechosos, realizar correlación de eventos para identificar ataques y facilitar la respuesta a incidentes. La aplicación de IA/ML para detectar anomalías y automatizar alertas es una práctica común hoy en día.
Principio del Menor Privilegio: Otorgar a cada servicio, usuario o componente solo los permisos mínimos necesarios para realizar su tarea. Implementar esto requiere una gestión cuidadosa de identidades y políticas (de nuevo, PBAC/OPA es útil aquí), pero minimiza el daño potencial si una parte del sistema es comprometida.
Cifrado de Datos: Proteger los datos tanto en tránsito (usando TLS/mTLS, ya cubierto) como en reposo (cifrado a nivel de base de datos, sistema de archivos, almacenamiento en la nube). Considerar técnicas como tokenización o enmascaramiento para datos altamente sensibles que no necesitan estar "en claro" para todas las operaciones.
Automatización de Seguridad (DevSecOps): Integrar pruebas de seguridad y verificaciones de cumplimiento dentro del pipeline de CI/CD. Esto incluye análisis estático (SAST), análisis dinámico (DAST) para APIs, análisis de composición de software (SCA) para dependencias, escaneo de imágenes de contenedor y escaneo de Infraestructura como Código (IaC) antes del despliegue. El objetivo es encontrar y solucionar problemas de seguridad lo antes posible ("Shift Left").
Segmentación de Red y Micro-segmentación: Dividir la red en zonas más pequeñas y aisladas para limitar el movimiento lateral de un atacante. En microservicios, esto se traduce en micro-segmentación, controlando la comunicación entre servicios individuales o grupos de servicios, a menudo implementado a través de políticas de red (Kubernetes Network Policies) o Service Meshes.
Topologías de Seguridad: Diseñando tu Arquitectura Segura
La implementación de los enfoques anteriores se materializa en diferentes topologías arquitectónicas. La elección (o combinación) dependerá de la complejidad del sistema, la experiencia del equipo y los requisitos de seguridad:
Seguridad Perimetral con API Gateway:
- Descripción: El API Gateway es el punto de entrada principal. Maneja autenticación/autorización para tráfico externo, rate limiting, logging, etc. La seguridad interna entre servicios puede depender menos de mTLS si se confía en la red (un enfoque menos recomendado en entornos modernos, donde la confianza cero es la norma).
- Pros: Relativamente simple de implementar inicialmente, centraliza la seguridad de entrada, clara separación entre tráfico externo e interno.
- Contras: No protege el tráfico interno (Este-Oeste) si el perímetro es violado, puede convertirse en un cuello de botella si el Gateway no escala, la lógica de autorización puede volverse compleja si necesita entender la lógica interna de los servicios.
- Uso: Ideal para aplicaciones más simples, o como una capa frontal que se complementa con seguridad interna más robusta.
Seguridad Distribuida (Service Mesh):
- Descripción: Introduce un proxy "sidecar" (como Envoy) junto a cada instancia de microservicio. Estos proxies interceptan toda la comunicación entrante y saliente del servicio. Una capa de control centralizada gestiona y configura estos proxies.
- Pros: Automatiza mTLS entre servicios (a menudo sin cambiar el código del servicio), permite aplicar políticas de autorización granular basadas en la identidad del servicio de forma centralizada (Policy as Code), proporciona observabilidad (métrica, logging, tracing) de la comunicación Este-Oeste, facilita la implementación de Zero Trust interno. Independiente del lenguaje del servicio.
- Contras: Añade complejidad operacional significativa (gestionar el Service Mesh), introduce latencia adicional por el proxy, requiere curva de aprendizaje.
- Uso: Muy adecuado para ecosistemas complejos con muchas interacciones entre servicios, donde la gestión centralizada y la seguridad Zero Trust interna son prioritarias. Plataformas como Istio, Linkerd o Consul Connect son ejemplos populares.
Seguridad Híbrida:
- Descripción: Combina lo mejor de ambos mundos. Un API Gateway (o WAF) para el tráfico Norte-Sur y la seguridad perimetral, complementado por un Service Mesh para asegurar la comunicación interna (Este-Oeste) con mTLS y políticas granulares. La lógica de seguridad muy específica puede aún residir en el código de la aplicación si es estrictamente necesario.
- Pros: Equilibra la centralización de la seguridad externa con la distribución y robustez de la seguridad interna, enfoque práctico para muchos entornos.
- Contras: Mayor complejidad general al gestionar múltiples capas de seguridad.
- Uso: La topología más común y recomendada para la mayoría de las organizaciones con arquitecturas de microservicios moderadas a grandes.
Seguridad a Nivel de Aplicación (Integrada en el Código del Servicio):
- Descripción: La lógica de seguridad (autenticación, autorización, validación de entrada, cifrado) se implementa directamente dentro del código de cada microservicio.
- Pros: Gran flexibilidad para implementar lógicas de seguridad muy específicas, control total por el equipo del servicio.
- Contras: Alta probabilidad de duplicación de código y vulnerabilidades si no se usan librerías estandarizadas, gestión de políticas de seguridad inconsistente y descentralizada, dificultad para aplicar cambios o auditorías de forma global. No es escalable para muchos servicios.
- Uso: Generalmente desaconsejado como enfoque principal. Puede ser necesario para integrar sistemas legados o para lógica de negocio de seguridad muy particular que no se puede externalizar fácilmente. Siempre que sea posible, externalizar la lógica de seguridad a un Gateway, Mesh o servicio de identidad.
Casos de Uso Comunes que Demandan Seguridad Robusta:
- Procesamiento de Pagos: Asegurar las APIs que manejan transacciones sensibles, cumplir PCI DSS. mTLS para comunicaciones internas, tokenización de datos de tarjeta, autorización granular basada en identidad.
- Gestión de Datos de Usuario/Cliente: Proteger APIs que acceden a información personal identificable (PII). Cumplimiento de GDPR, HIPAA, etc. Cifrado en reposo y tránsito, autorización basada en roles/políticas, auditoría exhaustiva.
- Sistemas Financieros (FinTech): Comunicación segura entre servicios que manejan transferencias, scoring de crédito, etc. Alto nivel de mTLS, validación criptográfica de mensajes, políticas de autorización estrictas.
- APIs de IoT: Autenticar y autorizar dispositivos que se conectan a servicios. Gestión de identidades de dispositivos, control de acceso a datos generados por dispositivos.
- Plataformas SaaS Multi-tenant: Asegurar que los datos de un cliente no sean accesibles por otro. Autorización granular basada en tenant ID, aislamiento a nivel de red/computación cuando sea posible.
Beneficios de una Postura de Seguridad Proactiva:
Más allá de evitar brechas (que ya es razón suficiente), una estrategia de seguridad bien pensada ofrece beneficios significativos:
- Cumplimiento Normativo: Facilita la adhesión a regulaciones estrictas (GDPR, HIPAA, PCI DSS, etc.).
- Confianza del Cliente: Protege los datos y la privacidad, construyendo reputación.
- Resiliencia del Sistema: Un sistema seguro es inherentemente más resistente a ataques y fallos relacionados.
- Innovación Acelerada: Permite a los equipos moverse más rápido y desplegar con mayor frecuencia, sabiendo que la seguridad está integrada.
- Operaciones Más Eficientes: La automatización de la seguridad reduce el esfuerzo manual y el riesgo de errores humanos.
- Mejor Capacidad de Respuesta: La observabilidad y el logging centralizado permiten detectar y responder a incidentes más rápidamente.
Dificultades Comunes y Cómo Navegarlas:
Implementar seguridad en microservicios no es trivial. Anticipar y planificar para estas dificultades es clave:
- Complejidad Operacional: Gestionar un entramado de herramientas de seguridad.
- Solución: Priorizar la automatización, usar plataformas unificadas (como Service Meshes para varias funciones), invertir en formación para los equipos.
- Gestión de Identidades de Servicio: ¿Cómo identificas y gestionas cientos de identidades de servicio?
- Solución: Usar soluciones de gestión de identidad y acceso diseñadas para cargas de trabajo (como SPIFFE/SPIFEE, integradas en Service Meshes).
- Visibilidad Distribuida: Tener una visión completa de la seguridad a través del sistema.
- Solución: Invertir en plataformas de logging, métricas y tracing centralizadas con correlación de eventos de seguridad.
- Coherencia entre Equipos: Asegurar que todos los equipos apliquen las mismas prácticas de seguridad.
- Solución: Establecer estándares claros, proporcionar "plataformas internas" con seguridad integrada (ej. un clúster de Kubernetes gestionado con Service Mesh preconfigurado), usar Policy as Code.
- Rendimiento: Las capas de seguridad (cifrado, validación) pueden añadir latencia.
- Solución: Optimizar configuraciones, offload de TLS en Gateway/Mesh, usar algoritmos criptográficos eficientes, realizar pruebas de rendimiento.
- Gestión de Secretos: Asegurar que los secretos se manejen correctamente en despliegues automatizados.
- Solución: Implementar una solución de gestión de secretos dedicada y flujos de trabajo automatizados para rotación y acceso.
- Pruebas de Seguridad: Probar la seguridad de un sistema distribuido es más difícil.
- Solución: Integrar pruebas automatizadas (SAST, DAST, SCA) en el pipeline, usar herramientas de seguridad de APIs, considerar Chaos Engineering con enfoque en fallos de seguridad.
El Paisaje en Evolución: Tendencias Clave
El panorama de la seguridad evoluciona constantemente. Algunas tendencias refuerzan la importancia de estos enfoques:
- Zero Trust como Estándar: La mentalidad de no confiar en ninguna red (interna o externa) impulsa la adopción de mTLS y autorización granular en todos los niveles.
- IA/ML Aplicada a la Seguridad: Desde la detección de anomalías en tiempo real hasta la gestión predictiva de riesgos, la IA juega un papel creciente.
- Plataformas de Seguridad Cloud-Native: Las herramientas y servicios nativos de la nube (IAM avanzado, gestores de secretos, WAFs, políticas de red) se vuelven fundamentales y se integran con soluciones open source como Service Meshes.
- Supply Chain Security: Asegurar todo, desde el código fuente y las dependencias hasta las imágenes de contenedor y la infraestructura desplegada, es una prioridad crítica.
- Policy as Code Everywhere: No solo para autorización, sino también para la gestión de configuraciones de seguridad, cumplimiento y políticas de red.
Tomando Decisiones: Un Enfoque Pragmatico
Entonces, ¿cómo decidir qué implementar primero y cómo?
- Evalúa tu Contexto: ¿Cuál es la sensibilidad de los datos que manejas? ¿Cuáles son tus requisitos regulatorios? ¿Cuál es la experiencia de seguridad de tu equipo? ¿Cuál es la complejidad actual y esperada de tu ecosistema de microservicios?
- Empieza por lo Básico (y Crítico): Autenticación y autorización robustas para usuarios y servicios, gestión segura de secretos, y DevSecOps básico (escaneo de vulnerabilidades en CI/CD). Estos son fundamentales independientemente de la topología.
- Asegura el Perímetro: Implementa un API Gateway con funcionalidades de seguridad para el tráfico externo.
- No Ignores el Tráfico Interno: A medida que tu número de servicios crece y las interacciones se vuelven más complejas, la seguridad Este-Oeste se vuelve crítica. Evalúa la adopción de un Service Mesh para automatizar mTLS y políticas de autorización interna. Considera un enfoque híbrido como punto de partida.
- Invierte en Observabilidad: No puedes proteger lo que no puedes ver. Un sistema de logging y monitorización centralizado es indispensable.
- Adopta la Cultura DevSecOps: La seguridad es responsabilidad de todos. Fomenta la colaboración entre desarrollo, operaciones y seguridad. Automatiza siempre que sea posible.
- Mantente Actualizado: El panorama de amenazas y las soluciones de seguridad evolucionan constantemente. Dedica tiempo a aprender y adaptar tus estrategias.
Conclusión
La seguridad en microservicios es un desafío continuo, no un destino. Requiere una planificación cuidadosa, la adopción de las herramientas y prácticas adecuadas, y una cultura de seguridad integrada en toda la organización. Al abordar la seguridad "by design", aprovechar las topologías adecuadas (a menudo un enfoque híbrido), y automatizar los controles de seguridad, puedes construir un ecosistema de microservicios que no solo sea ágil y escalable, sino también intrínsecamente seguro y resiliente frente a las amenazas en constante evolución del panorama digital actual. Tu viaje hacia la seguridad de microservicios es una inversión esencial en la longevidad y el éxito de tu arquitectura distribuida.
Optimizando el Acceso a Datos: La Importancia de las Proyecciones JPA en Spring Boot
- Mauricio ECR
- Persistencia
- 23 Apr, 2025
El Costo Oculto de Traer Demasiada Información En el desarrollo de aplicaciones que interactúan con bases de datos, una tarea fundamental es la recuperación de datos. Al usar Object-Relational Map
Optimizando el Acceso a Datos: La Importancia de las Proyecciones JPA en Spring Boot
- Mauricio ECR
- Persistencia
- 23 Apr, 2025
El Costo Oculto de Traer Demasiada Información
En el desarrollo de aplicaciones que interactúan con bases de datos, una tarea fundamental es la recuperación de datos. Al usar Object-Relational Mapping (ORM) como JPA (Java Persistence API), es común y tentador mapear nuestras tablas a entidades Java completas y, por defecto, recuperar estas entidades enteras cada vez que realizamos una consulta. Por ejemplo, si tenemos una entidad Usuario con 20 atributos (id, nombre, email, dirección, fecha de registro, último login, preferencias, etc.), una consulta simple como findById(1L) o findByEmail("[email protected]") a menudo se traduce, detrás de escena, en un SELECT u.* FROM usuario u WHERE ....
Si bien esto simplifica el desarrollo inicialmente, presenta un problema significativo a medida que la aplicación crece o cuando solo necesitamos una pequeña porción de esa información: el sobrecoste de datos (over-fetching).
¿Qué problemas concretos genera esto?
- Consumo de Ancho de Banda: Transferir columnas innecesarias entre la base de datos y la aplicación consume más ancho de banda de red.
- Uso de Memoria: La aplicación necesita más memoria para mantener en el Heap objetos más grandes de lo necesario.
- Rendimiento de la Base de Datos: La base de datos tiene que leer más datos del disco (potencialmente) y procesar más información.
- Latencia: La serialización/deserialización de objetos más grandes toma más tiempo, aumentando la latencia de las respuestas.
- Carga en el Garbage Collector: Objetos más grandes y potencialmente más numerosos (si se traen listas) ponen más presión sobre el recolector de basura de la JVM.
En resumen, no seleccionar específicamente los datos que necesitamos es ineficiente y puede degradar significativamente el rendimiento y la escalabilidad de nuestras aplicaciones, especialmente en escenarios de alta concurrencia o con tablas muy anchas (muchas columnas) o largas (muchas filas).
Posibles Soluciones para Optimizar la Recuperación de Datos
Ante el problema del over-fetching, existen varias estrategias que podemos emplear:
- Recuperar Entidades Completas (El Anti-Patrón): Como ya mencionamos, es la opción por defecto pero la menos eficiente si no necesitas toda la información.
- Consultas Nativas (Native Queries): Escribir SQL directamente. Permite un control total y seleccionar exactamente las columnas deseadas. Sin embargo, se pierde la portabilidad entre bases de datos, la seguridad de tipos en tiempo de compilación (parcialmente) y puede mezclar lógica SQL con el código Java de forma menos elegante.
- Criteria API de JPA: Una forma programática y type-safe de construir consultas. Es potente y flexible, permitiendo seleccionar atributos específicos. Su principal desventaja es que puede volverse bastante verbosa y compleja para consultas sencillas.
- Proyecciones (El Enfoque Recomendado): Utilizar las características de JPA y extensiones (como las de Spring Data JPA) para definir explícitamente qué atributos de una entidad queremos recuperar. Ofrece un excelente equilibrio entre eficiencia, legibilidad y seguridad de tipos.
Nos centraremos en esta última: las proyecciones.
Proyecciones JPA al Rescate
Una proyección en el contexto de JPA y Spring Data JPA es una técnica que nos permite definir una "vista" o subconjunto de los atributos de una entidad que deseamos recuperar de la base de datos. En lugar de traer el objeto completo, le indicamos al framework que solo queremos ciertos campos.
Spring Data JPA facilita enormemente el uso de proyecciones mediante dos mecanismos principales:
Proyecciones Basadas en Interfaces (Interface-based Projections)
Defines una interfaz Java que declara métodos get() para los atributos que deseas seleccionar. Los nombres de los métodos deben coincidir con los nombres de las propiedades de la entidad.
Spring Data JPA genera automáticamente la consulta SQL necesaria (SELECT columna1, columna2 FROM ...) y crea una instancia proxy de esa interfaz en tiempo de ejecución, rellenándola con los datos recuperados.
Es la forma más común y recomendada por su simplicidad y claridad.
Proyecciones Basadas en Clases (Class-based Projections - DTOs)
Creas una clase (típicamente un DTO - Data Transfer Object) con los campos que necesitas y un constructor que acepte esos campos como parámetros.
En tu consulta (usando @Query con JPQL), utilizas la sintaxis SELECT NEW com.tu.paquete.TuDTO(e.atributo1, e.atributo2) FROM Entidad e WHERE ....
JPA ejecutará la consulta seleccionando solo las columnas necesarias y las usará para instanciar tu DTO.
Es útil cuando necesitas más lógica en el objeto proyectado o si prefieres trabajar con clases concretas.
Ventajas Clave de Usar Proyecciones
- Eficiencia: Reduce drásticamente la cantidad de datos transferidos y procesados.
- Rendimiento: Consultas más rápidas y menor consumo de memoria y CPU.
- Claridad: El código (interfaces de proyección o DTOs) documenta explícitamente qué datos se esperan para un caso de uso específico.
- Seguridad (con interfaces): Mantiene la seguridad de tipos en gran medida.
Ejemplo Práctico con Spring Boot y JPA
Imaginemos una aplicación de e-commerce con una entidad Producto.
1. Entidad Producto:
package com.miblog.proyecciones.entity;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob; // Para campos grandes
@Entity
public class Producto {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String nombre;
@Lob // Indica que puede ser un objeto grande (TEXT, CLOB, BLOB)
private String descripcionDetallada; // Campo potencialmente pesado
private double precio;
private int stock;
private String categoria;
// Constructores, Getters y Setters (Omitidos por brevedad)
// Lombok @Data, @NoArgsConstructor, @AllArgsConstructor puede ser útil aquí
}
Supongamos que en una vista de listado rápido solo necesitamos mostrar el nombre y el precio de los productos con stock disponible. Traer descripcionDetallada sería un desperdicio.
2. Proyección Basada en Interfaz:
Creamos una interfaz que defina la vista que necesitamos:
package com.miblog.proyecciones.projection;
public interface ProductoResumen {
String getNombre();
double getPrecio();
// También puedes tener valores calculados con SpEL:
// @Value("#{target.nombre + ' (' + target.categoria + ')'}")
// String getNombreConCategoria();
}
3. Repositorio Spring Data JPA:
Modificamos nuestro repositorio para usar la proyección:
package com.miblog.proyecciones.repository;
import com.miblog.proyecciones.entity.Producto;
import com.miblog.proyecciones.projection.ProductoResumen;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface ProductoRepository extends JpaRepository<Producto, Long> {
// Spring Data JPA detecta que el tipo de retorno es una interfaz
// y automáticamente aplica la proyección.
List<ProductoResumen> findByStockGreaterThan(int stockMinimo);
// Ejemplo con DTO (requiere definir la clase ProductoDTO)
/*
@Query("SELECT NEW com.miblog.proyecciones.dto.ProductoDTO(p.nombre, p.precio) FROM Producto p WHERE p.stock > :stockMinimo")
List<ProductoDTO> findDtoByStockGreaterThan(@Param("stockMinimo") int stockMinimo);
*/
// También es posible usar proyecciones dinámicas:
// <T> List<T> findByCategoria(String categoria, Class<T> type);
// Al llamar: productoRepository.findByCategoria("Electrónicos", ProductoResumen.class);
// O productoRepository.findByCategoria("Electrónicos", Producto.class); // Trae la entidad completa
}
4. Uso en un Servicio (Ejemplo):
package com.miblog.proyecciones.service;
import com.miblog.proyecciones.projection.ProductoResumen;
import com.miblog.proyecciones.repository.ProductoRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class ProductoService {
@Autowired
private ProductoRepository productoRepository;
public List<ProductoResumen> obtenerResumenProductosEnStock() {
// Solo se traerán las columnas 'nombre' y 'precio' de la BD
return productoRepository.findByStockGreaterThan(0);
}
}
Al ejecutar obtenerResumenProductosEnStock(), Spring Data JPA generará una consulta SQL similar a:
SELECT p.nombre AS nombre, p.precio AS precio
FROM producto p
WHERE p.stock > 0; -- O el valor pasado como parámetro
Como puedes ver, la columna descripcionDetallada (y las demás no incluidas en ProductoResumen) ni siquiera se mencionan en el SELECT, logrando nuestro objetivo de eficiencia.
Conclusión:
No recuperar datos innecesarios de la base de datos es fundamental para construir aplicaciones performantes y escalables. Las proyecciones en JPA, especialmente con las facilidades que ofrece Spring Data JPA, son una herramienta poderosa y elegante para lograr este objetivo.
Adoptar el uso de proyecciones (ya sea basadas en interfaces o DTOs) siempre que no necesites la entidad completa debería considerarse una buena práctica estándar. Te permite:
- Minimizar la carga en la red y la base de datos.
- Reducir el consumo de memoria en tu aplicación.
- Acelerar los tiempos de respuesta.
- Escribir código más claro respecto a los datos requeridos para cada caso de uso.
La próxima vez que escribas una consulta, pregúntate: "¿Realmente necesito todos los atributos de esta entidad?". Si la respuesta es no, considera seriamente usar una proyección. Tu aplicación (y tus usuarios) te lo agradecerán.
Descubre el Poder del SemVer: Optimiza el Versionado de tu Software y Mantén un CHANGELOG Excepcional
- Mauricio ECR
- Convenciones
- 08 Apr, 2025
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 **
Descubre el Poder del SemVer: Optimiza el Versionado de tu Software y Mantén un CHANGELOG Excepcional
- Mauricio ECR
- Convenciones
- 08 Apr, 2025
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 CHANGELOG bien estructurado potencia la transparencia y la trazabilidad, ofreciendo una visión detallada de la evolución de cada versión. En este artículo, exploraremos en profundidad cómo adoptar SemVer y cómo mantener un CHANGELOG que vaya de la mano para optimizar tu proceso de desarrollo.
Introducción
El control de versiones en el desarrollo de software se convierte en una ventaja competitiva cuando se implementa de forma clara y estructurada. SemVer aporta un sistema numérico que indica el alcance de los cambios (cambios mayores, menores o correcciones), mientras que el CHANGELOG documenta y narra el proceso evolutivo de tu proyecto. Esta sinergia no solo facilita la colaboración interna, sino que también mejora la comunicación con los usuarios y clientes, ayudando a identificar qué, cuándo y por qué se han realizado determinados cambios.
1. Estructura Básica de SemVer
El formato principal de SemVer se compone de tres segmentos:
MAJOR.MINOR.PATCH
- MAJOR: Se incrementa cuando se realizan cambios incompatibles con versiones anteriores (por ejemplo, la eliminación o modificación de una API pública).
- MINOR: Se aumenta cuando se agregan nuevas funcionalidades de manera compatible.
- PATCH: Se incrementa al corregir errores sin afectar las funcionalidades existentes.
Etiquetas Adicionales
- Pre-release: Indica versiones inestables, por ejemplo,
1.0.0-beta.1. - Build Metadata: Añade información adicional de compilación, como en
1.0.0+20230901.
2. Reglas para Incrementar Versiones
Al actualizar una versión, es fundamental distinguir entre los diferentes tipos de cambios:
Versión MAJOR (X.y.z → X+1.0.0):
Se utiliza cuando se introducen cambios que rompen la compatibilidad con versiones anteriores.
Ejemplo: Modificar o eliminar una API de forma incompatible.Versión MINOR (x.Y.z → x.Y+1.0):
Se incrementa al introducir nuevas funcionalidades sin afectar la compatibilidad.
Ejemplo: Agregar un método opcional a una clase.Versión PATCH (x.y.Z → x.y.Z+1):
Se utiliza para corregir errores, preservando la compatibilidad con versiones anteriores.
Ejemplo: Arreglar un error en una función de cálculo.
3. Ejemplos Prácticos
Versión Inicial:
0.1.0
Indica la fase de desarrollo inicial donde el software puede sufrir cambios drásticos.Primera Versión Estable:
1.0.0
Marca el lanzamiento oficial cuando la API es considerada estable y está documentada.Actualización con Nueva Funcionalidad:
De1.0.0a1.1.0para incorporar mejoras sin romper compatibilidad.Corrección Crítica:
De1.1.0a1.1.1para solucionar errores puntuales.Cambio Incompatible:
De1.1.1a2.0.0cuando se realizan modificaciones que requieren cambios en el código del consumidor.
4. La Importancia de Mantener un CHANGELOG
Un CHANGELOG es un registro sistemático y estructurado que documenta de manera cronológica cada cambio, mejora y corrección en el software. Su incorporación al proceso de SemVer proporciona un contexto narrativo, detallando el "porqué" y el "cómo" detrás de cada versión.
Beneficios Clave del CHANGELOG
Claridad en la Comunicación:
Complementa los números de versión de SemVer con descripciones detalladas de los cambios implementados.Trazabilidad y Historial:
Permite rastrear la evolución del software a lo largo del tiempo, facilitando la depuración y la revisión histórica.Transparencia Interna y Externa:
Informa tanto a los desarrolladores como a los usuarios finales sobre las mejoras y cambios realizados, fortaleciendo la confianza en el proceso de actualización.Soporte a la Automatización:
Herramientas integradas pueden actualizar el CHANGELOG de forma automática al seguir convenciones de commits, como Conventional Commits.
Mejores Prácticas para un CHANGELOG Efectivo
Estructuración Clara:
Organiza el CHANGELOG por versiones, empezando por la más reciente. Dentro de cada versión, clasifica los cambios en secciones como "Añadido", "Modificado", "Corregido" y "Notas de Deprecación".Actualización Continua:
Registra los cambios a medida que se van implementando para capturar detalles precisos y evitar omisiones.Integración con el Proceso de Versionado:
Vincula cada entrada del CHANGELOG con commits específicos y números de versión, facilitando la sincronización entre lo que se comunica y lo que se versiona.Comunicación Externa:
Publica el CHANGELOG en el repositorio del proyecto y en las notas de lanzamiento para que usuarios y colaboradores comprendan la evolución del software.
Ejemplo Básico de CHANGELOG
# CHANGELOG
## [2.0.0] - 2025-04-01
### Añadido
- Nueva función para exportación de datos.
### Modificado
- Actualización del sistema de autenticación (rompe compatibilidad con versiones anteriores).
### Corregido
- Error en el módulo de notificaciones.
## [1.2.1] - 2025-03-15
### Corregido
- Solucionado el error en la actualización automática del perfil del usuario.
5. Herramientas para Generar CHANGELOG Automáticamente
Usar herramientas automatizadas facilita enormemente el mantenimiento de un CHANGELOG claro y actualizado. A continuación, se presentan algunas opciones destacadas:
Conventional Changelog
- Base de muchas herramientas automatizadas.
- Genera el
CHANGELOG.mda partir de commits con formato estándar. - Ejemplo:
npx conventional-changelog -p angular -i CHANGELOG.md -s
standard-version
- Automatiza el versionado y el changelog sin publicar a npm.
- Ideal para control manual con automatización parcial.
npm install --save-dev standard-version
npx standard-version
semantic-release
- Automatiza TODO: changelog, versionado, publicación en npm o GitHub.
- Requiere entorno CI/CD (ej: GitHub Actions).
npm install --save-dev semantic-release
release-it
- Personalizable, ideal para flujos mixtos.
npm install --save-dev release-it
npx release-it
6. Convenciones de Commit (Conventional Commits)
Estas convenciones estructuran los mensajes de commit para que puedan ser procesados automáticamente:
<tipo>(opcional: alcance): descripción
[opcional] cuerpo del mensaje
[opcional] notas de ruptura (BREAKING CHANGE)
Ejemplos:
feat(auth): agregar login con Google
fix(api): corregir error de serialización
docs(readme): actualizar instrucciones de uso
BREAKING CHANGE: se eliminó el endpoint /v1/user
Tipos más comunes:
| Tipo | Uso |
|---|---|
feat |
Nueva funcionalidad |
fix |
Corrección de errores |
docs |
Cambios en la documentación |
style |
Cambios de formato (espacios, comas, etc.) |
refactor |
Refactorización del código sin cambios de funcionalidad |
test |
Cambios relacionados a pruebas |
chore |
Tareas menores de mantenimiento |
7. Gestión de Dependencias
El versionado semántico también afecta cómo se definen y gestionan las dependencias en los proyectos:
Caret ( ^ ):
Permite actualizaciones de versiones MINOR y PATCH. Por ejemplo,^1.2.3abarca versiones de la serie1.x.x.Tilde ( ~ ):
Restringe las actualizaciones a parches solamente. Por ejemplo,~1.2.3asegura que se mantenga la versión1.2.x.
8. Herramientas Recomendadas
- semver: Librería para comparar y validar versiones.
- Conventional Commits: Estándar de mensajes para facilitar changelogs automáticos.
- GitHub Actions/GitLab CI: Automatiza versiones y publicaciones.
- semantic-release / standard-version / release-it: Para generar changelogs y manejar versiones automáticamente.
Tabla Resumen
| Aspecto | Descripción | Ejemplo |
|---|---|---|
| Formato Básico de SemVer | MAJOR.MINOR.PATCH | 2.4.1 |
| Versión MAJOR | Cambios incompatibles que rompen versiones anteriores | 1.1.1 → 2.0.0 |
| Versión MINOR | Nuevas funcionalidades sin romper compatibilidad | 1.0.0 → 1.1.0 |
| Versión PATCH | Correcciones de errores sin afectar la funcionalidad | 1.1.0 → 1.1.1 |
| Pre-release | Versión inestable para pruebas | 1.0.0-beta.1 |
| Build Metadata | Información adicional de compilación | 1.0.0+20230901 |
| Gestión de Dependencias | Uso de caret (^) para permitir MINOR y PATCH; tilde (~) para solo PATCH | ^1.2.3 y ~1.2.3 |
| CHANGELOG | Registro detallado de cambios, organizado por versiones y secciones claras | Ver ejemplo en el artículo |
| Convenciones de Commit | Estilo estructurado que facilita la generación automática de changelog | feat, fix, chore, etc. |
| Herramientas de Automatización | Facilitan la gestión de versiones y changelog | semantic-release, standard-version, release-it |
Conclusión
Integrar el Versionado Semántico con un CHANGELOG completo y bien documentado garantiza un proceso de actualización y mantenimiento de software más transparente y predecible. Adoptar ambas prácticas no solo mejora la organización interna y el flujo de trabajo, sino que también fortalece la comunicación con los usuarios al explicar de forma detallada cada cambio realizado. Utiliza esta guía para transformar tu estrategia de versionado y construir una base sólida para el crecimiento y la evolución de tu proyecto de software.
Desarrollo de Software Implementando Gitflow
- Mauricio ECR
- DevOps
- 19 Mar, 2025
Introducción El desarrollo de software requiere metodologías y flujos de trabajo que permitan un control eficiente del código fuente. Uno de los enfoques más utilizados para la gestión de versiones
Desarrollo de Software Implementando Gitflow
- Mauricio ECR
- DevOps
- 19 Mar, 2025
Introducción
El desarrollo de software requiere metodologías y flujos de trabajo que permitan un control eficiente del código fuente. Uno de los enfoques más utilizados para la gestión de versiones es Gitflow, una estrategia basada en Git que organiza el trabajo en ramas bien definidas.
Este artículo explora qué es Gitflow, su filosofía, los problemas que resuelve y cómo implementarlo paso a paso en un proyecto. También se comparará el uso de Gitflow con los comandos básicos de Git para quienes prefieran un enfoque manual.
¿Qué es Gitflow?
Gitflow es un modelo de ramificación para Git propuesto por Vincent Driessen. Define un conjunto de reglas y procedimientos para gestionar las diferentes etapas del desarrollo de software, asegurando estabilidad en la rama principal mientras se permite la integración de nuevas funcionalidades de manera organizada.
Filosofía de Gitflow
Gitflow se basa en la idea de separar el desarrollo en ramas específicas con propósitos bien definidos:
Explicación de cada rama en Gitflow
- main: Contiene la versión estable y en producción del software. Solo se realizan fusiones de versiones terminadas y correcciones críticas.
- develop: Es la rama principal de desarrollo donde se integran nuevas funcionalidades antes de ser lanzadas.
- feature: Se crean a partir de
developpara el desarrollo de nuevas características. Una vez terminadas, se fusionan de nuevo endevelop. - release: Se crean a partir de
developcuando se prepara una nueva versión. Aquí se realizan pruebas finales antes de integrarla enmain. - hotfix: Se crean a partir de
mainpara corregir errores críticos en producción y luego se fusionan enmainydevelop. - bugfix: Similar a
hotfix, pero se crean a partir dedeveloppara corregir errores antes de un lanzamiento.
¿Qué busca solucionar?
Gitflow aborda varios problemas comunes en el desarrollo de software, como:
- Integración desorganizada de nuevas características.
- Problemas al manejar versiones de lanzamiento.
- Falta de control sobre la corrección de errores en producción.
¿Cómo propone solucionarlo?
Gitflow impone un flujo de trabajo con reglas claras para la creación, fusión y eliminación de ramas, asegurando estabilidad y organización en el repositorio.
Implementación de Gitflow paso a paso
A continuación, se detalla cómo implementar Gitflow en un proyecto, tanto utilizando la herramienta git-flow como con comandos básicos de Git.
1. Inicializar un repositorio con Gitflow
Usando la herramienta git-flow:
git flow init
Manualmente con Git:
git init
git branch -M main
git checkout -b develop
2. Crear una nueva funcionalidad (feature)
Con git-flow:
git flow feature start nueva-funcionalidad
Con Git básico:
git checkout -b feature/nueva-funcionalidad develop
3. Finalizar una funcionalidad
Con git-flow:
git flow feature finish nueva-funcionalidad
Con Git:
git checkout develop
git merge --no-ff feature/nueva-funcionalidad
git branch -d feature/nueva-funcionalidad
4. Preparar una versión de lanzamiento
Con git-flow:
git flow release start v1.0.0
Con Git:
git checkout -b release/v1.0.0 develop
5. Finalizar una versión de lanzamiento
Con git-flow:
git flow release finish v1.0.0
Con Git:
git checkout main
git merge --no-ff release/v1.0.0
git tag -a v1.0.0 -m "Versión 1.0.0"
git checkout develop
git merge main
git branch -d release/v1.0.0
6. Aplicar una corrección en producción (hotfix)
Con git-flow:
git flow hotfix start fix-critico
Con Git:
git checkout -b hotfix/fix-critico main
Para finalizar el hotfix: Con git-flow:
git flow hotfix finish fix-critico
Con Git:
git checkout main
git merge --no-ff hotfix/fix-critico
git tag -a v1.0.1 -m "Corrección crítica"
git checkout develop
git merge main
git branch -d hotfix/fix-critico
Ventajas del uso de Gitflow
- Estructura clara: Organización de ramas para cada propósito.
- Mayor estabilidad: La rama principal se mantiene siempre en estado funcional.
- Mejor colaboración: Facilita la integración de cambios en equipos grandes.
- Gestión eficiente de versiones: Simplifica la publicación de nuevas versiones y correcciones de errores.
Conclusión
Gitflow es un modelo altamente eficiente para la gestión de versiones en proyectos de software. Aunque la herramienta git-flow automatiza muchos pasos, comprender los comandos básicos de Git permite un control más detallado del flujo de trabajo. Su implementación mejora la organización, facilita el trabajo en equipo y contribuye a la estabilidad del código en producción.
Estructuración de Carpetas en Proyectos de Software
- Mauricio ECR
- Convenciones
- 18 Mar, 2025
En proyectos de software de gran escala, la falta de una estructura de carpetas bien definida puede generar desorden, dificultando la mantenibilidad, escalabilidad y comprensión del código. Muchas vec
Estructuración de Carpetas en Proyectos de Software
- Mauricio ECR
- Convenciones
- 18 Mar, 2025
En proyectos de software de gran escala, la falta de una estructura de carpetas bien definida puede generar desorden, dificultando la mantenibilidad, escalabilidad y comprensión del código. Muchas veces, los proyectos crecen de manera descontrolada sin una estructura definida, lo que lleva a código difícil de navegar, dependencias circulares entre módulos, acoplamiento innecesario entre componentes y dificultad para incorporar nuevos desarrolladores al equipo.
Para estructurar un proyecto de software de manera eficiente, es recomendable seguir una organización de carpetas que refleje claramente la separación de responsabilidades. Esto ayuda a mantener un código modular, organizado y fácil de mantener.
Los principios clave para la organización de carpetas incluyen modularidad, donde cada módulo representa una unidad de negocio independiente; independencia, separando la lógica de negocio de la infraestructura y los adaptadores; cohesión, manteniendo agrupados los elementos relacionados dentro de un módulo; y evolución, permitiendo la escalabilidad sin afectar la estructura global.
Una estructura de carpetas recomendada puede verse de la siguiente manera:
/project-root
├── src
│ ├── core # Reglas de negocio y modelos
│ │ ├── domain
│ │ │ ├── entities
│ │ │ ├── value_objects
│ │ │ ├── repositories
│ │ │ ├── services
│ │ │ └── events
│ │ ├── application
│ │ │ ├── use_cases
│ │ │ ├── dtos
│ │ │ ├── mappers
│ │ │ └── queries
│ ├── modules # Módulos específicos del negocio
│ │ ├── user_management
│ │ │ ├── domain
│ │ │ ├── application
│ │ │ ├── infrastructure
│ │ │ ├── adapters
│ ├── infrastructure # Implementaciones técnicas y frameworks
│ │ ├── persistence
│ │ ├── messaging
│ │ ├── external_services
│ │ ├── config
│ ├── adapters # Interfaces externas y controladores
│ │ ├── api
│ │ ├── cli
│ │ ├── event_consumers
│ │ └── schedulers
├── tests # Pruebas unitarias y de integración
├── docs # Documentación del proyecto
├── scripts # Herramientas para automatización
├── config # Configuración general del proyecto
├── .gitignore
├── README.md
Dentro de esta estructura, 'core/' contiene la lógica de negocio (entidades, servicios, repositorios, etc.); 'modules/' agrupa los módulos del dominio de manera aislada; 'infrastructure/' contiene implementaciones técnicas como bases de datos, servicios externos y configuraciones; 'adapters/' define las interfaces de comunicación con el mundo exterior, incluyendo APIs, CLI y eventos; 'tests/' contiene pruebas unitarias e integración; 'docs/' almacena documentación técnica del proyecto; 'config/' centraliza archivos de configuración; y 'scripts/' incluye herramientas de automatización.
Por ejemplo, en un sistema de gestión de usuarios con autenticación, podríamos estructurar el módulo correspondiente de la siguiente manera:
/modules/user_management
├── domain
│ ├── entities
│ │ ├── UserEnt.ts
│ ├── value_objects
│ │ ├── EmailVO.ts
│ ├── repositories
│ │ ├── UserRepo.ts
├── application
│ ├── use_cases
│ │ ├── RegisterUserUC.ts
│ │ ├── AuthenticateUserUC.ts
│ ├── dtos
│ │ ├── UserDTO.ts
│ ├── mappers
│ │ ├── UserMap.ts
├── infrastructure
│ ├── persistence
│ │ ├── UserRepoImpl.ts
├── adapters
│ ├── api
│ │ ├── UserCtrl.ts
Aquí, la capa de dominio maneja las reglas de negocio, la capa de aplicación contiene los casos de uso, la infraestructura gestiona la persistencia y los adaptadores exponen las funcionalidades a través de controladores API.
Resumen de Carpetas y su Uso
| Carpeta | Descripción | Ejemplo en 'user_management' |
|---|---|---|
| 'core/domain' | Contiene entidades, objetos de valor, repositorios y servicios | 'UserEnt.ts' define la entidad de usuario |
| 'core/application' | Define casos de uso, DTOs, mappers y queries | 'RegisterUserUC.ts' implementa la lógica de registro de usuario |
| 'modules' | Agrupa módulos específicos del negocio | 'user_management/' encapsula toda la gestión de usuarios |
| 'domain/entities' | Modelos de negocio que representan objetos persistentes | 'UserEnt.ts' representa los datos del usuario en la base de datos |
| 'domain/value_objects' | Objetos de valor inmutables del dominio | 'EmailVO.ts' maneja la lógica del correo electrónico |
| 'domain/repositories' | Interfaces para acceso a datos | 'UserRepo.ts' define la interfaz para obtener usuarios |
| 'application/use_cases' | Casos de uso que orquestan la lógica de negocio | 'RegisterUserUC.ts' contiene la lógica de registro de usuario |
| 'application/dtos' | Transporte de datos entre capas | 'UserDTO.ts' define el formato de los datos de usuario |
| 'application/mappers' | Conversión entre entidades y DTOs | 'UserMap.ts' convierte entre 'UserEnt' y 'UserDTO' |
| 'infrastructure/persistence' | Implementación de acceso a datos | 'UserRepoImpl.ts' implementa 'UserRepo.ts' |
| 'adapters/api' | Exposición de funcionalidades vía API | 'UserCtrl.ts' maneja solicitudes HTTP |
| 'tests' | Contiene pruebas unitarias y de integración | 'UserSvcTest.ts' prueba 'RegisterUserUC.ts' |
| 'docs' | Documentación técnica del proyecto | Documentación sobre el flujo de autenticación |
| 'config' | Configuración general de la aplicación | Configuración de base de datos y variables de entorno |
| 'scripts' | Scripts de automatización | Scripts para migraciones o configuración |
Implementar una estructura de carpetas bien organizada ayuda a crear proyectos más mantenibles, escalables y fáciles de entender. Al separar claramente las responsabilidades, evitamos el acoplamiento innecesario y mejoramos la colaboración dentro del equipo de desarrollo. Desde el inicio del proyecto, es recomendable definir y documentar la estructura de carpetas para que todo el equipo la adopte y mantenga. Con esta estructura clara y modular, los proyectos de software pueden escalar sin comprometer la calidad del código. 🚀
Convención de Nombres en Clases Java: Mejora de Legibilidad y Mantenibilidad
- Mauricio ECR
- Convenciones
- 18 Mar, 2024
En proyectos de gran escala, identificar rápidamente el tipo de una clase facilita la comprensión del código, la colaboración y la depuración. Sin un esquema de nombres estandarizado, es común que los
Convención de Nombres en Clases Java: Mejora de Legibilidad y Mantenibilidad
- Mauricio ECR
- Convenciones
- 18 Mar, 2024
En proyectos de gran escala, identificar rápidamente el tipo de una clase facilita la comprensión del código, la colaboración y la depuración. Sin un esquema de nombres estandarizado, es común que los desarrolladores enfrenten dificultades al interpretar la funcionalidad de una clase solo por su nombre, lo que ralentiza el mantenimiento y el desarrollo. En entornos profesionales, donde múltiples equipos trabajan sobre el mismo código base, contar con una nomenclatura clara mejora la comunicación y reduce la posibilidad de errores. Para solucionar esto, se propone una convención de nombres basada en prefijos y sufijos que permita diferenciar los distintos tipos de clases en Java.
Para garantizar la claridad, adoptamos las siguientes reglas:
- Prefijos:
- Enumeraciones: E (Ejemplo: EUserRole)
- Interfaces: I (Ejemplo: IUserService)
- Sufijos:
- Entities (Ent): Representan objetos persistentes. Ejemplo: UserEnt
- Value Objects (VO): Objetos inmutables con valor significativo. Ejemplo: MoneyVO
- Data Transfer Objects (DTO): Transporte de datos entre capas. Ejemplo: UserDTO
- Repositories (Repo): Acceso a datos. Ejemplo: UserRepo
- Services (Svc): Lógica de negocio. Ejemplo: UserSvc
- Controllers (Ctrl): Gestionan solicitudes HTTP. Ejemplo: UserCtrl
- Exceptions (Ex): Manejo de errores. Ejemplo: InvalidUserEx
- Configuration (Cfg): Configuraciones de aplicación. Ejemplo: DatabaseCfg
- Utility (Util): Métodos reutilizables. Ejemplo: DateUtil
- Test (Test): Clases de prueba. Ejemplo: UserSvcTest
- Commands (Cmd): Acciones en el sistema. Ejemplo: CreateUserCmd
- Domain Events (Evt): Eventos de dominio. Ejemplo: UserCreatedEvt
- Validation (Val): Validaciones. Ejemplo: UserVal
- Adapters (Adp): Integraciones con otros sistemas. Ejemplo: ExternalServiceAdp
- Message Brokers (Brk): Comunicación con colas de mensajes. Ejemplo: KafkaMsgBrk
- Mappers (Map): Transformación de objetos. Ejemplo: UserMap
- Views (View): Modelos de presentación. Ejemplo: UserView
- Gateways (Gtw): Comunicación con servicios externos. Ejemplo: PaymentGtw
- Factories (Fct): Creación de objetos. Ejemplo: UserFct
- Specifications (Spec): Criterios de búsqueda. Ejemplo: UserSpec
- Middleware (Mid): Interceptores de solicitudes. Ejemplo: AuthMid
- Aggregates (Agg): Raíces de agregados DDD. Ejemplo: OrderAgg
- Use Cases (UC): Casos de uso específicos. Ejemplo: RegisterUserUC
- Queries (Qry): Consultas a la base de datos. Ejemplo: FindUserQry
- View Models (VM): Modelos de datos para UI. Ejemplo: UserVM
- Policy / Guard (Pol o Grd): Reglas de negocio. Ejemplo: UserAccessPol
Para aplicar esta convención en un proyecto, recomendamos documentarla dentro del repositorio, integrar validaciones automáticas en las revisiones de código mediante linters o revisiones de PR y aplicar la convención en nuevos desarrollos, refactorizando el código legado progresivamente.
Adoptar una convención de nombres en clases Java mejora la comprensión del código y la colaboración en equipos de desarrollo. Al estandarizar prefijos y sufijos, cada clase tiene un propósito claro y es más fácil de ubicar y utilizar. Implementar esta convención desde el inicio de un proyecto o refactorizar el código existente progresivamente traerá beneficios a largo plazo.
| Tipo de Clase | Prefijo/Sufijo | Ejemplo | Descripción |
|---|---|---|---|
| Enumeraciones | E | EUserRole | Define un conjunto de valores constantes |
| Interfaces | I | IUserService | Define contratos que deben implementar las clases |
| Entities | Ent | UserEnt | Representa objetos persistentes en la base de datos |
| Value Objects | VO | MoneyVO | Objetos inmutables que representan valores |
| DTOs | DTO | UserDTO | Transporte de datos entre capas |
| Repositories | Repo | UserRepo | Acceso a la base de datos |
| Services | Svc | UserSvc | Contiene la lógica de negocio |
| Controllers | Ctrl | UserCtrl | Gestiona las solicitudes HTTP |
| Exceptions | Ex | InvalidUserEx | Manejo de errores y excepciones |
| Configuration | Cfg | DatabaseCfg | Configuraciones de la aplicación |
| Utilities | Util | DateUtil | Métodos reutilizables |
| Tests | Test | UserSvcTest | Pruebas unitarias y de integración |
| Commands | Cmd | CreateUserCmd | Representa una acción o comando |
| Domain Events | Evt | UserCreatedEvt | Eventos del dominio |
| Validation | Val | UserVal | Validaciones de datos |
| Adapters | Adp | ExternalServiceAdp | Integración con sistemas externos |
| Message Brokers | Brk | KafkaMsgBrk | Comunicación con colas de mensajes |
| Mappers | Map | UserMap | Conversión entre objetos |
| Views | View | UserView | Modelos de presentación |
| Gateways | Gtw | PaymentGtw | Comunicación con servicios externos |
| Factories | Fct | UserFct | Creación de instancias de objetos |
| Specifications | Spec | UserSpec | Definición de criterios de búsqueda |
| Middleware | Mid | AuthMid | Interceptores de solicitudes |
| Aggregates | Agg | OrderAgg | Raíz de un agregado en DDD |
| Use Cases | UC | RegisterUserUC | Casos de uso específicos |
| Queries | Qry | FindUserQry | Consultas a la base de datos |
| View Models | VM | UserVM | Modelos de datos para UI |
| Policy/Guard | Pol/Grd | UserAccessPol | Reglas de negocio y restricciones |