Skip to content

Record-triggered automation

Record-triggered automation is the CAOS model’s single effectful tier: rules that fire on a record create, update, or delete and are permitted to do the things the pure tier is forbidden to do — mutate the triggering row, write other records, publish events, or reach outward. Everything else that computes a value (formulas, roll-ups, validation, calc functions) lives in the pure tier, evaluated deterministically at read or save time. Automation is the one place side effects are authorized, and it is deliberately the only such place: one model, one pass, no re-entrancy.

An automation is metadata — a named, typed, version-pinned rule bound to one object and one lifecycle event. It is not a value that lives anywhere on the record; it is a declared effect that the kernel executes when a matching write occurs. Its tier is effectful: the authoring context declares this, and the compiler enforces that only automation bodies may issue writes to other records or outward calls. A body typed as pure that attempts an effect fails to compile.

Automation runs in two in-transaction phases plus a post-commit tier, mapped by name onto the fixed save order of execution: a before_write rule is the save order’s Adjust phase (effectful, bounded to the triggering row, running before Validate), an after_write rule is its Effects phase, and a post_commit rule is its Post-commit phase.

Phase Runs May do May NOT do
Before-write In the save transaction, before the row is persisted Set fields on the triggering row directly (no second write) Touch other records, publish events, make outward calls
After-write In the same transaction, after the row is persisted but before commit Create / update / delete related records, enqueue events, invoke sub-rules Cheaply re-mutate its own triggering row (that is a before-write concern)
Post-commit (async) After the transaction commits, in a separate unit of work off the durable queue Outward calls, work that must observe committed data, bounded follow-up Participate in the original transaction’s rollback

The kernel collects all before-write field mutations from all matching rules, computes their fixpoint once, and applies the result in a single write. Related-record effects then run exactly once. There is no re-entrant second pass, no author-managed recursion flag, and no depth ceiling to design around — the properties Salesforce’s twenty-step, self-re-entering order forces developers to manage by hand (see How Salesforce does it).

Salesforce lets an object carry many triggers for the same event and does not guarantee the order they run in — so every mature org hand-builds the discipline the platform withholds: one trigger per object delegating to a handler class (Kevin O’Hara’s framework), later made metadata-driven by the community’s Trigger Actions Framework (bind classes to contexts, control order, bypass). CAOS makes that framework the platform. There is no trigger to write, and no way to create a second one.

Every object has exactly one automation structure: a fixed grid of slots, one per (operation × phase), defined and enforced by the kernel. The board is the surface that renders that structure — a screen delivered by an installable package (the admin/setup UI), not compiled into the engine — while the slot model and the dispatch it visualizes live in the kernel and hold no matter which surface (board, CLI, or editor) an author uses; the kernel enforces single-binding (CAOS-1024), though on the narrower key the shipped schema can express — see below. The slots themselves are fixed by the platform; a structurally-impossible slot (there is no “before” a row that is being restored) simply does not exist.

Operation Before-write After-write Post-commit
create create.before create.after create.done
update update.before update.after update.done
delete delete.before delete.after delete.done
undelete — undelete.after undelete.done

A slot is designed to bind to exactly one controller — the single unit of the user’s code that handles that (object, operation, phase). Binding a second controller to an occupied slot is meant to be a deploy-time error (“account · update.before is already handled by AccountRules”), so the answer to “what happens when an Account is updated?” is always one place — never scattered across N triggers, M flows, and a workflow rule the way it is in Salesforce. That collision check is built (CAOS-1024), on the key the automation schema can currently state. An automation declares trigger: { event, object }, where event is before_save or after_save; there is no on yet, so a slot is keyed (object, phase) and the operation dimension of the grid above is not yet distinguishable — two automations on account in the before-write phase collide whether one meant create.before and the other update.before. That is the narrower rule: it refuses a superset of what the full (object, operation, phase) key will refuse, so nothing it admits today would be admitted once on exists. The refusal names every claimant, and it runs at author time (metadata component-validate, metadata component-upsert) and again at deploy — one gate, reached from both, rather than two checks that can disagree. It is scoped to save events: job_tick and the approval events are not cells on this board and are left alone. Composition lives inside the controller: a controller is an explicitly ordered pipeline of steps (ordered by priority), so when several behaviors share a slot their order is declared and owned, never the accident Salesforce leaves undefined.

