Skip to content
Kona

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.

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,
)

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.

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 */ }
inline fun <reified T : Any> listen(listener: EventListener<T>): EventDispatcher
bus.listen<UserRegistered> { event ->
logger.info("user {}", event.userId)
}

Java has no reified form — always pass SomeEvent.class.

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())

There is no automatic class scanning, no @Listens attribute, and no EventServiceProvider map of event → listener class names. Registration is always explicit listen calls.

fun dispatch(event: Any)
bus.dispatch(UserRegistered(userId = 7, email = "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.

Matching uses the event instance’s runtime class, not interfaces or superclasses:

open class DomainEvent
class 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.

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.

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))

These do not tell you whether a particular dispatch already ran; they only describe the registry. For recording dispatches in tests, use FakeEvent.

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 empty

Use flush() between tests when you hold a shared dispatcher, or prefer a fresh EventDispatcher() / FakeEvent per test.

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))
}
}
}

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).

fun checkout(order: Order, events: EventDispatcher) {
orders.save(order)
events.dispatch(OrderPlaced(order.id, order.total))
}

Listeners send mail, update search indexes, or enqueue jobs without the checkout method knowing about them.

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.

events.listen(PaymentFailed::class.java) { event ->
logger.warn("payment failed order={} reason={}", event.orderId, event.reason)
}

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"))
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
Method Description
handle(T) Invoked once per matching dispatch
Method Description
register(Application) singleton(EventDispatcher::class.java) { EventDispatcher() }
boot Not overridden (no default listeners)
  1. Forgetting the provider. Resolving EventDispatcher without EventServiceProvider fails unless you bound it yourself.
  2. Exact type only. Listening on a base type does not receive subclass dispatches.
  3. Exceptions abort the chain. A throwing listener skips later listeners for that dispatch; catch inside the listener if you want best-effort fan-out.
  4. Long work on the request thread. dispatch blocks until all listeners finish — move heavy work to queues.
  5. Shared mutable state. Listeners run on the caller’s thread; keep them thread-safe if you dispatch from many virtual threads.
  6. Cadence vs application events. Model hooks use ModelDispatcher, not EventDispatcher. See Serialization, collections & events.
  7. No stop-propagation. You cannot cancel remaining listeners except by throwing (which surfaces to the publisher).
  8. forget is all-or-nothing per type. You cannot unregister one of several listeners for the same class.
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.