Skip to main content

Multi-Pass Trigger Processing: Handling Cascading Field Dependencies

In complex Salesforce implementations, a single trigger handler method may need to populate multiple fields where some fields depend on others being resolved first. A supplier display name field, for example, cannot be constructed until the supplier lookup has been resolved from an external ID. Processing all fields in a single pass fails when these dependencies exist.

When Single-Pass Processing Fails

Consider a trigger on an order line item that must:

  1. Resolve a supplier lookup from an external supplier code.
  2. Set a concatenated display field combining the supplier name and product code.

If both operations run in the same loop iteration, the display field reads the supplier name before the lookup has been resolved:

// BAD: Single pass -- display field reads null supplier name
for (Order_Line__c line : Trigger.new) {
// Resolve supplier lookup
if (line.Supplier_Code__c != null) {
Account supplier = supplierMap.get(line.Supplier_Code__c);
line.Supplier__c = supplier?.Id;
}

// Build display field -- but Supplier__r.Name is NOT available yet
// because we just set the lookup ID in this before-insert context
line.Display_Name__c = line.Supplier__r?.Name + ' - ' + line.Product_Code__c;
// Result: "null - PRD-001"
}

The problem: setting line.Supplier__c (the lookup ID) does not populate line.Supplier__r.Name in the same before-trigger context. The relationship field data is not available until after the record is committed and re-queried.

The Two-Pass Solution

Structure the trigger handler with explicit passes. Pass 1 resolves all lookups and gathers the data needed. Pass 2 uses that resolved data to compute derived fields.

public class OrderLineTriggerHandler {

public static void handleBeforeInsert(List<Order_Line__c> newLines) {
// Collect external keys for bulk query
Set<String> supplierCodes = new Set<String>();
Set<String> productCodes = new Set<String>();

for (Order_Line__c line : newLines) {
if (String.isNotBlank(line.Supplier_Code__c)) {
supplierCodes.add(line.Supplier_Code__c);
}
if (String.isNotBlank(line.Product_Code__c)) {
productCodes.add(line.Product_Code__c);
}
}

// Bulk queries -- one per related object
Map<String, Account> suppliersByCode = new Map<String, Account>();
for (Account a : [
SELECT Id, Name, Supplier_Code__c
FROM Account
WHERE Supplier_Code__c IN :supplierCodes
]) {
suppliersByCode.put(a.Supplier_Code__c, a);
}

Map<String, Product2> productsByCode = new Map<String, Product2>();
for (Product2 p : [
SELECT Id, Name, ProductCode
FROM Product2
WHERE ProductCode IN :productCodes
]) {
productsByCode.put(p.ProductCode, p);
}

// --- PASS 1: Resolve lookups ---
Map<Id, String> supplierNameByLineId = new Map<Id, String>();

for (Order_Line__c line : newLines) {
Account supplier = suppliersByCode.get(line.Supplier_Code__c);
if (supplier != null) {
line.Supplier__c = supplier.Id;
// Store the name for Pass 2 (can't access via relationship)
supplierNameByLineId.put(line.Id, supplier.Name);
}

Product2 product = productsByCode.get(line.Product_Code__c);
if (product != null) {
line.Product__c = product.Id;
}
}

// --- PASS 2: Compute derived fields ---
for (Order_Line__c line : newLines) {
String supplierName = supplierNameByLineId.get(line.Id);
String productCode = line.Product_Code__c;

List<String> parts = new List<String>();
if (String.isNotBlank(supplierName)) {
parts.add(supplierName);
}
if (String.isNotBlank(productCode)) {
parts.add(productCode);
}
line.Display_Name__c = String.join(parts, ' - ');
}
}
}

Why an Intermediate Map Is Necessary

In a before-insert trigger, Trigger.new records do not have IDs yet (they are null). For before-insert scenarios, use the list index or the external code as the map key instead:

// For before insert: use index-based approach
Map<Integer, String> supplierNameByIndex = new Map<Integer, String>();

for (Integer i = 0; i < newLines.size(); i++) {
Order_Line__c line = newLines[i];
Account supplier = suppliersByCode.get(line.Supplier_Code__c);
if (supplier != null) {
line.Supplier__c = supplier.Id;
supplierNameByIndex.put(i, supplier.Name);
}
}

for (Integer i = 0; i < newLines.size(); i++) {
Order_Line__c line = newLines[i];
String supplierName = supplierNameByIndex.get(i);
line.Display_Name__c = buildDisplayName(supplierName, line.Product_Code__c);
}

Testing Edge Cases

Multi-pass logic introduces edge cases that single-pass code does not have:

ScenarioExpected Behavior
Supplier code is nullPass 1 skips lookup; Pass 2 builds display name without supplier
Supplier code is invalid (no match)Pass 1 leaves lookup null; Pass 2 handles missing name
Product code is null but supplier is validDisplay name shows supplier only
Both codes are nullDisplay name is blank or a default value
Same supplier code across multiple linesAll lines resolve to the same supplier; map handles deduplication
@IsTest
static void testNullSupplierCode() {
Order_Line__c line = new Order_Line__c(
Supplier_Code__c = null,
Product_Code__c = 'PRD-001'
);
insert line;

line = [SELECT Display_Name__c FROM Order_Line__c WHERE Id = :line.Id];
System.assertEquals('PRD-001', line.Display_Name__c,
'Display name should contain only product code when supplier is null');
}

Key Takeaways

  • Relationship fields (Supplier__r.Name) are not available in the same before-trigger context where you set the lookup ID.
  • Use intermediate maps to carry resolved data from Pass 1 to Pass 2.
  • For before-insert triggers, use list indices or external keys as map keys since record IDs are null.
  • Test all permutations of null and valid values across dependent fields to catch edge cases that single-pass code would mask.