Nobody writes a trigger. A controller is ordinary code in the one typed language; it declares which slot it owns, and the kernel’s built-in dispatch routes the matching save into it. The dispatch table is inherited, not authored — the scaffolding Salesforce makes you write (and the frameworks the community bolts on to tame it) is simply how the platform works.

Both standards enforce single-binding, differing only in granularity. Which of the two an org has chosen is still not something the deploy pipeline checks — it enforces one controller per slot either way, which is the guarantee; the org-level convention on top of it remains a convention.

  • Standard A — one controller class per object. A single AccountRules class exposes well-known methods (updateBefore(record, prior), createAfter(record), …); the kernel routes each slot to its method. Everything Account does on save lives in one file; a method left undefined means that slot is empty. This is the classic handler pattern, made native.
  • Standard B — one controller per slot. Each slot binds its own small, single-purpose unit (account.update.before), independently deployable and testable. Better for large teams that want to own slots separately.

The board is one screen — delivered by the platform-administration package over the kernel’s slot model, since automation is a type the kernel defines — that answers “what fires when this object is saved?” at a glance, the view Salesforce cannot render because the logic is spread across generations and unordered triggers.

Account · Automation board [ Bypass ▾ ]
┌──────────┬───────────────┬───────────────┬───────────────┐
│ │ before-write │ after-write │ post-commit │
├──────────┼───────────────┼───────────────┼───────────────┤
│ create │ AccountRules │ AccountRules │ + add │
│ │ .createBefore │ .createAfter │ │
│ update │ AccountRules │ AccountRules │ SyncToGP │
│ │ .updateBefore │ .updateAfter │ .updateDone │
│ delete │ + add │ AuditTrail │ + add │
│ │ │ .deleteAfter │ │
│ undelete │ — │ + add │ + add │
└──────────┴───────────────┴───────────────┴───────────────┘
A filled cell links to its controller. An empty cell offers a
binding. A second binding to a filled cell is refused, not stacked.

The board is a projection of the controllers’ own declarations, so the code is the source of truth and the UI mirrors it. A controller names its slot inline:

// account.rules.ts — Standard A (one class per object)
@controller("account")
export class AccountRules {
@slot("update.before")
updateBefore(record: Account, prior: Account) { /* … */ }
@slot("create.after")
createAfter(record: Account) { /* … */ }
}

caos automation show lists the org’s automations flat — each one’s trigger (event, object, when) and its step count. Verified 2026-09-09 — that is the board’s underlying data and not the board: show is org-wide, takes no object argument, and renders no phase grid. There is no caos automation account — the automation noun carries only show and check, and the binary resolves the object name through the default verb and answers command automation:show:account not found, exit 2. A per-object rendering of the board is the intended shape, not a command that exists. caos automation check is where a board-level report of single-binding is meant to live; the command still reports itself as deferred. The rule itself is enforced in the kernel rather than in that verb (CAOS-1024), so a collision is refused whether or not anybody runs it, and caos metadata component-validate surfaces one today. An editor integration can surface the board projection — a VS Code extension, for instance, showing a CodeLens above each method with its slot (▶ account · update · before-write), a tree view of the board, and jump-to-controller from a slot — reading the controllers’ declarations the same way the CLI does. The one guarantee that never bends: a slot resolves to exactly one controller, checked in the kernel at author time and again at deploy. Both checks are the same check, reached from both moments — a second binding to a filled cell is refused, naming every claimant, so an author is told which components to go and look at rather than that something is wrong. The cell it is keyed on is (object, phase) until the schema can express the operation (above).

Bypass, declared and audited. Bulk loads and migrations need to suppress automation; the board carries a named bypass (whole-object or per-slot), the same capability the Trigger Actions Framework adds by hand — but here it is a first-class, logged toggle rather than a static flag a developer must remember to reset.

