How to Use Hooks to Normalize Data Before Save
Whitespace-trimming, lowercasing emails, and similar normalisation belong in before_save hooks. They run before SQL, mutations to the record persist, and the chain composes cleanly with other plugins.
The pattern
A small Shuttle plugin that registers normalisation hooks for one Thread:
package mynormaliser
import (
"context"
"regexp"
"strings"
"github.com/orbweaver-dev/loom/pkg/shuttle"
)
type Plugin struct{}
func (Plugin) Name() string { return "normaliser" }
func (Plugin) Version() string { return "0.1.0" }
func (Plugin) Init(r *shuttle.Registry) error {
if err := r.AddHook("Customer", shuttle.BeforeSave, normaliseCustomer); err != nil {
return err
}
return r.AddHook("Contact", shuttle.BeforeSave, normaliseContact)
}
var multiSpace = regexp.MustCompile(`\s+`)
func normaliseCustomer(_ context.Context, rec map[string]any) error {
if v, ok := rec["company_name"].(string); ok {
rec["company_name"] = multiSpace.ReplaceAllString(strings.TrimSpace(v), " ")
}
return nil
}
func normaliseContact(_ context.Context, rec map[string]any) error {
if v, ok := rec["email"].(string); ok {
rec["email"] = strings.ToLower(strings.TrimSpace(v))
}
return nil
}
Wire it into your runtime
plugins := []shuttle.Shuttle{
mynormaliser.Plugin{},
// ...other plugins
}
runtime.NewServer(runtime.ServerOptions{
DB: db,
Threads: threads,
Auth: auth,
Plugins: plugins,
})
That's it. Every Create / Update on Customer or Contact will pass through the normaliser before SQL runs.
Verify it works
POST a record with messy whitespace:
curl -X POST http://localhost:8080/api/customers \
-H 'Authorization: Bearer <jwt>' \
-H 'Content-Type: application/json' \
-d '{"company_name":" Acme Inc "}'
Then GET it back. company_name should be Acme Inc — trimmed and de-multispaced.
Why before_save and not after_save
before_save mutations are persisted because they happen between the JSON decode and the SQL exec. The handler builds its INSERT / UPDATE from the same map you mutated.
after_save mutations would be no-ops — the SQL has already run with the un-mutated values.
Composing multiple normalisers
Plugins register hooks in order. If two plugins both register before_save for Customer, they run in the order their Init ran:
plugins := []shuttle.Shuttle{
trimWhitespace{}, // runs first
lowercaseEmails{}, // runs second; sees the trimmed value
}
Keep individual hooks small and composable. Don't write a 200-line before_save — split it across multiple plugins, each doing one thing.
Error semantics reminder
Returning a non-nil error from a before_save hook returns HTTP 400 to the client and the SQL doesn't run. Use this for:
- Custom validation rules that the field-type system can't express
- Business-rule checks ("Customer status can only go forward in the pipeline")
- Refusing requests with malformed nested data
For pure normalisation (the use case here), return nil always.