Pointcut Expressions and Join Point Matching
Pointcut Expressions and Join Point Matching
A pointcut expression is a predicate. It answers one question: does this method on this bean match? If yes, the advice applies. If no, it does not. The precision of your pointcut expression determines whether your aspect applies to exactly the methods you intend, or whether it silently wraps infrastructure beans, third-party library classes, and framework internals that should never be proxied.
The execution() Designator
execution() is the primary pointcut designator for Spring AOP. It matches method execution join points. The syntax follows a pattern:
execution(modifiers? return-type declaring-type? method-name(param-pattern) throws-pattern?)
Everything marked with ? is optional. The minimum viable pointcut is a return type, method name, and parameter pattern.
Full Pattern Breakdown
For the SaaS backend, consider targeting all public methods in tenant service classes:
execution(public * com.saas.tenant.service.*Service.*(..))
Reading left to right:
public: modifier (optional, matches only public methods)*: any return typecom.saas.tenant.service: package*Service: any class ending in “Service”.*: any method name(..): any parameters (zero or more, any type)
The parameter pattern syntax is specific:
(): no parameters(*): exactly one parameter of any type(..): zero or more parameters of any type(String, ..): first parameter is String, followed by zero or more of any type(String, int): exactly two parameters, String and int
Package Matching
Single dot matches one package level. Double dot matches zero or more package levels:
// Matches com.saas.tenant.service only
execution(* com.saas.tenant.service.*.*(..))
// Matches com.saas.tenant.service and all sub-packages
execution(* com.saas.tenant.service..*.*(..))
// Matches any class in any sub-package of com.saas
execution(* com.saas..*.*(..))
The double-dot form is dangerous. com.saas..*.*(..) matches every method in every class under com.saas, including configuration classes, DTOs, exception handlers, and utility classes. This is rarely what you want.
The @annotation() Designator
@annotation() matches methods annotated with a specific annotation. This is the cleanest targeting mechanism for cross-cutting concerns because it makes the opt-in explicit at the method level.
Define a custom annotation for auditable operations:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Auditable {
String action() default "";
}
The aspect:
@Aspect
@Component
@Order(2)
public class AuditLoggingAspect {
@Around("@annotation(auditable)")
public Object audit(ProceedingJoinPoint pjp, Auditable auditable) throws Throwable {
String tenantId = TenantContextHolder.get().getTenantId();
String action = auditable.action().isEmpty()
? pjp.getSignature().getName()
: auditable.action();
log.info("AUDIT tenant={} action={}", tenantId, action);
return pjp.proceed();
}
}
The lowercase auditable in @annotation(auditable) binds the annotation instance to the method parameter of the same name. This gives the advice access to the annotation’s attributes at runtime. No reflection needed.
Usage in the service:
@Service
public class TenantService {
@Auditable(action = "CREATE_TENANT")
public Tenant createTenant(TenantCreateRequest request) {
// Only this method is advised
return tenantRepository.save(mapToEntity(request));
}
public Tenant getTenant(String id) {
// This method is NOT advised. No @Auditable annotation.
return tenantRepository.findById(id).orElseThrow();
}
}
@annotation() produces the most precise targeting because the developer marking a method with @Auditable explicitly opts that method into the aspect. No accidental matching. No collateral proxying.
The within() Designator
within() matches all methods within a type. It does not discriminate by method signature, return type, or parameters. Every method on the matched type is a join point.
// All methods in TenantService
within(com.saas.tenant.service.TenantService)
// All methods in all classes in the service package
within(com.saas.tenant.service.*)
// All methods in all classes in service and sub-packages
within(com.saas.tenant.service..*)
within() is useful for broad infrastructure concerns like performance monitoring where you want every method in a package timed, without listing them individually.
Combining Pointcuts
Pointcut expressions combine with && (and), || (or), and ! (not):
@Aspect
@Component
@Order(1)
public class TenantContextAspect {
// Match service methods that are also annotated with @RequestMapping
// (or any of its specializations like @GetMapping, @PostMapping)
@Before("within(com.saas.tenant.service..*) && @annotation(org.springframework.web.bind.annotation.RequestMapping)")
public void verifyTenantContext(JoinPoint jp) {
if (TenantContextHolder.get() == null) {
throw new IllegalStateException(
"No tenant context for " + jp.getSignature());
}
}
}
Negation excludes specific matches:
// All service methods EXCEPT those annotated with @SkipAudit
@Around("execution(* com.saas.tenant.service..*.*(..)) && !@annotation(com.saas.common.SkipAudit)")
public Object auditUnlessSkipped(ProceedingJoinPoint pjp) throws Throwable {
// ...
return pjp.proceed();
}
Named Pointcuts with @Pointcut
When the same pointcut expression appears in multiple advice methods, extract it into a named pointcut:
@Aspect
@Component
public class SaasPointcuts {
@Pointcut("execution(* com.saas.tenant.service..*.*(..))")
public void tenantServiceMethod() {}
@Pointcut("execution(* com.saas.tenant.repository..*.*(..))")
public void tenantRepositoryMethod() {}
@Pointcut("@annotation(com.saas.common.Auditable)")
public void auditableMethod() {}
@Pointcut("tenantServiceMethod() || tenantRepositoryMethod()")
public void tenantDataAccessMethod() {}
}
Other aspects reference these by fully qualified method name:
@Aspect
@Component
@Order(3)
public class PerformanceMonitoringAspect {
@Around("com.saas.common.aop.SaasPointcuts.tenantRepositoryMethod()")
public Object timeRepositoryCall(ProceedingJoinPoint pjp) throws Throwable {
long start = System.nanoTime();
try {
return pjp.proceed();
} finally {
long elapsed = (System.nanoTime() - start) / 1_000_000;
meterRegistry.timer("repository.call",
"method", pjp.getSignature().getName())
.record(elapsed, TimeUnit.MILLISECONDS);
}
}
}
Named pointcuts are not optional for non-trivial applications. When you change a package name or refine a matching rule, you update one @Pointcut method instead of searching every advice annotation across every aspect class.
AspectJExpressionPointcut Internals
When Spring parses a pointcut expression, it creates an AspectJExpressionPointcut instance. This class implements both ClassFilter and MethodMatcher:
ClassFilter.matches(Class<?>): Called once per bean class to determine if the pointcut could possibly match any method on this class. This is the coarse filter. If it returnsfalse, no methods on the class are evaluated, and the bean is not proxied for this advisor.MethodMatcher.matches(Method, Class<?>): Called for each method on classes that passed the class filter. This is the fine filter. It determines whether a specific method is a join point.
The class filter is critical for performance. When Spring boots a SaaS application with 500 beans, each advisor’s pointcut is evaluated against all 500 classes. A pointcut like execution(* com.saas.tenant.service..*.*(..)) immediately rejects any class not in com.saas.tenant.service at the class filter level. No method-level matching is needed for rejected classes.
A pointcut like execution(* *.*(..)) matches every class. All 500 beans pass the class filter. Every method on every bean is evaluated. And because at least one method will match on almost every class, almost every bean gets proxied. This is catastrophic for startup time and memory usage.
The Broken Pattern: Overly Broad Pointcut
// BROKEN: This pointcut matches everything in com.saas, including
// configuration classes, DTO records, exception handlers, and
// Spring infrastructure beans.
@Aspect
@Component
@Order(5)
public class UniversalLoggingAspect {
@Around("execution(* com.saas..*.*(..))")
public Object logEverything(ProceedingJoinPoint pjp) throws Throwable {
log.debug("Entering {}", pjp.getSignature());
Object result = pjp.proceed();
log.debug("Exiting {}", pjp.getSignature());
return result;
}
}
The damage:
-
Configuration classes get proxied.
@Configurationclasses are already CGLIB-proxied for@Beanmethod interception (CH3). Adding another proxy layer creates a proxy-of-a-proxy. Some lifecycle callbacks break.@PostConstructmethods may execute with the wrong proxy context. -
DTO records get proxied. Java records are final. CGLIB cannot subclass them. Spring falls back to JDK dynamic proxy, which requires an interface. Records typically have no interface. The result: a
BeanCreationExceptionat startup with a confusing message about proxy target class requirements. -
Spring infrastructure beans get proxied.
BeanPostProcessorimplementations,BeanFactoryPostProcessorimplementations, and condition evaluators should never be proxied. They execute during container bootstrap, before the AOP infrastructure itself is fully initialized. Proxying them creates circular dependencies or initialization order failures. -
Startup time increases. Every bean triggers method-level pointcut matching. For a 500-bean application, this adds hundreds of milliseconds to startup.
The Correct Pattern: Precise Scoping
// CORRECT: Pointcut targets only service-layer classes in specific
// packages, excludes configuration and infrastructure.
@Aspect
@Component
@Order(5)
public class ServiceLoggingAspect {
@Pointcut("execution(* com.saas.tenant.service..*.*(..))")
public void tenantService() {}
@Pointcut("execution(* com.saas.billing.service..*.*(..))")
public void billingService() {}
@Pointcut("tenantService() || billingService()")
public void businessService() {}
@Around("businessService()")
public Object logServiceCall(ProceedingJoinPoint pjp) throws Throwable {
log.debug("Entering {}", pjp.getSignature());
Object result = pjp.proceed();
log.debug("Exiting {}", pjp.getSignature());
return result;
}
}
The difference is scope. This aspect matches only service classes in two specific packages. Configuration classes, DTOs, repositories, controllers, and infrastructure beans are excluded by construction. The class filter rejects them immediately, and no proxy is created for them by this advisor.
For annotation-based targeting, the scoping is even tighter:
// CORRECT: Only methods explicitly marked for logging are advised.
@Around("@annotation(com.saas.common.Logged)")
public Object logAnnotatedMethod(ProceedingJoinPoint pjp) throws Throwable {
// ...
}
The rule: start narrow, widen only when necessary. An overly broad pointcut that “just works” on 50 beans becomes a production incident when the application grows to 500. Write pointcuts that name the packages and types they target. Use @annotation() when the set of advised methods is not determined by package structure. Combine named pointcuts for reuse. Never use execution(* *.*(..)) in production code.