Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .afv/rules/trigger-lib.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ alwaysApply: true
This project runs its Apex trigger logic through Trigger Lib (`TriggerOrchestrator.cls`).

- Each object has one trigger that lists all seven events (only `after insert` on a platform event or change event), and its body is only `TriggerOrchestrator.run(new <Object>TriggerOrchestrator());` or `TriggerOrchestrator.runFromMetadata();`. Don't put logic in a trigger body, and don't write `Trigger.isBefore` or `Trigger.isInsert` branches, loops over `Trigger.new`, or handler classes of other trigger frameworks.
- Write each concern as one top-level handler class with one role per context, and register it in run order: in the object's orchestrator, or as a `TriggerHandler__mdt` record with `Order__c` when the trigger calls `runFromMetadata()`. The roles: a Populator sets fields with `record.put` in before insert or before update, a Validator rejects with `record.addError` in before insert, before update or before delete, a Writer changes other records through `TriggerTypes.UnitOfWork` in before delete or an after context, and a Dispatcher makes one bulk call per chunk, such as `System.enqueueJob`, in an after context.
- Write each concern as one top-level handler class with one role per context, and register it in run order in each context it runs in: in the object's orchestrator, or as one `TriggerHandler__mdt` record per context, with `Order__c`, whose context field, such as `BeforeInsert__c`, names the object, when the trigger calls `runFromMetadata()`. The roles: a Populator sets fields with `record.put` in before insert or before update, a Validator rejects with `record.addError` in before insert, before update or before delete, a Writer changes other records through `TriggerTypes.UnitOfWork` in before delete or an after context, and a Dispatcher makes one bulk call per chunk, such as `System.enqueueJob`, in an after context.
- Never run SOQL or DML in a handler's predicate (`...When`) or action, because the library throws. Read parents with ParentQuery or PriorParentQuery, other records with RelatedQuery and configuration from static variables. Register writes with `unitOfWork.toInsert`, `toUpdate`, `toUpsert`, `toDelete` or `toPublish`, as new instances that hold the Id and the changed fields.
- In the update contexts, make every predicate require a change, such as `record.isChanged(...)` or `isChangedTo(...)`.
- Test handlers with `TriggerOrchestrator.mock().<ctx>For(X.class).with(...)` and `TriggerOrchestrator.runTestFor(new X())`, and mock the unit of work with `DML.mock('triggerUow').allDmls()`, instead of inserting records to fire the trigger. Keep one test per trigger that saves a record with DML, because a trigger without coverage can't be deployed to production.
Expand Down
10 changes: 5 additions & 5 deletions .afv/skills/trigger-lib-handler/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ A handler is a small class with one role in one trigger context. For every recor
2. No SOQL and no DML in a predicate, or in the per-record action of a Populator, Validator or Writer. Both throw a `TriggerTypes.TriggerLibException` and fail the save. Read other records as shown in [Reading other records](#reading-other-records), and register DML on the action's `unitOfWork`. A Dispatcher's dispatch method and a Finalizer run once per chunk, outside that check.
3. Change the record being saved only from a Populator, with `record.put(field, value)`, in before insert or before update. In the after contexts the rows are read-only: `put` throws a `System.FinalException` that no `catch` stops.
4. Before insert and before update allow no DML and no event publish anywhere in a handler, its Finalizer included. Change other records from a Writer in before delete or an after context.
5. In before update and after update, make the predicate require a change, with `isChanged`, `isChangedTo` or another change check. Every later update of the record in the transaction runs the update handlers again. The built-in cap of 3 actions per record per transaction only limits the damage.
5. In before update and after update, make the predicate require a change, with `isChanged`, `isChangedTo` or another change check. Every later update of the record in the transaction runs the update handlers again. After 3 actions on a record in the transaction, each later run logs an error, and only a RecursionGuard stops the loop.
6. When a Validator's predicate returns true, its error method must call `record.addError(...)`, or the library throws for the whole chunk. Put the whole condition in the predicate.
7. A new handler runs only once it is registered: add it to the object's orchestrator in run order (Populators before Validators), or add its `TriggerHandler__mdt` record when the trigger calls `runFromMetadata()`. The `trigger-lib-orchestrator` skill covers both.
7. A new handler runs only once it is registered, and only in the contexts it is registered in: add it to the object's orchestrator in run order (Populators before Validators), or add one `TriggerHandler__mdt` record per context the class runs in, each naming the object in that context's field, such as `BeforeInsert__c`, when the trigger calls `runFromMetadata()`. A handler that gains a context needs the same registration for it. The `trigger-lib-orchestrator` skill covers both.
8. Pass fields as `SObjectField` tokens (`Account.Rating`). Cast a row to read its fields: `(Account) record.getNewSObject()`.

## Before writing
Expand Down Expand Up @@ -266,13 +266,13 @@ Implement as many as you need next to the role interface. Each exists only in th
| PriorParentQuery | `Map<SObjectField, TriggerTypes.ParentFields> queryPriorParentsOn<Ctx>()` | Load the parents from before the save | BeforeUpdate, AfterUpdate, BeforeDelete, AfterDelete |
| RelatedQuery | `Map<String, <Ctx>.RecordsProvider> queryRelatedOn<Ctx>()` | Load children, siblings or any other records | every context |
| Bypassable | `Boolean bypassOn<Ctx>When()` | Skip the handler for the chunk on a condition that doesn't depend on the records | every context |
| RecursionGuard | `Integer maxRunsPerRecordOn<Ctx>()` | Change the cap of 3 actions per record per transaction | BeforeUpdate (Populator only), AfterUpdate |
| RecursionGuard | `Integer maxRunsPerRecordOn<Ctx>()` | Set the recursion depth and skip the record there, instead of only logging at the default depth of 3 | BeforeUpdate (Populator only), AfterUpdate |
| Finalizer | `void finalizeOn<Ctx>(records)` | Run once per chunk with the qualified records | every context |
| ContinueOnError | none, it's a marker | Log and swallow the handler's own exceptions | every context |
| OwnUnitOfWork | `DML.Committable ownUnitOfWorkOn<Ctx>()` | Give a Writer its own `DML` settings, committed right after it | AfterInsert, AfterUpdate, BeforeDelete, AfterDelete, AfterUndelete |

- **Bypassable** runs once per handler per run and gets no records. Use it for a static flag, `System.isBatch()` or `FeatureManagement.checkPermission('...')`. It's checked before the first handler of the run, so a flag that an earlier handler sets applies from the next run. An exception there isn't logged and fails the save. To skip single records, return false from the predicate instead.
- **RecursionGuard** counts every time the predicate returns true for a record, per class and context, for the whole transaction. `null` means no limit, and `0` or less skips every record. Avoid `1`: a partial save, such as `Database.update(rows, false)`, Data Loader or the Bulk API, rolls back and runs the trigger again for the rows that succeeded. The count isn't rolled back, so with a cap of `1` their action is lost without an error. Prefer a change gate in the predicate, which passes again on that re-run, and keep any cap at 2 or more.
- **RecursionGuard** counts every time the predicate returns true for a record, per class and context, for the whole transaction. When the record comes back at that count, it's skipped and the run logs an error. Without it the depth is 3, and the run logs the same error but the handler keeps going. `null` means no depth and no error, and `0` or less skips every record. Avoid `1`: a partial save, such as `Database.update(rows, false)`, Data Loader or the Bulk API, rolls back and runs the trigger again for the rows that succeeded. The count isn't rolled back, so with a cap of `1` their action is skipped on that re-run. Prefer a change gate in the predicate, which passes again on that re-run, and keep any cap at 2 or more.
- **Finalizer** runs after the handler's records, only when at least one qualified. SOQL is allowed there. It gets no unit of work, so to register more DML, keep the action's `unitOfWork` in an instance field.
- **ContinueOnError** is for nice-to-have work such as a notification. After an exception, the handler's remaining records and its Finalizer are skipped, later handlers still run, and a Writer's registrations are dropped. It never swallows a `TriggerTypes.TriggerLibException`, an `EventBus.RetryableException` that asks for redelivery of a platform event, a `System.LimitException`, the `FinalException` from writing a trigger row, or an error outside the handler's own methods. Never add it to a Validator that guards a save, and add a Logger, or a swallowed failure leaves no trace.
- **OwnUnitOfWork** returns a DML Lib unit, such as `new DML().userMode().allowPartialSuccess().identifier('OpportunityLossReviewWriter')`. A plain `new DML()` runs in user mode and throws on a duplicate registration, unlike the shared unit.
Expand All @@ -282,7 +282,7 @@ Implement as many as you need next to the role interface. Each exists only in th
| Context | Know this |
|---|---|
| BeforeInsert | `getId()` is `null` and `records.getIds()` is empty. Key maps by a lookup or a field value |
| BeforeUpdate | The old row is read-only. A Validator runs on every pass; a Populator acts at most 3 times per record |
| BeforeUpdate | The old row is read-only. A Validator runs on every pass; a Populator logs an error when a record comes back after 3 passes |
| BeforeDelete | Writes commit before the delete, while other records still point at the rows. Leave the rows being deleted alone: a `toDelete` of one fails with `SELF_REFERENCE_FROM_TRIGGER`, and an update of one is lost. Cascade-deleted children fire no triggers of their own, so handle them here with a RelatedQuery |
| AfterInsert, AfterUpdate | The rows are read-only. Inserting or updating the same object runs its triggers again, and the insert contexts have no recursion cap |
| AfterDelete | SOQL no longer finds the rows, so query by their old lookups, such as `records.getIdsOf(Contact.AccountId)`. Merge losers arrive here with `MasterRecordId` set |
Expand Down
Loading
Loading