8.2 Bulk Trigger Patterns & Best Practices
Key Takeaways
- Always assume Trigger.new can contain up to 200 records—collect Ids, query once, and use Map lookups instead of SOQL or DML inside per-record loops
- Keep triggers thin: route context to a handler class; put domain logic and data access in service classes
- Guard against recursion with static flags or processed-Id sets when after-context DML would re-fire the same trigger
- Prefer Flow (or other declarative tools) when the requirement is simple field updates or maintainable admin automation without complex bulk algorithms
- Framework pattern trigger → handler → service scales testing, bypasses, and multi-event routing without duplicating SOQL
8.2 Bulk Trigger Patterns & Best Practices
Quick Answer: Write every trigger as if 200 records arrived at once. Collect related Ids into a Set, run one SOQL, build a Map for lookups, apply rules in memory, then perform one DML on a list or map of changes. Put that logic in handler/service classes, not a fat trigger body. Use static recursion guards when your DML would re-enter the same trigger, and prefer Flow when declarative automation fully meets the requirement.
Platform Developer I hammers bulkification. A snippet that works for one Account in the UI and fails in Data Loader is the classic wrong answer—even if syntax compiles.
The Core Bulk Pattern: Collect → Query → Map → Act
Anti-pattern (fails governors):
// BAD: SOQL and DML inside a per-record loop
for (Contact c : Trigger.new) {
Account a = [SELECT Id, Industry FROM Account WHERE Id = :c.AccountId];
if (a.Industry == 'Agriculture') {
c.Description = 'Agro contact';
update c; // also wrong in before; disastrous in after
}
}
With 200 contacts this can burn hundreds of SOQL and DML operations and hit limits immediately.
Bulk-safe pattern:
// GOOD: one query, map lookup, field edits in before context
Set<Id> accountIds = new Set<Id>();
for (Contact c : Trigger.new) {
if (c.AccountId != null) {
accountIds.add(c.AccountId);
}
}
Map<Id, Account> accountsById = new Map<Id, Account>();
if (!accountIds.isEmpty()) {
accountsById = new Map<Id, Account>([
SELECT Id, Industry
FROM Account
WHERE Id IN :accountIds
]);
}
for (Contact c : Trigger.new) {
Account parent = accountsById.get(c.AccountId);
if (parent != null && parent.Industry == 'Agriculture') {
c.Description = 'Agro contact'; // before update/insert: no DML needed
}
}
When related records must be written in after context:
List<Task> toInsert = new List<Task>();
for (Opportunity o : Trigger.new) {
if (o.StageName == 'Closed Won') {
toInsert.add(new Task(
WhatId = o.Id,
Subject = 'Post-win review',
OwnerId = o.OwnerId,
Status = 'Not Started'
));
}
}
if (!toInsert.isEmpty()) {
insert toInsert; // single DML outside the loop
}
Rules of thumb
| Never in a per-record loop | Do instead |
|---|---|
| SOQL | Collect Ids → one WHERE Id IN :ids |
DML (insert/update/delete) | Build List/Map → one DML after loops |
| SOQL aggregate per row | One aggregate query grouped by key, or roll up in maps |
| Callouts | Collect work → async (@future, Queueable) after bulk prep |
Handler and Service Layering (Framework Pattern)
Fat trigger (logic, queries, and DML all in the .trigger file) is hard to test, reuse, and bypass. Preferred layering:
trigger AccountTrigger on Account (...) {
AccountTriggerHandler.dispatch();
}
│
▼
AccountTriggerHandler → routes by operationType / isBefore flags
│
▼
AccountService / AccountRelatedService → pure domain methods, SOQL/DML
Example skeleton:
public with sharing class AccountTriggerHandler {
public static void dispatch() {
switch on Trigger.operationType {
when BEFORE_INSERT, BEFORE_UPDATE {
AccountService.applyDefaults((List<Account>)Trigger.new);
}
when AFTER_UPDATE {
AccountService.syncRelatedOnIndustryChange(
(List<Account>)Trigger.new,
(Map<Id, Account>)Trigger.oldMap
);
}
}
}
}
public with sharing class AccountService {
public static void applyDefaults(List<Account> rows) {
for (Account a : rows) {
if (a.Type == null) {
a.Type = 'Prospect';
}
}
}
public static void syncRelatedOnIndustryChange(
List<Account> news,
Map<Id, Account> oldMap
) {
Set<Id> changedIds = new Set<Id>();
for (Account a : news) {
Account prior = oldMap.get(a.Id);
if (prior != null && a.Industry != prior.Industry) {
changedIds.add(a.Id);
}
}
if (changedIds.isEmpty()) {
return;
}
// query children once, build updates, single DML...
}
}
Benefits for the exam and real orgs
- Unit tests call service methods with constructed lists without performing full UI saves for every path
- Recursion and bypass flags live in one place
- Multiple events share collection helpers without copy-paste SOQL
- Trigger file stays a few lines—easy code review
Some orgs use a lightweight TriggerHandler base class with beforeInsert(), afterUpdate(), and a static bypass set. The exam does not require a specific open-source framework name; it does expect the idea: thin trigger, bulk handler, no SOQL/DML in loops.
Recursion Guards (Introduction)
After-context DML on the same object re-fires triggers and can recurse until CPU or DML limits fail. Simple static guard:
public with sharing class OpportunityTriggerHandler {
private static Boolean isRunning = false;
public static void afterUpdate(List<Opportunity> news, Map<Id, Opportunity> oldMap) {
if (isRunning) {
return;
}
isRunning = true;
try {
// related updates that might bounce back onto Opportunity
OpportunityService.rollupToParents(news, oldMap);
} finally {
isRunning = false;
}
}
}
Boolean guards are blunt: they block all re-entry for the rest of that path. Finer control uses a static Set<Id> of already-processed Ids so other records in a later chunk can still run. Full recursion, cascading, and transaction control are covered in 8.4; here, know that bulk patterns plus static guards are the standard defense.
Selective Logic: Only Work When Fields Change
Bulk CPU burns when every update re-processes unchanged rows. Compare old vs new:
for (Account a : Trigger.new) {
Account prior = Trigger.oldMap.get(a.Id);
if (prior.OwnerId == a.OwnerId) {
continue; // ownership did not change—skip expensive path
}
// collect for owner-change side effects
}
Combine change detection with Sets/Maps so side-effect queries only run for true deltas.
Error Handling in Bulk
- Prefer before
addErrorfor business validation so bad rows fail cleanly - If using
Database.update(records, false), inspect everyDatabase.SaveResult—partial success is not silent success - Do not empty-catch
DmlExceptionaround bulk DML and pretend the transaction is healthy - One failed row in
allOrNone=true(default DML) rolls back that DML operation’s unit of work as documented—design user-facing messages accordingly
When Not to Use Triggers
Triggers are powerful and easy to overuse. Prefer not writing a trigger when:
| Requirement | Prefer |
|---|---|
| Stamp fields on the same record on save | Before-save record-triggered Flow |
| Simple related create/update with admin-owned logic | After-save Flow |
| Guided multi-screen user process | Screen Flow |
| Time-based daily criteria sweep | Scheduled Flow or declarative schedule |
| Roll-up that master-detail already supports | Roll-up summary field |
| Point-and-click validation of field formats | Validation rules (or before-save Flow/before trigger when rules are insufficient) |
Use Apex triggers / invocable Apex when you need complex bulk algorithms, fine-grained recursion control, sophisticated collection processing, callout orchestration with maps, packaged logic that must be versioned in code, or scenarios the exam stem explicitly marks as unsuitable for declarative tools.
On “best solution” questions: if the stem stresses admin maintainability, no code, or simple field update, declarative wins. If the stem stresses bulk data loads, governor-safe complex related updates, or existing trigger frameworks, Apex patterns win.
Governor Mindset for Trigger Design
Triggers share the transaction’s limits with Flows, validation, workflow legacy steps, and roll-ups that fire in the same save. Design so your contribution is minimal:
- Fewest SOQL/DML statements possible (not fewest lines of code)
- Selective entry (change checks, handler short-circuits)
- Avoid updating the triggering record again in after context when before would suffice
- Do not query inside loops “just this once”—exam graders treat that as always wrong
Putting Patterns Together
- One trigger per object → handler dispatch
- Handler checks recursion/bypass → calls service
- Service collects Ids → one query → Maps
- Service builds lists → one DML
- Prefer before field fixes; after only for true side effects
- Prefer Flow when declarative fully solves it
Internalize that checklist and most bulk-trigger exam items become mechanical.
Which design best bulkifies a Contact after-insert requirement to create one Task per Contact when Account.Type is 'Customer'?
A requirement can be met by setting two fields on Opportunity when StageName changes, with no related records or callouts. What is generally the best approach on the exam?
Why should business logic live in handler/service classes instead of a large trigger body?