When a report becomes available, several parts of your application might want to observe it without the report service importing each observer. An event bus connects a named notification to local listener functions. Arkoren calls those functions directly; it does not store notifications or send them to another process.
Use the bus for short, best-effort observations. If work must survive a crash, record a durable handoff where the application owns the change; adding a queue call inside a listener does not protect the time before that call succeeds.
Focused snippets leave routine error handling to your application. Examples about failure decisions retain the relevant checks.
Choosing a Bus
With an existing application, use bus := app.Events(). It returns the same
nonnil *event.Bus throughout that application’s lifetime. Pass this bus to
components that need it rather than passing the whole application.
For an isolated component or test, construct a separate bus:
import "arkoren.dev/core/event"
bus := event.New()
Each bus has independent registrations. It needs no startup or shutdown and has
no resources to close. To choose where best-effort listener failures are logged,
use a construction callback; slog comes from log/slog:
bus := event.New(func(events *event.Builder) {
events.Logger(slog.Default())
})
This is an alternative construction. Settings are copied when construction ends;
retaining the builder does not reconfigure the bus. The logger remains shared.
Callbacks run once in order, later logger settings win, and nil selects
slog.Default() when a failure is logged. A supplied nil callback or callback
panic propagates. The application’s own bus uses its application logger.
Defining Topics
A topic associates an event name with its payload type. Define one topic per name in the package that owns the fact. A report notification needs its stable identifier, not its contents or a download credential:
type ReportReadyEvent struct {
ReportID string
}
var ReportReady = event.NewTopic[ReportReadyEvent]("reports.ready")
Typed calls check the payload at compile time, but routing uses the name. Topics with the same name share listeners even if constructed separately. Give each name exactly one payload type: a typed listener silently skips a nonnil payload of another type.
Names contain dot-separated tokens of 1–64 lowercase letters, digits, or
hyphens, with no leading or trailing hyphen in a token and at most 255 bytes
overall. reports.ready is valid; Reports.Ready is not. Construction does not
validate the name. Registration and dispatch return event.ErrInvalidName.
If a notification needs no data, define a signal instead:
var ReportsCleared = event.NewSignal("reports.cleared")
Publish it with bus.Signal(ctx, ReportsCleared); listeners take event.Void.
These signals are unrelated to workflow signals.
Registering Observers
Register before the operation can publish. With ctx from context and logger
an existing *slog.Logger, an observer can record only the report identifier:
unsubscribe, err := bus.Listen(ReportReady, func(ctx context.Context, notice ReportReadyEvent) error {
logger.InfoContext(ctx, "report ready", "report_id", notice.ReportID)
return nil
})
Registration returns an error and a removal function. Check the error during
wiring before retaining or calling unsubscribe. Keep the registration for its
owner’s lifetime; Writing Listeners explains
request, test, and composition ownership. Registering after publication does not
replay an earlier event.
Publishing Events
After the report is available, announce that fact from the application layer that knows it happened:
bus.Emit(ctx, ReportReady, ReportReadyEvent{ReportID: "report-2026-11"})
Emit returns no result. A listener error or panic is logged with the event name,
delivery stops, and later listeners are skipped. Panics include a diagnostic
stack in that log. The operation’s result is unchanged. A nil bus makes Emit
and Signal no-ops.
If a callback must be allowed to fail the operation, use Dispatch and handle
its error at the boundary that can still reject or roll back the change:
err := bus.Dispatch(ctx, ReportReady, ReportReadyEvent{ReportID: "report-2026-11"})
Dispatch returns the first listener error and does not recover listener panics.
It does not undo work that already happened. A direct function call is often
clearer for a mandatory dependency; Event Auditing shows where
transactional dispatch can enforce a required record.
Delivery Boundaries
Both publishing methods run listeners synchronously on the publisher’s goroutine. Exact-name listeners run first in registration order, then matching pattern listeners in their registration order. A slow listener delays the publisher; there is no automatic retry, buffering, or independent execution.
Listeners receive the supplied context unchanged, including cancellation, deadlines, and values. With a database transaction’s callback context, compatible database calls in a listener join that transaction. They do not observe a durable commit yet. Use after-commit hooks for best-effort effects that must wait for commit, or persist pending work in the transaction when losing it is unacceptable. Never keep using a completed transaction context from a background goroutine.
The bus supports concurrent registration and publication. Different publishers can call the same listener concurrently, so the listener owns synchronization for its state. Registration changes affect later snapshots; removing a listener does not cancel a callback already selected for delivery.
Use queues for worker delivery and broadcasting for messaging between processes. Neither is supplied by this bus. Arkoren components publish on the bus only when configured to do so, apart from the application’s own lifecycle events; Built-in Events is the discovery catalog.
For all functions and options, see the event API reference.