cicadel

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.Events and its handlers in App.Subscriptions.
  • Checked at startup. cicadel.New validates 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

  1. Declare each event once, as a package-level variable of the app that owns it. Use that app’s label and a lowercase name.
  2. Choose the payload type. Handlers receive it.
  3. List the declaration in the app’s Events.
  4. Call Emit with the request, command or hook context.
Go
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:

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

RuleBehavior
Synchronous and orderedEmit 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 queueThere is no queue and no retry. Enqueue a job from a handler for slow or retried work.
Errors stop deliveryThe 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.
PanicsHandler panics propagate, as they would from an HTTP handler.
CancellationEmit checks the context before each handler and returns ctx.Err() once it is done. Handlers should honour the context too.
NestingA handler may emit other events. Nesting deeper than events.MaxDepth (16) levels fails with events.ErrDepth instead of recursing forever.
Concurrencycicadel.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 Tx commits 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 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. Cicadel attaches it for:

  • every request served by the application;
  • app Start and 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:

Go
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:

Go
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

EventPayloadEmitted
accounts.UserCreatedUserCreation{User, Tx}by Service.Create (and createuser) after inserting the user
accounts.LoggedInLogin{User, Tx}by Service.Login, browser and JSON sign-in, after inserting the session
accounts.LoginFailedLoginFailure{Username}when sign-in is rejected with ErrCredentials; never for database or backend errors, and never with the password
accounts.LoggedOutLogout{UserID, Tx}by Service.Logout, browser and JSON sign-out, and when a browser signs in again over an older session
accounts.PasswordChangedPasswordChange{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:

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 jobs for durable work.

Type to search this version.