Hooks and triggers
Run record lifecycle logic before or after create, update, and delete.
Hooks are record lifecycle hooks, not React hooks. In NestJS, you write them as trigger classes with decorators from @stndrds/adapter-nestjs.
Use triggers for business rules that must run close to the record write: validation, normalization, denormalized flags, audit side effects, and external sync scheduling.
// @noverify
import { Injectable } from "@nestjs/common";
import { BeforeUpdate, Triggers, type TriggerContext } from "@stndrds/adapter-nestjs";
@Injectable()
@Triggers()
export class ContactSyncTriggers {
@BeforeUpdate("contacts", "*")
markBrevoDirty(ctx: TriggerContext) {
if (!ctx.getChange("email").changed) return;
ctx.newValues.brevoSyncStatus = "pending";
}
}Register trigger providers with SchemaModule.forRoot.
// @noverify
SchemaModule.forRoot({
adapter,
registry,
triggers: {
providers: [ContactSyncTriggers],
},
});Lifecycle decorators
| Decorator | Runs |
|---|---|
@BeforeCreate(objectName, attributeName?) | Before a record is created. |
@AfterCreate(objectName, attributeName?) | After a record is created. |
@BeforeUpdate(objectName, attributeName?) | Before a record update is saved. |
@AfterUpdate(objectName, attributeName?) | After a record update is saved. |
@BeforeDelete(objectName, attributeName?) | Before a record is deleted. |
@AfterDelete(objectName, attributeName?) | After a record is deleted. |
The second argument is an attribute filter. Use a specific attribute name to run only when that attribute changes, or "*" to run for every write of that lifecycle type.
// @noverify
class ContactTriggers {
@BeforeUpdate("contacts", "email")
normalizeEmail(ctx: TriggerContext) {
const email = ctx.newValues.email;
if (typeof email === "string") {
ctx.newValues.email = email.trim().toLowerCase();
}
}
}Trigger context
Every trigger receives a TriggerContext.
| Field | Use |
|---|---|
objectId, objectName, recordId, tenantId | Identify the object, record, and tenant involved in the write. |
record | The current raw record when available. |
oldValues | Values before the write. Empty for create. |
newValues | Values being saved. Before hooks can mutate this object. |
changedAttributes | Attribute names changed by the operation. |
getChange(attributeName) | Return { oldValue, newValue, changed } for one attribute. |
metadata | Hook metadata passed to the lifecycle operation. |
timestamp | Time when the hook context was created. |
services | Core services available to the trigger. |
TriggerContext also exposes userId and requestId when those values are available from hook metadata.
services includes the low-level adapter and createRecordService(). Prefer constructor-injected NestJS services for application dependencies, and use ctx.services when you need runtime record access from inside a trigger.
// @noverify
class ContactAuditTriggers {
@AfterUpdate("contacts", "stage")
async writeAudit(ctx: TriggerContext) {
const change = ctx.getChange<string>("stage");
if (!change.changed) return;
const records = ctx.services.createRecordService();
await records.createRecord("audit-object-id", {
action: "contact.stage_changed",
contactId: ctx.recordId,
from: change.oldValue,
to: change.newValue,
});
}
}Before and after hooks
Before hooks run inside the write path before the change is persisted. Use them to normalize values or reject invalid changes. Throwing from a before hook aborts the write.
// @noverify
class ContactStageTriggers {
@BeforeUpdate("contacts", "stage")
preventRegression(ctx: TriggerContext) {
const change = ctx.getChange<string>("stage");
if (change.oldValue === "customer" && change.newValue === "lead") {
throw new Error("Customers cannot move back to lead stage.");
}
}
}After hooks run after the write succeeds. Use them for side effects such as notifications, sync jobs, and audit records. Avoid mutating ctx.newValues in after hooks because the record has already been saved.
Priority ordering
Triggers default to priority 0. Lower priority values run earlier.
// @noverify
class PriorityTriggers {
@BeforeUpdate("contacts", "*", { priority: -10 })
normalizeFirst(ctx: TriggerContext) {
// Runs before default-priority triggers.
}
}Use priority when one trigger prepares data that another trigger depends on. Keep priority values sparse, such as -10, 0, and 10, so you can insert new triggers later.
Avoiding loops
Triggers can write records, and those writes can run triggers again. Avoid accidental loops by making trigger writes idempotent and by marking operations explicitly.
For low-level RecordService writes, pass skipHooks: true when the follow-up write must not run lifecycle hooks.
// @noverify
const records = ctx.services.createRecordService();
await records.updateRecord(
ctx.recordId,
{ brevoSyncStatus: "pending" },
{ skipHooks: true }
);When hooks should still run but a specific trigger should opt out, pass or check an explicit hook metadata marker.
// @noverify
const records = ctx.services.createRecordService();
await records.updateRecord(
ctx.recordId,
{ brevoSyncStatus: "synced" },
{ hookMetadata: { brevoSync: true } }
);Then the trigger can return early when ctx.metadata.brevoSync === true. Query-builder write options such as .update(values, { metadata }) update record metadata stored on the record; they are not trigger context metadata.
Bulk routes
Bulk routes do not all have the same lifecycle behavior. Bulk update uses the normal updateRecord pipeline for each item, so update hooks run unless skipHooks is passed in options. Bulk delete does not run per-item delete hooks.
Bulk create has intentional differences from interactive creates. When lifecycle semantics must be identical to an interactive record write, use per-record writes instead of a bulk route, or run explicit post-processing for bulk imports.