Status: Stable Package:
dev.vertique.aopArtifact:vertique-aopDepends on:vertique-core,vertx-coreAPI reference: javadoc.io
vertique-aop is the runtime SPI module for compile-time method AOP. It defines the public contracts that annotated beans, generated proxies, and user-authored aspect providers depend on. The module contains no annotation processor and emits no generated sources — it is a pure runtime library that the vertique-codegen-aop processor targets during compilation.
The dispatch chain is reflection-free. The generated proxy calls super.method(args) directly; no Method.invoke appears on that hot path. Aspect annotation attribute values are captured in generated annotation-literals rather than read via getAnnotation() at runtime. The framework-internal SelectorPaths resolver is the documented exception: selector accessors are invoked reflectively when an annotation family resolves a path.
When To Use It
Section titled “When To Use It”Add vertique-aop to any module that defines a custom aspect annotation (@Timed or any user-defined @Aspect-meta-annotated annotation), or to any module that implements AspectProvider<A> to supply the interceptor logic for an aspect. Applications inheriting vertique-app-parent declare runtime capabilities only and receive the complete processor facade automatically; custom-parent applications use the BOM plus vertique-codegen-all recipe in docs/packaging.md. vertique-codegen-aop remains the ownership boundary for generated AOP proxies.
The open-core framework ships one built-in aspect that depends on this module:
@Timed— invertique-micrometer-core, measures method execution time
Core Concepts
Section titled “Core Concepts”The AOP model is a compile-time subclass proxy. vertique-codegen-aop generates Bean$AopProxy extends Bean for every bean class that has at least one method annotated with an @Aspect-meta-annotated annotation. The proxy overrides each annotated method and runs the aspect chain via Invocations.run(...).
Interceptor chain construction. The chain is resolved once in the proxy constructor from the injected AspectProvider<A> instances and stored as a MethodInterceptor[] field per intercepted method. Each call to that method reuses the same pre-built array.
Chain ordering. Interceptors are ordered by descending @Aspect.ordering() — higher values are outermost and run first. Ties are broken deterministically by the fully-qualified name of the aspect annotation, making the chain order stable across compilations.
Allocated aspect-ordering registry
Section titled “Allocated aspect-ordering registry”The framework reserves these aspect bands. Higher values are outermost and run first:
| Ordering | Aspect | Module |
|---|---|---|
50 |
@Resilient |
vertique-resilience |
100 |
@CacheEvict |
vertique-cache-aop |
200 |
@Cacheable |
vertique-cache-aop |
300 |
@RateLimited |
vertique-rate-limit-aop |
1000 |
@Timed |
vertique-micrometer-core |
An application aspect ordered below 50 runs inside retry and is re-executed once per retry attempt. @RateLimited at 300 consumes quota before the @Cacheable lookup at 200, including on cache hits.
Sync-returning methods. When the intercepted method returns a non-Future type, the generated override unwraps the completed future and returns the value synchronously. Framework built-ins never defer the future, so this unwrapping succeeds. A custom aspect that defers on a sync-returning method receives an IllegalStateException at call time — never a blocked thread.
Key Classes
Section titled “Key Classes”@Aspect
Section titled “@Aspect”Meta-annotation that marks an annotation type as an aspect trigger. Placing @Aspect on @MyAnnotation tells vertique-codegen-aop that methods carrying @MyAnnotation should be wrapped by an AspectProvider<MyAnnotation>-produced interceptor.
@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)@Aspect(ordering = 2000)public @interface Timed { String value() default "";}| Attribute | Description |
|---|---|
ordering() |
Chain position — higher is outermost (default 1000) |
@Aspect is RUNTIME-retained so that AspectProvider implementations can read their aspect annotation from the generated literal instance.
AspectProvider<A>
Section titled “AspectProvider<A>”Functional interface — the framework-side factory that builds the MethodInterceptor for a specific aspect-annotated method. The type parameter A is the aspect annotation type; this is the only association: a binding of AspectProvider<Timed> supplies the interceptor for every @Timed-annotated method the proxy encounters.
public interface AspectProvider<A extends Annotation> { MethodInterceptor interceptor(MethodMetadata target, A annotation);}Both arguments are produced reflection-free by the processor: target is a generated MethodMetadata implementation (see MethodMetadata in vertique-core, dev.vertique.core.codegen), and annotation is a generated annotation-literal carrying the method’s attribute values. MethodMetadata’s constant-only accessors (name(), declaringType(), returnType(), parameterTypes(), parameters(), findAnnotation(), hasAnnotation()) are the reflection-free core that generated proxies call. Generated metadata also returns genericReturnType() from a reflection-free Type graph for consumers that need the declared payload shape; asMethod() remains an opt-in reflective accessor.
Dagger wiring example:
@ProvidesAspectProvider<Timed> timedAspectProvider(MeterRegistry registry) { return (target, timed) -> invocation -> { Timer.Sample sample = Timer.start(registry); return invocation.proceed().onComplete(ar -> { String name = timed.value().isEmpty() ? target.name() : timed.value(); sample.stop(registry.timer(name, "status", ar.succeeded() ? "ok" : "error")); }); };}MethodInterceptor
Section titled “MethodInterceptor”Functional interface for the around-advice. An interceptor receives an Invocation and returns Future<Object>. It is free to run logic before and after calling invocation.proceed(), and may call proceed() 0..n times.
@FunctionalInterfacepublic interface MethodInterceptor { Future<Object> intercept(Invocation invocation);}v1 aspects call proceed() exactly once. The contract permits multiple calls, so a future retry aspect can be implemented without any SPI change.
Invocation
Section titled “Invocation”Represents a single method invocation flowing through the interceptor chain. Passed to every MethodInterceptor.intercept() call.
| Method | Description |
|---|---|
target() |
Reflection-free MethodMetadata of the intercepted method |
arguments() |
The live argument array — mutations before proceed() are visible to the underlying call |
instance() |
The proxied bean instance on which the method was invoked |
proceed() |
Invokes the remainder of the chain, terminating in super.method(...) |
proceed() is re-entrant by construction. Each call independently traverses the downstream chain from this position; the continuation is captured per-call on the JVM call stack, not via a mutated shared cursor. Calling proceed() twice triggers two independent traversals to the terminal.
Invariants & Gotchas
Section titled “Invariants & Gotchas”- Never mutate
arguments()after callingproceed()and then call it again unless the argument change is intentional for the second call; the array is read live by the terminal on every invocation. - Sync-returning method contract. If the intercepted method does not return
Future, the generated proxy unwraps the completed future synchronously. A custom interceptor that returns a not-yet-completed future on a sync-returning method receives anIllegalStateExceptionat runtime. Only framework built-ins (which always complete synchronously) are safe with sync-returning methods in v1.
Selector-path grammar
Section titled “Selector-path grammar”SelectorPaths is the framework-internal runtime resolver used by annotation families such as
cache and rate limit. A selector path has a non-blank parameter root, identified by parameter name
or non-negative position (for example, "tenantId" or "0"), followed by optional dot-separated
property segments. A record segment resolves the matching record component accessor; on a bean,
the resolver tries getX() and then isX() for a segment x. Segments must be valid Java
identifiers. The path limit is 256 characters and eight segments, including the root. An explicitly
empty family key declaration is the family-specific constant-key form and does not call
SelectorPaths.resolve.
The terminal value must be one supported scalar: a primitive other than void, String,
Character, Boolean, Byte, Short, Integer, Long, Float, Double, BigInteger,
BigDecimal, UUID, an enum, Instant, LocalDate, LocalDateTime, OffsetDateTime, or
ZonedDateTime. The compile-time processors enforce this grammar; the runtime resolver preserves
the family-prefixed diagnostics for blank paths, unresolved arguments or accessors, blank property
segments, null results, and inaccessible scalar paths.
Invocations
Section titled “Invocations”Static utility class — the continuation nester. Called by generated proxies; not called by user code.
public static Future<Object> run( Object instance, MethodMetadata target, Object[] arguments, MethodInterceptor[] chain, Supplier<Future<Object>> terminal)run folds the ordered chain right-to-left into a continuation: interceptor 0 (outermost) wraps interceptor 1, which wraps interceptor 2, down to the terminal supplier that performs the super.method(...) call. With an empty chain, the terminal is invoked directly.
Synchronous throws from both the terminal supplier and from any interceptor’s intercept() are captured into Future.failedFuture(t) rather than allowed to propagate. This upholds the chain contract — the result is always a Future — and ensures that a sync-throwing method surfaces as a failed future outcome that interceptors (such as @Timed) can observe and handle uniformly.
Framework seams
Section titled “Framework seams”Invocations is a framework helper behind the Invocation contract and carries an INTERNAL
marker. An application writes Aspect and MethodInterceptor implementations and receives an
Invocation; it never constructs one.
Extension Points
Section titled “Extension Points”AspectProvider<A>
Section titled “AspectProvider<A>”The primary extension point. Bind an implementation to add an aspect to the framework.
Registration via Dagger:
// In a @Module@ProvidesAspectProvider<MyAnnotation> myAspectProvider() { return (target, annotation) -> invocation -> { // before return invocation.proceed() .onComplete(ar -> { /* after */ }); };}Aspect annotation definition:
@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)@Aspect(ordering = 1500) // below Timed(2000) by defaultpublic @interface MyAnnotation { String value() default "";}The processor discovers @MyAnnotation as an aspect trigger because it is meta-annotated with @Aspect. No registration step beyond annotating a method with @MyAnnotation is required — the proxy is generated automatically.
Supported annotation attribute kinds. Aspect annotation attributes may use every legal annotation member kind: boolean, byte, short, int, long, char, float, double, String, Class<?>, enum values, nested annotations, or arrays of any of these. Generated literals emit char values as Java-source-safe numeric expressions and preserve exact floating-point values, including NaN, infinities, and signed zero. Equality follows the annotation contract, including Float.compare/Double.compare for primitive floating-point members in nested annotations.
Dependencies
Section titled “Dependencies”| Artifact | Purpose |
|---|---|
vertique-core |
MethodMetadata / ParameterMetadata SPI (dev.vertique.core.codegen); Combinators is not used here — the Invocations nester is AOP-specific |
vertx-core |
io.vertx.core.Future — the uniform async return type of the interceptor chain |