Module 1 — Apex, Triggers, SOQL & Governor Limits
Interview weight: 40–50% · Estimated time: 6–8 sessions (~90 min each) Target: By the end, you can explain any governor-limit failure, recursion bug, or bulk-load outage to a colleague in 2 minutes, closed notes — and write the fix from memory.
M0 — THE MAP (read this first, 5–10 min)
The one idea everything hangs on: THE TRANSACTION
Every concept in this module — triggers, SOQL, DML, governor limits, recursion, order of execution, even async — is a consequence of one design decision Salesforce made:
Your code never runs alone. It runs inside a shared, policed bubble called a transaction — and everything that happens in a save or a method call shares one wallet.
Think of a theatre production:
- The stage = the transaction. One performance: one save of one record (which may fan out to hundreds of DML statements).
- The actors = triggers, flows, validation rules, workflows, classes — they all step onto the same stage in a fixed order (the order of execution).
- The wallet = governor limits. Every actor spends from the same budget: 100 SOQL queries, 150 DML statements, 10 seconds of CPU, 6 MB of heap. Spend the 101st query → the whole performance is cancelled (rolled back) and the audience gets
System.LimitException. - The stage manager = the platform. It decides who enters when, re-fires triggers when a workflow field update changes things, and counts every penny.
- Recursion = an actor calling himself through a mirror — the stage manager tolerates exactly 16 reflections before pulling the plug ("Maximum trigger depth exceeded").
- Async (future/queueable/batch) = a new performance in a new theatre — fresh wallet, but the actors are locked in a dressing room (separate transaction, no direct result to the user).
Why this map matters (the bridge): Every single "hard" interview question in this module — the 101 error, the double-firing trigger, the CPU timeout, the mixed DML error, the heap bomb, the silent data loss — is just a specific incident where someone forgot one of these facts:
- The wallet is shared and per-transaction.
- There's a fixed order to who runs when.
- Triggers get 200 records at a time and must be bulkified.
- Async = new wallet, new rules.
- Nothing is permanent until commit; everything is visible only under your sharing/security context.
By the end of this module, "knowing it" looks like this: given any one of the 9 incidents below, you can (a) name the mechanism that failed, (b) reproduce the failure reasoning on a whiteboard, (c) write the bulkified/guarded fix from memory, and (d) say which interview question it maps to.
The incidents (choose your own adventure — but recommended order)
| # | Incident | The villain mechanism |
|---|---|---|
| 1 | The 101 Wall | SOQL-in-loop, shared transaction wallet |
| 2 | The Trigger That Couldn't Stop Firing | Recursion + workflow double-fire |
| 3 | The 4:00 PM CPU Timeout | O(n²) + non-selective queries |
| 4 | The Batch That Burned the Night | start() subqueries + stateful heap |
| 5 | The Mixed DML Mystery | Setup objects vs transaction isolation |
| 6 | The One-Liner That Deleted Thousands | Null-id filter + delete semantics |
| 7 | The Rollup That Went Dark | Aggregate queries + cascade delete |
| 8 | The Security Hole Nobody Saw | Sharing modes + CRUD/FLS + injection |
| 9 | 🏆 CAPSTONE: The 11:34 PM Crisis | Everything, at once |
Protocol reminder (from file 00): For each incident — attempt in writing FIRST (≥2 hypotheses + 2 solution attempts), hard 45-min cap, hint ladder, then reveal, then REDO, then retrieval drill. You are expected to fail. The failure is the task.
INCIDENT 1 — THE 101 WALL
STAKES
Monday, 2:00 AM. A C# integration app starts pushing 7,000 updated Leads into production through the SOAP API, chunked at 200 records per call. By 2:07 AM the error log is a wall of System.LimitException: Too many SOQL queries: 101. The nightly sync dies. Sales ops wakes up to stale data. The dev who wrote the trigger is on holiday.
THE INCIDENT (what the team knew)
- The Lead trigger calls
Database.convertLead()on certain records, then repoints related custom objects from the old Lead to the new Contact — 4 more queries per conversion. - Unit tests for the trigger showed only 14 SOQL queries consumed. Everything passed.
- In production, the same code, same records, throws 101.
- The team tried: "turn the batch size down to 50" (helped, but didn't fix), "make the queries more selective" (no change), "check the queries in the trigger" (they found no loop).
THE PROBLEM (the gap)
Your unit test says 14 queries. Production says 101. You changed nothing. Where did the other 87 queries come from?
Answer in writing before reading on: (1) list ≥2 hypotheses, (2) write your diagnosis plan, (3) write the fix you'd ship if you had 2 hours and couldn't talk to the trigger author.
HINT LADDER (seal this — open in order)
- Hint 1 (the avenue): The trigger author counted queries inside one trigger invocation. What if the same transaction contains more than one trigger invocation — or more than one automation?
- Hint 2 (the mechanism): Governor limits are per transaction, not per trigger, not per DML call. What runs inside the same transaction as your Lead update, in addition to your trigger? (Think: other triggers, workflows, flows — and
convertLeaditself.) - Hint 3 (the skeleton): 200 records in one API call = the trigger fires once (200 records) — but if the trigger's DML (
convertLead) re-fires triggers, and those triggers run your trigger again... each re-fire burns fresh queries from the same wallet. Also:convertLeadis not free — it fires Lead and Contact triggers. Count the full chain, not just your class.
THE REVEAL — POSTMORTEM
What actually happened (real incident, 2019 — Salesforce StackExchange #250807, "Too many SOQL queries: 101" on 7,000-lead conversion):
The trigger author committed the classic accounting error: they counted queries per trigger invocation in a unit test that tested the trigger in isolation. Production doesn't run triggers in isolation.
The actual query chain in production:
Lead update (200 records) → transaction starts
├─ Lead after-update trigger → convertLead() [expensive!]
│ ├─ Lead triggers fire again → more queries
│ ├─ Contact triggers fire → more queries
│ └─ repoint custom objects (4 queries × conversions)
├─ Workflow rules on Lead fire → field updates → triggers re-fire
├─ Flow / Process Builder → more queries
└─ ... all sharing ONE 100-query walletEvery convertLead isn't one operation — it's a cascade of triggers. And because the import pushed 200 records per call, one API call could contain multiple trigger firings and multiple conversions — all consuming from the same 100-query budget. The 101st query kills the entire transaction: every record in that batch is rolled back.
Why the "obvious fixes" failed (the contrast):
- "Reduce batch size to 50" → reduces records per DML, not queries per transaction. It just moves the wall.
- "Make queries selective" → the queries were already fine; the problem was count, not cost. (Selectivity matters for CPU and timeouts — Incident 3 — but the 101 is purely a counting limit.)
- "Find the loop in the trigger" → there was no loop in this trigger. The loop was in the chain: the same 200 records passing through multiple automations, each spending queries.
The real diagnostic tool: CUMULATIVE_PROFILING in debug logs (or the LIMITS fields in the log). It shows you every SOQL query in the transaction and who executed it — not just the ones in your class. In a real variant of this incident, the developer found their loop wasn't even the main consumer: a downstream trigger was.
THE FIX (canonical)
- Profile first: enable
CUMULATIVE_PROFILINGon the integration user, reproduce with a 200-record batch, read the log. Find every query consumer. - Bulkify every consumer in the chain: collect
Set<Id>→ one query withIN :ids→ store inMap<Id, sObject>→ single DML. (See the pattern below.) - Guard the expensive
convertLead: only convert Leads that actually need it (change detection), and checkSystem.isBatch()/isFuture()/isQueueable()if the trigger can be re-entered from async contexts. - Kill the re-fire loop: if workflow rules are re-running the trigger (see Incident 2), migrate the workflow to Flow or add change-detection guards.
- Regression test the right way: a bulk test that inserts 200 records through the real entry point (the trigger + all its DML), asserting
Limits.getQueries()stays under 100 — not a unit test of one class.
KNOWLEDGE EXTRACTION (interview-ready answers you just earned)
- "Why does my trigger fail on bulk upload but not in the UI?" → Governor limits are per transaction. In the UI you're touching 1 record (1 trigger firing, few queries). A Data Loader/API batch fires the trigger per 200 records — and every other automation in that transaction spends from the same 100-SOQL/150-DML/10s-CPU wallet. Your trigger wasn't bulkified.
- "What is bulkification?" → Writing code that assumes 200 records at once: collect IDs into Sets → one query → Map lookups → one DML per object, all outside loops.
- "What does 101 mean?" → It's the 101st query. 100 is the limit; the failing query is #101.
- "How do you avoid the 101?" → Query once outside loops;
IN :idswith Sets; relationship queries (subqueries); SOQL for-loops for large result sets; async (200-query wallet) where appropriate. - "Can you catch a LimitException?" → Effectively no. The transaction is already rolled back. Prevention, not handling.
THE REDO (compressed, from memory — 15 min)
Write the bulkified pattern for this requirement: "An after-update trigger on Account must update Account.Contacts_Count__c with the real number of related Contacts." Include: Set collection → aggregate query → Map → single DML. Then say what test you'd write (hint: not a 1-record test).
Check your answer against:
trigger AccountAfterUpdate on Account (after update) {
Set<Id> ids = new Set<Id>();
for (Account a : Trigger.new) ids.add(a.Id);
Map<Id, Integer> counts = new Map<Id, Integer>();
for (AggregateResult ar : [SELECT AccountId a, COUNT(Id) c FROM Contact
WHERE AccountId IN :ids GROUP BY AccountId]) {
counts.put((Id) ar.get('a'), (Integer) ar.get('c'));
}
List<Account> toUpdate = new List<Account>();
for (Account a : Trigger.new) {
toUpdate.add(new Account(Id = a.Id, Contacts_Count__c = counts.get(a.Id) ?? 0));
}
update toUpdate;
}(The ?? 0 — null-coalescing — handles Accounts with zero Contacts; initialize defaults, always.)
RETRIEVAL DRILL (closed-book, written)
- What does
System.LimitException: Too many SOQL queries: 101actually mean, and what is rolled back? - Name 3 things that share the same governor-limit wallet inside one save.
- Why does "reduce Data Loader batch size" NOT fix a query-limit problem?
- Write the collect→query→map→DML pattern for: "Update all Contacts' Account's custom field when 200 Contacts change."
- What tool shows you who spent the queries?
INTERVIEW MAPPING
This incident is the #1 most asked scenario at your band (Capgemini 2026: "Your trigger works in UI but fails on bulk upload with 'Too many SOQL queries' — how do you fix it?"; PwC, Deloitte, Accenture all have variants). Your answer = the postmortem above. Bonus credibility: mention that limits are shared across triggers, flows, and workflow re-fires — most candidates don't.
INCIDENT 2 — THE TRIGGER THAT COULDN'T STOP FIRING
STAKES
The same org. A different team. Their Opportunity trigger "logs upgrades" — whenever StageName changes, it writes a row to Opportunity_Stage_Log__c and sends an email. The log is now full of duplicate rows — sometimes 15 copies of the same stage change. Worse: yesterday a data-load triggered the log loop so hard that production threw Maximum trigger depth exceeded and the entire batch rolled back. The trigger author insists: "I have a static Boolean guard. It's in the code."
THE INCIDENT
- The trigger:
after update on Opportunity, checksTrigger.oldMap.get(id).StageName != Trigger.newMap.get(id).StageName, inserts a log row + email. - Debug log shows the SAME
Opportunity afterUpdate for [006...]event 15+ times, then death. - The guard looks like this:
public class OppTriggerHandler {
public static Boolean isFirstRun = true;
public static void handleAfterUpdate(...) {
if (!isFirstRun) return;
// ... do logging, send email, insert log rows ...
isFirstRun = false; // ← set at the END
}
}- Also on this object: a workflow rule that performs a field update (
Forecast__c) when StageName is set to Closed Won. And a record-triggered Flow.
THE PROBLEM
The debug log shows your trigger firing 15 times for ONE record. The static guard is clearly present. Both statements are true. Explain how — and fix it so it fires exactly once per real stage change.
Write: (1) ≥2 hypotheses, (2) your fix (you may rewrite the guard), (3) how you'd prove the fix in a test.
HINT LADDER
- Hint 1 (avenue): Three different things can re-enter a trigger: (a) your own DML, (b) the platform re-firing it after a workflow field update, (c) another trigger. Which one is invisible in the code?
- Hint 2 (mechanism): Order of execution, step 11: workflow rules with field updates re-run validation + before/after update triggers ONE more time. Also — when does
isFirstRunget set, relative to the first DML that re-enters? - Hint 3 (skeleton): Guard set at the END of the method = every re-entry runs the logic BEFORE the flag flips. Combined with: the workflow's field update makes the platform run your after-update trigger again — and in that second pass,
Trigger.oldstill shows the ORIGINAL StageName, so your "stage changed" check still evaluates TRUE. 15 echoes = recursion + workflow re-fire compounding.
THE REVEAL — POSTMORTEM
What actually happened (two real incidents, 2013 + 2019):
- Incident A (StackExchange #14162): A trigger deleting/recreating opportunity line-item schedules ran 15+ times. The
already = trueflag was set at the end of the block — so every re-entry re-ran everything before the flag was set. The author "fixed" it by checkingisFirstRunfirst… and still saw duplicates. - Incident B (StackExchange #33088/#108079): An after-update trigger creating log records on Stage change created duplicates on every Closed Won — because a workflow field update re-fires after-update triggers one more time, and in that second pass
Trigger.oldstill holds the pre-workflow values, so the old-vs-new comparison is still true. The trigger did run twice. The code did what it was told.
The compounding in this org:
Opportunity update
└─ after-update trigger fires (guard not yet set → runs) [log #1 + email]
└─ insert log row → Opportunity trigger re-fires? (no, log object ≠ Opportunity)
└─ workflow rule field update (Forecast__c) [step 11 of OOE]
└─ after-update trigger fires AGAIN [log #2]
└─ email send → ...
└─ (and if any DML inside also touches Opportunity → recursion echo #3..15)
└─ Maximum trigger depth exceeded → rollbackWhy the "obvious fixes" failed (the contrast):
- "Move the flag to the top of the method" → fixes the recursion echo, but NOT the workflow double-fire (that's a separate, legitimate re-entry the platform performs).
- "Just use
isFirstRun" → the classicstatic Booleanguard has a second hidden failure: if the DML that re-fires the trigger contains records NOT in the first firing (e.g., a roll-up touching a different Opportunity in the same transaction), the guard wrongly suppresses legitimate work for chunk 2, 3, 4… (StackExchange #306796: the Account↔Contact ping-pong — Account trigger updates Contacts, Contact trigger updates Accounts — each side "guarding" wrongly, or not at all). - "Check Trigger.old vs Trigger.new" → correct instinct, but the workflow re-fire makes old-vs-new still true on the second pass. That's why the duplicate survived code review: the reviewer saw a proper change-detection check and approved.
THE FIX (canonical — the "senior" answer)
- Set the guard BEFORE any DML (first line of the handler), not at the end.
- Better than a Boolean:
static Set<Id> processedIds— per-record granularity. You can still process Opportunity #2 in the same transaction after processing #1, but never #1 twice. This survives multi-chunk transactions. - Real change detection with a twist: compare old vs new, BUT also record what you changed and skip if it's your own stamp (e.g., check
Forecast__con old vs new, and if only your automation changed it, don't re-log). This neutralizes the workflow double-fire. - Kill the workflow re-fire at the source: migrate the workflow rule to a Flow (Flows don't re-fire triggers the same way), or remove the field update from the workflow.
- Never do DML in the trigger body — collect into static Maps, DML at the end (the "Unit of Work" pattern).
public class OppTriggerHandler {
private static Set<Id> processedIds = new Set<Id>();
public static void onAfterUpdate(Map<Id, Opportunity> newMap, Map<Id, Opportunity> oldMap) {
List<Opportunity> changed = new List<Opportunity>();
for (Id id : newMap.keySet()) {
if (processedIds.contains(id)) continue; // per-record guard
if (newMap.get(id).StageName == oldMap.get(id).StageName) continue; // real change
processedIds.add(id);
changed.add(newMap.get(id));
}
if (changed.isEmpty()) return;
// ... one query, one DML, one email batch ...
}
}KNOWLEDGE EXTRACTION (interview-ready)
- "What is a recursive trigger and how do you prevent it?" → A trigger that re-fires itself via its own DML. Prevent with: static guards (
BooleanorSet<Id>) in a handler class (NOT in the trigger — trigger static vars reset between contexts), change detection via oldMap/newMap, and never-DML-in-trigger discipline. - "Why does my trigger fire twice on a single save?" → Workflow rule with a field update = order of execution step 11 = triggers re-fire exactly one extra time. Migrate to Flows or guard.
- "Why do you use
static Set<Id>instead ofstatic Boolean?" → 200-record chunks: a Boolean suppresses ALL records after the first chunk even if they're new; a Set only suppresses the ones already processed. - "What's the trigger handler pattern?" → Logic-less trigger → handler class with per-event methods → optional factory. Testable, one place per object, no order surprises between multiple triggers.
- "Max trigger depth?" → 16 (recursion). Apex call stack: 1,000.
- "Who wins — before trigger or validation rule?" → Before trigger runs first (step 4 vs step 5 of OOE). Before-save Flow runs even earlier. If a before Flow and before trigger both set the same field, the trigger's value is saved.
THE REDO (compressed, from memory)
Write the guarded handler for: "After update on Account: when OwnerId changes, update all Contacts' OwnerId to match, but only once, even if a workflow rule re-fires the trigger." Include the processedIds pattern AND the change detection AND where you'd put the DML.
RETRIEVAL DRILL (closed-book)
- Recite order of execution from validation to commit (one-liner version).
- Three ways to prevent recursion, ranked.
- Why did
Trigger.oldfool the change-detection check? - What happens at step 11 of OOE, and what does NOT re-run?
- Can static variables in a trigger body be used for recursion control? Why/why not?
INTERVIEW MAPPING
Recursion is the second most asked trigger topic (Wipro 2025: "How do you avoid recursive triggers in multi-object flows?"; EY: multi-object recursion; almost every R1). The OOE one-liner is a guaranteed question. Your answer should show you know the three re-entry sources — own DML, workflow re-fire, other automation — most candidates know one.
INCIDENT 3 — THE 4:00 PM CPU TIMEOUT
STAKES
Lead conversion. Sales reps convert Leads all day. At 4:00 PM, the validation-heavy conversion process deployed to production starts throwing System.LimitException: Apex CPU time limit exceeded. The deployment was validated in sandbox. Full sandbox. Same code. Same test data volume. It passed.
THE INCIDENT
- The process: on Lead conversion, for each Lead, the code loads every Account in the org and loops through all of them to find a match on Website/domain.
- Developer's logic: "I need to match the Lead's website to the right Account. I'll query all Accounts and search in a loop — simple."
- Sandbox: 3,000 Accounts, 200 Leads converted/hour → fine.
- Production: 1.2 million Accounts, 400 conversions at 4 PM →
Apex CPU time limit exceeded. - The developer enables debug logging to investigate… and now every save fails, even ones that worked before. "The logs made it worse."
THE PROBLEM
Three questions: (1) Why does the same code pass in sandbox and die in prod — which limit is it, exactly, and why is it a counting problem rather than a volume problem? (2) Why did enabling debug logs make it worse? (3) What is the actual fix?
Write: hypotheses, fix, and how you'd measure CPU before/after.
HINT LADDER
- Hint 1 (avenue): This is not the SOQL wallet (100 queries). This is the other wallet. And the query isn't the problem — what you do with the results is.
- Hint 2 (mechanism): CPU limit = 10,000 ms (10 s) synchronous. Non-selective queries force full-table scans, and iterating 1.2M records per conversion is O(n) per record → O(n²) per hour. Also:
System.debugand debug logging itself consumes CPU time — it counts against the limit. - Hint 3 (skeleton): Sandbox ≠ prod in data volume, and the limit is time, not rows. Fix = collect all Lead websites first → ONE selective query
WHERE Website IN :sites→Map<String, Account>→ look up in memory. AddLimits.getCpuTime()checkpoints. RemoveSystem.debugfrom hot paths.
THE REVEAL — POSTMORTEM
What actually happened (real incidents, 2016 + 2019):
- Incident A (StackExchange #191846): Lead-conversion validation queried every Account and iterated the full table per Lead. Non-selective query + nested loop = CPU death at production scale.
- Incident B (StackExchange #100464): The debug-log paradox — a save that was "just under" the CPU limit would always fail once logging was enabled, because log generation itself burns CPU. Salesforce support confirmed: "This is by design."
- Bonus (Beyond The Cloud, 2023): The CPU limit is a soft limit — orgs can burst past it when the pod isn't under stress, and measurement is non-deterministic. That's why "it passes locally, fails in prod, passes again after a retry" happens. This is also why
System.debugin production code is a real cost: it eats CPU on every execution path.
The math that killed it:
Sandbox: 3,000 Accounts × 200 conversions = 600,000 iterations → ~1.2 s CPU
Prod: 1,200,000 Accounts × 400 conversions = 480,000,000 iterations → way over 10 sThe limit is CPU milliseconds, and it's cumulative across the whole transaction — including code in managed packages. (StackExchange #411137: the error is thrown in whichever class happens to be executing when the shared budget runs out — "you can't blame one package; CPU is global.")
Why the "obvious fixes" failed (the contrast):
- "Optimize the SOQL" → the query wasn't the bottleneck; the iteration was. (Selectivity matters, but here the fix is algorithmic.)
- "Add more hardware / more threads" → you can't. It's one transaction, one wallet.
- "Catch the exception and retry" → LimitException is effectively uncatchable; the transaction rolls back.
- "Batch the work" → correct for bulk (async = 60 s CPU), but conversions are user-initiated and synchronous — the real fix is algorithmic, in-place.
THE FIX (canonical)
- Algorithmic fix: collect all Lead websites/domains into a
Set<String>→ one querySELECT Id, Website FROM Account WHERE Website IN :set→Map<String, Account>(lowercased keys) → in-memory lookup per Lead. O(n) total, ~2 queries, regardless of org size. - Change detection: skip records where nothing changed (no re-processing no-ops — this is also a CPU killer in trigger logic).
- Profile properly:
Limits.getCpuTime()/getLimitCpuTime()checkpoints in the code; debug log levels tuned (ApexCode=Debug, profiling elements OFF for the hot path); useDebug → Switch Perspective → Analysisrather than raw logging. - Remove
System.debugfrom production hot paths (or gate behind a flag). - Consider async for genuinely heavy work: 60 s CPU wallet in future/queueable/batch — but never as a mask for an O(n²) algorithm.
KNOWLEDGE EXTRACTION (interview-ready)
- "What are the limits you hit most?" → SOQL 101, DML 151, CPU 10 s (sync) / 60 s (async), heap 6/12 MB. The "big four."
- "Why do nested loops hurt?" → 200 records × 200 records = 40,000 iterations (trigger chunk); 10,000 records × 10,000 = 100M. Maps are O(1) lookups; Lists are O(n) scans.
- "Why did my code pass sandbox and fail prod?" → Volume: sandbox has 100–1,000× less data. CPU is time-based, queries are count-based — both scale with data, but CPU is cumulative and shared with debug logs and managed packages.
- "Can debug logs cause the CPU limit?" → Yes — logging consumes CPU; the log itself counts against the 10 s budget.
- "Is the CPU limit hard?" → Soft in practice — orgs can burst when the pod is idle. Never rely on it.
- "How do you profile CPU in Apex?" →
Limits.getCpuTime(), cumulative profiling, log analysis.
THE REDO
Rewrite the Lead→Account matching so it costs a constant number of queries and O(n) time regardless of Account volume. Then state which of the four limits you optimized and which you didn't touch.
RETRIEVAL DRILL (closed-book)
- CPU limit sync vs async. Heap limit sync vs async.
- Why is a
Mapfaster than aListfor 10,000 lookups? - What does "selective query" mean and which limit does it protect? (Hint: not the query count.)
- Two reasons debug logging can cause the failure you're investigating.
- Name two ways to skip no-op processing in a trigger.
INTERVIEW MAPPING
"Your trigger exceeds CPU time. Debug and refactor" (Accenture L2), "I redesigned with Queueable Apex after CPU timeouts on bulk Opportunity updates" (a passing candidate's real answer). The "debug log paradox" is a killer detail that impresses interviewers — almost nobody knows it.
INCIDENT 4 — THE BATCH THAT BURNED THE NIGHT
STAKES
A handover. The previous developer left a nightly batch job that processes a 50-million-record QueryLocator. You inherit it. Night 1: the job dies in "Preparing" state — First error: Apex CPU time limit exceeded — with no debug log at all. Night 2: you "fix" the obvious thing, and it now dies with Apex heap size too large: 12000034 (that's 12 MB plus 34 bytes). Night 3: you patch that, and it finishes 376 of 1,000 batches and marks itself Completed.
THE INCIDENT
- The batch class has a
start()that returns a QueryLocator over a parent object with a large child subquery ((SELECT ... FROM ChildRecords)) — millions of child rows. execute()isDatabase.Statefuland accumulates a growing JSON string across all chunks into an instance variable (a "nightly export" of all records).- The previous dev ran it in sandbox (200K records) — always green. Production has 50M.
THE PROBLEM
Three separate failures, three separate mechanisms, one class. Identify each mechanism precisely and name the line/pattern responsible. Then design the correct class.
Write: (1) what "First error" tells you about which method failed and why there's no log, (2) the heap math for the JSON accumulator (why 12,000,034), (3) what "Completed" with 376/1,000 batches means and how you'd detect it.
HINT LADDER
- Hint 1 (avenue): Three mechanisms: (a) what runs in
start()vsexecute()— which has which limits; (b) whatDatabase.Statefuldoes to instance variables across chunks — and where heap is measured; (c) what "Completed" really means in AsyncApexJob. - Hint 2 (mechanism): (a)
start()runs ONCE and shares limits with the scheduling transaction — parent-child subqueries there scale terribly (known failure: 2.9M permission-set assignment rows); (b) Stateful means instance vars survive every execute → the JSON grows until 12 MB; 200K records ≈ 50 MB; (c) a known platform bug (Spring '17, W-3634737) makes batches finish early with no error — so you must verify counts, not status. - Hint 3 (skeleton): Remove the subquery from start() → query parents only, children per chunk in execute(); stop accumulating JSON in Stateful → write per-chunk files/ContentVersions, keep only small counters; add a finish() that compares processed counts to expected and raises an alert; use
String.join/ Blob streaming instead of+=; test chunk = 200.
THE REVEAL — POSTMORTEM
What actually happened (three real incidents):
- Incident A (Jitendra Zaa, 2016): A 50M-record batch failed with CPU limit in
start(). No logs because start() died before execute(). Root cause: a relationship subquery in the start() SOQL — "First error" = the failure is in start(). Moving the subquery to execute() fixed it instantly. (Same failure, 2018: batch withPermissionSetAssignmentssubquery — 35K users, 2.9M rows — died in "Preparing".) - Incident B (Salesforce Dictionary, 2026): A nightly Case-export batch ran for 2 years at ~200K cases. Support case volume doubled. First job after the spike died with
Apex heap size too large: 12000034— aDatabase.Statefulinstance variable accumulating the full JSON across all execute() calls (~50 MB at 200K cases). Long-text fields loaded per row made it worse. - Incident C (Salesforce Known Issue W-3634737, Spring '17): A batch with batch size 1 processed 376 of 1,000 records and completed successfully — no error, no exception. Platform bug. Lesson: completion status ≠ full processing.
The three mechanisms (the contrast):
- "It's a SOQL limit problem" → No.
start()gets the async budget (200 queries) but CPU is the scarce one here — subquery expansion on millions of child rows is pure CPU burn, and start() shares the scheduling transaction's clock. - "It's a memory leak" → Not a leak — a correctly designed Stateful accumulator doing what you told it. The heap limit (12 MB async) includes ALL instance variables at ANY point in the transaction. There is no "pause"; each execute() re-enters with the same growing state.
- "Batch finished = job done" → No. Verify with a counter in
finish()vsAsyncApexJob.TotalJobItems.
THE FIX (canonical)
public class NightlyExportBatch implements Database.Batchable<sObject>, Database.Stateful {
// ONLY small state survives Stateful: counters, error IDs, totals.
private Integer processed = 0;
private List<String> errors = new List<String>();
public Database.QueryLocator start(Database.BatchableContext bc) {
// 1. NO relationship subqueries here. Flat, selective query only.
return Database.getQueryLocator('SELECT Id, CaseNumber FROM Case WHERE IsClosed = false');
}
public void execute(Database.BatchableContext bc, List<sObject> scope) {
// 2. Query children HERE, per chunk (fresh 200-query wallet per execute).
Set<Id> caseIds = new Map<Id, sObject>(scope).keySet();
Map<Id, List<Attachment>> atts = ...; // one query
// 3. Write per-chunk output (ContentVersion / external call) — don't accumulate.
// 4. Track errors, not data.
processed += scope.size();
}
public void finish(Database.BatchableContext bc) {
// 5. Compare processed vs expected; alert on mismatch.
// 6. Optionally chain the next batch here.
}
}Plus: batch size 200 (default) unless callouts demand ≤100; monitor AsyncApexJob (JobItemsProcessed vs TotalJobItems); register a BatchApexErrorEvent subscriber for async error visibility.
KNOWLEDGE EXTRACTION (interview-ready)
- "Batch Apex — the three methods?" →
start()(QueryLocator up to 50M records / Iterable),execute()(per chunk, default 200, max 2,000, fresh governor limits per chunk),finish()(once — notifications, chaining). - "When do limits reset in a batch?" → Every
execute()call is a new transaction: 200 SOQL, 150 DML, 60 s CPU, 12 MB heap per chunk.start()andfinish()get their own. - "What does
Database.Statefuldo?" → Keeps instance variables across chunks (default: reset every chunk). Cost: serialization. Use only for small state (counters, errors). Never accumulate data. - "Why put subqueries in execute(), not start()?" → start() runs once with the scheduling transaction's budget; parent-child expansion burns CPU at scale. Query children per chunk.
- "How many batch jobs at once?" → 5 concurrent; 100 in Flex Queue holding; 1 start() at a time. Queuing a 6th throws.
- "Can batch call future? Callouts?" → No future from batch (AsyncException). Callouts yes — from execute()/finish() if the class implements
Database.AllowsCallouts; 100 per chunk → batch size = 100 ÷ callouts-per-record. - "6M records with batch size 1?" → 6M execute() calls → blows the 250K async executions/day limit. Use size 200 (30K calls) or Bulk API. (Real incident: AsyncApexExecutions exceeded.)
- "What does 'First error:' prefix mean?" → The failure happened in
start()(no per-chunk context yet).
THE REDO
Redesign the class above from memory: correct start(), correct execute(), correct finish(), and list the 3 AsyncApexJob fields you'd monitor.
RETRIEVAL DRILL
- Batch: default scope, max scope, max records via QueryLocator, max concurrent jobs.
- Which limits reset per execute()?
- Two reasons a batch can "complete" without processing everything.
- Why is Stateful + growing collection a heap bomb?
- Can scheduled Apex make callouts directly? (No — wrap or use batch/queueable.)
INTERVIEW MAPPING
"Explain a real use case where you used Database.Stateful" (Wipro), "Your batch fails only in production" (the 2013 parallel-batches incident — sandbox monitoring is more pessimistic, plus lock contention), "How do you test a batch?" (startTest/stopTest runs it synchronously). Batch internals are a favorite "senior filter" — knowing per-execute limit resets is the differentiator.
INCIDENT 5 — THE MIXED DML MYSTERY
STAKES
Community admins create community Users. A User after-insert trigger is supposed to create a matching Contact. At 9:00 AM Monday, production starts throwing:
MIXED_DML_OPERATION: DML operation on setup object is not permitted after you have updated a non-setup object.
The trigger author read the docs, found the "solution" — set UserRoleId = null when creating the User — and implemented it. It still fails. And separately: a suite of 13 unit tests that "ran fine for years" suddenly fails with the same error — in production only; sandbox and deployment validation pass.
THE PROBLEM
The documented loophole (null UserRoleId) is in place. Why does it still fail? And why would 13 old tests start failing in production but not sandbox?
Write: hypotheses for both, the fix, and the test strategy.
HINT LADDER
- Hint 1 (avenue): The "null role" loophole only works if nothing else in the transaction touches a setup object. What other automation could be touching Users? And for the tests: what's different between sandbox and production environments (installed stuff)?
- Hint 2 (mechanism): A workflow rule on the User object counts as a second setup-object DML in the same transaction — the documented loophole dies. For the tests: cloned running user for
runAstests inheritsUserRoleId; prod-only installed packages (e.g., Lightning Sync / S2X internal objects) add setup-object activity. - Hint 3 (skeleton): Fix = isolate setup-object work in a separate transaction (@future / Queueable / Platform Event). Test = clone users with
UserRoleId = null; audit workflow rules on User; check installed managed packages.
THE REVEAL — POSTMORTEM
What actually happened (three real incidents, 2015–2019):
- Incident A (StackExchange #72591): after-insert User trigger creating a Contact → MIXED_DML_OPERATION. Classic fix: @future. Community variant (#103327): creating Contact + User in one context fails; even
UserInfo.getUserRoleId()in the transaction can trip it. - Incident B (StackExchange #321975): Account trigger creating a community User with
UserRoleId = null(the documented loophole) — still failed. Root cause: "There was a workflow rule on the User object updating the User… since it is called from the Account trigger transaction, and updating of User is not allowed in the same transaction as standard objects." The workflow rule's update = a second setup-object DML. - Incident C (StackExchange #246596, sfdcfox): 13 tests passing for years suddenly failed with MIXED_DML_OPERATION in production (Winter '19), while sandbox + validation passed. Root cause: production had Lightning Sync (Exchange) enabled, which creates internal S2X objects; tests that clone the running user for
runAsinherited a non-nullUserRoleId, tripping the restriction. "One of the most disturbing Salesforce bugs to date."
The mechanism (the contrast):
- "UserRoleId = null is the documented fix, so this must be a platform bug" → No. The restriction is about transaction composition, not the User record's fields. Any setup-object DML (User, Profile, PermissionSet, Group, GroupMember, UserRole…) anywhere in the transaction — including via workflow rules, flows, or packages — mixes illegally with non-setup DML (Account, Contact…).
- "Why do my tests pass in sandbox?" → Environment drift: production-only managed packages and platform features add setup-object activity your sandbox doesn't have. Tests validate against the org you're in.
- "Can I just use
without sharing?" → Unrelated. Sharing ≠ transaction composition. This is a platform-level rule; no Apex keyword bypasses it.
THE FIX (canonical)
- Separate the transaction: move the User DML into
@futureor Queueable (each runs in its own transaction), OR move the Contact creation out. - Audit setup-object automation: workflow rules / flows on User, UserRole, PermissionSet — any field update re-enters the same transaction.
- Test hygiene: in
runAstests, clone users withUserRoleId = null; useTest.getMock/system.runAswith isolated profiles; check installed packages in prod vs sandbox. - For batch contexts: setup-object work must chain into a separate batch from
finish()(batch + future + GroupMember triangle — StackExchange #145185).
KNOWLEDGE EXTRACTION (interview-ready)
- "What is a Mixed DML error?" → Setup objects (User, Profile, PermissionSet, Group, UserRole) can't share a transaction with non-setup objects (Account, Contact, Opportunity…). Platform rule, not fixable in code.
- "How do you fix it?" → Separate transactions: @future, Queueable, Platform Event, or chained batch.
- "What counts as a setup object?" → Anything in Setup: User, Profile, Permission Set, Role, Group, Queue (partially), Organization, etc.
- "Why did my tests break in prod only?" → Installed packages/features (Lightning Sync/S2X, NPSP, etc.) add setup-object activity; cloned users inherit UserRoleId. Test with
UserRoleId = nullclones. - "Can you run DML on User at all in a trigger?" → Yes — if it's in its own transaction (async). Not synchronously mixed with data-object DML.
THE REDO
A flow must create a community User AND a Contact for each new Account. Write the architecture (where each DML lives, why), and the test class skeleton (including the UserRoleId clone detail).
RETRIEVAL DRILL
- List 5 setup objects.
- Does
with sharingfix mixed DML? - Which async tools can host the User DML?
- Why do prod-only failures happen in tests? Name one real example.
- What's the null-UserRoleId loophole, and when does it fail?
INTERVIEW MAPPING
"You have a Mixed DML Error while inserting User + Account. How do you solve it?" — JP Morgan 2025, verbatim. The "workflow rule saboteur" detail is a level-above answer: most candidates know the @future fix, almost none know the loophole-killer.
INCIDENT 6 — THE ONE-LINER THAT DELETED THOUSANDS
STAKES
Friday, 11:34 PM. A dev runs "cleanup" code to remove child records of an old test Account. One line of Apex. It passed code review. It deletes thousands of unrelated records — every Contact with a null AccountId in the org. No error. No warning. Nobody notices until Monday's board meeting. (This is a real class of incident: a null-id filter bug, a hard-delete 800-Opportunity oops, a 3,000-active-Opportunity deletion — all real.)
THE INCIDENT
// "Cleanup: remove orphaned Contacts of this test account"
delete [SELECT Id FROM Contact WHERE AccountId = :acc.Id];Where acc comes from a query that can return null.
THE PROBLEM
One line. Passed review. Deleted thousands. Explain the exact mechanism — and then design a cleanup process that could never do this, plus the recovery play.
Write: (1) the mechanism, (2) ≥2 defensive layers, (3) recovery steps, (4) how you'd test it.
HINT LADDER
- Hint 1 (avenue): What does
WHERE AccountId = :acc.Idbecome whenaccis null? What does SOQL do with null bind variables in a comparison? - Hint 2 (mechanism):
= :nullis NOT an error — it's a silent no-op filter → the query matches every record where AccountId is null →deleteremoves them all. Also:deletehere is a soft delete (Recycle Bin) — but Data Loader's Hard Delete, orDatabase.emptyRecycleBin(), is permanent. - Hint 3 (skeleton): Guard null; assert count before delete; delete in a transaction you can audit; test with bulk + null inputs; recovery = Recycle Bin (parents first), or backups; never hard-delete without a soft-delete dry run.
THE REVEAL — POSTMORTEM
What actually happened (real incidents, 2022–2026):
- Incident A (LinkedIn, Shantanu R Desai, 2026): exactly this line. "The trap is the filter. If
acc.Idhappens to be null, that WHERE clause stops filtering. The query matches every Contact with a null AccountId… thousands of unrelated records. The delete runs against all of them. No error. No warning. Just gone." - Incident B (Glen Bradford): Data Loader Hard Delete checked during test-record cleanup — ~800 real Opportunities (with activities/attachments) gone. No Recycle Bin pass. Salesforce data-recovery case: two weeks, client money.
- Incident C (Medium, 2025): Friday 11:34 PM — admin "cleaned up old data", filtered for "closed" records, accidentally deleted 3,000+ active Opportunities via Data Loader in delete mode, board meeting Monday. Recovery took 4 hours + rebuilding relationships + "therapy for the admin."
- Incident D (CapStorm, 2022): half-configured two-way sync integration orphaned every Opportunity from its Account overnight. Only a mirror backup saved them. "Don't mess with integrations if you don't know what they do!"
Why reviews can't catch it (the contrast):
- "But there's a WHERE clause" → The WHERE clause is only as good as its inputs. Null semantics in SOQL:
field = :nullmatches nothing by design (that's how you'd write "no filter" on purpose) — which is exactly why it's dangerous. - "But we tested it" → Tested with the happy path. Negative tests (null input, empty input, bulk) are the ones that catch it. 1-record tests catch nothing.
- "But it's a soft delete" → Only if nothing hard-deletes after it. Data Loader hard-delete mode,
emptyRecycleBin, or scheduled cleanup can make it permanent before anyone checks the bin.
THE FIX (canonical)
- Guard:
if (acc == null || acc.Id == null) return;— first line, always. - Count check: query +
System.debug/assert count; delete only when count is within expected bounds. - Delete via a logged, auditable transaction (Error_Log/audit object), never anonymous code at 11 PM.
- Soft-delete dry run first: always. Hard delete = review the ID list twice, and use
emptyRecycleBinonly after a 7-day soft-delete window. - Backups + tested restore: the Recycle Bin is not a backup (15 days, storage-dependent, and hard deletes skip it entirely). Know your backup tool's restore procedure before you need it.
- Recovery play: Recycle Bin restore — parents first, then children (new IDs), then re-run automation, then verify counts. For hard deletes: restore from backup; verify; test in sandbox first.
KNOWLEDGE EXTRACTION (interview-ready)
- "SOQL and nulls?" →
= :nullmatches nothing (silent no-filter);NOT INwith nulls excludes null results (another classic gotcha); useIS NULLexplicitly. - "Soft vs hard delete?" →
delete= Recycle Bin (recoverable viaundelete, 15 days).Database.emptyRecycleBin()/ Data Loader Hard Delete = permanent, counts toward the 10,000 DML-rows limit. - "Can you query deleted records?" →
SELECT ... ALL ROWS(includes Recycle Bin). Can't combine withFOR UPDATE. - "Cascade deletes?" → Master-detail: deleting the master soft-deletes details without firing detail triggers (this is Incident 7's setup).
- "What's your number-one defensive habit?" → Guard every input, assert every count, test the negative path, never hard-delete.
THE REDO
Write the safe version of the cleanup one-liner: null-guard + count assertion + audit log + soft delete. Then write the 3 test cases (normal, null-acc, bulk) that would have caught the original bug.
RETRIEVAL DRILL
- What does
WHERE AccountId = :nullmatch? What aboutNOT INwith nulls? - How long is the Recycle Bin retention, and what bypasses it?
- How do you restore a master-detail hierarchy? (Order matters.)
- Name the two tools that permanently delete data.
- Why is 1-record testing dangerous for delete logic?
INTERVIEW MAPPING
Data-loss questions are rare in interviews but project stories are not — "tell me about a time something broke" is a guaranteed behavioral question, and this is a compelling, honest war story that shows depth. Also: "delete + recycle bin + ALL ROWS" is a classic trick-question cluster.
INCIDENT 7 — THE ROLLUP THAT WENT DARK
STAKES
Finance needs Account.Total_Open_Amount__c — a sum of open Opportunities. But Opportunities link to Accounts via Lookup (they're shared across teams, so MD is impossible), and roll-up summary fields don't work on lookups. You build a trigger to maintain it. It works beautifully for two weeks. Then someone deletes a parent Account, and the totals on other Accounts start going stale — and your trigger never even ran.
THE INCIDENT
- Your trigger: after insert/update/delete on Opportunity → aggregate
SUM(Amount)per Account → single update on Accounts. - You tested: insert 200 Opportunities, update them, delete a few. All green.
- The silent failure: a user deletes an Account that has Contacts with Lookups to other Accounts… no, wait — the actual case: a user deletes an Account (master in a different MD relationship, e.g., Account→Case MD) and the cascade soft-deletes its Cases. Your Opportunity trigger doesn't fire for cascade deletes. Totals stay stale.
- Second symptom: you do handle
after deleteon Opportunity — but your aggregate query still includes the just-deleted rows. Your totals are wrong by exactly the deleted amounts.
THE PROBLEM
Two mechanisms, both silent: (1) Why doesn't your after-delete logic fire when the parent is deleted? (2) Why does your after-delete aggregate query still "see" the rows you just deleted? Fix both — and make the whole thing bulkified.
Write: hypotheses, the mechanism names, the fixed design.
HINT LADDER
- Hint 1 (avenue): Two facts: (a) cascade deletes happen without firing detail-object triggers; (b) within a trigger transaction, the delete isn't committed yet — SOQL runs against the pre-commit database. (c) What does "after delete" give you that "after update/insert" doesn't?
- Hint 2 (mechanism): (a) Deletion of a master cascades to details bypassing their triggers — the platform performs the cascade internally. Workaround:
before deleteon the master explicitlydeletethe details so triggers fire. (b) Inafter delete, your aggregate query still sees the deleted rows because the transaction hasn't committed — you must excludeTrigger.oldIDs. (c) Aggregate queries: GROUP BY, HAVING,AggregateResult, 2,000-row cap on grouped results. - Hint 3 (skeleton): Opportunity trigger handles insert/update/undelete + delete, collects AccountIds (new + old), aggregate → Map<Id, Decimal>, exclude deleted IDs (
WHERE Id NOT IN :deletedIds), initialize defaults, single DML. Plus master-side before-delete trigger for cascade. Or use DLRS.
THE REVEAL — POSTMORTEM
What actually happened (real incidents, 2015–2024):
- Incident A (DLRS GitHub #257/#928): Deleting a master cascades to details without firing detail triggers — rollup logic silently skips deleted children; parents keep stale sums for 15 days until the Recycle Bin purge. Workaround:
before deletetrigger on the master explicitlydeletethe details so their triggers fire. - Incident B (StackExchange #44440): After-delete rollups that re-query children include the just-deleted rows because the delete isn't committed yet. Must filter
Trigger.oldIDs from the query. - Incident C (the classic PwC/EY interview scenario): "Count total Contacts per Account on insert/update/delete/undelete" — and the aggregate Map pattern with defaults initialized, or parents with zero children never get cleared.
The mechanism (the contrast):
- "after delete fires for cascade deletes too, right?" → No. The platform's cascade delete is internal; detail-object triggers don't fire. This is why real rollups (on MD) are maintained by the platform (which knows about the cascade) — custom code on Lookup relationships must handle cascade manually.
- "The query counts the deleted rows because I didn't commit" → Exactly — trigger code runs before commit.
Trigger.oldis your only view of what's going away. - "I'll just add a rollup summary" → Can't — Lookup relationship. (Roll-up summaries: master side of MD only. COUNT/SUM/AVG/MIN/MAX. This is why people use DLRS — but it has the same two bugs above, hence the GitHub issues.)
THE FIX (canonical)
// after insert / after update / after delete / after undelete on Opportunity
public static void maintainAccountTotals(Set<Id> accountIds, Set<Id> deletedOppIds) {
if (accountIds.isEmpty()) return;
Map<Id, Decimal> totals = new Map<Id, Decimal>();
for (AggregateResult ar : [SELECT AccountId a, SUM(Amount) s FROM Opportunity
WHERE AccountId IN :accountIds
AND Id NOT IN :deletedOppIds // ← the after-delete fix
AND StageName != 'Closed Lost'
GROUP BY AccountId]) {
totals.put((Id) ar.get('a'), (Decimal) ar.get('s'));
}
List<Account> toUpdate = new List<Account>();
for (Id id : accountIds) {
toUpdate.add(new Account(Id = id, Total_Open_Amount__c = totals.get(id) ?? 0));
}
update toUpdate;
}Plus: a before delete trigger on Account (or Contact) that explicitly deletes related child records so their triggers fire. And: for the 2,000-row aggregate cap, page with GROUP BY subsets or process per chunk.
KNOWLEDGE EXTRACTION (interview-ready)
- "Aggregate queries?" →
SELECT AccountId, SUM(Amount) total FROM Opportunity GROUP BY AccountId. Results =AggregateResult; access viaar.get('total').WHEREfilters before grouping,HAVINGfilters after. Grouped results cap: 2,000 rows (no queryMore) — page or subdivide. - "Roll-up summary on a lookup?" → Not possible. Apex trigger (all 4 events), Flow, or DLRS. Must handle the after-delete ID filtering and cascade-delete gap.
- "Rollup vs Formula?" → Rollup = aggregate of child records on the MD master, platform-maintained, fires parent triggers; Formula = per-record computation, no child aggregation.
- "Why initialize defaults?" → If a parent has no qualifying children, it's absent from the AggregateResult map — leave it = stale value. Always
?? 0(or null semantics you choose). - "Cascade delete + triggers?" → Detail triggers don't fire on cascade. Master-side before-delete trigger can force the cascade through triggers.
THE REDO
Write the full Opportunity rollup trigger set (all 4 events, aggregate Map, deleted-ID filter, defaults, single DML) from memory. Then add the master-side cascade fix.
RETRIEVAL DRILL
- Write the query for "total Contacts per Account, only active".
- When do grouped aggregate results hit a limit, and what is it?
- Two ways to maintain a rollup on a Lookup.
- Why do cascade deletes break custom rollups?
- In after delete, what does SOQL still see, and how do you exclude it?
INTERVIEW MAPPING
"Count total contacts per Account based on Gender (Male/Female) and store in Male_Count__c/Female_Count__c" — EY real question, nearly verbatim. "Ensure Opportunity.Amount = sum of Quote Line Items" — JP Morgan real question (same pattern, QuoteLineItem on lookup). The after-delete + cascade nuances are the "senior" extras that separate you.
INCIDENT 8 — THE SECURITY HOLE NOBODY SAW
STAKES
An Experience Cloud site for a healthcare-adjacent org. A custom LWC shows "your related cases" via an @AuraEnabled Apex method. Every demo works perfectly for logged-in users. Then a security researcher spends a weekend enumerating records through the Guest User — thousands of contact records with PII, pulled silently. Event Monitoring wasn't enabled, so nobody even saw it happen.
THE INCIDENT
public class CaseSearch {
@AuraEnabled(cacheable=true)
public static List<Case> search(String term) { // ← no sharing keyword!
return [SELECT Id, CaseNumber, Subject, ContactId FROM Case
WHERE Subject LIKE :'%' + term + '%']; // ← concatenated input
}
}- The class has no sharing keyword (defaults to system mode → sees ALL records).
- The query concatenates user input (SOQL injection).
- No CRUD/FLS checks anywhere.
- Guest User still has "legacy read" access to Case/Contact objects from an older page that was never removed.
THE PROBLEM
The page worked perfectly in every demo. Three separate security failures hide in 3 lines. Name them, explain the real-world impact of each, and write the secure version. Then explain: if you add with sharing, what does it fix — and what does it NOT fix?
Write: hypotheses, secure rewrite, and the "with sharing vs CRUD/FLS" explanation.
HINT LADDER
- Hint 1 (avenue): Three layers: (a) who can see records (sharing keyword), (b) who can touch objects/fields at all (CRUD/FLS — NOT enforced by Apex by default), (c) what the input can do to your query (injection).
- Hint 2 (mechanism): Apex default = system mode (sees everything).
with sharingenforces record-level sharing ONLY — not object/field permissions. Concatenated user input into dynamic SOQL = injection (SOQL has no UNION but has subqueries + error-based blind extraction).@AuraEnabledmethods run in system context with no implicit checks. - Hint 3 (skeleton):
with sharing+Security.stripInaccessibleorWITH USER_MODE+ bind variables (or escapeSingleQuotes + whitelist) + explicitisAccessible()checks + Guest User profile audit.
THE REVEAL — POSTMORTEM
What actually happened (real incidents, 2024–2025):
- Incident A (Salesforce Ben / consultants, 2025): Exactly this story — custom LWC +
@AuraEnabledmethod without sharing, zero CRUD/FLS checks, Guest User with legacy read access. A security researcher enumerated records and pulled PII over a weekend. Event Monitoring off → invisible. - Incident B (Project Black, 2025):
public without sharing class VulnerableAccountSearchwithsearchTermconcatenated intoDatabase.query— a user who could see ONE Account could enumerate every Account in the tenant via...AND Name LIKE '%test%') OR (Name LIKE '%'). - Incident C (Rooted0x01 bug bounty, 2024): Aura radio-button filter concatenated
FirstNameintoWHERE FirstName LIKE '%...%'. Payload%' and LastName != 'NotEvenExists— blind injection extracted a customAccount_Password__cfield and dumped users' passwords. Three accepted SOQL injections, paid in full. - Incident D (TurtleSec, 2025): A default platform controller (
CsvDataImportResourceFamilyController) had blind SOQL injection affecting "thousands of deployments" — predictable Salesforce IDs let attackers dump document names, emails, addresses, phones, even password hashes. So: not just your code — even platform controllers were an attack surface.
The three failures (the contrast):
- "It's
without sharingthat's the problem" → Wrong focus. The class has no sharing keyword — Apex defaults to system mode (with sharingmust be explicit). But evenwith sharingfixes only record visibility — it does NOT enforce CRUD/FLS.with sharing+ no FLS checks = "user can't see the record via query, but can still query the field" (FLS-restricted fields return without error). - "I'll just validate the input with a regex" → Not enough. Bind variables are the only safe way to pass values; whitelisting object/field names is separate (you can't bind object/field names, so dynamic names must be validated against
Schema.getGlobalDescribe()). - "Guest User has no access to the page anymore" → Profiles/permission-sets accumulate; "legacy read" survives page removals. Least privilege + regular audit.
THE FIX (canonical)
public with sharing class CaseSearch {
@AuraEnabled(cacheable=true)
public static List<Case> search(String term) {
if (!Schema.sObjectType.Case.isAccessible()) return new List<Case>(); // CRUD
if (!Schema.sObjectType.Case.fields.Subject.isAccessible()) return new List<Case>(); // FLS
if (String.isBlank(term)) return new List<Case>();
String safeTerm = '%' + String.escapeSingleQuotes(term) + '%'; // injection-safe
List<Case> results = [SELECT Id, CaseNumber, Subject FROM Case
WHERE Subject LIKE :safeTerm
WITH USER_MODE]; // enforce CRUD/FLS on the query
return Security.stripInaccessible(AccessType.READABLE, results).getRecords();
}
}Plus: Guest User profile audit (remove legacy object access), enable Event Monitoring/security health checks, and treat every @AuraEnabled method as public API.
KNOWLEDGE EXTRACTION (interview-ready)
- "with sharing / without sharing / inherited sharing?" → with = enforce running user's record sharing; without = bypass (privileged ops only); inherited (API 45+) = inherit from caller — the recommended default.
- "Does with sharing enforce CRUD/FLS?" → No. Only record-level sharing. You must add explicit checks,
WITH USER_MODE, orstripInaccessible. - "How do you enforce CRUD/FLS in Apex?" → (1) explicit
isAccessible()/isCreateable()describe checks; (2)WITH SECURITY_ENFORCED(SELECT/FROM fields only) vsWITH USER_MODE(full: WHERE/polymorphic — modern default); (3)Security.stripInaccessible(AccessType.READABLE, records)for collections. - "SOQL injection prevention?" → Bind variables always;
String.escapeSingleQuotes()for dynamic values; whitelist object/field names (can't bind them); never concatenate untrusted input. - "Why can a user see a field in UI but not in Apex?" → Apex runs in system mode for CRUD/FLS — your code must enforce what the UI enforces implicitly.
- "What's wrong with
without sharingon an @AuraEnabled method?" → Privilege escalation — the page user's context is bypassed; if the method is callable by Guest User, it's a data breach (Incident A).
THE REDO
Rewrite the vulnerable class from memory with all 4 defenses. Then write the SOQL-injection-safe version of a dynamic query (where the field name is also user input).
RETRIEVAL DRILL
- Three sharing keywords + what each does.
- What does
with sharingNOT enforce? WITH SECURITY_ENFORCEDvsWITH USER_MODE— which is the modern default and why?- Why can't you use bind variables for field names?
- Two real-world SOQL injection impacts (from incidents above).
INTERVIEW MAPPING
Security is a 10–15% block and a seniority signal. "By default Apex runs in system mode. Why would we still explicitly use without sharing?" — EY, verbatim. "Why can a user see a field in the UI but not in Apex?" — constant. Mentioning WITH USER_MODE + stripInaccessible (not just with sharing) is the level-above answer. The Guest User breach is also a great "tell me about a hard problem" story.
🏆 CAPSTONE — THE 11:34 PM PRODUCTION CRISIS
STAKES
Friday, 11:34 PM. You're the on-call developer. A single page-save by one sales rep — updating one Account — has triggered a chain reaction: within 6 minutes, three different error classes are flooding the log, and a data-deletion job you didn't schedule is running. You have 45 minutes before you must present a triage to leadership. You have all the knowledge from Incidents 1–8. The clues are real.
THE INCIDENT (the evidence file)
- Clue A:
System.LimitException: Too many SOQL queries: 101— from an Account trigger that "has no loops." - Clue B: The same trigger fired twice in the same save, and an
Opportunity_Stage_Log__ctable shows triple entries for a single stage change. - Clue C:
Apex CPU time limit exceededin a Lead-conversion helper that "queries all Accounts." - Clue D: A Community User insert inside an Account trigger →
MIXED_DML_OPERATION. - Clue E: A scheduled "cleanup" batch that "completes" — but a Contact-count reconciliation shows 4,000 records missing, and the Recycle Bin is empty.
THE PROBLEM (the transfer test — the real interview scenario round)
Produce, in writing, a complete incident report:
- For each clue: name the mechanism (1 line), the root cause (2–3 lines), and the fix (pointer to the pattern — no need to write full code for all).
- Prioritize: what do you fix tonight vs Monday?
- Identify the shared root cause that connects at least 3 clues (there is one — find it).
- Write the 3 regression tests you'd add before Monday's release.
- Now role-play the interview: leadership asks "why did this happen and how do you guarantee it won't again?" — answer in 2 minutes, closed notes.
This is deliberately hard. Produce your best report even if incomplete — the comparison with the model answer below is where the learning lives. (40–45 min cap.)
THE MODEL REPORT (reveal after your attempt)
- Clue A → Incidents 1: Not a loop in the trigger — the chain: workflow field update re-fires the trigger (Clue B), Flow elements, and
convertLead-style cascades all share the 100-query wallet. Fix: cumulative profiling → find all consumers → bulkify each → kill re-fire loops. - Clue B → Incident 2: Workflow rule with field update (OOE step 11) re-runs after-update triggers exactly once more;
Trigger.oldstill shows original values → change-detection is fooled; plus a static Boolean guard set at the END of the method. Fix:static Set<Id> processedIds+ change detection + migrate workflow to Flow; set guards before DML. - Clue C → Incident 3: Full-table scan per Lead (non-selective query + nested loop) = O(n²) CPU. Fix: collect → single
INquery → Map. RemoveSystem.debugfrom hot path. - Clue D → Incident 5: Setup-object DML (User) mixed with data-object DML (Account) in one transaction; a workflow rule on User kills the null-UserRoleId loophole. Fix: isolate User DML in @future/Queueable.
- Clue E → Incidents 4 + 6: (a) The "cleanup" batch's delete ran without a null-guard or count check (the one-liner bug) — 4,000 records gone; Recycle Bin empty = either hard delete (
emptyRecycleBin/Data Loader hard-delete) or the bin purged. (b) "Completes" with missing rows = possible early-completion bug or silent per-chunk failures (noBatchApexErrorEventsubscriber, nofinish()count reconciliation). Fix: restore from backup (tested restore path!), audit the delete job, add guards, add count-reconciliation in finish(). - Priorities: Tonight — stop the bleeding: disable the cleanup job, halt the sync, restore data from backup (parents first), roll back the bad deployment if needed. Monday — permanent fixes: bulkify all consumers, guard+count-check every delete, migrate workflow rules to Flows, add BatchApexErrorEvent + monitoring, write the 3 regression tests.
- The shared root cause: "The code was written for a single record and a single transaction, tested at single-record volume, and never profiled against the real transaction chain." Every clue is the same disease — single-record thinking — in a different organ. That is also exactly what interviewers at 3–4 yr are fishing for when they ask "why did this fail in production?".
- The 3 regression tests: (a) bulk 200-record trigger test asserting
Limits.getQueries()< 100 and DML < 150; (b) double-fire test — simulate workflow field update re-entry, assert exactly one log row per stage change (processedIds test); (c) delete test with null input + bulk + count assertion, asserting nothing is deleted whenacc.Idis null and nothing hard-deletes.
KNOWLEDGE EXTRACTION (the meta-lesson)
The capstone is a model of the interview scenario round — interviewers give you a multi-symptom story and watch how you prioritize, connect, and communicate. The 3-sentence summary you should now be able to produce:
"Salesforce development is single-transaction thinking. Every production disaster I've seen — the 101 wall, the double-firing trigger, the CPU timeout, the silent deletion — is code written for one record and one automation, tested at demo volume. The fix is always the same discipline: bulkify, guard, profile, and test the chain — not the class."
THE KNOWLEDGE SPINE (the allowed 5% — memorize after the incidents)
Governor limits (synchronous / asynchronous)
| Limit | Sync | Async | Notes |
|---|---|---|---|
| SOQL queries | 100 | 200 | Per transaction, shared by ALL automation |
| SOQL rows | 50,000 | 50,000 | |
| SOSL | 20 (2,000 rows each) | 20 | |
| DML statements | 150 | 150 | |
| DML rows | 10,000 | 10,000 | |
| CPU | 10 s | 60 s | Soft limit — burst allowed when pod idle |
| Heap | 6 MB | 12 MB | Includes query results + JSON |
| Callouts | 100 (cumulative 120 s) | 100 | Request/response 6 MB / 12 MB |
| @future calls | 50 | 0 from batch/future; 50 from queueable | |
| Queueable enqueued | 50 | 1 per async context | |
| Emails | 10 | 10 | |
| Trigger depth | 16 | 16 | Apex stack: 1,000 |
| Concurrent batches | 5 (100 flex queue) | — | |
| Scheduled classes | 100 (5 DE) | — | |
| Batch scope | 200 default, 2,000 max | — | Fresh limits per execute() |
Order of execution (one-liner)
System validation → before-save Flow → before trigger → validation rules → duplicate rules → save (ID assigned) → after trigger → assignment/auto-response → workflow (re-fires triggers once) → escalation → after-save Flow → rollups → criteria-based sharing → commit → post-commit (emails, async).
The 5 patterns that fix 90% of incidents
- Collect→Query→Map→DML (bulkification)
static Set<Id>processedIds + change detection (recursion)- Single selective query + in-memory Map (CPU)
- Small Stateful state, per-chunk processing, count reconciliation in finish() (batch)
with sharing+WITH USER_MODE+stripInaccessible+ bind variables (security)
Rapid-fire trick questions (module 1 scope)
| Question | Answer |
|---|---|
| What's 101? | The 101st query — not an error code |
| Before insert: ID available? | No (assigned at save, step 7). Before update: yes |
| Before trigger vs validation rule? | Trigger first |
| After trigger vs rollup recalc? | Rollups recalc AFTER after-triggers (stale reads!) |
| Multiple triggers per object? | Order not guaranteed — one trigger + handler |
| Max records per trigger firing? | 200 |
| Recursion depth? | 16 |
| Can you catch LimitException? | Effectively no |
| Static var lifetime? | One transaction, then reset |
| DML in a trigger? | Collect + single DML at the end |
= :null in SOQL? | Matches nothing — silently |
NOT IN with nulls? | Nulls excluded — gotcha |
ALL ROWS + FOR UPDATE? | Not allowed together |
| Aggregate >2,000 rows? | Runtime error (no queryMore) |
| delete vs emptyRecycleBin? | Soft vs permanent (counts toward 10k DML rows) |
| Cascade delete fires detail triggers? | No |
| Rollup on Lookup? | Not possible — trigger/Flow/DLRS |
WITH SECURITY_ENFORCED covers WHERE? | No — WITH USER_MODE does |
| Setup + data object DML together? | MIXED_DML_OPERATION |
INTERLEAVED PRACTICE SET (do 1–2 per session, closed-book)
Pick the technique before solving — the choice is the training.
- Which limit fires and why? (a) 200-record trigger with a query per record; (b) batch that re-queries per chunk without Stateful; (c) 200K-case export accumulating JSON in Stateful; (d) converting 400 Leads against 1.2M Accounts.
- Write the aggregate + Map pattern for: Account → sum of all Children's
AmountwhereStage != 'Closed', including the after-delete filter. - A trigger "runs twice." List the 4 possible reasons, in order of likelihood, and how you'd confirm each in a debug log.
- Your
with sharingclass still returns records the user shouldn't see. What did you forget? (Two things.) - Design a "safe bulk cleanup" job: guards, count checks, soft-delete window, audit, monitoring. Now add the batch-specific protections (size, Stateful, finish() reconciliation).
SPACED REPETITION SCHEDULE (log it in the canvas)
- Today: after each incident — retrieval drill + redo.
- Tomorrow: re-answer the 5-question drills from Incidents 1–3 (closed-book).
- +1 week: the Interleaved Practice Set + rapid-fire bank.
- +1 month: the Capstone (re-do it from memory) + all drills.
Incident sources (real, for your curiosity): StackExchange #250807 (7,000-lead 101), #14162 (15× trigger), #33088/#108079 (workflow double-fire), #306796 (ping-pong), #191846 (lead CPU), #100464 (debug-log paradox), #411137 (CPU global), #72591/#321975/#246596 (mixed DML), #145185 (batch+future+GroupMember), jitendrazaa.com (start() subquery), salesforcedictionary.com (stateful heap export), issues.salesforce.com W-3634737 (early batch completion), LinkedIn Shantanu R Desai 2026 (null-id delete), glenbradford.com (hard delete), medium.com/jcarmona86 (11:34 PM), capstorm.com (orphaned opportunities), DLRS #257/#928 (cascade rollups), StackExchange #44440 (after-delete filtering), salesforceben.com (Guest User LWC breach), projectblack.io (without sharing search), rooted0x01.medium.com (SOQL injection bounty), turtlesec.io (Aura 0-day). Full URLs in the master report (Salesforce_Interview_Prep_Report_3to4yr.md §23).