Intermediate May 3, 2026 · 3 min read

Hooks let you run Go code at four well-defined points in a Thread's CRUD lifecycle without ejecting the model. They're the minimal escape hatch — enough for normalisation and audit logging, not so much that they replace ejection for complex business logic.

The four events

Event When Use case
before_save Just before SQL INSERT (Create) or UPDATE (Update) runs Normalise input, validate against complex rules, trim whitespace, generate derived fields
after_save Just after SQL succeeded Audit logs, notifications, cache invalidation, downstream API calls
before_delete Just before SQL DELETE runs Cascade cleanup of dependent records, refusal-to-delete checks
after_delete Just after SQL succeeded Audit logs, downstream cleanup

Mutation rules

before_save hooks receive the request body as a map[string]any. Mutations to that map are written to the database — that's how normalisation works:

shuttle.AddHook("Customer", shuttle.BeforeSave,
    func(ctx context.Context, rec map[string]any) error {
        if name, ok := rec["company_name"].(string); ok {
            rec["company_name"] = strings.TrimSpace(name)
        }
        return nil
    })

after_save hooks also receive the record, but mutations are no-ops — the SQL has already committed. They're for side effects.

Error semantics

Returning a non-nil error from a hook has different consequences depending on phase:

  • Before errors:* The CRUD operation aborts. Loom returns HTTP 400 with the error message in the body. The database is untouched.
  • After errors:* The error is logged at warn level (plugin=… event=… err=…) but the HTTP response stays 200/201. The row is already saved; pretending otherwise would mislead the client.

This is intentional. If your "audit log" hook fails, the user shouldn't see a generic 500 for a successful save. If a future operation depends on the audit log, that's a workflow problem to solve at a higher level (e.g. dual-write the audit synchronously to a queue).

Hook chains

Multiple plugins can register hooks for the same (model, event) pair. They run in registration order, synchronously:

// Plugin A registers first:
r.AddHook("Customer", shuttle.BeforeSave, normalizeWhitespace)
// Plugin B registers second:
r.AddHook("Customer", shuttle.BeforeSave, validateEmail)

normalizeWhitespace runs first; if it succeeds, validateEmail runs next. If normalizeWhitespace returns an error, validateEmail doesn't run at all.

The chain composition is how you keep individual hooks small.

Declaring hooks in YAML

The Thread schema has a hooks: block that names the function for each event:

hooks:
  before_save: NormalizeInvoiceNumber
  after_save: NotifyCustomer

The loom check validator confirms each value is a valid Go identifier. Note: in v0.1.0 the YAML names are documentation-only — the runtime dispatches hooks via the Shuttle registry, not by string-name lookup. Authoring intent is captured in the YAML; actual wiring happens in your plugin's Init.

Performance

Hooks run on every request. They're fast — direct function calls, no IPC, no goroutine — but they're synchronous and they block the response until they return. For anything that takes more than a few milliseconds (sending an email, hitting a webhook), use an after_save hook to enqueue the work somewhere external rather than blocking the HTTP response.

See also

  • Concepts → Plugins (Shuttle) — the registry and Init contract
  • Recipes → Use Hooks to Normalize Data Before Save — concrete example
Was this article helpful?