Skip to main content

Bidirectional Field Synchronization with Actor-Type Guards

In integration-heavy orgs, it is common for two fields to represent the same concept from different directions. For example, Supplier_Price__c is set by an inbound integration from a procurement system, while Quoted_Unit_Price__c is set by a sales user in the UI. When one changes, the other should update to stay in sync. Without careful design, this creates an infinite loop.

The Circular Dependency Problem

Consider a before-update trigger that syncs two fields bidirectionally:

// Naive implementation -- DO NOT USE
if (newRecord.Supplier_Price__c != oldRecord.Supplier_Price__c) {
newRecord.Quoted_Unit_Price__c = newRecord.Supplier_Price__c;
}
if (newRecord.Quoted_Unit_Price__c != oldRecord.Quoted_Unit_Price__c) {
newRecord.Supplier_Price__c = newRecord.Quoted_Unit_Price__c;
}

When the integration updates Supplier_Price__c, the trigger sets Quoted_Unit_Price__c. Because Quoted_Unit_Price__c is now different from its old value, the second condition fires and overwrites Supplier_Price__c. In an after-update trigger performing DML, this would cause recursive execution. Even in a before-update trigger (no re-trigger), the logic contradicts itself.

The Solution: Actor-Type Guards

The key insight is that the sync direction depends on who is making the change. An inbound integration should propagate values in one direction; a UI user should propagate in the other.

Identifying the Actor

Use a dedicated integration user for all API-based operations. Then check the running user in the trigger handler:

public class SyncGuard {

private static final Id INTEGRATION_USER_ID;

static {
// Cache the integration user's ID
List<User> integrationUsers = [
SELECT Id FROM User
WHERE Username = 'integration@acmecorp.com'
LIMIT 1
];
INTEGRATION_USER_ID = integrationUsers.isEmpty() ? null : integrationUsers[0].Id;
}

public static Boolean isIntegrationContext() {
return UserInfo.getUserId() == INTEGRATION_USER_ID;
}
}

Applying Directional Sync Rules

With the actor identified, apply sync rules scoped by direction:

public class QuoteLineItemSyncHandler {

public static void syncPricingFields(
List<QuoteLineItem> newRecords,
Map<Id, QuoteLineItem> oldMap
) {
Boolean isIntegration = SyncGuard.isIntegrationContext();

for (QuoteLineItem qli : newRecords) {
QuoteLineItem oldQli = oldMap.get(qli.Id);

if (isIntegration) {
// Inbound integration: Supplier_Price drives Quoted_Unit_Price
if (qli.Supplier_Price__c != oldQli.Supplier_Price__c) {
qli.Quoted_Unit_Price__c = qli.Supplier_Price__c;
}
} else {
// UI user: Quoted_Unit_Price drives Supplier_Price
if (qli.Quoted_Unit_Price__c != oldQli.Quoted_Unit_Price__c) {
qli.Supplier_Price__c = qli.Quoted_Unit_Price__c;
}
}
}
}
}

This approach guarantees that only one sync direction executes per transaction, eliminating the circular dependency entirely.

Alternative: Custom Header Flag

If multiple integration users exist or the check needs more granularity, use a static variable flag set by the integration entry point:

public class IntegrationContext {
public static Boolean isInboundSync = false;
}

The integration's REST endpoint or trigger entry point sets IntegrationContext.isInboundSync = true before performing DML. The sync handler checks this flag instead of the user ID.

Testing Both Directions

Test classes must validate both sync directions independently:

@IsTest
static void testIntegrationContextSyncsSupplierToQuoted() {
// Run as integration user
User integrationUser = [SELECT Id FROM User WHERE Username = 'integration@acmecorp.com'];
System.runAs(integrationUser) {
QuoteLineItem qli = createTestQLI();
qli.Supplier_Price__c = 42.50;
update qli;

qli = [SELECT Quoted_Unit_Price__c FROM QuoteLineItem WHERE Id = :qli.Id];
System.assertEquals(42.50, qli.Quoted_Unit_Price__c,
'Integration update to Supplier_Price should sync to Quoted_Unit_Price');
}
}

@IsTest
static void testUIContextSyncsQuotedToSupplier() {
// Run as standard user (non-integration)
User salesUser = TestDataFactory.createStandardUser();
System.runAs(salesUser) {
QuoteLineItem qli = createTestQLI();
qli.Quoted_Unit_Price__c = 38.75;
update qli;

qli = [SELECT Supplier_Price__c FROM QuoteLineItem WHERE Id = :qli.Id];
System.assertEquals(38.75, qli.Supplier_Price__c,
'UI update to Quoted_Unit_Price should sync to Supplier_Price');
}
}

Edge Cases

  • Admin edits both fields simultaneously: When both fields change in the same transaction, the actor-type guard still applies only one direction. Document this behavior so admins understand that the integration direction or UI direction wins based on who they are logged in as.
  • Data loader operations: Data loader runs as the logged-in user. If an admin bulk-loads data that should follow integration rules, they should either use the integration user's credentials or set the static flag.
  • Flow and Process Builder: Automated updates triggered by Flows run as the user who initiated the transaction, so the guard correctly routes sync direction based on the original actor.

Key Takeaway

Bidirectional sync is safe when each transaction knows who is making the change and applies sync rules in only one direction. The actor-type guard -- whether based on User ID, a static flag, or a custom setting -- is the architectural boundary that prevents circular dependencies.