Mixed DML Operations: Enterprise User Provisioning Patterns for Salesforce

TL;DR

  • Understand why Mixed DML errors occur when combining setup objects (like Users, Groups) with standard objects in a single transaction.
  • Implement sophisticated asynchronous patterns to handle complex enterprise user provisioning workflows.
  • Utilize a state machine architecture for reliable, step-by-step provisioning with comprehensive error recovery.
  • Apply a circuit breaker pattern to prevent cascade failures during bulk user operations.
  • Build comprehensive audit trails and error handling as critical components for enterprise compliance.

The 3 AM Call That Changed Everything

Marcus's phone rings at 3:14 AM. "All new customer onboarding is down," the voice says urgently. "We have 200+ new accounts stuck in the system—customers can't access their portals, and the sales team is panicking."

The root cause? A seemingly innocent change to the customer onboarding process that introduced a Mixed DML violation. What started as a simple requirement—"When we create a customer account, automatically provision their portal users"—became a production nightmare when setup and non-setup objects collided in the same transaction.

Sound familiar? Mixed DML operations are one of the most frustrating limitations in Salesforce development, yet most examples only show toy scenarios, not enterprise-grade solutions.


Understanding Mixed DML: Beyond the Basics

What Setup vs Non-Setup Objects Really Means

Within any single transaction, Salesforce places a strict boundary between two types of objects:

  • Non-Setup Objects: These are standard and custom objects that hold business data, such as Account, Contact, Opportunity, and custom objects.
  • Setup Objects: These are objects that define the configuration and security of your Salesforce organization, such as User, Group, Profile, PermissionSet, and Role.

The platform throws a Mixed DML operation error when you try to perform DML on both object types within the same transaction. This is a security measure to prevent issues where a change to an organizational setting might be partially committed while a related business data change fails, leading to an inconsistent state.

The Deceptive Simplicity Problem

Most documentation shows this "solution" to be avoided:

// WRONG: This will fail with Mixed DML error
public void createCustomerWithUsers(Account customer, List<User> users) {
    insert customer;      // Non-setup object
    insert users;         // Setup object - MIXED DML ERROR!
}

And suggests a naive "fix":

// NAIVE: This doesn't solve enterprise problems
@future
public static void createUsersAsync(Id customerId, List<User> users) {
    insert users; // Works, but what about error handling? Dependencies? Rollbacks?
}

While this naive solution works for simple cases, real enterprise scenarios involve:

  • Complex dependency chains
  • Partial failure recovery and rollbacks
  • Audit and compliance requirements
  • Bulk operations with hundreds of users
  • Integration with external systems

The Enterprise Reality: Customer Onboarding Case Study

The Business Requirement

TechCorp's customer onboarding process requires:

  1. Account Creation (non-setup)
  2. Contact Creation for admin users (non-setup)
  3. User Provisioning for portal access (setup)
  4. Permission Assignment based on customer tier (setup)
  5. Integration Setup with external systems (non-setup)
  6. Notification Triggers for welcome emails (non-setup)

All of this must happen atomically—if any step fails, everything should roll back to prevent partial provisioning states.

The Traditional Approach (That Fails)

The classic approach of running all steps in a single method is brittle and will inevitably fail due to Mixed DML violations.

// BROKEN: Multiple Mixed DML violations
public class CustomerOnboardingService {
    public void onboardCustomer(CustomerOnboardingRequest request) {
        // Step 1: Create account (non-setup)
        Account customer = createAccount(request);
        insert customer;
        
        // Step 2: Create contacts (non-setup)
        List<Contact> contacts = createContacts(request, customer.Id);
        insert contacts;
        
        // MIXED DML ERROR: Can't do this in same transaction
        List<User> users = createUsers(contacts);
        insert users;  // FAILS!
        
        // These never execute due to failure above
        assignPermissions(users);
        setupIntegrations(customer);
        sendWelcomeEmails(customer);
    }
}

Advanced Pattern 1: Async State Machine Architecture

A state machine breaks a complex workflow into discrete, independent states. Each state is processed asynchronously, often in its own transaction, which completely eliminates the Mixed DML error.

The State-Driven Approach

The provisioning process can be broken down into an OnboardingState enum. This provides a clear, auditable trail of where the process is at any given time.

