Skip to main content
New tool CRON Expression Builder — preview next run times before you schedule Apex. Open the builder →
Interview prep

Apex interview questions and answers

Triggers, async, governor limits and the order of execution.

13 questions · 5 of them scenarios

Junior round

Junior Apex questions

Apex junior

When would you use a before trigger instead of an after trigger?

Testing Whether the answer comes from the Id, or from a memorised table.

junior

Before, when I want to change something on the record being saved — default a field, tidy up a phone number, block the save with an error. I get that for free, because the save has not happened yet. After, when I need the record's Id to do anything else, like creating a child record or a task against it.

mid

The question I actually ask is whether the record has an Id yet. In a before trigger it does not, so Trigger.new is still writable and I can set fields with no DML at all. In an after trigger the Id exists, and that is the only reason to be there — related records, anything keyed on Id. Trigger.new is read-only in an after trigger, and touching it throws System.FinalException: Record is read-only. Doing the same field default after the save instead costs an extra update, which re-enters the trigger and spends part of the 100-query budget on a second pass I did not need.

Deep dive on this
Apex junior

What does it actually mean to bulkify a trigger?

Testing That you have watched your own trigger take 200 records at once.

junior

Writing the trigger as if it always gets a list, because it does. No SOQL and no DML inside a loop over Trigger.new — I collect the Ids I need, run one query, then one update at the end. A trigger that works for one record and falls over on a data load is exactly what bulkifying prevents.

mid

A trigger fires once per chunk of up to 200 records, so every line inside a loop over Trigger.new runs 200 times. That is where the limits bite: a query per record hits System.LimitException: Too many SOQL queries: 101, an update per record trips Too many DML statements: 151. The shape I write instead is a loop that only collects — Ids into a Set<Id>, records into a Map<Id, Contact> — then one query outside the loop, then one update on a list at the end. The test that proves it inserts 200 records, not one, and asserts on Limits.getQueries().

Deep dive on this
Apex junior scenario

A data load of 5,000 Contacts fails with "Too many SOQL queries: 101". What do you tell the admin who ran it?

Testing How you explain a limit to the person who did not write the code.

What they tell you

The admin loaded 5,000 Contacts through Data Loader at the default batch size of 200. Three batches went in; the other 22 failed with the same error. Contact has one after-insert trigger with a handler class.

junior

First, that it is not their fault and the file is fine. That error means the Contact trigger runs a query inside a loop, so it runs out about a hundred records into a batch of 200 — and the whole batch rolls back, not just the row it died on. The limit is per transaction and cannot be raised, so a re-run fails in the same place. Then I move the query above the loop.

mid

The number is doing the explaining. Data Loader sends 5,000 rows as 25 transactions of 200, each with its own budget of 100 queries, and 101 means the trigger queries once per record. A System.LimitException cannot be caught, so the batch that trips it rolls back whole — the three that got in are the ones where fewer records met the trigger's condition and stayed under 100. What I tell the admin is two things: nothing they did caused this, and no setting fixes it. Then I fix the trigger — collect the Ids into a Set<Id>, one query above the loop, Map lookup inside it. Dropping the batch size to 50 would get tonight's load in, and I will say so, but it only hides the bug.

Ask before you answer
  • What batch size was Data Loader set to, and did every batch fail or only some of them?
  • Has a bulk Contact load ever worked in this org, or is this the first one?
Do not say this

Raise a support case asking Salesforce to lift the query limit for the duration of the load.

Governor limits are per transaction and support cannot raise the 100-query ceiling — there is no switch for it. The error is not saying the org is too small, it is saying the Contact trigger runs a query for every record. Waiting on the case only delays the load.

Deep dive on this
Mid-level round

Mid-level Apex questions

Apex mid scenario

A trigger needs to notify an external system when a Case closes, and it is throwing. Walk me through it.

Testing Whether the refusal gets understood before it gets worked around.

What they tell you

An after-update trigger on Case posts to a partner endpoint when Status changes to Closed. It passed the developer's single-record test and throws in the org with System.CalloutException: You have uncommitted work pending. Please commit or rollback before calling out.

