Events
Apps announce what happened through typed events, and other installed apps
react to them. Cicadel uses events where Django uses signals, but registration is
explicit: an app lists the events it emits in App.Events and its handlers in
App.Subscriptions, and cicadel.New checks both before the application starts.
Nothing connects at import time and there is no global registry.
The standard project subscribes in project/events.go. A package made with
startpackage declares and emits greeted as a template.
#Declare and emit an event
Declare each event once, as a package-level variable of the app that owns it,
with that app’s label and a lowercase name. The type parameter is the payload
that handlers receive. List the declaration in the app’s Events, then call
Emit with the request, command or hook context:
package orders
import (
"context"
"errors"
"net/http"
"github.com/fauzaanu/cicadel"
"github.com/fauzaanu/cicadel/database"
"github.com/fauzaanu/cicadel/events"
"github.com/fauzaanu/cicadel/response"
"github.com/fauzaanu/cicadel/urls"
"gorm.io/gorm"
)
// Placed is emitted inside the transaction that stores a new order.
var Placed = events.New[Placement]("orders", "placed")
// Placement is the Placed payload. Handlers write through Tx so their work
// commits or rolls back with the order.
type Placement struct {
OrderID int64
Tx *gorm.DB
}
// App stores orders in an orders table created by its recorded migration.
// confirm runs after commit, so a rolled-back order is never confirmed.
func App(db *gorm.DB, confirm func(context.Context, int64) error) cicadel.App {
return cicadel.App{
Name: "orders",
Events: []events.Declaration{Placed},
Subscriptions: []events.Subscription{
Placed.Handle(func(ctx context.Context, e Placement) error {
id := e.OrderID
return database.OnCommit(e.Tx, func(ctx context.Context) error { return confirm(ctx, id) })
}),
},
Routes: []urls.Route{urls.Path("POST", "/orders", "orders:place", func(w http.ResponseWriter, r *http.Request) {
err := database.Atomic(r.Context(), db, func(tx *gorm.DB) error {
var id int64
if err := tx.Raw("INSERT INTO orders DEFAULT VALUES RETURNING id").Scan(&id).Error; err != nil {
return err
}
return Placed.Emit(r.Context(), Placement{OrderID: id, Tx: tx})
})
var refused *events.HandlerError
switch {
case errors.As(err, &refused):
response.Error(w, http.StatusConflict) // a handler rejected the order; nothing was stored
case err != nil:
response.Error(w, http.StatusInternalServerError)
default:
w.WriteHeader(http.StatusCreated)
}
})},
}
}events.New panics on an invalid label, like regexp.MustCompile, because it
runs when the package is initialized.
#Subscribe
Event.Handle(fn) returns a subscription; list it in any installed app’s
Subscriptions, including the declaring app’s own. The project subscribes in
project/events.go:
func Subscriptions() []events.Subscription {
return []events.Subscription{
orders.Placed.Handle(func(ctx context.Context, e orders.Placement) error {
return e.Tx.Exec("INSERT INTO order_audit(order_id) VALUES (?)", e.OrderID).Error
}),
}
}go run manage.go apps lists each app’s emitted (emits) and handled
(handles) events.
#What the application checks
cicadel.New fails, naming the app, when:
- an app declares an event whose owner label is a different app;
- the same declaration is listed twice, or two declarations share a name;
- a subscription refers to an event that no installed app declares, for example after its owning app was removed (the error asks whether that app is installed);
- a subscription has a nil handler or was not created with
Handle.
Subscriptions refer to the declared Go value, not its name, so a handler always receives the payload type the emitter sends.
#Delivery
- Synchronous and ordered.
Emitruns handlers on the calling goroutine, in app dependency order (the ordermanage.go appsprints), then in the order each app lists them. There is no queue and no retry; enqueue a job from a handler for slow or retried work. - Errors stop delivery. The first handler error stops the remaining handlers
and returns a
*events.HandlerErrornaming the event and the subscribing app;errors.Is/errors.Asreach the handler’s own error. The emitter decides what that error means. Handler panics propagate, as they would from an HTTP handler. - Cancellation.
Emitchecks the context before each handler and returnsctx.Err()once it is done. Handlers should honour the context too. - Nesting. A handler may emit other events. Emission nested more than
events.MaxDepth(16) levels deep fails withevents.ErrDepthinstead of recursing forever. - Concurrency. The dispatcher is built once by
cicadel.Newand never changes, so concurrent requests can emit freely. Handlers that share state must synchronize it.
#Transactions
Emit inside the transaction that made the change, with database.Atomic, and pass
the transaction in the payload as Tx when handlers may need to write. A handler
that writes through Tx commits or rolls back together with the change, and a
handler error rolls the change back. Use database.OnCommit(e.Tx, fn) for email,
cache invalidation and other effects that must happen only after commit; see
transactions. Handlers must not keep Tx after they return.
Emit events that do not describe a database change, such as a rejected request,
outside any transaction.
#Where events are delivered
Emit finds the application’s dispatcher in the context. It is attached for:
- every request served by the application;
- app
Startand cleanup hooks; - app commands, and every command run by
manage.go, including project commands.
For other work the application owns, such as a River worker or a goroutine
started in Start, attach it with Application.Context, which also attaches the
signing keys and static-file manifest:
ctx = app.Context(ctx)With no dispatcher in the context, Emit has no subscribers and returns nil.
This keeps services usable on their own, but events
emitted from a bare context.Background() are not delivered. In package tests,
build a dispatcher directly:
d, err := events.NewDispatcher(
events.App{Name: "greet", Events: []events.Declaration{greet.Greeted}},
events.App{Name: "listener", Subscriptions: []events.Subscription{greet.Greeted.Handle(record)}},
)
ctx := events.WithDispatcher(t.Context(), d)or install a listener app beside the package with apptest.New.
#Not covered
Model save and delete events are not emitted automatically; use GORM hooks or
emit from the code that saves. Request start and finish are middleware concerns.
Events are in-process: they do not cross processes or survive a restart. Use the
River integration in jobs for durable work.