public class CustomerOnboardingStateMachine {
    public enum OnboardingState {
        INITIATED,
        ACCOUNT_CREATED,
        CONTACTS_CREATED,
        USERS_CREATED,
        PERMISSIONS_ASSIGNED,
        INTEGRATIONS_SETUP,
        COMPLETED,
        FAILED
    }
    
    public class OnboardingContext {
        public OnboardingState currentState;
        // ... properties for tracking the request and state data
    }
    
    public static OnboardingContext processState(OnboardingContext context) {
        // Switch on the current state to execute the appropriate logic
        switch on context.currentState {
            when INITIATED {
                return processInitiated(context);
            }
            when ACCOUNT_CREATED {
                return processAccountCreated(context);
            }
            // ... and so on for all states
        }
    }
}

State Implementation with Mixed DML Awareness

The key is to group DML operations by object type within each state's processing method.

private static OnboardingContext processInitiated(OnboardingContext context) {
    // NON-SETUP OPERATIONS: Account and Contact creation
    try {
        Account customer = createAccount(context.originalRequest);
        insert customer;
        
        List<Contact> contacts = createContacts(context.originalRequest, customer.Id);
        insert contacts;
        
        // Store data for the next state and queue it
        context.stateData.put('customerId', customer.Id);
        context.stateData.put('contactIds', getContactIds(contacts));
        context.currentState = OnboardingState.CONTACTS_CREATED;
        
        // Queue next state for SETUP operations in a new transaction
        queueNextState(context);
        
    } catch(Exception e) {
        context.currentState = OnboardingState.FAILED;
        context.errors.add('Account/Contact creation failed: ' + e.getMessage());
    }
    return context;
}

private static OnboardingContext processContactsCreated(OnboardingContext context) {
    // SETUP OPERATIONS: User creation and permissions
    try {
        List<Id> contactIds = (List<Id>)context.stateData.get('contactIds');
        List<Contact> contacts = [SELECT Id, Email FROM Contact WHERE Id IN :contactIds];
        
        List<User> users = createUsers(contacts, context.originalRequest);
        insert users;  // Safe: Only setup operations in this transaction
        
        // Assign permissions immediately while in setup context
        assignPermissions(users, context.originalRequest.customerTier);
        
        context.stateData.put('userIds', getUserIds(users));
        context.currentState = OnboardingState.PERMISSIONS_ASSIGNED;
        
        // Queue next state for NON-SETUP operations
        queueNextState(context);
        
    } catch(Exception e) {
        context.currentState = OnboardingState.FAILED;
        context.errors.add('User creation failed: ' + e.getMessage());
        // Initiate rollback of previous states
        initiateRollback(context);
    }
    return context;
}

Async Orchestration with Platform Events

Using Platform Events is the most reliable way to orchestrate the state machine. An event is published when a state completes, and a separate process (like an Apex trigger) consumes the event to execute the next state in a new transaction.

// The OnboardingEventHandler is an async method that processes a state
@future
public static void processOnboardingState(String contextJson) {
    OnboardingContext context = (OnboardingContext)JSON.deserialize(contextJson, OnboardingContext.class);
    context = CustomerOnboardingStateMachine.processState(context);
    persistOnboardingState(context);
    
    // If not completed and not failed, continue processing
    if(context.currentState != OnboardingState.COMPLETED && 
       context.currentState != OnboardingState.FAILED) {
        queueNextState(context);
    }
}

// This method publishes a Platform Event to queue the next state
private static void queueNextState(OnboardingContext context) {
    // Use platform events for reliable async processing
    Customer_Onboarding_Event__e event = new Customer_Onboarding_Event__e(
        Request_Id__c = context.requestId,
        Context_Data__c = JSON.serialize(context)
    );
    Database.SaveResult result = EventBus.publish(event);
    
    if(!result.isSuccess()) {
        // Fallback to @future if platform events fail
        processOnboardingState(JSON.serialize(context));
    }
}

Advanced Pattern 2: Circuit Breaker for Bulk Operations

High-volume user provisioning can lead to system instability if failures occur repeatedly. A circuit breaker pattern monitors for consecutive failures and, after a certain threshold, "trips" to prevent new operations from starting, protecting the system from a cascading failure.

public class BulkUserProvisioningService {
    private static final Integer FAILURE_THRESHOLD = 5;
    
