sf-trigger-constraints

Enforce one-trigger-per-object, handler delegation, bulkification, and recursion prevention. Use when writing or reviewing ANY Apex trigger or handler. Do NOT use for non-trigger Apex, LWC, or Flow.

Trigger Constraints

When to Use

This skill auto-activates when writing, reviewing, or modifying any Apex trigger or trigger handler. It enforces one-trigger-per-object, handler delegation, bulkification, and recursion prevention rules for all trigger artifacts.

Hard rules that every Apex trigger and trigger handler must satisfy. Violations are blockers -- flag them before any other review feedback.

Reference: @../_reference/TRIGGER_PATTERNS.md (order of execution, context variables, framework comparison).


Never Rules

These are absolute prohibitions. Any occurrence is a defect.

IDRuleWhy
N1No logic in the trigger bodyTrigger files contain only the handler invocation (new Handler().run() or fflib_SObjectDomain.triggerHandler(Domain.class)). Zero conditionals, zero loops, zero DML.
N2No multiple triggers per objectMultiple triggers on the same sObject have no guaranteed execution order (see @../_reference/TRIGGER_PATTERNS.md, Step 5/9). Consolidate into one trigger file per object.
N3No DML inside loopsinsert/update/delete/upsert/Database.* calls inside for loops hit governor limits. Collect records first, DML once outside the loop.
N4No SOQL inside loopsQueries inside for loops risk the per-transaction SOQL limit (see @../_reference/GOVERNOR_LIMITS.md). Query before the loop, store results in a Map<Id, SObject>.
N5No hardcoded IDsRecord IDs, profile IDs, or record-type IDs must never appear as string literals. Use Schema.SObjectType.*.getRecordTypeInfosByDeveloperName(), Custom Metadata, or Custom Labels.
N6No direct calloutsApex triggers cannot make HTTP callouts synchronously. Use @future(callout=true) or Queueable with Database.AllowsCallouts.
N7No Trigger.new modification in after triggersTrigger.new is read-only in after contexts. Field updates in after triggers must go through a separate DML statement on queried/cloned records.

Always Rules

Every trigger implementation must include these elements.

IDRuleHow
A1Delegate to a handler classTrigger body calls handler: new AccountTriggerHandler().run();. All logic lives in the handler or in service classes the handler calls.
A2Bulkify all logicEvery method must handle List<SObject> (up to 200 records per chunk). No assumption of single-record input. Iterate Trigger.new / Trigger.old, never index [0] alone.
A3Use a recursion guardPrevent infinite re-entry. Recommended: static Set<Id> of processed IDs (allows workflow re-fire for unprocessed records while blocking true recursion). Alternatives: depth counter in base class, setMaxLoopCount(). See @../_reference/TRIGGER_PATTERNS.md recursion patterns.
A4Use Trigger.newMap / Trigger.oldMap for comparisonsWhen detecting field changes in update triggers, compare Trigger.newMap.get(id).Field__c against Trigger.oldMap.get(id).Field__c. Never rely on list index alignment.
A5Register all events in one triggerThe single trigger file should subscribe to all seven events (before insert, before update, before delete, after insert, after update, after delete, after undelete) even if the handler only overrides a subset today. This prevents needing a trigger file redeploy when new events are handled later.
A6Include a bypass mechanismSupport disabling the handler without a code deploy. Use TriggerHandler.bypass() / .clearBypass(), Custom Metadata (Trigger_Setting__mdt), or Hierarchy Custom Settings. Always reset bypass state in a finally block.
A7Keep handler methods focusedEach onBeforeInsert(), onAfterUpdate(), etc. should call named service methods. If a handler method exceeds ~30 lines, extract to a service class.

Anti-Pattern Table

Anti-PatternExampleCorrect Alternative
Logic in trigger bodytrigger T on Account (before insert) { for (Account a : Trigger.new) { a.Name = 'X'; } }trigger T on Account (...) { new AccountTriggerHandler().run(); } with logic in handler
Two triggers on same objectAccountTrigger.trigger + AccountOwnerTrigger.triggerSingle AccountTrigger.trigger delegating to one handler
DML in loopfor (Account a : accts) { update a; }update accts; outside loop
SOQL in loopfor (Account a : accts) { Contact c = [SELECT ...]; }Map<Id, Contact> cMap = new Map<Id, Contact>([SELECT ...]); // before loop
Hardcoded IDif (acc.RecordTypeId == '012000000000001')Schema.SObjectType.Account.getRecordTypeInfosByDeveloperName().get('Customer').getRecordTypeId()
No recursion guardAfter-update handler updates same records with no static checkprivate static Set<Id> processedIds = new Set<Id>(); -- skip IDs already in set
Boolean recursion flagstatic Boolean hasRun = false; if (hasRun) return;Set<Id> -- boolean flag blocks legitimate workflow re-fire for unprocessed records
Modifying Trigger.new in after contextfor (Account a : Trigger.new) { a.Status__c = 'Done'; } in onAfterInsertQuery records, update separately: update [SELECT Id FROM Account WHERE Id IN :newMap.keySet()]

Quick Checklist

Use when writing or reviewing a trigger PR:

  • Exactly one .trigger file per sObject
  • Trigger body is a single handler call (no logic)
  • Handler extends TriggerHandler (or FFLIB fflib_SObjectDomain)
  • All seven events registered in trigger definition
  • Every loop processes List<SObject>, not a single record
  • Zero SOQL or DML inside any loop
  • No hardcoded IDs anywhere
  • Recursion guard present (prefer Set<Id> pattern)
  • Bypass mechanism available
  • No Trigger.new mutation in after-trigger methods
  • No synchronous callouts

Related

  • Skill: sf-trigger-frameworks -- Framework patterns, base class code, migration guide
  • Reference: @../_reference/TRIGGER_PATTERNS.md -- Order of execution, context variables, framework comparison
  • Agent: sf-architect -- Interactive trigger design guidance