Documentation
Features

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.

SignalConstantFired when
After createforge.AfterCreateA new item is persisted (any initial status)
After updateforge.AfterUpdateAn existing item's fields are changed
After publishforge.AfterPublishAn item transitions to Published
After unpublishforge.AfterUnpublishAn item leaves Published status
After archiveforge.AfterArchiveAn item transitions to Archived
After scheduleforge.AfterScheduleAn item transitions to Scheduled
After deleteforge.AfterDeleteAn 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 typeAPIScopeReceives
App-level busapp.OnSignalAll modulesSignalEvent
Module-level typedforge.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.