Intermediate May 3, 2026 · 2 min read

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.

Was this article helpful?