mid

You have uncommitted work pending means what it says: the transaction that fired this trigger has open DML, and Salesforce will not call out while a rollback is still possible. So the callout has to run in its own transaction. Two ways there — a @future(callout=true) method, which takes primitives and collections of them, so I pass a Set<Id> and re-query, or a Queueable implementing Database.AllowsCallouts, which I prefer because System.enqueueJob gives me a job Id to check. Either way I pass Ids rather than records, and send one request for the batch instead of one per record — 200 Cases closing at once would otherwise need 200 callouts, and I only get 100.

senior

The error is a design smell before it is a bug. You have uncommitted work pending is the platform telling me a trigger is a poor place to talk to another system, because my save can still roll back and the partner cannot. The mechanical fix is a Queueable with Database.AllowsCallouts — Ids in, one re-query, one batched request — or @future(callout=true) if the org is already full of futures. The questions I actually ask are about failure. If the endpoint is down at 2am, who finds out: a Finalizer that writes an error record and re-enqueues once, or nobody? Is a duplicate notification safe on their side, because any retry will send one. And if this has to survive an outage, I stop writing callout code in a trigger at all and publish a Platform Event with publishBehavior set to publish after commit, then call out from the subscriber — the event only exists if the Case really closed, and the platform owns the retries.

Ask before you answer
  • Does the partner need to know within seconds, or is a minute late fine? That decides whether this can be asynchronous at all.
  • Is one request per Case acceptable to them, or do they want a batched payload? Two hundred Cases closing at once is otherwise two hundred callouts.
Do not say this

Move the callout to the top of the handler, before the trigger does any of its own DML, so there is nothing uncommitted when it fires.

The uncommitted work is the save that fired the trigger, not the handler's own DML. A trigger runs inside an open transaction by definition, so reordering statements inside it changes nothing. The callout has to leave the transaction, which means @future(callout=true) or a Queueable implementing Database.AllowsCallouts.

Deep dive on this
Apex mid

Future, Queueable, Batch or Scheduled — how do you choose?

Testing The reason behind the choice, more than the choice itself.

mid

I start with volume, and with whether I need to know it finished. Under a few thousand records and genuinely fire-and-forget, @future still works, but Queueable does the same job and hands me back a job Id, so Queueable is my default. Past roughly 10,000 records I want Batch, because each execute chunk gets a fresh set of limits — that is the whole point of it, not the scheduling. Scheduled is not really a fourth option; it is a way to start one of the others at 2am with System.schedule.

senior

I would revise the volume rule I just gave. 10,000 is where one transaction stops being comfortable, but what actually decides it is whether the work fits in one transaction at all: the 50,000 row query cap, and needing the limits to reset partway through. Past that it is Batch, where the QueryLocator streams and Database.Stateful can carry a running total across chunks. Under it I want Queueable rather than @future for one reason: System.enqueueJob hands back a job Id, so there is a row in AsyncApexJob to alert on when it fails at 3am, and a future method gives me nothing to go looking for. Scheduled is just the starter motor — I schedule a class whose only job is to kick off the batch. And before I promise anyone a nightly job I check what else runs at that hour, because 5 concurrent batch jobs is the ceiling and everything past it waits in the flex queue.

Deep dive on this
Apex mid

Which governor limits do you actually hit in practice, and what do you do about them?

Testing Which limits you have actually watched fail, and how you knew.

mid

Three, over and over. SOQL queries — 100 in a synchronous transaction, and 101 is usually a query inside a loop. DML statements — 150, same loop with an insert in it instead. And CPU time, 10,000 ms synchronous, which is the one that is not obviously my fault: it covers the whole transaction, so my trigger plus every flow that runs on the same save. The first two I fix by moving work out of the loop. The third I fix by doing less in the save path, usually by pushing the heavy part into a Queueable.

senior

