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
| Builder | Method | Fires? |
|---|---|---|
| Select | Exec, ExecTx | Once (single row) |
| Query | Exec, ExecTx | Per row |
| Create | Exec, ExecTx | Once (RETURNING row) |
| Update | Exec, ExecTx | Once (RETURNING row) |
| Compound | Exec, ExecTx | Per row |
| Delete | Exec, ExecTx | No (int64 return) |
| Aggregate | Exec, ExecTx | No (float64 return) |
| Create batch | ExecBatch | No (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
| Builder | Method | Fires? |
|---|---|---|
| Create | Exec, ExecTx | Once (before INSERT) |
| Create | ExecBatch | Per record (before INSERT) |
| Create (upsert) | Exec, ExecTx | Once (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:
OnRecordfires (before INSERT)- INSERT executes
OnScanfires (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.