    public static ProvisioningResult provisionUsersInBulk(List<CustomerOnboardingRequest> requests) {
        ProvisioningResult result = new ProvisioningResult();
        Integer consecutiveFailures = 0;
        
        List<List<CustomerOnboardingRequest>> batches = createBatches(requests);
        
        for(List<CustomerOnboardingRequest> batch : batches) {
            // Check if the circuit breaker is tripped
            if(consecutiveFailures >= FAILURE_THRESHOLD) {
                result.circuitBreakerTripped = true;
                result.errors.add('Circuit breaker tripped after ' + consecutiveFailures + ' failures');
                break;
            }
            
            try {
                // Process non-setup DML first
                ProvisioningResult batchResult = processBatch(batch);
                
                // Track results and reset/increment failure counter
                if(batchResult.failureCount == 0) {
                    consecutiveFailures = 0;
                } else {
                    consecutiveFailures++;
                }
            } catch(Exception e) {
                consecutiveFailures++;
                result.errors.add('Batch processing failed: ' + e.getMessage());
            }
        }
        return result;
    }
}

Advanced Pattern 3: Comprehensive Error Recovery

Beyond simply logging errors, enterprise systems need sophisticated rollback strategies to ensure data integrity.

Sophisticated Rollback Strategies

public class OnboardingRollbackService {
    public enum RollbackStrategy {
        FULL_ROLLBACK,
        PARTIAL_ROLLBACK,
        COMPENSATING_ACTION,
        MANUAL_INTERVENTION
    }

    public static void initiateRollback(OnboardingContext context) {
        RollbackStrategy strategy = determineRollbackStrategy(context);
        
        switch on strategy {
            when FULL_ROLLBACK {
                // Delete everything created
                executeFullRollback(context);
            }
            when PARTIAL_ROLLBACK {
                // Rollback only the failed parts
                executePartialRollback(context);
            }
            when COMPENSATING_ACTION {
                // Try to fix the issue instead of rolling back
                executeCompensatingAction(context);
            }
            when MANUAL_INTERVENTION {
                // Flag for human review
                flagForManualReview(context);
            }
        }
    }
    
    @future
    private static void executeFullRollback(OnboardingContext context) {
        // Step 1: Delete users (SETUP objects)
        if(context.stateData.containsKey('userIds')) {
            List<Id> userIds = (List<Id>)context.stateData.get('userIds');
            delete [SELECT Id FROM User WHERE Id IN :userIds];
        }
        // Queue non-setup deletions for a separate transaction
        queueNonSetupRollback(context);
    }
}

Advanced Pattern 4: Comprehensive Audit and Monitoring

For compliance and operational visibility, every step of the provisioning process must be logged.

Enterprise-Grade Logging and Compliance

public class OnboardingAuditService {
    
    public class AuditEvent {
        public String requestId;
        public String eventType;
        // ... other audit fields
    }
    
    public static void logOnboardingEvent(OnboardingContext context, String eventType, String outcome) {
        AuditEvent event = new AuditEvent();
        event.requestId = context.requestId;
        event.eventType = eventType;
        event.timestamp = DateTime.now();
        // ... populate event data
        
        // Persist audit event to a custom object
        Customer_Onboarding_Audit__c auditRecord = new Customer_Onboarding_Audit__c(
            Request_Id__c = event.requestId,
            Event_Type__c = event.eventType,
            // ... other fields
            Outcome__c = outcome
        );
        
        // Use Database.insert with partial success for resilience
        Database.SaveResult result = Database.insert(auditRecord, false);
        
        if(!result.isSuccess()) {
            // Fallback: Log to platform events if direct insert fails
            Audit_Event__e platformEvent = new Audit_Event__e(
                Request_Id__c = event.requestId,
                Event_Type__c = event.eventType
            );
            EventBus.publish(platformEvent);
        }
    }
}

Testing Strategy: Handling Async Complexity

Testing these advanced patterns requires a robust approach that simulates the asynchronous transactions.

@IsTest
public class CustomerOnboardingTest {
    
    @IsTest
    static void testSuccessfulOnboardingFlow() {
        Test.startTest();
        // Initiate onboarding, which handles non-setup DML and queues the next state
        OnboardingContext context = CustomerOnboardingStateMachine.processState(createTestRequest());
        Test.stopTest();
        
        // Assert that the initial state was processed and the next was queued
        System.assertEquals(OnboardingState.CONTACTS_CREATED, context.currentState);
    }
    
