Events
Events let one app announce what happened and other installed apps react. Payloads are typed, registration is explicit, and the application checks every subscription before it starts. If you know Django, they fill the role of signals.
#How events differ from signals
- Explicit. An app lists the events it emits in
App.Eventsand its handlers inApp.Subscriptions. - Checked at startup.
cicadel.Newvalidates both before the application starts. - No magic. Nothing connects at import time, and there is no global registry.
The standard project subscribes in project/events.go. The accounts app emits
user_created, logged_in, login_failed, logged_out and password_changed.
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. Use that app’s label and a lowercase name.
- Choose the payload type. Handlers receive it.
- List the declaration in the app’s
Events. - Call
Emitwith 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. A handler always receives the payload type the emitter sends.
#Delivery
| Rule | Behavior |
|---|---|
| Synchronous and ordered | Emit runs handlers on the calling goroutine. Apps run in dependency order (the order manage.go apps prints), then handlers in the order each app lists them. |
| No queue | 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. Emit returns a *events.HandlerError naming the event and the subscribing app. errors.Is and errors.As reach the handler’s own error. The emitter decides what the error means. |
| Panics | Handler panics propagate, as they would from an HTTP handler. |
| Cancellation | Emit checks the context before each handler and returns ctx.Err() once it is done. Handlers should honour the context too. |
| Nesting | A handler may emit other events. Nesting deeper than events.MaxDepth (16) levels fails with events.ErrDepth instead of recursing forever. |
| Concurrency | cicadel.New builds the dispatcher once and it 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. Pass the
transaction in the payload as Tx when handlers may need to write.
- A handler that writes through
Txcommits or rolls back with the change. - 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
Txafter 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. Cicadel attaches it 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. This also attaches the
signing keys and static-file manifest:
ctx = app.Context(ctx)Warning
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.
#Test a package’s events
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.
#Accounts events
| Event | Payload | Emitted |
|---|---|---|
accounts.UserCreated | UserCreation{User, Tx} | by Service.Create (and createuser) after inserting the user |
accounts.LoggedIn | Login{User, Tx} | by Service.Login, browser and JSON sign-in, after inserting the session |
accounts.LoginFailed | LoginFailure{Username} | when sign-in is rejected with ErrCredentials; never for database or backend errors, and never with the password |
accounts.LoggedOut | Logout{UserID, Tx} | by Service.Logout, browser and JSON sign-out, and when a browser signs in again over an older session |
accounts.PasswordChanged | PasswordChange{UserID, Reset, Tx} | by ChangePassword and ResetPassword after replacing the hash and ending sessions |
Payloads with Tx are emitted inside the account transaction. A refused
UserCreated leaves no user, and a refused LoggedIn leaves no session. For
example, create a profile with every user from project/events.go:
accounts.UserCreated.Handle(func(ctx context.Context, e accounts.UserCreation) error {
return e.Tx.Exec("INSERT INTO profiles(user_id) VALUES (?)", e.User.ID).Error
})LoginFailure.Username is the submitted, untrusted value. Escape it when you
display it and bound it when you store it. A LoginFailed handler error is
joined to ErrCredentials, so callers still see a rejected sign-in.
#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.
- Cross-process delivery. Events are in-process. They do not cross processes
or survive a restart. Use the River integration in
jobsfor durable work.