Plugins
A plugin is a small Go value that bundles related additions to an App —
routes, middleware, hooks, MCP tools, logger swap, anything an App can
host — and registers them in one call. Plugins exist so a feature
can be shipped as a single package and dropped into any GoFastr app
without touching main.
Minimum plugin
type MyPlugin struct{}func (MyPlugin) Name() string { return "my-plugin" }func (MyPlugin) Init(app *framework.App) error { return nil }app.RegisterPlugin(MyPlugin{})
The Init method runs at App.Start (or App.InitPlugins) and is the
plugin's single integration point. From inside Init a plugin does
everything it needs by calling into the App:
func (MyPlugin) Init(app *framework.App) error { app.Router().Get("/hello", helloHandler) app.Use(myMiddleware) app.MCP.RegisterTool("my_tool", "Does the thing", schema, handler) app.HookRegistry("users").RegisterHook(framework.AfterCreate, sendWelcomeEmail) return nil}
There are no optional interfaces — no HasRoutes, HasMiddleware,
HasHooks, HasTools. The router resolves its middleware chain
late-bound per request, so middleware added from Init wraps routes
registered earlier (e.g. by Mount). There is no ordering footgun to
dodge.
Directly registered MCP tools do NOT pass through route middleware —
the handler runs as-is for any caller who can reach /mcp. To
auth-gate one, register it with mcp.WithToolGate and a precondition;
battery/auth ships ready-made gates that read the identity the app's
global session/JWT middleware resolved onto the request context:
app.MCP.RegisterTool("reports_rebuild", "Rebuild the reports cache", schema, rebuildHandler, mcp.WithToolGate(auth.MCPRole("admin")))// auth.MCPUser() = any signed-in caller; auth.MCPRole("a", "b") = any of the roles.
WithToolGate also hides the tool from tools/list for callers who
cannot invoke it — the inputSchema is a disclosure in its own right. The
older mcp.Gated(gate, handler) wrapper still works but only reaches
tools/call, so the schema stayed visible to everyone; prefer the option.
(Entity CRUD tools don't need this — they re-dispatch through the
router and inherit HTTP auth, owner scoping, and RBAC wholesale.)
Init lifecycle
RegisterPluginqueues the plugin; it does NOT init.InitPluginsruns every queued plugin'sInitin registration
order, then every battery'sInitin dependency-resolved order.App.StartcallsInitPluginsthen binds the HTTP listener.- Tests can call
InitPluginsmanually before driving the router
in-memory; the call is idempotent.framework.TestHarness(t, app)
invokes it automatically.
RegisterPlugin after InitPlugins panics — the new plugin would
never get to run, which would silently break the caller's expectations.
A plugin's Init panic (or an error return) is caught and attributed
to the plugin by name in the resulting error. Already-initialised
plugins are skipped on retry so a partial-init-then-fix retry doesn't
double-register routes / middleware.
Lifecycle hooks
Plugins that need to run code at App start / stop call the App's hook
APIs from Init:
func (MyPlugin) Init(app *framework.App) error { app.OnStart(func(ctx context.Context) error { // ... start a background worker ... return nil }) app.OnStop(func() error { // ... stop the worker ... return nil }) return nil}
For shutdown hooks that must run AFTER every other plugin's OnStop —
e.g. the logging plugin's "close every sink" hook — use OnStopFirst:
it prepends to the hook list, so the reverse-order Shutdown iteration
runs it last. battery/log uses this so log sinks are still open while
other shutdown code emits its final entries.
OnStart runs before the port binds. To run code only once the HTTP
listener is actually up — a startup banner, a "register with service
discovery" call — use App.OnReady:
<!-- gofastr:compile
import "github.com/DonaldMurillo/gofastr/framework"
var app = framework.NewApp()
import "fmt"
-->
app.OnReady(func(addr string) { fmt.Printf("listening on http://%s\n", addr)})
OnReady hooks fire after every Start phase (auto-migrate, seeds,
plugin init, OnStart hooks) AND the bind itself succeeded, just before
the server accepts its first connection. The addr argument is the
listener's resolved address — starting on ":0" delivers the real
port. Hooks run in registration order, take no error return, and must
not block. If any Start phase fails, OnReady never fires — so a
banner printed here can't lie about a server that never came up.
Blueprint-generated apps and gofastr init scaffolds print their
startup banner this way. The framework's own server ready banner follows
the same bind boundary and includes Listening: http://<resolved-address>;
a bind failure emits no readiness banner.
Post-migrate seeding: App.WithSeed
App.Start runs auto-migration as one of its first phases, so a
db.Exec("INSERT …") written in main() before Start() fails with
no such table — the table doesn't exist yet. App.WithSeed registers
seed logic to run at the right moment instead:
site := framework.NewApp(framework.WithDB(db))site.Entity("foods", foodsConfig)site.WithSeed(func(ctx context.Context) error { _, err := db.ExecContext(ctx, "INSERT INTO foods (id, name) VALUES ('1', 'apple') ON CONFLICT DO NOTHING") return err})site.Start(":8080")
Seed funcs run during Start after auto-migration (every table
exists) and the per-entity EntityConfig.Seed phase, and before
plugins/batteries init and the listener binds. Multiple WithSeed
calls run in registration order; the first non-nil error aborts Start
(the partial-startup teardown drains anything an earlier phase spawned).
The context is the app's lifecycle context, so a long-running seed
respects shutdown.
Across replicas, WithSeed hooks acquire the SAME seed advisory lock as
the per-entity phase (distinct from the migration lock), so two replicas
booting at once cannot run a hook concurrently. WithSeed hooks have no
ledger — they serialize-per-boot but still run on every replica, so keep
them idempotent (INSERT … ON CONFLICT DO NOTHING). (Exception: a
MaxOpenConns(1) Postgres pool skips the lock with a WARN and is not
coordinated across replicas — keep the pool above 1 connection.)
Use WithSeed for app-level or cross-entity seed logic; use
EntityConfig.Seed (idempotent, ledger-tracked) for per-entity fixtures.
Plugin vs Battery
Plugin and Battery share the same single-Init contract. The
difference:
Batteryaccepts dependency declarations at registration time
(app.RegisterBattery(b, "needs-this")) and runs in dependency-resolved
order. Use Battery when one battery must initialise before another.Batterycan also implementBatteryLifecycle(OnStart / OnStop)
for structured per-battery start/stop, separate from the App-wide
OnStart/OnStophooks.- Module: a Battery that also needs runtime enable/disable. See
Modules for the Module tier.
For everything else, prefer Plugin.
Full example
type Webhooks struct { deliveries chan webhook}func (w *Webhooks) Name() string { return "webhooks" }func (w *Webhooks) Init(app *framework.App) error { w.deliveries = make(chan webhook, 128) app.OnStart(w.start) app.OnStop(w.stop) app.Router().Post("/__webhooks/test", w.testHandler) for name := range app.Registry.All() { app.HookRegistry(name).RegisterHook(framework.AfterCreate, w.fanOut) } return nil}// In main:app := framework.NewApp(framework.WithDB(db))app.Entity("posts", entityConfig)app.RegisterPlugin(&Webhooks{})log.Fatal(app.Start(":8080"))
Lookups
<!-- gofastr:compile
stmt: = p
stmt: = err
import "github.com/DonaldMurillo/gofastr/framework"
var app = framework.NewApp()
stmt: = all
stmt: = names
-->
p, err := app.Plugins.Get("webhooks")all := app.Plugins.All() // []Plugin, registration ordernames := app.Plugins.Names() // []string, registration order
Get returns an error rather than nil when the name is unknown so
callers can distinguish "not yet wired" from "wired but disabled".
Typed lookups: PluginGetAs / GetAs
Get returns the bare Plugin (or Battery) interface — you'd then
hand-write a type assertion and an error path. The generic helpers do
both in one call and return a typed error when the assertion fails:
// Plugin by concrete type or an optional interface it satisfies:logp, err := framework.PluginGetAs[*logplugin.Plugin](app.Plugins, "log")// Battery equivalent:searcher, err := framework.GetAs[search.Backend](app.Batteries, "search")
Both return an error (never a usable zero value) when the name is
unknown OR the registered module doesn't implement the requested type
T, so a wrong-type lookup can't silently hand you a nil you'd
dereference. Prefer these over Get(...) + a manual assert.
Common mistakes
- Registering after
InitPlugins. The plugin queue is frozen at
init time. The framework panics with an explicit message; register
plugins beforeStartor before any manualInitPluginscall. - Returning early from
Initafter partial side effects. Side
effects (already-registered routes) don't roll back, but per-module
init tracking ensures a retry won't re-run a successful plugin.
Either return the error early before any side effects, or finish
the wiring before returning. - Calling
app.UsefromOnStart. The router IS race-safe for
concurrent Use+ServeHTTP, but middleware added there only wraps
requests that arrive AFTER the Use returns — operationally
surprising. Stay inInitfor chain composition. - Naming a plugin with whitespace / control chars / very long
strings.RegisterPluginrejects these with a clear error; pick
a stable, human-readable name ("auth","telemetry","log").