I read a limit error as a shape of code more than a number to argue with. 101 queries is usually a loop with SOQL in it — and when it is not, it is three or four things each quietly spending twenty queries. Too many DML statements: 151 is the same loop with an insert. Heap at 6 MB means I queried more rows than I needed and then held on to them. CPU at 10,000 ms is the interesting one, because it is cumulative across the whole transaction, so the code that trips it is often not the code that spent it — I have watched a 60-node flow eat the budget and the Apex take the blame. Which is why I stop guessing and read the LIMIT_USAGE_FOR_NS lines that follow each CODE_UNIT_FINISHED, not only the totals in the final CUMULATIVE_LIMIT_USAGE block: the totals say the transaction overspent, the per-unit lines say who did. If the work genuinely does not fit in a synchronous transaction it moves async, where the query budget doubles to 200 and CPU goes to 60,000 ms. I would rather defend that in a design review than shave milliseconds off the wrong loop.

Deep dive on this
Apex mid scenario

Your code updates a User and an Account in the same transaction and throws MIXED_DML_OPERATION. What now?

Testing Whether you know the transaction is the boundary, not the class or the trigger.

What they tell you

A service class updates the Account, then deactivates the owner's User record — one method, two DML statements. The unit test wraps the User update in System.runAs and passes. Production throws MIXED_DML_OPERATION, DML operation on setup object is not permitted after you have updated a non-setup object.

mid

Salesforce splits objects into setup and non-setup and will not let one transaction write both. User, Group, GroupMember and PermissionSetAssignment are setup objects; Account is not. That is what the message means by DML on a setup object not being permitted after a non-setup object. In a test I wrap the setup object DML in System.runAs(someUser), which gives it its own context — which is also why this reaches production, because the test goes green and nobody notices runAs is the reason. In real code there is no runAs, so one side has to move: I push the User update into a Queueable and let the Account save commit first.

senior

The rule is that setup and non-setup objects cannot be written in one transaction, and the useful word in that sentence is transaction — not class, not trigger, not the order of my statements. So the fix is always to move one side into a transaction of its own. System.runAs is the cheap way to do that in a test, and it is exactly why this bug ships: green test, red org. In production it goes async. I put the setup object work in a Queueable so the Account save commits first and the User update runs after, and I take on what that costs me — the two are no longer atomic, so if the User update fails the Account has already changed. That is usually the right trade, but it has to be a decision, with an error record or a retry behind it. I would rather write that up front than find out the first night the async half fails. If the two cannot come apart, the User change does not belong in this save path.

Ask before you answer
  • Which of the two has to happen first, and does the User change belong in this save at all?
  • Is it failing in a test or in real use? System.runAs fixes the test and changes nothing about production.
Do not say this

Split the two DML statements apart — two handler methods, or two triggers — so the User update is not sitting next to the Account update.

The boundary is the transaction, not the class or the trigger. Both halves still run inside the same save, so the second DML still lands after the first and the error is identical. The only thing that helps is getting the setup object DML into a transaction of its own, which means async — or, in a test, System.runAs.

Deep dive on this
Apex mid

Walk me through the order of execution when a record is saved.

Testing Whether you have debugged a recursive trigger, or only read the diagram.

junior

Validation rules, before triggers, the save to the database, after triggers, then flows and workflow field updates, then roll-up summaries, then commit.

mid

I hold on to the commit boundary rather than the list. Before the save I can change field values on the record in memory with no DML at all. After the save the record has an Id, so that is the only place I can create or update related records. A workflow field update or a roll-up then re-fires the triggers, and that second pass is what usually pushes a bad trigger into System.LimitException: Too many SOQL queries: 101.

senior

The list is easy to recite and useless on its own, so I use it to answer two questions: has this record got an Id yet, and can this step re-enter my code. Before triggers, no Id, free mutation. After triggers, Id exists, everything costs DML. Field updates from workflow and roll-ups re-enter the trigger, so any trigger that queries in a loop or lacks a static guard fails on the second pass rather than the first. When something misbehaves I look at the second pass first — counting CODE_UNIT_STARTED entries in the debug log tells me how many passes I am actually dealing with.

Deep dive on this
Apex mid

What is the lifespan of a static variable in Apex?