A bypass carries four things, and none of them is optional: a scope (the object, or one slot on it), a reason in free text, the actor who set it, and an expiry.

  • The expiry is mandatory and there is no open-ended form. A bypass declared without one does not deploy and cannot be set from the board; the attempt is a validation-class error naming the maximum. This is the decision the whole feature turns on. A bypass with no end is a business rule somebody stopped declaring — the automation still exists, still passes review, and no longer runs, and the gap between what the metadata says the object does and what it actually does is invisible to everyone who reads the metadata.
  • The maximum duration is 24 hours. A bypass exists to cover a bounded operation — a migration, a backfill, a one-time load — and such an operation either finishes inside a day or is a batch job, in which case the bypass belongs on the job, where it is scoped to that job’s writes rather than to every write anyone makes against the object. Suppression that genuinely needs to last longer is a change to the rule’s entry conditions, authored, diffed, and reviewed like any other change.
  • It is granted by automation.bypass. A system permission of its own, in no starter bundle, held by nobody by default. It is deliberately not implied by automation.author: writing the rules an object obeys and switching them off for everyone are different trust decisions, and binding them to one grant means every org with a builder also has someone who can silently disable the object’s controls.

Expiry is enforced by the kernel, not by a sweep — the board resolves an expired bypass as absent, so automation resumes on the next save with no job having to run and no one having to remember. Re-arming is a new bypass with a new reason and a new actor; there is no extend. And every save that ran under one is marked: the bypass id is carried on the execution trace and on each data-history entry the save wrote, so “which rows skipped automation, and on whose authority” is a query rather than an investigation.

An automation is declared as a metadata component. Its config vocabulary:

Binding

  • object — the SObject the rule triggers on. Exactly one.
  • on — the lifecycle event(s): create, update, delete, the composite create_or_update, or undelete. undelete fires when a soft-deleted row is restored; because the row’s fields already exist, it is available only in the after_write and post_commit phases — a restore has no distinct before-write adjustment slot — which mirrors Salesforce exposing an after-undelete trigger but no before-undelete one.
  • phase — before_write | after_write | post_commit. Determines what the body is permitted to do (table above) and where it sits in the save order.

Entry conditions

  • when — a pure boolean expression (same syntax subset as validation and formulas) evaluated against the candidate record. The rule fires only if it returns true. Because when is pure it is statically analyzable and can be pushed into the trigger’s WHEN clause at the storage layer.
  • changed_to_meet — boolean. When true, the rule fires only on the save where when first becomes true (a transition edge), not on every subsequent save where it remains true. Compares the pre-image against the post-image.
  • changed — an optional list of field keys; the rule fires only if at least one listed field’s value differs between pre-image and post-image.

Body

  • body — the effect, written in the one typed language, in the effectful tier. It receives two bindings: record (the post-image, mutable in before_write, read-only afterward) and prior (the pre-image; null on create). Available effect verbs by phase are exactly those in the model table.

Lifecycle & fan-out

  • related — an explicit, statically-known set of related-record paths this rule may touch. Declaring the blast radius up front is what makes the per-write lifecycle bounded (see Limits). An after-write body that writes outside its declared related set fails to compile.
  • priority — integer ordering among rules on the same object+phase, used to make the before-write fixpoint deterministic when two rules touch the same field.

A worked example — a before-write derivation plus an after-write related-record effect on the Invoice object:

// automation: invoice_stamp_and_notify
{
"key": "invoice_stamp_and_notify",
"label": "Stamp approval need and open a review task",
"type": "automation",
"body": {
"object": "invoice",
"rules": [
{
"phase": "before_write",
"on": "create_or_update",
"when": "record.total_price > 250000",
"changed_to_meet": true, // only on the edge where it first crosses
"priority": 10,
"do": [
// effect on the TRIGGERING row → no second write
"record.needs_approval = true",
"record.approval_tier = 'executive'"
]
},
{
"phase": "after_write",
"on": "create_or_update",
"when": "record.needs_approval == true",
"changed": ["needs_approval"],
"related": ["invoice.review_tasks"], // declared blast radius
"do": [
"insert invoice.review_tasks {",
" subject: 'Executive review required',",
" status: 'open'",
"}"
]
},
{
"phase": "post_commit",
"on": "update",
"when": "record.needs_approval == true",
"changed": ["needs_approval"],
"do": [
// outward call is only legal post-commit
"emit event 'invoice.approval_requested' { invoice_id: record.id }"
]
}
]
}
}

Save-order placement. Before-write rules run before the transactional write persists the row and maintains storage-enforced roll-ups; the write then tests the lifecycle gate; after-write rules run against the persisted-but-uncommitted row; commit is atomic; post-commit rules run off the durable queue. Because the transactional write maintains storage-enforced roll-ups as part of persisting the row, an after-write body that reads a roll-up sees the value as of this save, not a pre-save aggregate — freshness is a property of where roll-up maintenance sits in the pass, not something an author arranges. This is the fixed sequence documented in save order of execution — one pass, no re-entrancy.

