Dispatching & listeners
This page is the full public API for EventDispatcher and EventListener. For wiring and concepts, start with Events. For test doubles, see Testing.
Defining events
Section titled “Defining events”An event is any non-null object. There is no required base class or marker interface. Prefer immutable payloads with clear fields:
data class UserRegistered( val userId: Long, val email: String,)
data class PaymentFailed( val orderId: String, val reason: String,)public final class UserRegistered { public final long userId; public final String email;
public UserRegistered(long userId, String email) { this.userId = userId; this.email = email; }}
public record PaymentFailed(String orderId, String reason) {}Keep events free of framework types when you can. Pass identifiers and value objects; let listeners resolve Mailer, repositories, or Bus from the container if they need services.
Registering listeners
Section titled “Registering listeners”listen(Class, EventListener)
Section titled “listen(Class, EventListener)”Adds a handler for exactly that class. Returns this so you can chain calls.
val bus = EventDispatcher()
bus.listen(UserRegistered::class.java) { event -> // trailing lambda → EventListener SAM}
bus.listen(UserRegistered::class.java, EventListener { event -> // explicit SAM})
bus .listen(UserRegistered::class.java) { /* A */ } .listen(PaymentFailed::class.java) { /* B */ }EventDispatcher bus = new EventDispatcher();
bus.listen(UserRegistered.class, event -> { // lambda implements EventListener});
bus.listen(UserRegistered.class, (EventListener<UserRegistered>) event -> { // explicit SAM when the compiler needs help});
bus .listen(UserRegistered.class, event -> { /* A */ }) .listen(PaymentFailed.class, event -> { /* B */ });Kotlin reified listen
Section titled “Kotlin reified listen”inline fun <reified T : Any> listen(listener: EventListener<T>): EventDispatcherbus.listen<UserRegistered> { event -> logger.info("user {}", event.userId)}Java has no reified form — always pass SomeEvent.class.
Listener shape
Section titled “Listener shape”fun interface EventListener<T : Any> { fun handle(event: T)}Any class (or method reference) that matches handle works:
class SendWelcomeEmail : EventListener<UserRegistered> { override fun handle(event: UserRegistered) { // … }}
bus.listen(UserRegistered::class.java, SendWelcomeEmail())public final class SendWelcomeEmail implements EventListener<UserRegistered> { @Override public void handle(UserRegistered event) { // … }}
bus.listen(UserRegistered.class, new SendWelcomeEmail());There is no automatic class scanning, no @Listens attribute, and no EventServiceProvider map of event → listener class names. Registration is always explicit listen calls.
Dispatching
Section titled “Dispatching”fun dispatch(event: Any)bus.dispatch(UserRegistered(userId = 7, email = "a@b.c"))bus.dispatch(new UserRegistered(7, "a@b.c"));Behavior:
| Situation | Result |
|---|---|
Listeners registered for event.javaClass |
Each handle runs in registration order |
| No listeners for that class | Returns immediately; no error |
| A listener throws | Exception propagates; remaining listeners for this dispatch are skipped |
| Dispatch a type with zero registrations | Silent no-op (same as “no listeners”) |
There is no Event::dispatch static helper and no top-level event(...) function — call dispatch on an EventDispatcher instance.
Exact class matching
Section titled “Exact class matching”Matching uses the event instance’s runtime class, not interfaces or superclasses:
open class DomainEventclass OrderShipped : DomainEvent()
bus.listen(DomainEvent::class.java) { /* never runs for OrderShipped */ }bus.dispatch(OrderShipped()) // no DomainEvent listeners invoked
bus.listen(OrderShipped::class.java) { /* runs */ }bus.dispatch(OrderShipped())If you need shared handling, register the same listener (or a shared function) on each concrete class, or put shared logic in a helper the concrete listeners call.
Multiple listeners
Section titled “Multiple listeners”bus.listen(OrderShipped::class.java) { audit.log(it) }bus.listen(OrderShipped::class.java) { mail.sendReceipt(it) }bus.listen(OrderShipped::class.java) { metrics.increment("orders.shipped") }
bus.dispatch(OrderShipped("42"))// audit → mail → metrics (registration order)Listeners are stored in a concurrent map of copy-on-write lists — safe to register from multiple threads, but prefer registering during boot before traffic arrives.
Inspection
Section titled “Inspection”| Method | Returns |
|---|---|
hasListeners(Class<*>) |
true when at least one listener is registered for that class |
listenerCount(Class<*>) |
Number of listeners for that class (0 if none) |
assertTrue(bus.hasListeners(OrderShipped::class.java))assertEquals(3, bus.listenerCount(OrderShipped::class.java))assertEquals(0, bus.listenerCount(PaymentFailed::class.java))assertTrue(bus.hasListeners(OrderShipped.class));assertEquals(3, bus.listenerCount(OrderShipped.class));These do not tell you whether a particular dispatch already ran; they only describe the registry. For recording dispatches in tests, use FakeEvent.
Removing listeners
Section titled “Removing listeners”| Method | Effect |
|---|---|
forget(Class<*>) |
Removes all listeners for that event class |
flush() |
Clears every listener for every class |
There is no API to remove a single listener instance while leaving others for the same type.
bus.forget(OrderShipped::class.java)assertFalse(bus.hasListeners(OrderShipped::class.java))
bus.flush()// registry emptybus.forget(OrderShipped.class);bus.flush();Use flush() between tests when you hold a shared dispatcher, or prefer a fresh EventDispatcher() / FakeEvent per test.
Register from the container
Section titled “Register from the container”Typical production pattern: one singleton bus, listeners attached in boot.
class EventBindings : ServiceProvider { override fun register(app: Application) { // EventServiceProvider already bound EventDispatcher; nothing here }
override fun boot(app: Application) { val events = app.resolve(EventDispatcher::class.java) val mailer = app.resolve(Mailer::class.java)
events.listen(UserRegistered::class.java) { event -> mailer.send(/* welcome using event.email */) }
events.listen(OrderShipped::class.java) { event -> app.resolve(Bus::class.java).dispatch(SendShipmentNotification(event.id)) } }}public final class EventBindings implements ServiceProvider { @Override public void register(Application app) { }
@Override public void boot(Application app) { EventDispatcher events = app.resolve(EventDispatcher.class); Mailer mailer = app.resolve(Mailer.class);
events.listen(UserRegistered.class, event -> mailer.send(/* welcome using event.email */) );
events.listen(OrderShipped.class, event -> app.resolve(Bus.class).dispatch(new SendShipmentNotification(event.id)) ); }}Ensure EventServiceProvider is registered before any provider that resolves EventDispatcher in boot. Providers that only call listen in boot are fine as long as EventServiceProvider appears earlier in the provider list (or was registered before boot).
Common use cases
Section titled “Common use cases”Decouple a write path from side effects
Section titled “Decouple a write path from side effects”fun checkout(order: Order, events: EventDispatcher) { orders.save(order) events.dispatch(OrderPlaced(order.id, order.total))}void checkout(Order order, EventDispatcher events) { orders.save(order); events.dispatch(new OrderPlaced(order.id, order.total));}Listeners send mail, update search indexes, or enqueue jobs without the checkout method knowing about them.
Fan-out to a queue job
Section titled “Fan-out to a queue job”events.listen(OrderPlaced::class.java) { event -> bus.dispatch(ProcessOrderPlaced(event.orderId)) // Job + ShouldQueue}The event stays synchronous and tiny; the job does the slow work. See Jobs & dispatch.
Audit logging
Section titled “Audit logging”events.listen(PaymentFailed::class.java) { event -> logger.warn("payment failed order={} reason={}", event.orderId, event.reason)}Grind / steam
Section titled “Grind / steam”From a booted application in steam or a custom grind command, resolve the same singleton and dispatch for manual ops:
app.resolve(EventDispatcher::class.java).dispatch(OrderShipped("ops-1"))API reference
Section titled “API reference”EventDispatcher
Section titled “EventDispatcher”| Method | Description |
|---|---|
listen(Class<T>, EventListener<T>): EventDispatcher |
Register a listener; chainable |
listen<T>(EventListener<T>): EventDispatcher |
Kotlin reified overload |
dispatch(Any) |
Invoke listeners for event.javaClass in order |
forget(Class<*>) |
Drop all listeners for one type |
flush() |
Drop all listeners |
hasListeners(Class<*>): Boolean |
Whether any listener exists for the type |
listenerCount(Class<*>): Int |
Count of listeners for the type |
EventListener<T>
Section titled “EventListener<T>”| Method | Description |
|---|---|
handle(T) |
Invoked once per matching dispatch |
EventServiceProvider
Section titled “EventServiceProvider”| Method | Description |
|---|---|
register(Application) |
singleton(EventDispatcher::class.java) { EventDispatcher() } |
boot |
Not overridden (no default listeners) |
Pitfalls
Section titled “Pitfalls”- Forgetting the provider. Resolving
EventDispatcherwithoutEventServiceProviderfails unless you bound it yourself. - Exact type only. Listening on a base type does not receive subclass dispatches.
- Exceptions abort the chain. A throwing listener skips later listeners for that dispatch; catch inside the listener if you want best-effort fan-out.
- Long work on the request thread.
dispatchblocks until all listeners finish — move heavy work to queues. - Shared mutable state. Listeners run on the caller’s thread; keep them thread-safe if you dispatch from many virtual threads.
- Cadence vs application events. Model hooks use
ModelDispatcher, notEventDispatcher. See Serialization, collections & events. - No stop-propagation. You cannot cancel remaining listeners except by throwing (which surfaces to the publisher).
forgetis all-or-nothing per type. You cannot unregister one of several listeners for the same class.
Not available today (detail)
Section titled “Not available today (detail)”| Feature | Notes |
|---|---|
| Subscribers | No subscribe(dispatcher) class that maps many events in one place |
| Queued listeners | No ShouldQueue on EventListener; use Bus from a sync listener |
| After commit | No deferral until a DB transaction commits |
| Discovery | No package scan or KSP index of listeners |
| Facades / helpers | No Event.dispatch / event() — use the dispatcher instance |
| Wildcards | No listen("order.*") on this bus |
| Priority / sorted listeners | Order is registration order only |
| Halt / stop | No return-value or API to stop remaining listeners |
If you need those behaviors, implement them in application code (for example a thin wrapper around EventDispatcher) or wait for a future deepen milestone — do not invent APIs that are not in modules/events.