Signal bus
Forge fires a signal every time a content item changes state. The signal bus lets your application code subscribe to those signals with a single call — no module options, no coupling to specific content types.
app.OnSignal(forge.AfterPublish, func(ctx context.Context, ev forge.SignalEvent) error {
log.Printf("published: %s %s", ev.Type, ev.Slug)
return nil
})
Multiple handlers per signal are supported. Handlers fire in registration order. Webhook delivery is itself a bus subscriber — custom handlers and webhooks coexist naturally.
Available signals
The bus dispatches seven lifecycle signals. All fire asynchronously, after the database write has committed.
| Signal | Constant | Fired when |
|---|---|---|
| After create | forge.AfterCreate | A new item is persisted (any initial status) |
| After update | forge.AfterUpdate | An existing item's fields are changed |
| After publish | forge.AfterPublish | An item transitions to Published |
| After unpublish | forge.AfterUnpublish | An item leaves Published status |
| After archive | forge.AfterArchive | An item transitions to Archived |
| After schedule | forge.AfterSchedule | An item transitions to Scheduled |
| After delete | forge.AfterDelete | An item is permanently deleted |
AfterPublish and AfterUpdate can fire together on the same operation — when an item is updated and simultaneously published, both signals fire.
AfterSchedule fires in addition to AfterUpdate, not instead of it.
The SignalEvent type
Every handler receives a SignalEvent value with the full context of the change.
type SignalEvent struct {
Type string // Go type name (e.g. "Post")
Slug string
Title string // empty if the type does not implement Titled
URL string // BaseURL + module prefix + "/" + slug
Timestamp time.Time
PreviousState string // status before the transition; "" on create
ActorRole string // first role of the acting user, or "guest"
ActorID string // user ID of the acting user, or ""
}
PreviousState tells you what state the item was in before the change. For AfterPublish, it is typically "draft" or "scheduled". For AfterCreate, it is "".
ActorRole and ActorID identify the user who triggered the action. For background operations (e.g. a scheduled post going live), ActorRole is "guest" and ActorID is "".
Registering a handler
Call app.OnSignal before app.Run. Handlers registered after Run are silently ignored.
app := forge.New(forge.MustConfig(forge.Config{...}))
// Audit log — fires on every content change
app.OnSignal(forge.AfterCreate, logToAuditDB)
app.OnSignal(forge.AfterUpdate, logToAuditDB)
app.OnSignal(forge.AfterDelete, logToAuditDB)
// Cache invalidation — fires when published content changes
app.OnSignal(forge.AfterPublish, invalidateCache)
app.OnSignal(forge.AfterUnpublish, invalidateCache)
app.Run(":8080")
Handler semantics
Async. Handlers run inside the existing background goroutine that Forge uses for post-write operations. They do not block the HTTP response.
Timeout. Each handler receives a context.Context with a 100 ms timeout, detached from the originating HTTP request (via context.WithoutCancel). Long-running work should be dispatched to a separate goroutine or queue.
Error handling. If a handler returns an error, it is logged at Warn level. Subsequent handlers for the same signal still fire — one failing handler does not block the chain.
Ordering. Handlers fire in the order they were registered with OnSignal.
Module-level typed handlers
OnSignal is the app-level bus. For handlers that need access to the typed content value (not just SignalEvent metadata), use forge.On[T] as a module option instead:
forge.NewModule((*Post)(nil),
forge.At("/posts"),
forge.Repo(repo),
forge.On(forge.BeforeCreate, func(ctx forge.Context, p *Post) error {
p.Author = ctx.User().Name
return nil
}),
)
forge.On[T] supports Before and After signals. Before handlers can abort the operation by returning an error.
| Handler type | API | Scope | Receives |
|---|---|---|---|
| App-level bus | app.OnSignal | All modules | SignalEvent |
| Module-level typed | forge.On[T] | One module | *T (concrete value) |
The OutboundDelivery interface
If you are building a delivery engine that needs retry-backed outbound HTTP (for example, a notification service), implement OutboundDelivery:
type OutboundDelivery interface {
Enqueue(ctx context.Context, job OutboundJob) error
}
This decouples your delivery engine from Forge's webhook internals. The webhook worker pool satisfies this interface, so a custom engine can use the same job queue without reimplementing retry logic.
Practical examples
Audit log:
app.OnSignal(forge.AfterCreate, func(ctx context.Context, ev forge.SignalEvent) error {
return auditDB.Record(ev.ActorID, "create", ev.Type, ev.Slug, ev.Timestamp)
})
Cache invalidation:
app.OnSignal(forge.AfterPublish, func(ctx context.Context, ev forge.SignalEvent) error {
return cache.Invalidate(ev.URL)
})
Discord notification:
app.OnSignal(forge.AfterPublish, func(ctx context.Context, ev forge.SignalEvent) error {
msg := fmt.Sprintf("Published: [%s](%s)", ev.Title, ev.URL)
return discord.PostMessage(ctx, channelID, msg)
})
SSE broadcast:
app.OnSignal(forge.AfterPublish, func(ctx context.Context, ev forge.SignalEvent) error {
hub.Broadcast(ev.Slug)
return nil
})
For outbound webhook delivery to external URLs, see Webhooks. For information on what triggered devlog thinking behind this feature, see the devlog post From webhooks to a signal bus.