The in-transaction / post-commit split is drawn at commit and is declared per rule by phase: before_write and after_write bodies run inside the save transaction and roll back with it; post_commit bodies run after commit, observe committed data, and carry at-least-once semantics. A rule’s durability guarantee is therefore visible in its own metadata, not inferred from what it happens to do.

One-pass fixpoint. Within before_write, every matching rule’s field assignments are gathered and resolved to a fixpoint before the single write. If two rules on the same object+phase assign the same field, priority breaks the tie deterministically; equal priority writing the same field is a compile-time conflict, not a runtime surprise. There is no last-writer-wins fallback and no implicit merge policy — the author resolves the ambiguity with priority, so no field’s final value ever depends on undefined rule ordering. There is likewise no mechanism by which one before-write rule observes another’s write and re-fires: determinism is bought by giving up re-entrant observation, a deliberate trade for a single, analyzable pass.

Null/blank handling. prior is null on create; any when, changed, or changed_to_meet predicate referencing prior must be null-safe (the pure subset’s null semantics apply — comparisons against null yield unknown, not true). changed treats a null→value or value→null transition as a change. A when that evaluates to null (not true) does not fire the rule.

Determinism & precision. Entry predicates are pure and therefore reproducible: the same pre/post image pair always yields the same fire/no-fire decision. Effect bodies are not pure and are not reproducible in general (they may read other rows, the clock, or outward state) — this is the definitional boundary between the tiers. Any pure calc a body calls is version-pinned, so the pure portion of an effect is reproducible even though the effect as a whole is not.

Type contracts. The binding declares the object; record and prior are typed as that object’s row shape. Effect verbs are typed by phase — the set of legal verbs in a before_write body is a strict subset of those legal in after_write, which is a strict subset of post_commit. The compiler rejects a verb outside its phase’s set.

Salesforce’s limits are per-transaction fairness fuses for a shared multi-tenant pod; a shared Postgres cluster needs equivalents, but the shape differs because CAOS bounds fan-out statically rather than metering an unbounded cascade at runtime.

Concern Salesforce exact limit (cited) The WHY (constraint it guards) CAOS approach
Queries per transaction 100 sync / 200 async SOQL Cap read amplification; force bulkification Set-based by default (statement-level triggers); no per-row query loop to cap. A budget still exists, surfaced in dry-run.
Rows read 50,000 per transaction In-memory read cap Streamed/bounded reads; budget surfaced, not a hidden fuse
Write statements 150 DML per transaction Cap write amplification; force bulk DML Set-based writes; declared related fan-out bounds the count at author time
Rows written 10,000 per transaction Cap rows mutated per unit Bounded by the statically-known related closure
Compute 10,000 ms sync / 60,000 ms async CPU Wall-clock-independent compute budget — the limit real orgs hit most Sandboxed effect execution with a declared, previewable CPU/time budget shown before ship
Memory 6 MB sync / 12 MB async heap In-memory data cap Declared memory budget per sandboxed effect
Outward calls 100 callouts, 120 s cumulative timeout External-dependency cap Post-commit only; per-rule outward budget with visible retry/backoff
Async chaining Queueable chains exactly 1; @future primitive-args-only, no chaining/return Bound async fan-out to prevent runaway chains One durable queue with idempotency keys, visible retry, dead-letter — uniform semantics, no six-tool zoo
Re-entrancy 16-level recursive trigger/DML depth, then LimitException Hard stop on self-re-entering saves None needed — one-pass model has no re-entrancy to bound
Batch size (async paths) 200 records/chunk (settable 1–200) Chunk bulk follow-up Configurable queue batch; same intent

Salesforce has shipped four overlapping generations of record-triggered automation, and a single save can fire several of them at once — which is the root of its order-of-execution complexity.

Generation Kind Status
Workflow Rules Declarative End of support 2025-12-31; new creation blocked in Winter ’23
Process Builder Declarative End of support 2025-12-31; new creation blocked in Summer ’23
Flow (record-triggered) Declarative, go-forward Current / strategic — before-save, after-save, scheduled, async paths
Apex triggers Imperative code Current — before/after × insert/update/delete + after undelete

