Lifecycle Hooks — before_save, after_save, before_delete, after_delete
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