Testing That you know where a transaction ends, and what does not count as one.

junior

One transaction. A static is set up fresh when the execution context starts and thrown away when it ends, so it is not shared between users or between saves the way a static in Java would be. That is why a static flag works as a recursion guard — its life is exactly the life of the save.

mid

It lives for the transaction, per class, and the trap is what counts as a transaction. A @future call or a System.enqueueJob starts a new one, so every static resets on the other side of that boundary — a guard I set before enqueuing is gone by the time execute runs. Same with a data load: every chunk of 200 that Data Loader sends is its own transaction, so a static Set<Id> guard starts empty again for each one, with its own 100 queries to spend. In Batch Apex statics are re-initialised for each execute chunk; only instance variables survive, and only when the class implements Database.Stateful.

senior

Transaction scope, and I care about it for two opposite reasons. It is short enough to be safe — a Map<String, SObject> of settings cached in a static costs one query instead of two hundred and cannot go stale, because the whole thing dies with the transaction. It is also long enough to be dangerous. A boolean hasRun guard is the classic: it stops the second pass, and it also stops the legitimate third write in a transaction where a flow, a workflow field update and my own code all touch the record. So I key guards by record Id instead of by a flag, and skip only the record I have already handled. The other thing I watch is heap, because statics count against the 6 MB synchronous limit for the whole transaction — caching 50,000 rows in a static trades one limit for another. Static state is a cache with a known lifetime, not a place to keep decisions.

Deep dive on this
Senior round

Senior Apex questions

Apex senior scenario

A nightly batch over 12 million rows started timing out last month. Nothing in the code changed. Where do you look?

Testing Whether you go after query selectivity, or start tuning the scope size.

What they tell you

A Database.Batchable job over a 12 million row custom object has run nightly for two years. Since last month it fails most nights, and it fails in the start method — no execute chunk runs at all. The last deploy that touched the class was in March.

mid

Nothing in the code changed, so what changed is the data. At 12 million rows the QueryLocator in start has to be selective, and selectivity is a threshold rather than a property — a custom index stops being used once the filter returns more than 10% of the first million rows, capped at 333,333. Crossing that line is exactly the sort of thing that happens quietly over a month. First thing I do is paste the start query into the Query Plan tool in the Developer Console and read the leading operator: a TableScan with a cost above 1 tells me the answer without guessing.

senior

Nothing changed in the code, so I go looking for what changed underneath it, and at 12 million rows that is nearly always selectivity. A custom index only gets used while the filter returns under 10% of the first million rows and 5% beyond that, with a hard ceiling of 333,333 rows — so a filter that returned 300,000 last year and 400,000 now falls off the index with nobody touching a line. Query Plan in the Developer Console settles that in seconds: leading operator TableScan, cost over 1. Then two things people skip. Soft-deleted rows still count toward those thresholds while they sit in the Recycle Bin, so a month of deletes with no hard delete can tip a query over on its own. And a != filter cannot use an index at all, while nulls are not in a custom index unless you have asked Support to put them there — so a WHERE Processed__c != true filter was never selective, it was only ever small enough not to matter. So the fix lives in the filter: a date window, an Id range, something indexed and bounded. Scope size is the last thing I would touch.

Ask before you answer
  • Does it die in start or partway through the execute chunks? Those are two completely different investigations.
  • How many rows has the object gained since it last ran clean, and is anything hard deleting or only soft deleting?
Do not say this

Drop the scope size from 200 to 50 so each chunk does less work, and re-run it tonight.

The job is dying in start, before a single chunk executes, so the scope size has not come into play yet. All it changes is arithmetic — 12 million rows at 50 a chunk is 240,000 transactions instead of 60,000, which makes the job longer rather than likelier to finish. The QueryLocator filter is what has to become selective.

Deep dive on this
Apex senior

Why would you reach for Queueable over @future?

Testing Whether the case is about handling failure, or just a feature table.

mid

