Skip to content

AOP and events

View Markdown

Vertique’s two in-process cross-cutting mechanisms share one design: annotate plain code, let the compile-time processor generate the wiring, and dispatch with no reflection:

  • Aspects are annotations. Meta-annotate your own annotation with @Aspect, or use the built-in @Timed, and implement one aspect provider. Every annotated method is then wrapped by a subclass proxy generated at compile time. No runtime weaver is involved.
  • Framework capabilities share the seam. @Resilient, @Cacheable, @CacheEvict, @RateLimited, and @Timed are framework-provided aspect triggers. Their family modules supply the providers; vertique-codegen-aop generates the proxy and its Dagger binding.
  • Events are typed. Fire a plain record through an injected typed publisher. Any bean method with an @Observes parameter receives it, matched by subtype routing.
  • No reflection at dispatch time. Generated proxies call the original method directly, observers register through generated Dagger bindings, and aspect annotation values are captured as generated literals, so annotations are not read at runtime.
  • Deterministic aspect ordering. Interceptors order by the aspect’s declared ordering, outermost first, with stable tie-breaking across compilations.
  • Notification-only event semantics. Firing an event always succeeds. A throwing observer does not fail the fan-out or prevent later observers from running.

Reach for an aspect when the same around-behavior (timing, auditing, guarding) belongs on methods across unrelated beans. A per-operation concern on a service contract belongs in a services interceptor. Reach for an event when beans inside one application must react to each other without coupling. An event is not cross-verticle dispatch, which is a service contract, and it is not durable messaging, which is inbox/outbox.

Cache and rate-limit annotations use the same selector vocabulary: key is an ordered list of parameter or accessor paths, while subject selects the caller dimension (CacheIdentity for caching and RateLimitSubject for rate limiting). Use the current subject attribute when composing these annotations. See the vertique-cache-aop module reference and vertique-rate-limit-aop module reference for their complete contracts.

The public vertique-example-aop example applies the built-in @Timed aspect (from vertique-micrometer-core) to plain bean methods. This excerpt is abridged from its Greeter:

@Singleton
public class Greeter {
@Inject
public Greeter() {}
@Timed("vertique.example.greet")
public Future<String> greet(String name) {
return Future.succeededFuture("Hello, " + name + "!");
}
@Timed(
value = "vertique.example.farewell",
extraTags = {"tone", "formal"})
public Future<String> farewell(String name) {
return Future.succeededFuture("Goodbye, " + name + ".");
}
}

At compile time the AOP processor generates a subclass proxy for every bean with at least one aspect-annotated method, plus a generated Dagger module that binds the bean type to its proxy. Injectors receive the proxied instance. The only application wiring is installing the generated module and binding the aspect’s provider, as the example’s own component does.

@Resilient is the method-level anchor for resilience on a concrete, Dagger-managed bean. Add it to the method together with @Retry, @Timeout, @CircuitBreaker, or @Bulkhead. The generated proxy then runs the common resilience pipeline. A concrete method carrying a resilience declaration without @Resilient fails at compile time, so a declaration cannot be left inert. The method must return a Vert.x future because the resilience components are asynchronous.

This abridged method comes from the public vertique-example-services resilience probe:

@Resilient(policy = "probe")
@Retry(maxRetries = 2, delayMs = 1, maxDelayMs = 1)
public Future<String> probe(String key) {
// The example fails the first four attempts and succeeds on the fifth.
}

policy is optional. With a non-empty policy name, configuration supplies a named tier. Without a policy name, at least one inline resilience declaration is required. The Services page shows the complete service-handler path, including the runtime and generated modules that must be installed in the component.

Framework aspects use reserved ordering bands, with higher values outermost. In the shipped composition, rate-limit admission surrounds cache lookup, and resilience runs inside the cache. A cache hit therefore bypasses retry and breaker execution, and retries on a cache miss do not consume additional quota. The AOP module reference owns the complete ordering table and the edge-case rules.

An aspect is two pieces: an annotation meta-annotated with @Aspect, and an aspect provider that builds the interceptor for each annotated method. From the vertique-aop module reference:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Aspect(ordering = 2000)
public @interface Timed {
String value() default "";
}
@Provides
AspectProvider<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"));
});
};
}

The interceptor is around-advice: run logic before and after invocation.proceed(), read the method’s reflection-free metadata from target, and read the annotation’s attribute values from the generated literal. An aspect on a method that does not return a future must complete synchronously. A deferring interceptor on a sync-returning method fails with an IllegalStateException at call time, and no thread is blocked. The vertique-aop module reference documents the interceptor chain, ordering, and every invariant.

An event is a plain record. The publisher is an injected typed interface, and an observer is any bean method with an @Observes parameter. From the public vertique-example-events example:

public record OrderCreated(String orderId) {}
public class OrderService {
private final Event<OrderCreated> events;
@Inject
public OrderService(Event<OrderCreated> events) {
this.events = events;
}
public Future<Void> placeOrder(String orderId) {
return events.fire(new OrderCreated(orderId));
}
}
@Singleton
public class OrderObserver {
public void onOrder(@Observes OrderCreated event) {
// react to the event
}
}

The events processor generates the publisher implementation and the observer registrations into a generated Dagger module the application installs. The application does not assemble an observer list by hand, and no classpath scanning happens at runtime. Dispatch is notification-only: fire completes successfully even when an observer throws, and every remaining observer still runs.

Applications parented on vertique-app-parent already run the complete annotation-processor facade, so declaring the runtime modules is all that is needed:

Add For
vertique-aop The @Aspect meta-annotation, AspectProvider, and the interceptor chain runtime
vertique-resilience @Resilient, the resilience runtime, named-policy registry, and ResilienceAopModule
vertique-events @Observes, the typed event publisher, and the observer registry

Install each generated module in your application component alongside your own modules. The AOP processor’s generated module binds each proxied bean, and the events processor’s generated module registers publishers and observers. Both examples’ components show the shape.