Skip to content

Code generation AOP

View Markdown

Status: Stable Package: dev.vertique.codegen.aop Artifact: vertique-codegen-aop Depends on: vertique-codegen-core (compile), vertique-aop (compile) API reference: javadoc.io

vertique-codegen-aop is an annotation processor that generates reflection-free AOP proxy classes at compile time. For every bean that has at least one method annotated with an @Aspect-meta-annotated annotation, it generates:

  • Bean$AopProxy extends Bean — a subclass proxy that overrides each intercepted method to run the aspect chain via Invocations.run(...).
  • A MethodMetadata implementation per intercepted method (via MetadataEmitter from vertique-codegen-core), including a nested per-parameter ParameterMetadata impl whose findAnnotation/hasAnnotation are backed by generated parameter-level annotation literals — not reflection.
  • An AOP-owned annotation-literal class (Ann$AopLiteral implements Ann) per distinct aspect annotation type, plus one per distinct runtime-retained annotation type found on an intercepted method’s parameters.
  • A single GeneratedAopModule Dagger @Module that substitutes each proxy for its bean via a @Binds method, replicating the bean’s declared scope.

This processor owns proxy generation for beans whose methods carry @Timed or another @Aspect-meta-annotated annotation. Without generated proxies, those annotations have no runtime effect.


Step 1 — use the application processor boundary

Section titled “Step 1 — use the application processor boundary”

Applications inheriting vertique-app-parent declare vertique-aop and the runtime modules that provide their aspect annotations, then receive the complete processor facade automatically. Custom-parent applications import vertique-bom and configure only the versionless Dagger and vertique-codegen-all processor paths. See docs/packaging.md.

Step 2 — add GeneratedAopModule to @Component

Section titled “Step 2 — add GeneratedAopModule to @Component”
@Singleton
@Component(modules = {
VertxModule.class,
AppModule.class,
GeneratedAopModule.class, // generated by the AOP processor
})
interface AppComponent { ... }

If GeneratedAopModule is omitted, no proxy is substituted for any bean — the original unproxied beans are injected and aspects silently have no effect. Including the class in @Component turns processor misconfiguration into a compile-time error (the generated class does not exist if the processor is absent from annotationProcessorPaths).


The processor generates a proxy only for beans bound by their own @Inject constructor. Beans supplied by a user @Provides or @Binds method cannot be proxied:

  • Same compilation unit. When the user @Provides method is in the same compilation unit as the bean, the processor detects the conflict and emits a compile error naming the bean.
  • Cross-module. When the @Provides is in a different module, the generated @Binds and the user binding both target the same Dagger key, producing a deterministic Dagger duplicate-binding error at the component — never a silent or corrupt graph.

The following bean and method shapes are rejected with a compile error:

Shape Reason
final class Cannot be subclassed by Bean$AopProxy extends Bean
final intercepted method Cannot be overridden by the proxy subclass
private intercepted method Not visible to a subclass
static intercepted method Not an instance method; cannot be overridden
Method with a type-variable throws clause (e.g., <E extends IOException> ... throws E) The sync guard would emit a non-reifiable instanceof E
@AssistedInject bean or bean with @Assisted constructor parameters Built by a Dagger factory; the subclass-proxy constructor cannot replicate the factory wiring
Raw Future return on an intercepted method Misclassified as sync-returning; meters wrong outcome
Wildcard Future<? ...> return on an intercepted method Would require an invalid back-cast in the generated override

All legal annotation member kinds are materialized into generated literals: boolean, byte, short, int, long, char, float, double, String, Class, enum constants, nested annotations, and arrays of those. char uses a four-hex-digit \uXXXX literal. float and double use Float.intBitsToFloat(...) and Double.longBitsToDouble(...) with the exact raw bit pattern, preserving NaN, infinities, and negative zero. Literal equality uses Float.compare/Double.compare for floating-point members and their hash codes use Float.valueOf(...).hashCode()/Double.valueOf(...).hashCode(); arrays use Arrays.equals/Arrays.hashCode.


@Generated("dev.vertique.codegen.aop.AopProcessor")
public final class UserService$AopProxy extends UserService {
// MethodMetadata constant per intercepted method
private static final MethodMetadata GREET_0_METADATA = new GreetMethod0Metadata();
// Annotation-literal instance per aspect occurrence per method
private static final Timed GREET_0_TIMED = new Timed$AopLiteral("user.greet");
// Interceptor chain field per intercepted method (resolved once in the constructor)
private final MethodInterceptor[] greet_0_chain;
@Inject
public UserService$AopProxy(
// Verbatim replica of UserService's @Inject constructor parameters
UserRepository repo,
// AspectProvider<A> appended per distinct aspect annotation used on this bean
AspectProvider<Timed> timedProvider) {
super(repo);
this.greet_0_chain = new MethodInterceptor[] {
timedProvider.interceptor(GREET_0_METADATA, GREET_0_TIMED)
};
}
@Override
public Future<String> greet(String name) {
return Invocations.run(this, GREET_0_METADATA, new Object[] {name}, greet_0_chain,
() -> super.greet(name).map(r -> (Object) r));
}
}

