Standards Docs
Guides

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

DecoratorRuns
@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.

FieldUse
objectId, objectName, recordId, tenantIdIdentify the object, record, and tenant involved in the write.
recordThe current raw record when available.
oldValuesValues before the write. Empty for create.
newValuesValues being saved. Before hooks can mutate this object.
changedAttributesAttribute names changed by the operation.
getChange(attributeName)Return { oldValue, newValue, changed } for one attribute.
metadataHook metadata passed to the lifecycle operation.
timestampTime when the hook context was created.
servicesCore 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.

On this page