Modules

A Module is a Battery plus a manifest. Everything a module registers during Init is attributed to the module: routes, entities, cron jobs, queue consumers, MCP tools. A runtime enable/disable gate enforces at dispatch time: a disabled module's routes 404, its cron jobs and queue consumers skip, and its MCP tools refuse.

Toggling is live (no restart), persisted when WithDB is configured, and propagates across replicas via WithFanout when they share that database.

When to use module vs battery vs plugin

  • Plugin: lightweight, no dependencies, no structured lifecycle. Use for small features that bundle a few routes or hooks.
  • Battery: heavyweight, dependency-aware, structured OnStart/OnStop. Use when one extension must initialise before another or needs its own background workers.
  • Module: a battery that also needs runtime enable/disable. Use when an operator should be able to turn a feature off without a redeploy: feature flagging at the module level, not the route level.

A module IS a battery. Everything batteries do (dependency ordering, lifecycle hooks) applies unchanged. The manifest adds the metadata and the enable/disable machinery adds the gates.

Registration

18 lines
type FeatureModule struct{}

func (FeatureModule) Name() string { return "feature" }
func (FeatureModule) Init(app *framework.App) error {
    app.Router().Get("/feature/status", statusHandler)
    app.Entity("widgets", widgetConfig)
    return nil
}
func (FeatureModule) Manifest() framework.ModuleManifest {
    return framework.ModuleManifest{
        Version:        "1.0.0",
        Description:    "Widget management with CRUD + background jobs",
        DependsOn:      []string{"auth"},
        MigrationGroup: "feature",
    }
}

app.RegisterModule(FeatureModule{})

RegisterModule validates the name with the same rules as RegisterBattery (non-empty, no control characters, ≤ 128 chars), registers it as a battery with deps = Manifest().DependsOn so the topo-sort orders module init, and records the manifest.

Registering two modules with the same name panics, exactly like a duplicate battery.

Init runs after Start's first migrate-and-seed pass, so Start migrates and seeds again once every Init has run: an entity, view or routine a module declares there has its table and seed rows before the server listens. See migrations for the generate path.

What gets attributed to a module

During a module's Init, the framework marks the module as "current" and every registration funnel stamps ownership:

  • Routes: every app.Router().Handle / Get / Post / … call and every Group sub-registration records "METHOD /path" → module. Two modules can own different HTTP methods on the same path independently; disabling one does not affect the other. Entity CRUD routes (mounted by app.Entity) are attributed the same way.
  • MCP tools: every app.MCP.RegisterTool records tool → module.
  • Entities: app.Entity records entity → module so it can be reported later.
  • Cron: app.AddCron(scheduler) called from a module's Init stamps the scheduler with a gate that skips jobs when the module is disabled.
  • Queues: app.AddQueue(queue) called from a module's Init duck-types SetGate on the queue (both DBQueue and MemoryQueue implement it), deferring jobs when the module is disabled.

Routes or tools registered outside a module's Init (from main, from a plugin, or from an OnStart hook) are not attributed and pass through the gates unchecked. Attribution is an Init-time concept.

Runtime enable/disable

7 lines
// Toggle at runtime, no restart:
err := app.Modules().Disable(ctx, "feature")
err = app.Modules().Enable(ctx, "feature")

// Query:
enabled := app.Modules().Enabled("feature")
list := app.Modules().List() // []ModuleInfo

Agents get the same controls over MCP: framework.WithMCPControl() registers app_module_enable / app_module_disable (and the dev loop implies the option; see agent-ready). The tools call the exact methods above, so persistence and the fail-closed dependency rules apply identically.

  • Disabled → 404, not 403. A disabled module's routes return a plain http.NotFound. The middleware chain does not run, so auth, logging, and recovery never see the request. The module's existence does not leak. Method probing (e.g. DELETE against a path that only has a gated GET) also returns 404: the Allow header in a 405 response lists only non-gated methods, so a disabled method is never advertised. A live GET route's Allow names HEAD too, since the mux answers it, and a gated GET takes its HEAD with it.
  • Disabled → jobs deferred, not dropped. Cron jobs skip the tick. Queue jobs are released back to pending (without consuming a retry attempt) and run when the module re-enables.
  • Disabled → MCP tools refuse. A tools/call for a disabled module's tool returns a generic "tool unavailable" JSON-RPC error (no module name or disabled state is leaked). The tool is also excluded from tools/list so it is not advertised while disabled.
  • Fail-closed dependency rules. Disable refuses if any enabled module lists it in DependsOn: no cascade, no orphaning. Enable refuses if any of the module's DependsOn is disabled. The error names the blocking dependents or dependencies.
  • Persist first, then flip. On a successful toggle the store is written first; only on store success does the in-memory cache flip and the fanout publish. A store failure leaves state unchanged.

