Lifecycle Hooks
Quark runs lifecycle hooks around every write and read. Implement the matching
interface on your *Model and Quark picks it up automatically — no registration:
type Order struct {
ID int64 `db:"id" pk:"true"`
Status string `db:"status"`
}
func (o *Order) BeforeCreate(ctx context.Context) error {
if o.Status == "" {
o.Status = "pending"
}
return nil
}
func (o *Order) AfterCreate(ctx context.Context) error {
return audit.Log(ctx, "order.created", o.ID)
}
Now a create with no status fills it in:
o := Order{}
quark.For[Order](ctx, client).Create(&o)
fmt.Println(o.Status)
// => pending
When each hook fires
| Hook | Phase | Fires from |
|---|---|---|
BeforeCreate | before INSERT | Create, CreateBatch, Upsert, UpsertBatch |
AfterCreate | after commit (in a tx) / after INSERT (no tx) | Create |
BeforeUpdate | before UPDATE | Update, UpdateFields, Tracked.Save, UpdateBatch |
AfterUpdate | after commit / after UPDATE | Update, UpdateFields, Tracked.Save |
BeforeDelete | before DELETE | Delete |
AfterDelete | after commit / after DELETE | Delete |
BeforeFind | before the SELECT is built | List, First, Find, Iter, Cursor |
AfterFind | after scan + Preload | List, First, Find, Iter, Cursor |
Batch and upsert writes run their Before* hook once per entity, so
timestamp / default / derived-field hooks apply to batched rows too. Their
After* hooks don't fire, and the WHERE-based deletes (DeleteBatch,
DeleteBy) skip hooks entirely — see Limitations.
Hooks and transactions
Outside a transaction, hooks run inline: Before* before the statement,
After* right after it.
Inside Client.Tx, the timing changes for After*: a Before* hook still runs
inline (returning an error rolls the transaction back), but an After* hook is
queued and fired only after the transaction commits — so a side-effect can't
fire for work that later rolls back. Multiple operations queue their After*
hooks in FIFO order and fire them in that order at commit:
err := client.Tx(ctx, func(tx *quark.Tx) error {
for _, o := range orders {
if err := quark.ForTx[Order](ctx, tx).Create(&o); err != nil {
return err // BeforeCreate already ran; the queued AfterCreate is discarded
}
}
return nil // each BeforeCreate ran inline; each AfterCreate fires after commit, FIFO
})
BeforeCreate(o1) → INSERT → BeforeCreate(o2) → INSERT → BeforeCreate(o3) → INSERT
↓
Tx.Commit succeeds
↓
AfterCreate(o1) → AfterCreate(o2) → AfterCreate(o3)
An After* hook that returns an error post-commit is logged
(quark.hook.after_post_commit_error) and the rest still run — once the database
has committed, nothing application-side can undo it.
The non-transactional path is intentionally left inline: wrapping every
single-statement CRUD in an implicit transaction would add two round-trips and a
connection pin to buy safety that doesn't exist (there's no transaction to roll
back). To run one cross-cutting side-effect after all the inserts commit —
rather than one AfterCreate per row — register a
Tx.OnCommit callback, which
fires once per transaction after the model hooks:
err := client.Tx(ctx, func(tx *quark.Tx) error {
var ids []int64
for _, o := range orders {
if err := quark.ForTx[Order](ctx, tx).Create(&o); err != nil {
return err
}
ids = append(ids, o.ID)
}
tx.OnCommit(func(ctx context.Context) error {
return bus.PublishBatch(ctx, "orders.created", ids)
})
return nil
})
Read hooks
BeforeFind and AfterFind share the family's signature — only ctx, no result
slice — so they're for audit and telemetry, not for inspecting rows:
func (d *Document) BeforeFind(ctx context.Context) error {
return audit.LogRead(ctx, "documents.read") // once per query, before SQL
}
func (d *Document) AfterFind(ctx context.Context) error {
return audit.LogReadComplete(ctx, "documents.read") // once per query, after hydration
}
AfterFind fires for Iter only when the loop completes without error, and for
Cursor from Cursor.Close() when rows.Err() is nil. To enrich scanned rows,
use a Scope helper rather than a hook.
Limitations
- CRUD hooks see the entity; Find hooks see a zero
*T. Create/Update/Delete hooks get the struct the caller passed; Find hooks have no instance at query time, so they can read context but not row state. - Hooks can't mutate the
Query[T]. They receivectx, not the builder — use a scope for conditionalWHERE/ORDER. Tracked.Saveruns bothBeforeUpdateandAfterUpdate, matching plainUpdate.- Batch/upsert run
Before*but notAfter*.CreateBatch/UpdateBatchrunBeforeCreate/BeforeUpdateper entity, andUpsert/UpsertBatchrunBeforeCreate(insert-prep; on conflictupdateColswin). The post-commitAfter*queue doesn't map cleanly onto a multi-row write, so it's skipped — if you need per-rowAfter*side-effects, loop through single-rowCreate/Updateinsideclient.Tx. - WHERE-based deletes skip hooks.
DeleteByandDeleteBatchissue a single DELETE without loading the rows, so there's no entity to call the hook on. Load the rows and loop throughDeleteif you need them. - A savepoint rollback un-queues the
After*hooks queued since that savepoint.RollbackTotruncates theAfter*,OnCommit, andOnRollbackqueues back to where they were when the savepoint was created — mirroring theROLLBACK TO SAVEPOINTthat just undid the SQL those hooks were reacting to, so they do not fire on the outerCommit. One caveat: the truncation is keyed to savepoints created through the quark API (Savepoint/nestedTx.Tx); rolling back to a savepoint issued via rawExecis invisible to the hook queues and leaves them untouched.