End of support is not a forced shutdown: existing Workflow Rules and Processes keep running past 2025-12-31, but Salesforce stops fixing bugs, and a “Migrate to Flow” tool converts them into Flows. The practical guidance is “consolidate onto Flow + Apex triggers.” The multi-generation reality is a migration burden Salesforce created for itself by shipping three declarative engines over fifteen years, each with different semantics and a different slot in the save order.

The interleaving is the cost. In the twenty-step save order, before-save flows (step 3) run, then all before triggers (step 4), then validation and duplicate rules, then the uncommitted save (step 7), then after triggers (step 8), then — much later — workflow field updates (step 11) re-enter the save, re-firing before and after update triggers, and on that recursive pass Salesforce silently skips steps 9–17. After-save flows land at step 14; parent and grandparent roll-ups recompute at steps 16–17 (so an after-trigger reading a parent roll-up sees a stale value); commit is step 19; async and post-commit events at step 20. Apex has no built-in recursion guard — developers hand-roll a static Boolean per transaction, and forgetting it is among the most common trigger bugs. The hard ceiling is 16 levels of recursive depth.

Salesforce positions before-save flow as roughly 10× faster than the equivalent after-save flow or Process Builder for same-record field updates, because it avoids a second save on the triggering record. The exact 10× multiplier is Salesforce-stated but is not confirmed against a fetched primary doc; the directional claim (avoiding the second write is cheaper) is sound and is the same reason CAOS routes same-row updates through before_write.

What CAOS does better — mechanism-named, parity separated from win:

  • BETTER — one effectful tier, one-pass, no re-entrancy. SF re-enters its own save (step 11) and skips steps 9–17 on the second pass; that re-entrancy is the source of “worked once, broke on the second pass,” the mandatory hand-rolled recursion flag, and the 16-deep ceiling. CAOS computes the before-write fixpoint once and runs related-record effects exactly once. Cost/risk: a one-pass model must define what happens when two rules disagree on the same field — CAOS makes that a priority tie-break or a compile-time conflict, which is explicit design work, not free, and it gives up SF’s (accidental) ability to let effects observe each other’s writes.

  • BETTER — bounded per-related-record lifecycles. SF cascades saves through triggers → flows → roll-ups → sharing with per-transaction governor fuses as the only backstop, so blast radius is discovered at runtime when a limit trips. CAOS’s declared related closure makes the blast radius knowable at author time. Cost/risk: genuinely recursive business rules (A updates B updates A) need a termination guarantee, and CAOS gets one from the same declared sets — the union of every automation’s related closure forms a static fan-out graph, and a deploy that would introduce a cycle in it is rejected with the offending path named. The cost is expressiveness: an intentionally cyclic cascade that Apex would run (governed only by the 16-deep depth fuse) must instead be restructured, or broken across a post-commit hop that re-enters as a fresh save rather than an in-pass cascade.

  • BETTER — one durable event/queue tier. SF has @future, Queueable, Batch, Scheduled Apex, Platform Events, and CDC — six overlapping async tools with different limits, chosen by tribal knowledge. CAOS exposes one durable queue with uniform semantics (at-least-once delivery, idempotency keys, visible retry/backoff, dead-letter) over Postgres (SELECT … FOR UPDATE SKIP LOCKED or a queue extension), giving transactional-outbox guarantees SF approximates with “publish after commit.” Cost/risk: CAOS now owns delivery guarantees, ordering, poison-message handling, and back-pressure — operational surface SF customers never see; under-building it is worse than SF.

  • BETTER — sandboxed effects with inspectable budgets. SF’s governor limits are opaque fuses that abort the whole transaction with a LimitException. CAOS runs effects in a sandbox with declared budgets surfaced in the authoring UI and a dry-run preview. Cost/risk: sandboxing arbitrary logic on a Postgres/Node stack is hard to do safely and cheaply; isolation and metering both cost latency. The effectful tier is a real code tier — the one typed language, not a click-only builder — so it matches Apex’s expressiveness rather than settling for the narrower reach of a declarative-only engine; the price is that the sandbox, its metering, and its budgets must be built and operated to a higher bar than a declarative surface would demand.

  • BETTER — fresh aggregates in the same pass. SF recomputes parent and grandparent roll-ups late (steps 16–17), so an after-trigger that reads a roll-up sees a stale value and the usual workaround is a second update. CAOS maintains storage-enforced roll-ups inside the transactional write, before after-write effects run, so an after-write body reads the post-save aggregate directly. Cost/risk: roll-up maintenance is synchronous on the write path, so a wide fan-in adds latency to every triggering save — which is why roll-ups stay storage-enforced and bounded rather than arbitrary aggregate queries evaluated per rule.

  • BETTER (and free) — no dead generations. CAOS ships one automation model: no Workflow-vs-Process-vs-Flow migration debt, no “end of support 2025-12-31,” no three slots in the save order. Cost/risk: the flip side is SF’s fifteen years of edge-case coverage — the single model must eventually express the long tail (approvals, entitlements, sharing recalculation, roll-ups) SF accreted those generations to handle. “One model” is only better if it stays expressive enough.

  • PARITY (table stakes, not innovation): before/after semantics; create/update/delete conditions with entry filters and a “changed to meet criteria” gate; bulk-set correctness (the Postgres statement-level default, rather than a discipline SF must enforce); source-deployable definitions; async follow-up decoupled from the transaction. These match SF because customers notice their absence — they are not claimed as wins.

