Skip to content

Status: Stable Package: dev.vertique.events Artifact: vertique-events Depends on: vertique-core, vertx-core, jakarta.inject-api API reference: javadoc.io

vertique-events is the runtime module for compile-time typed event dispatch. It provides an @Observes annotation, the Event<T> abstract publisher, and the ObserverRegistry that matches fired events to registered observers by subtype routing. Observers are registered through Dagger multibinding — no runtime classpath scanning or annotation reflection occurs at dispatch time.

The module provides notification-only semantics: Event<T>.fire(T) always succeeds, and a throwing observer never fails the fan-out or prevents subsequent observers from running.


Install this module when application code needs to publish or observe typed events across bean boundaries without coupling publishers to observer lists. Applications inheriting vertique-app-parent receive the complete processor facade automatically; custom-parent applications use the BOM plus vertique-codegen-all recipe in docs/packaging.md. Publisher and registration generation is owned by vertique-codegen-events.

Common use cases:

  • Domain event fan-out (e.g., OrderCreated notifying audit, notification, and stock modules)
  • Lifecycle hooks across independent beans without shared interfaces
  • Decoupling framework modules from application concerns

Event type inventory. The processor (vertique-codegen-events) builds a type inventory from @Observes parameter types and constructor-injected Event<T> injection points. For each type in the inventory, a concrete X$Event extends Event<X> publisher is generated and bound so that Event<X> is injectable.

Subtype routing. ObserverRegistry.match(Class<?>) returns all observers whose declared event type is the fired type, a supertype of it (walking the superclass chain), or an interface transitively implemented by it. An observer declared for AnimalCreated automatically receives a fired DogCreated event if DogCreated is a subtype of AnimalCreated.

Notification-only contract. Event<T>.fire(T) returns Future<Void>. The future completes successfully after all matched observers have settled. Any Throwable — including Error subclasses — thrown by an observer is swallowed and logged (event type name and throwable class only; no payload is logged). The publisher’s future never fails.

Sequential, priority-ordered dispatch. Observers are dispatched in ascending priority order (lower fires earlier), sequentially, using Combinators.foldSequential. The ordering of observers with equal priority is unspecified and depends on the iteration order of the injected Set<ObserverRegistration>; callers that need a strict ordering must assign distinct priority values.

Observer constraints. Observers are void-returning and synchronous (void-returning methods only). Async observer support (Future<Void>-returning) is deferred.


Parameter annotation that marks a method as an observer of its parameter’s type.

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.SOURCE)
public @interface Observes {
int priority() default 1000;
}

@Observes is SOURCE-retained — it is invisible at runtime and across module boundaries. Cross-module aggregation of observers is achieved through Dagger @IntoSet ObserverRegistration multibinding generated by vertique-codegen-events, not through runtime annotation scanning.

Usage:

@Singleton
public class OrderAuditListener {
@Inject
public OrderAuditListener() {}
public void onOrderCreated(@Observes OrderCreated event) {
// synchronous, void-returning observer
auditLog.record(event.orderId());
}
public void onOrderCreated(@Observes(priority = 500) OrderCreated event) {
// fires BEFORE the default priority (1000) observer above
}
}

Observer methods must:

  • return void
  • declare exactly one parameter
  • annotate that parameter with @Observes
  • not observe java.lang.Object directly (the registry’s superclass walk excludes Object)
  • Equal-priority observers have unspecified relative order. Assign distinct priority values when sequence matters.
  • @Observes Object is rejected at compile time: the registry excludes Object from the supertype walk, so such an observer would silently never fire.

Abstract publisher class. Application code injects the parameterized type Event<X> and calls fire(X). The concrete X$Event extends Event<X> subclass is generated by vertique-codegen-events.

public abstract class Event<T> {
public Future<Void> fire(T event) { ... }
}

Usage:

@Singleton
public class OrderService {
private final Event<OrderCreated> orderCreated;
@Inject
public OrderService(Event<OrderCreated> orderCreated) {
this.orderCreated = orderCreated;
}
public Future<OrderId> createOrder(CreateOrderRequest req) {
return persist(req)
.compose(order -> orderCreated.fire(new OrderCreated(order.id()))
.map(ignored -> order.id()));
}
}

fire() dispatches to every observer whose declared event type is OrderCreated or a supertype of OrderCreated (including interfaces). The returned future completes successfully once all observers have settled.

Injecting Event<X> when no observers are registered for X (and no @Observes X is declared anywhere) requires including the event type as an Event<X> injection point in an @Inject constructor — this places X in the inventory so the processor generates a no-op publisher. An unregistered event type fires successfully as a no-op.

Singleton registry that indexes the application’s observer registrations and matches them against a fired event type.

@Singleton
public final class ObserverRegistry {
@Inject
public ObserverRegistry(Set<ObserverRegistration> registrations) { ... }
public List<ObserverRegistration> match(Class<?> firedType) { ... }
}

Built at startup from the Dagger-aggregated Set<ObserverRegistration>. At dispatch time, match(firedType) builds the full supertype set of firedType (the type itself, every superclass up to but not including Object, and all transitively reachable interfaces via TypeResolver.getAllInterfaces), filters the registered observers against that set, removes duplicates, and returns them sorted by ascending priority.

  • Object.class is excluded from the supertype walk. An observer that declares @Observes Object is rejected at compile time.
  • match() is called on every fire() invocation and performs a Set filter + sort. Registry instances are @Singleton; the Set<ObserverRegistration> is fixed at startup.

Immutable record carrying the runtime descriptor of a single observer method.

public record ObserverRegistration(
Class<?> eventType,
int priority,
Consumer<Object> invoke
) {}

invoke is a direct, reflection-free lambda generated by vertique-codegen-events — no Method.invoke. The lambda casts the event object to the declared event type and calls the observer method on the Dagger-injected bean instance. Type safety is enforced by the processor at generation time; a runtime type mismatch would be a processor bug, not a user error.


ObserverRegistration (via @Provides @IntoSet)

Section titled “ObserverRegistration (via @Provides @IntoSet)”

Observers participate in the application’s event fan-out by contributing an ObserverRegistration to the Set<ObserverRegistration> multibinding. This happens automatically through the GeneratedEventsModule generated by vertique-codegen-events. Manual registration is possible for cases where the processor is not used:

@Provides @IntoSet
static ObserverRegistration myObserver(MyBean bean) {
return new ObserverRegistration(
OrderCreated.class,
1000,
event -> bean.onOrderCreated((OrderCreated) event)
);
}

Artifact Purpose
vertique-core Combinators.foldSequential (sequential fan-out with swallow step); TypeResolver.getAllInterfaces (interface supertype walk in ObserverRegistry)
vertx-core io.vertx.core.Future — the return type of Event.fire()
jakarta.inject-api @Inject, @Singleton on ObserverRegistry