zoobzio December 19, 2025 Edit this page

Lifecycle Callbacks

Soy supports two optional callbacks on Soy[T] that fire during builder execution paths. They enable cross-cutting concerns — validation, normalisation, enrichment — without wrapping every builder type.

OnScan

Fires after a row is scanned into *T. Registered with OnScan():

users.OnScan(func(ctx context.Context, result *User) error {
    result.Email = strings.ToLower(result.Email)
    return nil
})

Where it fires

BuilderMethodFires?
SelectExec, ExecTxOnce (single row)
QueryExec, ExecTxPer row
CreateExec, ExecTxOnce (RETURNING row)
UpdateExec, ExecTxOnce (RETURNING row)
CompoundExec, ExecTxPer row
DeleteExec, ExecTxNo (int64 return)
AggregateExec, ExecTxNo (float64 return)
Create batchExecBatchNo (no scan)

Error handling

Returning an error aborts the operation:

users.OnScan(func(ctx context.Context, result *User) error {
    if result.Status == "banned" {
        return errors.New("banned user")
    }
    return nil
})

user, err := users.Select().Where("id", "=", "id").Exec(ctx, params)
// err wraps "banned user" if the scanned record is banned

For Query and Compound, an error on any row aborts the entire result.

OnRecord

Fires before a *T is written. Registered with OnRecord():

users.OnRecord(func(ctx context.Context, record *User) error {
    if record.Email == "" {
        return errors.New("email is required")
    }
    record.Email = strings.ToLower(record.Email)
    return nil
})

Where it fires

BuilderMethodFires?
CreateExec, ExecTxOnce (before INSERT)
CreateExecBatchPer record (before INSERT)
Create (upsert)Exec, ExecTxOnce (before write)

OnRecord does not fire on Update or Delete because those operations take parameter maps, not *T records.

Error handling

Returning an error aborts the write:

users.OnRecord(func(ctx context.Context, record *User) error {
    if record.Email == "" {
        return errors.New("email is required")
    }
    return nil
})

_, err := users.Insert().Exec(ctx, &User{Name: "No Email"})
// err wraps "email is required"

For batch inserts, an error on any record aborts the entire batch.

Combining callbacks

Both callbacks can be registered simultaneously. On an Insert with RETURNING, the order is:

  1. OnRecord fires (before INSERT)
  2. INSERT executes
  3. OnScan fires (after RETURNING row is scanned)
users.OnRecord(func(ctx context.Context, record *User) error {
    record.Email = strings.ToLower(record.Email)
    return nil
})

users.OnScan(func(ctx context.Context, result *User) error {
    result.FullName = result.FirstName + " " + result.LastName
    return nil
})

Unregistering

Pass nil to remove a callback:

users.OnScan(nil)   // Removes the OnScan callback
users.OnRecord(nil) // Removes the OnRecord callback

Performance

Callbacks are nil-checked before invocation. When not registered, there is zero overhead — no function call, no allocation, no interface dispatch.

Use with grub

The primary motivation for lifecycle callbacks is integration with grub, which wraps soy with higher-level lifecycle hooks (BeforeSave, AfterLoad, etc.). By registering OnScan and OnRecord on the Soy[T] instance, grub's hooks fire automatically through all builder paths — including when users access builders directly via db.Query(), db.Select(), etc.