Skip to content

Code generation events

View Markdown

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

vertique-codegen-events is an annotation processor that generates typed event publishers and observer registrations at compile time. For each event type in the inventory it generates a concrete X$Event extends Event<X> publisher class, and for each @Observes observer method it generates a @Provides @IntoSet ObserverRegistration provider. All generated bindings land in a single GeneratedEventsModule Dagger @Module.

The processor requires no configuration for common cases — it discovers event types automatically from @Observes parameters and Event<T> injection points.


This processor owns event generation for modules that:

  • declares observer methods (@Observes T parameter on a method), or
  • injects Event<T> in an @Inject constructor.

A module that only fires events (injects Event<T>) and has no observers still needs the processor so the X$Event publisher is generated and bindable.


Step 1 — use the application processor boundary

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

Applications inheriting vertique-app-parent declare vertique-events as a runtime dependency and 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 GeneratedEventsModule to @Component

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

When GeneratedEventsModule is omitted:

  • No Event<X> binding exists → injection of Event<X> fails at Dagger graph construction.
  • Set<ObserverRegistration> has no @Multibinds anchor → ObserverRegistry injection may fail unless another module provides the multibinding.

Listing GeneratedEventsModule.class in @Component turns processor misconfiguration into a compile error: the generated class will not exist if the processor is absent from annotationProcessorPaths.


The processor builds the inventory from two sources in the same compilation unit:

  1. @Observes parameters — every method that has a parameter annotated with @Observes T contributes T to the inventory.
  2. @Inject constructor parameters — every Event<T>, Provider<Event<T>>, or Lazy<Event<T>> parameter on an @Inject constructor contributes T to the inventory.

Field injection, method injection, and @Component provision methods are not scanned. This matches the framework’s constructor-injection convention.

For each type in the inventory, the processor generates an X$Event extends Event<X> publisher. An unobserved type that is only injected as Event<X> still gets a valid no-op publisher.


The processor enforces the following at compile time:

Validation Error
Observer method must return void @Observes on a non-void method is a compile error
Observer method must have exactly one parameter A method with multiple parameters (even if one carries @Observes) is rejected
@Observes Object is rejected The registry’s superclass walk excludes Object; such an observer would silently never fire

// placed in the event type's own package (e.g. com.example.events)
@Generated("dev.vertique.codegen.events.EventsProcessor")
public final class OrderCreated$Event extends Event<OrderCreated> {
@Inject
public OrderCreated$Event(ObserverRegistry registry) {
super(registry, OrderCreated.class);
}
}

Each publisher is placed in the event type’s own package (not a shared LCP package). This guarantees that two distinct-FQN event types sharing the same simple name — for example com.acme.OrderCreated and com.shop.OrderCreated — emit com.acme.OrderCreated$Event and com.shop.OrderCreated$Event respectively, with no name collision.

@Generated("dev.vertique.codegen.events.EventsProcessor")
@Module
public abstract class GeneratedEventsModule {
// One @Binds Event<X> per inventory type
// Method name: bind<SimpleName>_<ordinal>Event (ordinal = FQN-sorted position)
@Binds
abstract Event<OrderCreated> bindOrderCreated_0Event(OrderCreated$Event impl);
@Binds
abstract Event<OrderShipped> bindOrderShipped_1Event(OrderShipped$Event impl);
// Always emitted — satisfies ObserverRegistry even when zero observers exist
@Multibinds
abstract Set<ObserverRegistration> observerRegistrations();
// One @Provides @IntoSet ObserverRegistration per observer method
@Provides
@IntoSet
static ObserverRegistration provideOrderServiceOrderCreated_0ObserverRegistration(
OrderService bean) {
return new ObserverRegistration(
OrderCreated.class,
1000,
e -> bean.onOrderCreated((OrderCreated) e));
}
}

@Binds method names — bind<SimpleName>_<ordinal>Event — where the ordinal is the zero-based position of the event type in the inventory sorted by fully-qualified name (natural String order). FQN sorting is reproducible across compilation runs and JVM implementations, independent of the non-deterministic RoundEnvironment.getRootElements() iteration order.

@Provides method names — provide<BeanSimple><EventSimpleName>_<ordinal>ObserverRegistration — where the ordinal is the zero-based position of the observer in a list sorted by (bean-FQN, method-name, event-FQN). The ordinal alone guarantees uniqueness; the simple-name prefix is included only for human readability.

The ordinal disambiguates all collision scenarios:

  • Multiple observer methods in the same bean sharing the same simple name (overloads like void on(@Observes EventA e) and void on(@Observes EventB e))
  • Two event types sharing the same simple name but different FQNs (com.acme.OrderCreated vs com.shop.OrderCreated)
  • FQNs differing only in casing or underscore/dot placement

GeneratedEventsModule always emits @Multibinds abstract Set<ObserverRegistration> observerRegistrations(). This is required even when observers exist in the module, because Dagger requires an explicit @Multibinds declaration when the set may be empty in any compilation unit that includes the module. Without this anchor, a module that has no observers would fail to compile because ObserverRegistry’s Set<ObserverRegistration> constructor parameter has no 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 observes all elements in the compilation without being pre-configured with specific annotation types. Returns false to avoid claiming annotations; sibling processors (Dagger, Lombok) see the same elements.

Processing occurs in a single pass per compilation round:

  1. Scan @Observes parameters — validate return type (void), parameter count (exactly 1), and event type (!= Object).
  2. Scan @Inject constructor parameters — unwrap Event<T>, Provider<Event<T>>, Lazy<Event<T>>.
  3. Resolve output package (option override → LCP of inventory types → vertique.generated.events fallback).
  4. Emit one X$Event publisher per inventory type (deduplicated by publisher FQN).
  5. Emit GeneratedEventsModule with @Binds, @Multibinds, and @Provides @IntoSet methods.

Option Default Description
vertique.codegen.package LCP of inventory-type packages Override the output package for GeneratedEventsModule

The publisher classes (X$Event) are always placed in the event type’s own package regardless of this option; only the module’s output package is affected.


@Observes is SOURCE-retained and therefore invisible across module boundaries. Cross-module observer aggregation works through Dagger multibinding:

  • Each module’s GeneratedEventsModule contributes its observers via @Provides @IntoSet ObserverRegistration.
  • An observer in a library module fires in the application if and only if the library’s GeneratedEventsModule is included in the application’s @Component.
  • An observer whose GeneratedEventsModule is omitted silently has no effect — the same “omitted-module” caveat that applies to GeneratedServicesModule and GeneratedAopModule.

When GeneratedEventsModule is not included in @Component, the Set<ObserverRegistration> multibinding is empty (or absent) and ObserverRegistry.match() returns no registrations for any event type. Firing an event is a no-op. There is no compile error from the events module itself — the failure is detectable only if ObserverRegistry injection also fails (no @Multibinds anchor).

Mitigation: include GeneratedEventsModule.class explicitly in @Component. This turns processor misconfiguration (missing annotationProcessorPaths entry) into a compile error because the generated class will not exist.

When a bean injects Event<X> but the processor is absent from annotationProcessorPaths, no X$Event class is generated and no @Binds Event<X> binding exists. Dagger fails at component compilation with a missing binding error.


Artifact Scope Purpose
vertique-codegen-core compile CodegenContext, AnnotationMirrors, Diagnostics, PackageResolver, DaggerModuleWriter
vertique-events compile Event, ObserverRegistry, ObserverRegistration, @Observes — FQN constants and ClassName references for generated source
javapoet compile Source generation (via DaggerModuleWriter and inline TypeSpec/MethodSpec construction)

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