rei.r.joshua • July 2, 2026
There is a Salesforce architecture problem that does not get named often enough.
It usually shows up in code review like this:
“The trigger is bulkified. No SOQL in loops. No DML in loops. We’re good.”
Maybe.
But not always.
Bulkification answers one important question:
Can this code handle a collection of records?
It does not answer the bigger architecture question:
How much work can that collection create?
That second question is where a lot of Salesforce designs quietly get into trouble.
The term I like for this is transaction fan-out.
In Salesforce, the records in Trigger.new are the starting point. They are not necessarily the full workload. A trigger may run for up to 200 records at a time, but those 200 records can quickly spread across the data model once the logic starts querying children, updating parents, touching siblings, recalculating rollups, invoking Flows, firing managed-package automation, or creating async work. Salesforce documents that DML operations over 200 records are processed in batches, with the trigger invoked for each batch.
That spread is transaction fan-out.
Or, less formally: the blast radius.
This is related to data volume, but it is not exactly the same as Salesforce data skew. Data skew usually refers to large concentrations of records under the same parent, owner, or lookup value, which can create locking and sharing-performance problems. Transaction fan-out is about something different: how much work a single transaction creates as it moves through related data and automation.
Both matter. They just fail in different ways.
The mistake is thinking 200 records means 200 records of work
A trigger batch gives us a bounded entry point.
It does not give us a bounded transaction.
A trigger can start with 200 Account records and then query 50,000 related Contacts. The code can still be bulkified in the normal Apex sense. It can use one SOQL query. It can use one DML statement. It can pass the standard smell test.
And it can still be a scalability problem.
That is the distinction I care about.
Apex bulkification is table stakes. It prevents obvious mistakes like querying inside a loop or running DML one record at a time. But scalable automation requires another layer of thinking: understanding how many records the transaction may touch after the original trigger context fans out across the data model.
The question is not only:
Can this trigger handle 200 records?
The better question is:
What can those 200 records turn into?
A simple example
Let’s use a normal Salesforce requirement.
When an Account’s Customer_Status__c changes, all related Contacts need their Engagement_Status__c recalculated.
Nothing exotic. No integration. No monster data migration. Just parent-to-child automation.
A straightforward implementation might look like this.
trigger AccountTrigger on Account (after update) {
AccountContactRecalcHandler.afterUpdate(Trigger.new, Trigger.oldMap);
}
public inherited sharing class AccountContactRecalcHandler {
public static void afterUpdate(
List<Account> newAccounts,
Map<Id, Account> oldAccountMap
) {
Set<Id> accountIds = new Set<Id>();
for (Account accountRecord : newAccounts) {
Account oldAccount = oldAccountMap.get(accountRecord.Id);
if (accountRecord.Customer_Status__c != oldAccount.Customer_Status__c) {
accountIds.add(accountRecord.Id);
}
}
if (accountIds.isEmpty()) {
return;
}
List<Contact> contactsToUpdate = new List<Contact>();
for (Contact contactRecord : [
SELECT Id, AccountId, Engagement_Status__c, Account.Customer_Status__c
FROM Contact
WHERE AccountId IN :accountIds
]) {
contactsToUpdate.add(new Contact(
Id = contactRecord.Id,
Engagement_Status__c = AccountContactRules.calculateStatus(contactRecord)
));
}
if (!contactsToUpdate.isEmpty()) {
update contactsToUpdate;
}
}
}
This is not the beginner version of the problem.
There is no SOQL inside a loop. There is no DML inside a loop. It handles the trigger context as a collection. It checks whether the driving field changed before doing the expensive work.
That is all good.
It still has a design problem.
The number of related Contacts is unbounded.
If 200 Accounts each have 5 Contacts, this touches 1,000 Contacts. Probably fine.
If 200 Accounts each have 100 Contacts, this touches 20,000 Contacts. Maybe not fine.
If 200 Accounts each have 500 Contacts, this tries to touch 100,000 Contacts. Definitely not fine.
The code scales with the number of related Contacts, not with the number of Account records in Trigger.new.
That is transaction fan-out.
Bulkified code can still have an unbounded workload
Bulkified code usually focuses on mechanics:
Use sets. Use maps. Query once. Update once. Avoid per-record work.
All correct.
But transaction fan-out is about cardinality:
How many related records can each input record cause us to touch?
You can think about it like this:
Effective transaction size = trigger records
- related records queried
- records updated
- automation caused by those updates
- async work created by the transaction
Or more simply:
Fan-out ratio = related work created per input record
A trigger with a fan-out ratio of 1:3 is usually manageable.
A trigger with a fan-out ratio of 1:500 needs a different architecture.
And because Salesforce likes to keep us humble, the fan-out may not even be entirely in your Apex. Updating those Contacts may fire Contact triggers, record-triggered Flows, rollup logic, duplicate rules, sharing recalculation, managed-package automation, and integrations.
The original Account trigger may be clean.
The transaction may still be doing too much.
Pattern 1: Filter aggressively before fan-out
The first pattern is not Queueable Apex.
It is not Batch Apex.
It is not Platform Events.
The first pattern is avoiding unnecessary work.
Before querying related records, make sure the change actually matters.
public inherited sharing class AccountContactRecalcSelector {
public static Set<Id> selectAccountsNeedingRecalc(
List<Account> newAccounts,
Map<Id, Account> oldAccountMap
) {
Set<Id> accountIds = new Set<Id>();
for (Account accountRecord : newAccounts) {
Account oldAccount = oldAccountMap.get(accountRecord.Id);
if (oldAccount == null) {
continue;
}
if (accountRecord.Customer_Status__c != oldAccount.Customer_Status__c) {
accountIds.add(accountRecord.Id);
}
}
return accountIds;
}
}
This is boring code.
Boring is good here.
Every Account filtered out here prevents downstream queries, DML, Flow execution, trigger execution, possible row locks, and async work.
Do not fan out because a record was updated.
Fan out because a meaningful state transition happened.
Pattern 2: Measure the fan-out before doing the work
If the related record count can vary widely, estimate it before loading the full record set.
public inherited sharing class AccountContactFanOutEstimator {
public static Integer countRelatedContacts(Set<Id> accountIds) {
if (accountIds.isEmpty()) {
return 0;
}
AggregateResult result = [
SELECT COUNT(Id) contactCount
FROM Contact
WHERE AccountId IN :accountIds
];
return (Integer) result.get('contactCount');
}
}
This gives the transaction a decision point.
That matters.
Without this step, the code does not know whether it is about to process 500 Contacts or 50,000 Contacts until it is already doing the work.
With this step, the transaction can choose the right execution model before walking off the limit cliff.
Pattern 3: Route the work based on fan-out
Not every fan-out scenario needs the same solution.
Small and bounded fan-out can stay synchronous.
Medium and bounded fan-out may fit Queueable Apex.
Large or unbounded fan-out should usually be staged and processed in a controlled batch.
The important part is that the code makes this decision explicitly.
public inherited sharing class AccountContactRecalcDispatcher {
private static final Integer SYNC_CONTACT_THRESHOLD = 2000;
private static final Integer QUEUEABLE_CONTACT_THRESHOLD = 8000;
public static void dispatch(Set<Id> accountIds) {
if (accountIds.isEmpty()) {
return;
}
Integer contactCount =
AccountContactFanOutEstimator.countRelatedContacts(accountIds);
if (contactCount <= SYNC_CONTACT_THRESHOLD) {
AccountContactRecalculator.recalculate(accountIds);
return;
}
if (
contactCount <= QUEUEABLE_CONTACT_THRESHOLD &&
Limits.getQueueableJobs() < Limits.getLimitQueueableJobs()
) {
System.enqueueJob(new AccountContactRecalcQueueable(accountIds));
return;
}
AccountContactRecalcRequestService.stage(accountIds);
}
}
The thresholds here are examples, not platform constants.
Do not set thresholds by asking, “What is the absolute governor limit?”
Set them by asking, “What can this transaction safely do while leaving room for everything else in the transaction?”
That includes other Apex, Flows, validation rules, duplicate rules, managed packages, sharing work, and whatever else the org has accumulated over the years.
In a real implementation, I would also consider moving these thresholds into Custom Metadata so they can be tuned without redeploying code.
The larger point is this: async is a tool. It is not a trash can for work we did not model.
Salesforce’s asynchronous processing guidance makes that same point in platform terms: async processing has tradeoffs, no guaranteed completion SLA, capacity constraints, and different behavior depending on the mechanism being used.
Pattern 4: Keep the trigger thin
Once the fan-out logic is explicit, the trigger handler gets much easier to reason about.
public inherited sharing class AccountTriggerHandler {
public static void afterUpdate(
List<Account> newAccounts,
Map<Id, Account> oldAccountMap
) {
Set<Id> accountIds =
AccountContactRecalcSelector.selectAccountsNeedingRecalc(
newAccounts,
oldAccountMap
);
AccountContactRecalcDispatcher.dispatch(accountIds);
}
}
This is the shape I prefer.
The trigger identifies that related work may be needed.
The selector determines which records actually matter.
The estimator measures the potential fan-out.
The dispatcher chooses the execution model.
The recalculation service performs the business logic.
That separation is not ceremony for the sake of ceremony. It gives the architecture a clear place to make volume-based decisions.
Without that, limit handling tends to get scattered across trigger handlers, helper classes, Flows, and utility methods until nobody can tell where the transaction actually gets expensive.
That is how orgs become haunted houses with login screens.
Pattern 5: Make the recalculation idempotent
Any work that may run asynchronously or in batches needs to be safe to run more than once.
The recalculation should set the Contact to the correct current value. It should not depend on a previous job having run successfully. It should not increment counters blindly. It should not assume it is the only automation touching the record.
public inherited sharing class AccountContactRecalculator {
public static void recalculate(Set<Id> accountIds) {
if (accountIds.isEmpty()) {
return;
}
List<Contact> contactsToUpdate = new List<Contact>();
for (Contact contactRecord : [
SELECT Id, AccountId, Engagement_Status__c, Account.Customer_Status__c
FROM Contact
WHERE AccountId IN :accountIds
]) {
String recalculatedStatus =
AccountContactRules.calculateStatus(contactRecord);
if (contactRecord.Engagement_Status__c == recalculatedStatus) {
continue;
}
contactsToUpdate.add(new Contact(
Id = contactRecord.Id,
Engagement_Status__c = recalculatedStatus
));
}
if (!contactsToUpdate.isEmpty()) {
update contactsToUpdate;
}
}
}
There are two useful details here.
First, the method derives the Contact value from current data. That makes it repeatable.
Second, it only updates Contacts whose value actually changed. That reduces DML and avoids triggering more automation for records that are already correct.
That second point is easy to skip. It is also one of the cheapest ways to reduce transaction fan-out.
Pattern 6: Use Queueable Apex for bounded async work
Queueable Apex is a good fit when the work should not happen inside the original save transaction, but the workload is still bounded enough to fit inside a single async transaction.
For this example:
public inherited sharing class AccountContactRecalcQueueable implements Queueable {
private static final Integer MAX_CONTACTS_PER_JOB = 8000;
private List<Id> accountIds;
public AccountContactRecalcQueueable(Set<Id> accountIds) {
this.accountIds = new List<Id>(accountIds);
}
public void execute(QueueableContext context) {
Set<Id> accountIdSet = new Set<Id>(accountIds);
Integer contactCount =
AccountContactFanOutEstimator.countRelatedContacts(accountIdSet);
if (contactCount > MAX_CONTACTS_PER_JOB) {
AccountContactRecalcRequestService.stage(accountIdSet);
return;
}
AccountContactRecalculator.recalculate(accountIdSet);
}
}
The Queueable checks the fan-out again.
That may look redundant, but it is intentional. Data can change between the original transaction and the async job execution. Defensive async code is cheaper than explaining why a background job exploded because yesterday’s assumption was optimistic.
The important anti-pattern to avoid is this:
for (Id accountId : accountIds) {
System.enqueueJob(new AccountContactRecalcQueueable(new Set<Id>{ accountId }));
}
One Queueable per record feels clean until a bulk update turns into 200 Queueables, or a data load turns into thousands of async jobs.
Queueable should represent a bounded unit of work.
Not a confetti cannon.
Salesforce’s record-triggered automation guide warns that async Apex executions are shared across the org, and that a 20,000-record data load can create 100 trigger invocations when processed in chunks of 200 records. If each invocation enqueues async work, one data load can consume a significant portion of the org’s async allocation.
Pattern 7: Stage large fan-out work
When the related record set is large or unbounded, I prefer staging the work.
A staging object turns invisible transaction fan-out into visible operational work.
For example:
Account_Contact_Recalc_Request__c
Account__cStatus__cRequest_Key__cAttempts__cLast_Error__c
Request_Key__c should be unique and external ID-backed so repeated trigger executions can collapse into the same pending request.
public inherited sharing class AccountContactRecalcRequestService {
public static void stage(Set<Id> accountIds) {
if (accountIds.isEmpty()) {
return;
}
List<Account_Contact_Recalc_Request__c> requests =
new List<Account_Contact_Recalc_Request__c>();
for (Id accountId : accountIds) {
requests.add(new Account_Contact_Recalc_Request__c(
Account__c = accountId,
Status__c = 'Pending',
Request_Key__c = String.valueOf(accountId) + ':ContactEngagementRecalc'
));
}
Database.upsert(
requests,
Schema.SObjectType.Account_Contact_Recalc_Request__c.fields.Request_Key__c,
false
);
}
public static Set<Id> claimPendingAccounts(Integer maxRequests) {
List<Account_Contact_Recalc_Request__c> requests = [
SELECT Id, Account__c, Status__c
FROM Account_Contact_Recalc_Request__c
WHERE Status__c = 'Pending'
ORDER BY CreatedDate
LIMIT :maxRequests
FOR UPDATE
];
Set<Id> accountIds = new Set<Id>();
for (Account_Contact_Recalc_Request__c request : requests) {
request.Status__c = 'Processing';
accountIds.add(request.Account__c);
}
if (!requests.isEmpty()) {
update requests;
}
return accountIds;
}
public static void markCompleted(List<Id> accountIds) {
if (accountIds.isEmpty()) {
return;
}
List<Account_Contact_Recalc_Request__c> requests = [
SELECT Id, Status__c
FROM Account_Contact_Recalc_Request__c
WHERE Account__c IN :accountIds
AND Status__c = 'Processing'
];
for (Account_Contact_Recalc_Request__c request : requests) {
request.Status__c = 'Completed';
}
if (!requests.isEmpty()) {
update requests;
}
}
}
Now the trigger does not need to process every related Contact immediately.
It records that work is needed.
That gives you deduplication, retry handling, monitoring, operational visibility, and throttling.
Those are not small benefits. Those are the difference between “this usually works” and “this system can be operated.”
Pattern 8: Use Batch Apex for large record sets
Batch Apex is the better fit when the related record volume can be large.
For the Contact recalculation:
public inherited sharing class AccountContactRecalcBatch
implements Database.Batchable<SObject>, Database.Stateful {
private List<Id> accountIds;
private Set<Id> failedAccountIds = new Set<Id>();
public AccountContactRecalcBatch(Set<Id> accountIds) {
this.accountIds = new List<Id>(accountIds);
}
public Database.QueryLocator start(Database.BatchableContext context) {
return Database.getQueryLocator([
SELECT Id, AccountId, Engagement_Status__c, Account.Customer_Status__c
FROM Contact
WHERE AccountId IN :accountIds
]);
}
public void execute(Database.BatchableContext context, List<SObject> scope) {
List<Contact> contactsToUpdate = new List<Contact>();
List<Id> contactAccountIds = new List<Id>();
for (SObject record : scope) {
Contact contactRecord = (Contact) record;
String recalculatedStatus =
AccountContactRules.calculateStatus(contactRecord);
if (contactRecord.Engagement_Status__c == recalculatedStatus) {
continue;
}
contactsToUpdate.add(new Contact(
Id = contactRecord.Id,
Engagement_Status__c = recalculatedStatus
));
contactAccountIds.add(contactRecord.AccountId);
}
if (!contactsToUpdate.isEmpty()) {
Database.SaveResult[] results = Database.update(contactsToUpdate, false);
for (Integer index = 0; index < results.size(); index++) {
if (!results[index].isSuccess()) {
failedAccountIds.add(contactAccountIds[index]);
}
}
}
}
public void finish(Database.BatchableContext context) {
Set<Id> completedAccountIds = new Set<Id>(accountIds);
completedAccountIds.removeAll(failedAccountIds);
AccountContactRecalcRequestService.markCompleted(
new List<Id>(completedAccountIds)
);
}
}
The missing piece is how the batch starts.
I would not call Database.executeBatch directly from the trigger. That just moves the fan-out problem into the async queue. Salesforce’s record-triggered automation guide calls direct Database.executeBatch from trigger context an anti-pattern and recommends scheduled processing for high-volume work that does not need to complete in real time.
A safer approach is a scheduled processor that claims a controlled amount of staged work.
public inherited sharing class AccountContactRecalcScheduled
implements Schedulable {
public void execute(SchedulableContext context) {
Set<Id> accountIds =
AccountContactRecalcRequestService.claimPendingAccounts(200);
if (accountIds.isEmpty()) {
return;
}
Database.executeBatch(
new AccountContactRecalcBatch(accountIds),
200
);
}
}
This creates a more stable flow:
Account trigger → detect meaningful Account changes → estimate related Contact fan-out → process small work synchronously → process medium work with one Queueable → stage large work → scheduled job claims staged work → Batch Apex processes Contacts in chunks
That is a lot safer than:
Account trigger → query everything → update everything → hope
Hope is not an architecture pattern.
It is barely a debugging strategy.
The real design decision is latency versus stability
There is always a tradeoff.
Synchronous processing gives the user immediate consistency, but it keeps all the work inside the save transaction.
Queueable Apex separates the work from the user’s save, but it still consumes shared async capacity and still needs a bounded workload.
Batch Apex gives you scale, but it introduces latency and operational complexity.
Staged work gives you control, but it means the system is eventually consistent.
None of those are universally right.
The right answer depends on the business requirement.
If Contacts must be correct before the Account save finishes, then the process needs strict synchronous limits and probably a hard cap on eligible related records.
If Contacts can be correct within a few minutes, Queueable or scheduled processing may be better.
If this is cleanup, enrichment, scoring, rollup maintenance, or anything that can tolerate delay, staged Batch Apex is usually the stronger design.
The point is not “always go async.”
The point is to choose the execution model based on expected fan-out, latency requirements, and operational risk.
Salesforce’s async decision guide frames cascading updates through an object graph as a use case for Queueable Apex when the goal is to let the user’s save complete quickly while deferring related work, but it also distinguishes that from large-volume processing scenarios that need different tools.
What I would test
The test coverage for this kind of design should not only prove that one Account updates three Contacts.
That test is useful, but it does not prove the architecture.
I would want tests around the routing behavior:
When the related Contact count is small, process synchronously.
When the related Contact count is medium, enqueue one Queueable.
When the related Contact count is large, stage the work request.
When the same Account is staged multiple times, dedupe into one pending request.
When Contacts already have the correct value, do not update them.
The goal is not just code coverage.
The goal is to prove the fan-out boundaries.
Because that is where the design either holds or fails.
The takeaway
Bulkified is not the same as scalable.
Bulkification protects you from a specific class of Apex mistakes. It does not guarantee that the transaction has a safe workload.
Transaction fan-out is the part of the design where 200 input records become 2,000, 20,000, or 100,000 affected records because the process spreads across the data model.
That does not mean every parent-to-child update needs a complex async framework.
It means the design should know its own blast radius.
Filter aggressively.
Measure fan-out before doing expensive work.
Route based on expected volume.
Keep synchronous work bounded.
Use Queueable Apex for bounded async work.
Use staged Batch Apex for large or unbounded work.
Make the processing idempotent.
And most importantly, stop asking only whether the trigger can handle 200 records.
Ask what those 200 records can turn into.




