Straight answer: you wrote to a setup object and a regular object in the same transaction. Salesforce does not allow that. In a test, wrap the setup-object work in System.runAs(). In production code, move one side into a separate transaction with a Queueable or future method.
Which objects are “setup” objects
The distinction is not about permissions, it is about how these records commit. The common ones:
UserandUserRoleGroupandGroupMemberPermissionSet,PermissionSetAssignment,PermissionSetGroupObjectPermissionsandFieldPermissionsQueueSObjectTerritoryand related assignment objects
Everything else (Account, Contact, your custom objects) is a non-setup object. Write to one category, then the other, in a single transaction, and you get the exception. The order does not matter. The mixing is what is disallowed.
Why the platform forbids it
Setup objects participate in a different commit path from ordinary data. Allowing both in one transaction would make it possible to leave the system in a state where a permission change committed but the data change it was meant to accompany did not. Rather than expose that risk, the platform refuses the combination.
Which reframes the error usefully: it is not an arbitrary restriction to be circumvented. It is telling you these two writes belong in separate transactions, and the fix is to put them there.
The fix in tests
Most people meet this error while writing a test that creates a User and then some data. System.runAs() establishes a separate context for the setup-object work:
@IsTest
static void userSeesOnlyOwnRecords() {
Profile p = [SELECT Id FROM Profile WHERE Name = 'Standard User' LIMIT 1];
User u = new User(
Alias = 'tuser',
Email = 'test.user@example.com.invalid',
LastName = 'Tester',
Username = 'test.user.' + DateTime.now().getTime() + '@example.com.invalid',
ProfileId = p.Id,
TimeZoneSidKey = 'America/Los_Angeles',
LocaleSidKey = 'en_US',
EmailEncodingKey = 'UTF-8',
LanguageLocaleKey = 'en_US'
);
// Setup-object DML isolated here.
System.runAs(u) {
insert new Account(Name = 'Owned by test user');
}
}
Two details that save time. Use a genuinely unique Username (a timestamp works) because usernames are unique across every Salesforce org, not just yours, and a hardcoded one will collide the second time the test runs anywhere. And use an invalid email domain so no test run can send mail to a real address.
The fix in production code
Separate transactions. Queueable is the modern choice and is easier to test and chain than a future method:
public class PermissionAssigner implements Queueable {
private final Set<Id> userIds;
private final Id permSetId;
public PermissionAssigner(Set<Id> userIds, Id permSetId) {
this.userIds = userIds;
this.permSetId = permSetId;
}
public void execute(QueueableContext context) {
List<PermissionSetAssignment> assignments = new List<PermissionSetAssignment>();
for (Id uid : userIds) {
assignments.add(new PermissionSetAssignment(
AssigneeId = uid,
PermissionSetId = permSetId
));
}
insert assignments;
}
}
Called from wherever the non-setup DML happens:
insert accounts; // non-setup
System.enqueueJob(new PermissionAssigner(userIds, permSetId)); // setup, separate transaction
Note what you have accepted by doing this: the permission assignment is now eventually consistent. It happens shortly after, not atomically with, the data change. If a downstream process assumes the assignment already exists, it will intermittently be wrong. Handle that explicitly rather than discovering it in production.
A trap worth knowing
The exception surfaces at the second DML statement, which means the stack points at innocent code. If your trigger inserts an Account and something further along assigns a permission set, the error appears to come from the permission set line even though the mixing was caused by the earlier insert. Read the whole transaction, not the line the error names.
This also means the error can appear after a change to seemingly unrelated code — someone adds a GroupMember insert to a utility method, and a trigger that has worked for two years starts failing.
Frequently asked
Does the order of the two writes matter?
No. Either order produces the exception. The combination is what is disallowed.
Can I use System.runAs() in production code?
No, it is test-context only. Production code needs an actual separate transaction.
Is Queueable better than a future method here?
Generally yes — it accepts complex types as constructor arguments, can be chained, and is easier to test. Future methods remain fine for simple cases.
Why does the platform allow this in some tests without runAs?
If the test only ever writes to one category, there is no mixing. The error appears when both happen in the same transaction.
Does creating a Queueable count as DML?
No. Enqueuing a job is not DML on either category, so it is safe to call from either side.
Reference: sObjects That Cannot Be Used Together in DML Operations, Salesforce Apex Developer Guide.