    @IsTest
    static void testAsyncUserCreation() {
        // Create an initial context as if the previous state completed
        OnboardingContext context = new OnboardingContext(createTestRequest());
        context.currentState = OnboardingState.CONTACTS_CREATED;
        
        Test.startTest();
        // Process the state that handles setup DML
        context = CustomerOnboardingStateMachine.processState(context);
        Test.stopTest();
        
        // Verify users were created in the new transaction
        List<User> createdUsers = [SELECT Id FROM User];
        System.assertEquals(1, createdUsers.size());
    }
    
    @IsTest
    static void testBulkProvisioningWithCircuitBreaker() {
        Test.startTest();
        // Create a large number of requests to test batching
        List<CustomerOnboardingRequest> requests = createTestRequests(150);
        BulkUserProvisioningService.ProvisioningResult result = 
            BulkUserProvisioningService.provisionUsersInBulk(requests);
        Test.stopTest();
        
        // Verify the results and that the circuit breaker was not tripped
        System.assert(result.successCount > 0);
        System.assertEquals(0, result.failureCount);
        System.assertEquals(false, result.circuitBreakerTripped);
    }
}

Key Takeaways

  • The Mixed DML error is a security feature, not a bug. Understanding the difference between setup and non-setup objects is the first step to solving it.
  • For enterprise-grade solutions, move beyond the basic @future method and embrace more robust asynchronous patterns like a state machine.
  • Use Platform Events for reliable, decoupled orchestration of your state machine.
  • Protect your system with a circuit breaker pattern to handle bulk provisioning without risking a full system outage.
  • Integrate comprehensive auditing and error recovery into your architecture to meet compliance standards and build a resilient application.
  • Thoroughly test your asynchronous logic using Test.startTest() and Test.stopTest() to ensure your solutions work as expected in a multi-transaction environment.

FAQ

Q: What exactly is a "transaction boundary" in Salesforce?

A transaction boundary marks the start and end of a single unit of work in Salesforce. All DML operations within this boundary are treated as a single, atomic operation. If any part of the transaction fails, all changes are rolled back. Mixed DML errors occur because Salesforce will not allow a single transaction to contain DML on both setup and non-setup objects.

Q: Can I use Database.insert with allOrNone = false to get around the Mixed DML error?

No, this won't work. The allOrNone parameter controls what happens when some records in a list fail to insert, but all the records are still part of the same transaction. If the transaction itself violates the Mixed DML rule, the entire DML operation will fail, regardless of the allOrNone setting.

Q: Why use Platform Events over Queueable Apex for the state machine?

While Queueable Apex is a great tool, Platform Events offer a more robust and scalable solution for orchestrating a state machine.

  • Decoupling: They completely decouple the publisher (one state) from the subscriber (the next state).
  • Scalability: Events can be consumed by multiple subscribers, like Apex triggers, Flows, or external systems.
  • Durability: Platform Events are stored and delivered reliably, so if the subscriber fails, the event isn't lost.

Q: What about using the Database.setSavepoint and Database.rollback methods?

You can't use Database.setSavepoint and Database.rollback to get around a Mixed DML error. These methods are designed to roll back changes within a single transaction, not to create new, independent transactions. The Mixed DML error is thrown at the point of the DML call, preventing the savepoint from being useful in this context.

Q: Is there a simpler way to handle this without all the code?\

For truly simple, one-off cases, a basic @future method might be sufficient. However, if your process involves multiple steps, error recovery, or bulk operations—which most enterprise provisioning does—then a more structured approach like a state machine is essential. The upfront effort in building a robust solution saves you from the "3 AM call" and ensures your application is stable and maintainable in the long run.

Remember: The goal isn't to eliminate Mixed DML restrictions—it's to build systems that work elegantly within these constraints while delivering reliable, scalable user provisioning at enterprise scale.


Building enterprise-grade Salesforce solutions requires mastering platform constraints and turning them into architectural strengths. Want to see more advanced patterns for production Salesforce development? Follow along for deep dives into battle-tested enterprise solutions.

Comments (0)

Loading comments...