An automation is a canonical JSON component, source-deployable and diffable:

{
"key": "invoice_stamp_and_notify", // stable identifier
"label": "Stamp approval need and open a review task",
"type": "automation",
"body": {
"object": "invoice",
"rules": [ /* phase / on / when / changed_to_meet / related / priority / do */ ]
}
}

The workflow is retrieve → diff → deploy: pull the component’s JSON, diff against the working copy in version control, deploy the changed component. Because entry predicates are pure and related is declared, a deploy can be statically validated — conflicting field writers, undeclared fan-out, phase/verb violations, and cycles in the combined related fan-out graph are caught at deploy time, not first-run.

Salesforce Metadata API analogs (element names cited):

  • ApexTrigger — Apex source plus a -meta.xml sidecar (apiVersion, status Active/Inactive). Trigger events are declared in the Apex body (trigger X on Account (before insert, after update) {…}), not the XML; activation is per-environment via status.
  • Flow — a single XML document; the record-trigger configuration lives in the <start> element (FlowStart, API v47.0+). Key fields: <triggerType> = RecordBeforeSave / RecordAfterSave / Scheduled (confirmed queryable via FlowDefinitionView.TriggerType); <recordTriggerType> = Create / Update / CreateAndUpdate / Delete; <object>; <filters> / <filterFormula>; a “require record changed to meet criteria” toggle; <scheduledPaths>. Flows are versioned metadata — FlowDefinition carries the active-version pointer, and deploys create new versions rather than mutating in place.
  • Triggers and Order of Execution — the twenty-step save order; workflow field updates re-enter and skip steps 9–17.
  • Trigger Context Variables — Trigger.*, operationType enum, new/old availability, events including after undelete.
  • Apex Governor Limits / Execution Governors — SOQL 100/200, DML 150, CPU 10k/60k ms, heap 6/12 MB, rows 50k, DML rows 10k, recursion depth 16, callouts 100, @future 50, queueable 50/1.
  • ApexTrigger metadata type — source + -meta.xml (apiVersion, status).
  • Flow metadata type / FlowStart — <start> / FlowStart (v47.0+), triggerType RecordBeforeSave / RecordAfterSave, versioning via FlowDefinition.
  • Workflow Rules & Process Builder retirement — Salesforce “Go with the Flow” guidance (end of support 2025-12-31; new creation blocked Winter ’23 / Summer ’23; Migrate to Flow tool).
  • Record-Triggered Flow before/after + scheduled/async path considerations — help.salesforce.com Record-Triggered Flow considerations.
  • Schedule-Triggered Flow limits — help.salesforce.com considerations (batch size 200; 250,000 or user-licenses × 200 interviews per 24 h).
  • sfdc-trigger-framework — Kevin O’Hara — the canonical one-trigger-per-object handler framework the community hand-builds because the platform withholds it.
  • Trigger Actions Framework — Mitch Spano — metadata-driven binding of Apex/Flow actions to trigger contexts, with explicit ordering and bypass; the closest prior art to a first-class dispatch board.
  • The Salesforce Trigger Handler Framework — Salesforce Ben — why “one trigger per object, logic in a handler” is the near-universal best practice, and that Salesforce does not guarantee multi-trigger order.