standards
SDK

Triggers

Run server-side code on record lifecycle events — validate before a write commits, or react after it does.

Triggers are server-side handlers that run inside record write operations — validate a change before it commits, or react after it does. They are plain NestJS providers: decorate a method, register the class, and the SDK discovers your handlers at startup.

Lifecycle events

Six events are available as decorators — one for each combination of phase (before/after) and operation (create, update, delete):

DecoratorFiresTypical use
@BeforeCreatebefore a record is insertedvalidation, defaults
@AfterCreateafter a record is insertednotifications, side effects
@BeforeUpdatebefore changes are persistedvalidation, guarding fields
@AfterUpdateafter changes are persistedsync, notifications
@BeforeDeletebefore a record is deletedguarding deletions
@AfterDeleteafter a record is deletedcleanup

All six share the same signature:

@BeforeUpdate(objectName: string, attributeName?: string | "*", options?: { priority?: number })
  • objectName — which object the trigger watches (e.g. "products").
  • attributeName — fire only when this attribute changed. Omit or pass "*" to fire on any change.
  • priority — execution order within a phase; lower runs earlier (default 0).

Matching triggers run sequentially in priority order, never in parallel. Each one is awaited, so handlers can be sync or async — but a slow handler holds up the write for as long as it takes.

before* handlers abort the operation by throwing: the error propagates to the API caller, nothing is persisted, and no further trigger runs — neither the remaining before* handlers nor any after* handler. A throw inside an after* handler likewise skips the after* handlers queued behind it.

Throwing from an after handler still fails the request

after* handlers run once the write is committed, and an error thrown there still reaches the API caller — but it does not roll the write back. If your @AfterCreate notification service is down, the caller sees a failed request while the record stays in the database, and a client that retries creates a duplicate. Catch anything you don't want surfaced, or move the work to a domain event.

Writing a trigger

Mark the class with @Triggers() and @Injectable(). It is a regular NestJS provider: constructor injection works as usual.

import { Injectable } from "@nestjs/common";
import {
  AfterUpdate,
  BeforeUpdate,
  Triggers,
  type TriggerContext,
} from "@stndrds/adapter-nestjs";

@Injectable()
@Triggers()
export class ProductTriggers {
  constructor(private readonly emailService: EmailService) {}

  @BeforeUpdate("products", "price")
  async validatePriceChange(ctx: TriggerContext<number>) {
    const { oldValue, newValue } = ctx.getChange<number>("price");
    if (newValue && oldValue && newValue < oldValue * 0.5) {
      // throwing aborts the update
      throw new Error("Price cannot be reduced by more than 50%");
    }
  }

  @AfterUpdate("products", "status")
  async onStatusChange(ctx: TriggerContext<string>) {
    const { newValue } = ctx.getChange<string>("status");
    if (newValue === "published") {
      await this.emailService.notify(ctx.record);
    }
  }
}

Registering triggers

Pass trigger classes to SchemaModule.forRoot — the SDK's TriggerRegistry discovers the decorated methods at module init:

SchemaModule.forRoot({
  adapter,
  registry,
  triggers: {
    providers: [ProductTriggers, OrderTriggers],
  },
});

Without a triggers config, the SDK falls back to a no-op registry and no trigger code runs.

The trigger context

Every handler receives a TriggerContext:

interface TriggerContext<T = unknown> {
  objectId: string;
  objectName: string;
  recordId: string;
  tenantId: string;
  record: ObjectRecord | null; // null before create
  oldValues: Record<string, unknown>;
  newValues: Record<string, unknown>;
  changedAttributes: string[];
  getChange<V = T>(attributeName: string): {
    oldValue: V | undefined;
    newValue: V | undefined;
    changed: boolean;
  };
  metadata: Record<string, unknown>;
  timestamp: Date;

  // NestJS enrichment
  services: TriggerServices;
  userId?: string; // user who triggered the operation, when available
  requestId?: string; // trace id, when available
}

getChange is the primary way to inspect a change. It returns the old and new value of an attribute plus a changed flag, so a handler watching "*" can tell which attributes actually changed.

To adjust the record being written, mutate ctx.newValues inside a before* handler — no separate update call needed. The write reads its final values from ctx.newValues, so a handler can override a field the caller submitted and add one the caller never sent. One caveat on added fields: a value that already matches what is stored counts as no change and isn't written, so a trigger can't use ctx.newValues to force a no-op rewrite.

Performing writes from a trigger

To read or write other records, prefer injecting the typed query builder — see Backend data access. When the object name is only known at runtime, ctx.services gives access to the data layer without importing app services:

interface TriggerServices {
  adapter: DatabaseAdapter; // low-level database access
  createRecordService: () => RecordService; // tenant-scoped record CRUD
}

For example, writing an audit log entry after a record is created:

@AfterCreate("orders")
async logOrder(ctx: TriggerContext) {
  const records = ctx.services.createRecordService();
  await records.createRecord(auditLogObjectId, {
    action: "order.created",
    recordId: ctx.recordId,
  });
}

Triggers vs domain events

Triggers run inside the write operation: synchronously awaited, able to abort, ideal for validation and immediate consistency.

For durable, decoupled reactions, the SDK also emits domain eventsrecord.created, record.updated, record.deleted, record.restored, form.submitted, schema.object.*, schema.attribute.*, and more. They flow through an outbox-backed event bus by default, so delivery survives restarts — no setup required to start receiving them. The async option of SchemaModule.forRoot tunes event dispatch (journalSignal, eventFallbackPollIntervalMs); subscribe on the runtime's event bus to react to what it delivers.

Rule of thumb: guard the write with a trigger, react to the fact with an event handler.