Hooks
Normalize input, derive values, coordinate nested work, and react after commit.
The lifecycle
Hooks run as part of the same operation engine used by REST, the local API, the SDK, and the admin. Within a phase, each []ridu.Hook runs in slice order. Choose the narrowest phase that owns the work: normalize before validation, derive before persistence, and leave external effects until after commit.
| Phase | When it runs | Good for |
|---|---|---|
BeforeDuplicate |
After the source is copied | Resetting slugs and copy-only fields |
BeforeValidate |
Before field validators | Trimming and normalizing input |
BeforeChange |
After validation, before persistence | Derived values and audit fields |
BeforeOperation |
Immediately before storage | Last transactional preparation |
BeforeRead |
Before documents are read | Request-scoped read setup |
BeforeDelete |
After the original is loaded | Dependent transactional cleanup |
AfterChange / AfterDelete |
After persistence, inside the transaction | Writes that must commit or roll back together |
AfterRead |
Before field-level redaction | Decorating the returned document |
AfterOperation |
After the operation, before commit | General transactional follow-up |
AfterError |
When the resource operation fails | Metrics and contextual logging |
AfterCommit |
Only after commit succeeds | Email, webhooks, indexing, and cache invalidation |
Read HookContext
Application code should use ridu.HookContext. It is the ergonomic alias of core.HookContext, so both names describe the same value.
| Value | What it contains |
|---|---|
Operation |
The create, duplicate, read, update, delete, publish, or unpublish operation |
Actor |
The authenticated document, or nil for an anonymous operation |
Data |
Mutable incoming values during write phases |
Document |
The current result once the phase has one |
Original |
The persisted value before update or delete |
Context |
Cancellation, deadline, and the active transaction boundary |
Local |
Nested operations through the normal engine; pre-commit phases reuse the active transaction |
Error |
The original failure while AfterError runs |
Locale / AllLocales |
The locale view selected for this operation |
Fields are store.Value values rather than any. Read strings with StringValue() and write them with store.String(...).
Normalize before validation
BeforeValidate is the right place to make user input canonical. The validator sees the value written back to ctx.Data.
func trimString(name string) ridu.Hook {
return func(ctx ridu.HookContext) error {
value, exists := ctx.Data[name]
if !exists {
return nil
}
text, valid := value.StringValue()
if valid {
ctx.Data[name] = store.String(strings.TrimSpace(text))
}
return nil
}
}
var Posts = ridu.Collection{
Slug: "posts",
FieldHooks: map[string]ridu.CollectionHooks{
"title": {
BeforeValidate: []ridu.Hook{trimString("title")},
},
},
}Derive values before persistence
Use BeforeChange when a value should be derived from already-valid input and written in the same transaction. Inspect Operation when behaviour differs between create and update.
func recordLastEditor(ctx ridu.HookContext) error {
if ctx.Actor == nil {
return nil
}
if ctx.Operation != ridu.OperationCreate &&
ctx.Operation != ridu.OperationUpdate {
return nil
}
ctx.Data["lastEditedBy"] = store.String(ctx.Actor.ID)
return nil
}
var Posts = ridu.Collection{
Hooks: ridu.CollectionHooks{
BeforeChange: []ridu.Hook{recordLastEditor},
},
}Keep related writes atomic
During a transactional phase such as AfterChange, ctx.Local runs nested work through normal access rules, validation, and hooks while reusing the outer transaction. If the nested call fails, return its error and the outer operation rolls back too.
func writeAuditEntry(ctx ridu.HookContext) error {
if ctx.Document == nil {
return nil
}
_, err := ctx.Local.Create(
ctx.Context,
"audit-log",
store.Values{
"document": store.String(ctx.Document.ID),
"operation": store.String(string(ctx.Operation)),
},
ctx.Actor,
)
return err
}
var Posts = ridu.Collection{
Hooks: ridu.CollectionHooks{
AfterChange: []ridu.Hook{writeAuditEntry},
},
}Cross the transaction boundary
Use AfterCommit for work that must not happen when the database rolls back. The write is already durable: a returned error is reported as a committed-hook failure and cannot roll the document back. Configure Config.AfterCommit with a dispatcher when effects need retries or a durable worker boundary.
func reindexPost(ctx ridu.HookContext) error {
if ctx.Document == nil {
return nil
}
return search.Enqueue(ctx.Context, ctx.Document.ID)
}
var Posts = ridu.Collection{
Hooks: ridu.CollectionHooks{
AfterCommit: []ridu.Hook{reindexPost},
},
}Observe failures without hiding them
AfterError runs outside the failed transaction and receives the original failure on ctx.Error. Use it for context-rich logging and metrics; returning nil does not turn the failed operation into a success.
func countFailure(ctx ridu.HookContext) error {
metrics.OperationFailure(
string(ctx.Operation),
string(ctx.CollectionID),
)
log.Printf("ridu operation failed: %v", ctx.Error)
return nil
}
var Posts = ridu.Collection{
Hooks: ridu.CollectionHooks{
AfterError: []ridu.Hook{countFailure},
},
}See ridu.CollectionHooks for every phase and ridu.Hook for the callback contract.