Three things @future cannot do. It cannot hand me a job Id — the invocation does land in AsyncApexJob with JobType = 'Future', but nothing tells me which row was mine. System.enqueueJob returns the Id, and I can query it straight back. It takes primitives and collections of them, so I pass a Set<Id> and re-query rather than the records I already have in memory. And it cannot chain — a future method calling another future throws, where a Queueable can enqueue one child job from execute. Since Queueable does everything @future does, the honest answer is that it is my default and @future only turns up in code I inherited.

senior

I reach for it for what happens after the job fails. While they run, both look the same. @future is a void method that disappears into the queue — no Id back, so nothing to tell its row from every other future in the job table, and no hook to retry from — and it hits System.AsyncException: Future method cannot be called from a batch start, execute, or finish method the moment someone wants to call it from somewhere sensible. Queueable gives me the job Id from System.enqueueJob, a row in AsyncApexJob I can monitor and alert on, typed member variables instead of primitive parameters, and a Finalizer that runs whether the job succeeded or blew up, which is where the retry and the error record go. Chaining matters less often than people think, and when it does I stay aware there is no depth limit in production — a runaway chain keeps re-enqueueing until someone spots it. The real cost is that both share the same async capacity, so Queueable is not free for being nicer: 50 enqueues per transaction is the ceiling, and I batch the payload rather than enqueue one job per record.

Deep dive on this
Apex senior scenario

A trigger on Case is firing twice for a single update and the second pass is throwing a limit error. How do you work out why?

Testing Whether you look for what re-enters your code, or start rewriting the trigger.

What they tell you

One after-update trigger on Case with a handler class. There is also a record-triggered flow on Case and a workflow field update setting a Last_Reviewed__c date. The error only appears in production.

mid

I turn on a debug log and count the trigger entries. Two entries for one update means something is writing back to the record — here the workflow field update on Last_Reviewed__c is the obvious suspect. Then I check whether the handler queries inside a loop, because the second pass starts with most of the 100-query budget already spent.

senior

I start from the log rather than the code. Set a fine Apex debug level, reproduce, and count CODE_UNIT_STARTED entries for the handler. If there are two, the question is what re-entered, and on Case that is almost always the workflow field update on Last_Reviewed__c or the flow writing back. Then I look at where the query count went: 101 on the second pass means the first pass spent most of the budget, so the fix is bulkifying the query out of the loop and adding a static guard, in that order. The guard alone would hide a query bug that surfaces again the next time volumes rise.

Ask before you answer
  • Is anything else on Case writing back to the same record — a flow, a workflow field update, a roll-up on a child?
  • Does it fail on every update or only for records with many related rows?
Do not say this

Wrap the handler body in a try/catch and move on, since the second pass is the one that fails.

The catch hides the symptom and leaves the second pass running. The limit error is telling you the trigger re-entered with a query in the wrong place; swallowing it converts a loud failure into silent partial data.

Apex senior

Do you use a trigger framework? Talk me through why or why not.

Testing Whether you can name what the framework buys, or just which one you downloaded.

mid

Yes, but a thin one. What I need is one trigger per object that does nothing except dispatch — TriggerHandler.run() with before and after methods on a handler class — plus a bypass so a data migration can turn a handler off without a deploy. That gets me an order I can read, handlers I can unit test without DML, and one place for the recursion guard instead of five. What I do not need is an inheritance tree four classes deep to insert a Contact.

senior

I use one, and I am fussy about why, because a framework is a cost paid on every read of the code. The two problems worth solving are ordering and control. Two triggers on Account have no defined order, so the framework exists to make one-trigger-per-object the rule and put the sequence in a handler I can read top to bottom. Control is the bypass: a Trigger_Setting__mdt row or a static flag that lets a 2 million row migration run without the handler, and lets me switch one handler off in production at 2am without shipping code. Most of the rest gets oversold. Recursion guards belong in the framework rather than scattered; virtual before and after methods are fine; a full domain layer with unit-of-work and selectors is a real pattern with a real cost, and I would only introduce it where the team is big enough to keep the convention alive. The failure mode I have cleaned up twice is a framework nobody understood, where people added a second raw trigger beside it because that was easier than learning the base class.

Deep dive on this