
El problema que resuelve AOP
En cualquier proyecto Spring Boot serio aparecen patrones que se repiten en docenas de métodos:
- Loggear la entrada y salida de los métodos del paquete
service. - Medir el tiempo de ejecución de los endpoints REST y publicarlo a Micrometer.
- Auditar quién modificó una entidad, cuándo y qué cambió.
- Aplicar rate limiting a ciertos endpoints sensibles.
- Reintentar automáticamente en caso de fallo de un servicio externo.
La solución intermedia es escribir esa lógica en cada método o usar herencia. Ambos caminos llevan a duplicación o a frameworks rígidos. La programación orientada a aspectos (AOP) ofrece una salida limpia: el código transversal vive en una clase aparte (un aspecto) y se aplica declarativamente a los métodos que cumplen un patrón.
Spring AOP usa proxies dinámicos de JDK o CGLIB para interceptar las llamadas. Para los casos comunes (anotación + método público) eso basta. Para casos extremos (interceptar llamadas internas dentro de la misma clase, modificar campos privados) hace falta AspectJ con weaving en tiempo de compilación, raro en Spring Boot estándar.
Estructura de un aspecto
Un aspecto es una clase Spring con @Aspect y @Component:
@Aspect
@Component
@Slf4j
public class LoggingAspect {
@Around("execution(* com.empresa.empleados.service.*Service.*(..))")
public Object logServiceMethods(ProceedingJoinPoint pjp) throws Throwable {
var className = pjp.getSignature().getDeclaringType().getSimpleName();
var methodName = pjp.getSignature().getName();
var start = System.nanoTime();
log.debug("Entrando: {}.{}", className, methodName);
try {
var result = pjp.proceed();
var elapsed = Duration.ofNanos(System.nanoTime() - start);
log.debug("Saliendo: {}.{} en {}ms", className, methodName, elapsed.toMillis());
return result;
} catch (Exception e) {
log.warn("Excepción en {}.{}: {}", className, methodName, e.getMessage());
throw e;
}
}
}
Para que Spring AOP funcione es necesaria la dependencia spring-boot-starter-aop:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
Spring Boot habilita AOP automáticamente si detecta esta dependencia.
Pointcut expressions
El pointcut define qué métodos intercepta el aspecto. Las expresiones más usadas:
execution()
La más común. Selecciona métodos por firma:
// Cualquier método público de cualquier *Service en el paquete service
"execution(public * com.empresa.empleados.service.*Service.*(..))"
// Métodos llamados crear* que devuelven cualquier tipo
"execution(* com.empresa..*.crear*(..))"
// Métodos del controller que reciben un solo parámetro de tipo Long
"execution(* com.empresa.empleados.controller.*.*(Long))"
Sintaxis: execution(<modificador> <tipo-retorno> <paquete>.<clase>.<método>(<argumentos>)). El .. en (..) significa "cualquier número de argumentos de cualquier tipo". El .. en paquete significa "cualquier subpaquete".
within()
Selecciona por clase, no por método:
// Cualquier método de cualquier clase del paquete service y subpaquetes
"within(com.empresa.empleados.service..*)"
// Métodos de clases anotadas con @Service
"within(@org.springframework.stereotype.Service *)"
@annotation()
Aplica al método anotado con una anotación específica. Patrón muy potente para anotaciones personalizadas:
@Around("@annotation(com.empresa.audit.Audited)")
public Object auditarLlamada(ProceedingJoinPoint pjp) throws Throwable { ... }
@target() y @within()
@target(X) aplica si la clase del bean tiene la anotación X. @within(X) aplica si la clase declarante tiene la anotación X. Diferencia sutil: @target mira el bean en runtime (después de la inyección), @within mira la clase estática.
Combinaciones
Los pointcuts se componen con &&, ||, !:
"execution(public * com.empresa..*.*(..)) && @annotation(com.empresa.audit.Audited)"
"within(com.empresa.empleados.service..*) && !execution(* *.toString())"
Pointcuts nombrados
Para reutilizar:
@Aspect
@Component
public class AuditAspect {
@Pointcut("@annotation(com.empresa.audit.Audited)")
public void metodoAuditado() {}
@Pointcut("within(com.empresa.empleados.service..*)")
public void capaServicios() {}
@Around("metodoAuditado() && capaServicios()")
public Object auditar(ProceedingJoinPoint pjp) throws Throwable {
// ...
}
}
Tipos de advice
| Anotación | Cuándo se ejecuta | Permite cancelar/modificar | Uso típico |
|-----------|-------------------|----------------------------|------------|
| @Before | Antes del método | No (no puede saltar la ejecución) | Validación pre-llamada, logging de entrada |
| @After | Después (siempre, exitoso o no) | No | Cleanup, log final |
| @AfterReturning | Después de retorno exitoso | Permite leer el valor devuelto | Auditar resultados, post-procesado |
| @AfterThrowing | Solo si hay excepción | Permite leer la excepción | Métricas de errores, logging de excepciones |
| @Around | Envuelve el método | Sí: puede no llamar a proceed() o devolver otro valor | Caching, timing, retry, rate limiting |
@Around es el más potente porque controla la ejecución completa. Los demás son atajos para casos comunes.
@AfterReturning(pointcut = "@annotation(com.empresa.audit.Audited)",
returning = "result")
public void auditarResultado(JoinPoint jp, Object result) {
log.info("Método {} devolvió: {}", jp.getSignature().getName(), result);
}
@AfterThrowing(pointcut = "execution(* com.empresa..service.*.*(..))",
throwing = "ex")
public void auditarFallo(JoinPoint jp, Throwable ex) {
log.error("Método {} falló: {}", jp.getSignature().getName(), ex.getMessage());
}
Caso 1: medición de tiempos con Micrometer
Anotación personalizada:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Timed {
String value() default ""; // nombre de la métrica
String[] tags() default {};
}
Aspecto:
@Aspect
@Component
@RequiredArgsConstructor
public class TimingAspect {
private final MeterRegistry meterRegistry;
@Around("@annotation(timed)")
public Object medirTiempo(ProceedingJoinPoint pjp, Timed timed) throws Throwable {
var nombre = timed.value().isBlank()
? pjp.getSignature().toShortString()
: timed.value();
var sample = Timer.start(meterRegistry);
boolean exito = true;
try {
return pjp.proceed();
} catch (Throwable t) {
exito = false;
throw t;
} finally {
sample.stop(Timer.builder(nombre)
.tag("class", pjp.getSignature().getDeclaringType().getSimpleName())
.tag("method", pjp.getSignature().getName())
.tag("success", String.valueOf(exito))
.register(meterRegistry));
}
}
}
Uso:
@Service
public class EmpleadoService {
@Timed("empleado.crear")
public EmpleadoDto crear(EmpleadoCrearDto dto) {
// ...
}
}
Cualquier endpoint que llame a crear queda monitorizado en Prometheus sin tocar el método. Spring Boot tiene @Timed similar en io.micrometer.core.annotation, así que en proyectos reales basta con activar MeterAspect. Pero el ejemplo muestra cómo escribir uno propio cuando hace falta más control.
Caso 2: auditoría declarativa
Anotación que marca métodos auditables:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Audited {
String entityType();
AuditAction action();
enum AuditAction { CREATE, UPDATE, DELETE, READ }
}
Aspecto que registra cada llamada:
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class AuditAspect {
private final AuditLogRepository repository;
private final ObjectMapper objectMapper;
@AfterReturning(pointcut = "@annotation(audited)", returning = "result")
public void registrarAuditoria(JoinPoint jp, Audited audited, Object result) {
try {
var entry = AuditLog.builder()
.entityType(audited.entityType())
.action(audited.action().name())
.userId(currentUserId())
.timestamp(Instant.now())
.arguments(objectMapper.writeValueAsString(jp.getArgs()))
.result(objectMapper.writeValueAsString(result))
.correlationId(MDC.get("correlationId"))
.build();
repository.save(entry);
} catch (Exception e) {
log.warn("Fallo al registrar auditoría: {}", e.getMessage());
}
}
private Long currentUserId() {
return Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
.map(Authentication::getPrincipal)
.filter(p -> p instanceof UserDetails)
.map(p -> ((UserDetails) p).getUsername())
.map(Long::parseLong)
.orElse(null);
}
}
Uso:
@Audited(entityType = "Empleado", action = AuditAction.CREATE)
public EmpleadoDto crear(EmpleadoCrearDto dto) { ... }
@Audited(entityType = "Empleado", action = AuditAction.DELETE)
public void eliminar(Long id) { ... }
Para auditoría de cambios en entidades JPA, Hibernate Envers (lección posterior) es más completo y eficiente. AOP es preferible cuando la auditoría no se mapea a entidades (operaciones de servicio, llamadas a APIs externas, eventos de dominio).
Caso 3: rate limiting transversal
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimited {
int requestsPerMinute() default 60;
String key() default ""; // expresión SpEL para extraer key del usuario
}
@Aspect
@Component
@RequiredArgsConstructor
public class RateLimitAspect {
private final ConcurrentHashMap<String, Bucket> buckets = new ConcurrentHashMap<>();
@Around("@annotation(rateLimited)")
public Object checkRateLimit(ProceedingJoinPoint pjp, RateLimited rateLimited) throws Throwable {
var bucket = buckets.computeIfAbsent(
keyFromContext(rateLimited.key()),
k -> Bucket.builder()
.addLimit(Bandwidth.simple(rateLimited.requestsPerMinute(), Duration.ofMinutes(1)))
.build()
);
if (!bucket.tryConsume(1)) {
throw new BusinessException(ErrorCode.RATE_LIMIT_EXCEEDED);
}
return pjp.proceed();
}
private String keyFromContext(String keyExpression) {
// En real: extraer del SecurityContext o del IP de la request
return "default";
}
}
Uso:
@RateLimited(requestsPerMinute = 10)
public EmpleadoDto buscarPorEmail(String email) { ... }
Orden de aspectos
Cuando varios aspectos aplican al mismo método, el orden importa. Spring usa la anotación @Order para definir prioridades (menor número = más exterior):
@Aspect
@Component
@Order(1) // se ejecuta primero (más exterior)
public class CorrelationIdAspect {
@Around("execution(* com.empresa..*.controller.*.*(..))")
public Object propagar(ProceedingJoinPoint pjp) throws Throwable {
// 1. Antes
try { return pjp.proceed(); }
finally { /* 4. Después */ }
}
}
@Aspect
@Component
@Order(2)
public class TimingAspect {
@Around("@annotation(io.micrometer.core.annotation.Timed)")
public Object medir(ProceedingJoinPoint pjp) throws Throwable {
// 2. Antes
try { return pjp.proceed(); }
finally { /* 3. Después */ }
}
}
El método se ejecuta dentro del último aspecto (mayor @Order). El correlation ID se propaga desde fuera; la medición de tiempos queda dentro de la propagación.
Orden con anotaciones de Spring
Las anotaciones core como @Transactional y @Cacheable también se aplican como aspectos. El orden por defecto es:
@Transactional(más exterior)@Cacheable@Async- Aspectos personalizados
Esto significa que @Cacheable se evalúa dentro de la transacción: si el cache está poblado, no se abre transacción innecesaria. Si se quiere otro orden, hay que cambiarlo con spring.aop.order (cuidado, raramente es buena idea).
Limitaciones de Spring AOP
Spring AOP usa proxies, lo que tiene dos limitaciones importantes:
1. Llamadas internas no se interceptan
@Service
public class MiServicio {
@Audited(...)
public void metodoPublico() {
metodoInterno(); // <- esta llamada NO pasa por el proxy
}
@Audited(...)
public void metodoInterno() {
// No será auditado cuando se llama desde metodoPublico()
}
}
Soluciones:
- Inyectarse a sí mismo:
@Autowired private MiServicio self;y luegoself.metodoInterno(). Feo pero funciona. - Mover
metodoInternoa otra clase Spring. - Usar AspectJ con compile-time weaving (modifica el bytecode, no usa proxies).
2. Solo métodos públicos por defecto
Los proxies no interceptan métodos private, protected o package-private. Para AOP en métodos no públicos hace falta AspectJ.
AOP + @Transactional: la trampa más común
@Service
public class MiServicio {
public void metodoExterno() {
metodoTransaccional(); // <- la transacción NO se abre
}
@Transactional
public void metodoTransaccional() {
// ...
}
}
El llamador externo a metodoTransaccional sí abre la transacción (pasa por el proxy). El llamador interno desde metodoExterno NO. Es la causa #1 de bugs sutiles con @Transactional "que no funciona".
Regla: las anotaciones AOP (
@Transactional,@Cacheable,@Async,@Audited) solo funcionan en llamadas externas al bean. Si necesitas aplicarlas a llamadas internas, separa el método en otro bean o inyectaself.
Buenas prácticas
- Pointcuts específicos:
execution(* com.empresa.empleados.service.*Service.*(..))es preferible aexecution(* *.*(..)). Pointcuts amplios afectan al rendimiento (cada llamada pasa por el proxy aunque no aplique). - Anotaciones personalizadas para aspectos custom:
@Audited,@RateLimited,@Timed. Hace explícito qué métodos son afectados y desde qué punto del código. - No abusar: AOP es potente, pero un código con cinco aspectos cruzados es difícil de seguir. Reservar para casos transversales claros.
@Ordercuando hay varios aspectos: explícito siempre. Implícito lleva a sorpresas.- Tests del aspecto en aislamiento: levantar un contexto Spring mínimo con el aspecto y un bean dummy para verificar que se aplica.
AOP bien usado elimina código repetitivo y centraliza patrones transversales. Mal usado oculta lógica donde nadie la espera. La diferencia está en aplicarlo a comportamientos genuinamente cross-cutting (logging, métricas, auditoría) y no a lógica de negocio.
Alan Sastre
Ingeniero de Software y formador, CEO en CertiDevs
Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Spring Boot es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.
Más tutoriales de Spring Boot
Explora más contenido relacionado con Spring Boot y continúa aprendiendo con nuestros tutoriales gratuitos.
Aprendizajes de esta lección
Crear aspectos con @Aspect aplicables a múltiples capas. Dominar pointcut expressions con execution(), within(), @annotation y @target. Aplicar @Around para envolver métodos con timing y métricas. Diseñar anotaciones personalizadas para activar aspectos selectivamente. Combinar AOP con Spring Cache, @Transactional y Bean Validation respetando el orden.