Browse all documentation

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.

content/posts.go
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.

content/posts.go
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.

content/posts.go
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.

content/posts.go
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.

content/posts.go
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.