Skip to main content

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

HookTimingExecution modelCan reject
{model}.create.beforeBefore INSERTSynchronous — runs in the same request✅ Yes
{model}.create.afterAfter INSERT committedAsync — published to Event Bus❌ No
{model}.update.beforeBefore UPDATESynchronous✅ Yes
{model}.update.afterAfter UPDATE committedAsync — Event Bus❌ No
{model}.delete.beforeBefore soft DELETESynchronous✅ Yes
{model}.delete.afterAfter soft DELETE committedAsync — 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 typeDefault timeoutOverride
before2 secondsDATA_HOOK_BEFORE_TIMEOUT_MS
after10 seconds per attemptDATA_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

ScenarioHTTPtype URI suffix
before hook rejected422hook-rejected
before hook timeout422hook-rejected (code: HOOK_TIMEOUT)
before hook panic (unhandled error)500internal-server-error
after hook failureDead-lettered after 5 retries, no client impact