Persistence

When the app has a DB (WithDB), the module state persists in a gofastr_modules table:

columntype
nameTEXT PRIMARY KEY
enabledBOOLEAN NOT NULL
updated_atTIMESTAMP NOT NULL

The table is self-migrating (CREATE TABLE IF NOT EXISTS); it is NOT a migrate group. Modules absent from the table are enabled by default; store rows for unknown module names (a removed module) are kept but ignored. Without a DB the state is in-memory, stays local to the replica, and resets on every boot. NewApp warns when WithFanout is configured without WithDB; refresh messages cannot synchronize independent stores. If the table cannot be created (e.g. a read-only or corrupt DB), Start fails closed rather than silently falling back to in-memory: a deliberately disabled module must not come back enabled on a broken store.

Multi-replica propagation

With WithFanout attached, the module manager subscribes to topic gofastr.modules at boot. Every successful toggle publishes {"name":…,"enabled":…} JSON via the fanout, wrapped in the standard node-ID envelope so a replica ignores its own publishes. The message is treated as a refresh signal only: the receiving replica re-reads the authoritative state from its own store and sets its cache to whatever the store says, never to what the payload says. This makes message ordering irrelevant (the store is the source of truth) and neuters crafted payloads.

Because the signal carries no state, cross-replica propagation requires a shared DB-backed store: every replica must read the same gofastr_modules table (i.e. WithDB against the shared database). With a fanout but no shared store, each replica re-reads its own in-memory store and toggles never propagate. Without a fanout, other replicas don't learn about a toggle until they restart and re-read the store; persistence is orthogonal (the store is always written).

Migration-group tie-in

ModuleManifest.MigrationGroup defaults to the module name and is an informational pointer to the -- +migrate Group <name> directive from the migrations system. The framework does not enforce that it matches a registered migration group, but the convention keeps schema migrations and module enable/disable in sync.

Disabled-module migration policy: a named migration group with no registered migrations is treated as a disabled module by the migration runner; its applied rows are shown by status but never compared, blocked on, rolled back, or dropped. See the migrations doc for details. force --group=<name> is the reconciliation escape hatch when a module is permanently removed.

Reading module state

The app_modules MCP tool (available via WithMCPIntrospection) lists every module's name, version, description, dependencies, migration group, enabled state, and how many routes, entities, and tools it owns. Enable/disable is Go-API-only for now: no mutating MCP tools.

Common mistakes

  • Disabling a shared dependency. If module B depends on module A and both are enabled, disabling A fails with an error naming B. Disable B first, then A. The framework refuses to cascade: a silent breakage is worse than a clear error.
  • Expecting queued jobs to drop. When a module is disabled, its queue jobs are deferred (released to pending), not dropped. They run when the module re-enables. If you need to purge them, drain the queue explicitly.
  • Registering routes outside Init and expecting attribution. Routes added from OnStart, from main, or from a plugin are not attributed to any module. The gate cannot block what it cannot see. Keep route registration inside the module's Init.
  • Expecting process isolation. A disabled module's code is still loaded in the process: its types, closures, and goroutines spawned outside the gate are still live. The gates cover routes, cron, queue, and MCP dispatch, not arbitrary Go code. Process isolation is explicitly out of scope.
  • Existence probing via trailing-slash redirects. A GET /modsub for a module that registered /modsub/ triggers Go ServeMux's automatic 307 redirect to /modsub/ before any gate fires. Similarly, method probing (trying methods to see which return 405 vs 404) can reveal a disabled module's registered methods. These are inherent to the mux-layer gate; they are only fully closed by process-level isolation.