Lifecycle Hooks
Lifecycle hooks let modules inject business logic into the CRUD
pipeline without modifying the Data Layer. Hooks are Go functions
registered via module.manifest.json and invoked by the Data Layer
at predictable points in the request lifecycle.
Hook Types
| Hook | Timing | Execution model | Can reject |
|---|---|---|---|
{model}.create.before | Before INSERT | Synchronous — runs in the same request | ✅ Yes |
{model}.create.after | After INSERT committed | Async — published to Event Bus | ❌ No |
{model}.update.before | Before UPDATE | Synchronous | ✅ Yes |
{model}.update.after | After UPDATE committed | Async — Event Bus | ❌ No |
{model}.delete.before | Before soft DELETE | Synchronous | ✅ Yes |
{model}.delete.after | After soft DELETE committed | Async — Event Bus | ❌ No |
before hooks run synchronously inside the request. They receive
the pending payload, can mutate it (add fields, normalise values), and
can reject the operation by returning an error. If a before hook
returns an error, the Data Layer aborts the operation and returns
422 Unprocessable Entity to the client.
after hooks fire after the database transaction commits and are
delivered via the Event Bus. Delivery is guaranteed (at-least-once,
Kafka retry). after hooks cannot roll back the operation — use them
for side effects: sending notifications, updating caches, or triggering
downstream workflows.
Manifest Registration
Hooks are declared in module.manifest.json:
{
"name": "@acme/crm",
"version": "1.2.0",
"hooks": [
{
"model": "contacts",
"event": "create.before",
"handler": "NormalizeEmailHandler"
},
{
"model": "contacts",
"event": "create.after",
"handler": "SendWelcomeEmailHandler"
},
{
"model": "deals",
"event": "update.before",
"handler": "ValidateDealStageHandler"
},
{
"model": "deals",
"event": "delete.before",
"handler": "BlockDeleteIfOpenInvoiceHandler"
}
]
}
Handler names map to registered Go functions in the module's server process. The Data Layer calls them via gRPC on the module's sidecar.
before Hook — Payload Mutation
A before hook receives the full pending payload and can
return a modified version. The modified payload is used for the
database INSERT/UPDATE instead of the original:
// services/crm/internal/hooks/contacts.go
func NormalizeEmailHandler(ctx context.Context, req *hookpb.BeforeRequest) (*hookpb.BeforeResponse, error) {
payload := req.GetPayload()
// Normalise email to lowercase
if email, ok := payload["email"].(string); ok {
payload["email"] = strings.ToLower(strings.TrimSpace(email))
}
// Add a default tag if none provided
if _, ok := payload["tags"]; !ok {
payload["tags"] = []string{"untagged"}
}
return &hookpb.BeforeResponse{
Payload: payload, // modified payload used for INSERT
Allow: true,
}, nil
}
before Hook — Rejection
Return Allow: false with a human-readable Reason to reject the
operation. The Data Layer converts this to 422 Unprocessable Entity:
func ValidateDealStageHandler(ctx context.Context, req *hookpb.BeforeRequest) (*hookpb.BeforeResponse, error) {
payload := req.GetPayload()
currentStage := getCurrentStage(ctx, req.GetRecordId())
newStage, _ := payload["stage"].(string)
if !isValidTransition(currentStage, newStage) {
return &hookpb.BeforeResponse{
Allow: false,
Reason: fmt.Sprintf(
"invalid stage transition: %s → %s",
currentStage, newStage,
),
}, nil
}
return &hookpb.BeforeResponse{Allow: true, Payload: payload}, nil
}
Client receives:
{
"type": "https://api.septemcore.com/problems/hook-rejected",
"status": 422,
"detail": "invalid stage transition: prospect → closed_won",
"extensions": {
"hook": "deals.update.before",
"handler": "ValidateDealStageHandler"
}
}
after Hook — Side Effects
after hooks receive the committed record and run asynchronously.
Failures are retried with exponential backoff. A permanently failing
after hook is dead-lettered but does not roll back the
database operation:
func SendWelcomeEmailHandler(ctx context.Context, event *hookpb.AfterEvent) error {
contact := event.GetRecord()
return kernel.Notify().Send(ctx, notify.Message{
Channel: "email",
Recipient: contact["email"].(string),
Template: "welcome",
Data: map[string]any{
"name": contact["name"],
"tenant": event.GetTenantId(),
"created_at": contact["created_at"],
},
})
// On error → Kafka retry (max 5 attempts, backoff 1s/5s/30s/2m/10m)
// On permanent failure → dead letter topic: platform.data.hooks.dlq
}
Hook Execution Order
Multiple hooks on the same event and model run in the order they are
declared in module.manifest.json. For before hooks, the modified
payload from hook N is passed as the input to hook N+1:
Request payload
│
▼ contacts.create.before → NormalizeEmailHandler (modifies payload)
│
▼ contacts.create.before → ValidateEmailDomainHandler (receives modified payload)
│
▼ Data Layer: INSERT contacts (uses final payload)
│
▼ contacts.create.after → SendWelcomeEmailHandler (async, committed record)
▼ contacts.create.after → UpdateSearchIndexHandler (async, committed record)
Hook Timeout
| Hook type | Default timeout | Override |
|---|---|---|
before | 2 seconds | DATA_HOOK_BEFORE_TIMEOUT_MS |
after | 10 seconds per attempt | DATA_HOOK_AFTER_TIMEOUT_MS |
A before hook that exceeds its timeout causes the operation to
return 422 Unprocessable Entity with code: HOOK_TIMEOUT. This
protects the request pipeline from a misbehaving module.
Error Reference
| Scenario | HTTP | type URI suffix |
|---|---|---|
before hook rejected | 422 | hook-rejected |
before hook timeout | 422 | hook-rejected (code: HOOK_TIMEOUT) |
before hook panic (unhandled error) | 500 | internal-server-error |
after hook failure | — | Dead-lettered after 5 retries, no client impact |