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):
| Decorator | Fires | Typical use |
|---|---|---|
@BeforeCreate | before a record is inserted | validation, defaults |
@AfterCreate | after a record is inserted | notifications, side effects |
@BeforeUpdate | before changes are persisted | validation, guarding fields |
@AfterUpdate | after changes are persisted | sync, notifications |
@BeforeDelete | before a record is deleted | guarding deletions |
@AfterDelete | after a record is deleted | cleanup |
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 (default0).
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 events — record.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.