For sync-returning methods (non-Future return type), the generated override calls Invocations.run(...) and synchronously unwraps the completed future result. An interceptor that returns a not-yet-complete future on a sync-returning method causes an IllegalStateException at runtime.

public final class Timed$AopLiteral implements Timed {
private final String value;
public Timed$AopLiteral(String value) { this.value = value; }
@Override public String value() { return value; }
@Override public Class<? extends Annotation> annotationType() { return Timed.class; }
@Override
public boolean equals(Object other) {
if (!(other instanceof Timed that)) return false;
return Objects.equals(value, that.value());
}
@Override
public int hashCode() {
// Per java.lang.annotation.Annotation contract
return (127 * "value".hashCode()) ^ value.hashCode();
}
}

One AOP-owned Ann$AopLiteral class is written per distinct aspect annotation type (not per occurrence). Per-method literal instances are baked with the attribute values read from that method’s annotation mirror. Two methods sharing the same aspect type but carrying different attribute values materialize distinct literal instances from the same literal class. The Aop namespace is processor ownership: it keeps these generated types distinct from a literal emitted for the same annotation by another processor.

The literal’s equals/hashCode conform to the java.lang.annotation.Annotation contract, making a literal usable as a map key in caching-style aspects.

@Generated("dev.vertique.codegen.aop.AopProcessor")
@Module
public abstract class GeneratedAopModule {
@Binds
@Singleton // scope replicated from UserService's declared scope
abstract UserService bindUserService$AopProxy(UserService$AopProxy proxy);
}

The @Binds method replicates the target bean’s declared scope — it never hard-codes @Singleton. An unscoped bean remains unscoped in the proxy binding.


AbstractProcessor registered via META-INF/services/javax.annotation.processing.Processor.

@SupportedAnnotationTypes("*")
@SupportedSourceVersion(SourceVersion.RELEASE_21)
@SupportedOptions({ "vertique.codegen.package" })

Runs @SupportedAnnotationTypes("*") (wildcard) so it receives all annotations and can discover user-defined aspect triggers — annotations meta-annotated with @Aspect — without being configured for each one. Returns false to avoid claiming annotations; Dagger, Lombok, and other processors see the same elements.

Trigger methods enclosed by an ElementKind.CLASS are collected on that class. A trigger annotation on an interface method — including an inherited default method — is woven onto every concrete implementing class in the same compilation that inherits or overrides the method: the implementor receives the $AopProxy, and proxyability / constructor validation run against that class. An interface alone is never treated as a subclass-proxy bean; when no concrete implementor is present, no proxy is generated for the interface-hosted trigger.

The module (GeneratedAopModule) is emitted in the first round that yields any binding, not in the processingOver() round. This ensures the module exists before Dagger’s ComponentProcessingStep validates a @Component that references it.

Generates the Bean$AopProxy class, its MethodMetadata implementations (via MetadataEmitter), and the annotation-literal instances. Per-method member names (metadata constant, chain field, nested metadata type) are disambiguated by an ordinal (position in the intercepted-method list) so overloaded intercepted methods — same simple name, different parameter types — never collide.

For each intercepted method, materializeParameterAnnotations walks every parameter’s @Retention(RUNTIME) annotations and emits one <Ann>$AopLiteral per distinct annotation type encountered (deduplicated per compilation, same as the method-level literals), passing the per-parameter literal refs into MetadataEmitter.methodMetadataType so the nested MethodMetadata’s per-parameter ParameterMetadata.findAnnotation/hasAnnotation resolve from generated literals. All legal annotation member kinds are supported on parameters as well as methods; the same exact-bit-pattern and Annotation-contract equality/hash behavior applies, rather than falling back to reflection.

Accumulates (bean, proxy, scope) entries and emits the single GeneratedAopModule containing one abstract @Binds method per entry.


Option Default Description
vertique.codegen.package LCP of bean packages Override the output package for GeneratedAopModule only

The option relocates only GeneratedAopModule. The Bean$AopProxy is always generated in the bean’s own package, regardless of this option. This is required so the proxy can call package-private super-constructors and override package-private intercepted methods — access that would be denied if the proxy were placed in a different package. Cross-package @Binds declarations in the relocated module are legal because the proxy is public.


When a method carries multiple aspect annotations, the processor resolves the chain order at compile time:

  1. Sort by descending @Aspect.ordering() — higher is outermost.
  2. Tiebreak by ascending fully-qualified annotation name — deterministic across compilations.

Example: @Outer (ordering=1000) + @Timed (ordering=2000) → chain is [Timed interceptor, Outer interceptor] — Timed wraps Outer which wraps the real method.


Artifact Scope Purpose
vertique-codegen-core compile CodegenContext, MetadataEmitter, AnnotationLiteralEmitter, InjectConstructorValidator, Identifiers, DaggerModuleWriter
vertique-aop compile @Aspect, AspectProvider, MethodInterceptor, Invocation, Invocations — referenced as ClassName constants in generated source
javapoet compile Source generation (via DaggerModuleWriter and inline TypeSpec/MethodSpec construction)

Test-only: vertique-codegen-test, dagger (for Dagger wiring tests).