Too many DML Statements: 1

When this error occurs

This error arises when a DML operation (like insert, update, or delete) is attempted inside an Apex method annotated with @AuraEnabled(cacheable=true). Since such methods are executed in a read-only context to enable client-side caching in Lightning components, any DML operation inside them results in a System.LimitException: Too many DML statements: 1.

Demonstration Setup

In the Apex class below, a custom log record is inserted to capture the details of the executed query, along with an additional log entry in case an exception occurs within the try block. However, since the method is annotated with @AuraEnabled(cacheable=true), it operates in a read-only context where DML operations are not permitted. As a result, any attempt to perform a DML operation triggers a runtime exception. This poses a challenge for scenarios where customers require logging of exceptions—a valid and common use case—as the method fails and the end user encounters an error.

Apex class

public with sharing class GoverLimitSimulatorController {
    
    @AuraEnabled(cacheable=true)
    public static List<Contact> getContacts() {
        List<Contact> contacts = new List<Contact>();
        try{
            String queryString = 'SELECT Id, FirstName, LastName, Email, Phone FROM Contact LIMIT 100';
            contacts = Database.query(queryString);
            //The below line throws an error as DML operation cannot be performed in a read-only transaction.
            SystemLogUtils.insertSystemLogs(SystemLogUtils.LogLevel.Info, queryString, '', 'GoverLimitSimulatorController.getContacts', queryString, 'Governor Limit Errors App', 'Simulator for DML One Exception');
        }catch(Exception e) {
            SystemLogUtils.insertSystemLogs(SystemLogUtils.LogLevel.Error, e.getMessage(), '', 'GoverLimitSimulatorController.getContacts', e.getMessage(), 'Governor Limit Errors App', 'Simulator for DML One Exception');
            System.debug('DML operation cannot be performed in a read-only transaction.'+e.getMessage());   
            // throw new AuraHandledException('Error fetching contacts: ' + e.getMessage());
        }
        return contacts;    
    }
    
LWC Code    
import { LightningElement, wire } from 'lwc';
import getContacts from '@salesforce/apex/GoverLimitSimulatorController.getContacts';
import logFromLWC from '@salesforce/apex/GoverLimitSimulatorController.logFromLWC';


const COLUMNS = [
    { label: 'First Name', fieldName: 'FirstName' },
    { label: 'Last Name', fieldName: 'LastName' },
    { label: 'Email', fieldName: 'Email', type: 'email' },
    { label: 'Phone', fieldName: 'Phone', type: 'phone' }
];

export default class DmlOneErrorSimulator extends LightningElement {
    columns = COLUMNS;
    contacts;
    error;

    @wire(getContacts)
    wiredContacts({ data, error }) {
        if (data) {
            this.contacts = data;
            this.error = undefined;
        } else if (error) {
            this.error = error;
            this.contacts = undefined;
        }
    }
}
<template>
    <lightning-card title="Contact List - DML Error Simulator" icon-name="standard:contact">
        <template if:true={contacts}>
            <lightning-datatable
                key-field="Id"
                data={contacts}
                columns={columns}
                hide-checkbox-column
                show-row-number-column>
            </lightning-datatable>
        </template>
        <template if:true={error}>
            <p class="slds-text-color_error">Error fetching contacts: {error.body.message}</p>
        </template>
    </lightning-card>
</template>

Recommended Solutions

  • There are several approaches to resolve this issue effectively.
  • A recommended and efficient solution is to:
    • Throw the exception from the @AuraEnabled(cacheable=true) method.
    • Handle the error in the Lightning Web Component (LWC).
    • Perform the required DML operation using an imperative Apex method that is not marked as cacheable=true.
  • Alternative solutions include
    • Removing the cacheable=true annotation, although this may reduce performance due to the loss of client-side caching.
    • Using Queueable Apex to perform the DML operation asynchronously (note: no SLA and subject to asynchronous Apex limits).
    • Publishing Platform Events for logging or DML processing (be mindful of platform event limits and processing behavior).

Using Imperative method (most efficient)

  • In this approach, when using an Apex method marked as read-only (@AuraEnabled(cacheable=true)), any exceptions encountered should be thrown back to the caller (the Lightning Web Component). The LWC can then handle the exception and invoke an imperative Apex method to perform the required DML operation.
  • This pattern is suitable specifically for logging exceptions. For informational logs—such as logging the executed query—this approach is not applicable, as exceptions cannot be thrown for normal execution flow. In such cases, alternative mechanisms like Queueable Apex or Platform Events should be considered.
  • To simulate this scenario, a query can be intentionally written to reference a non-existent field, which will trigger a query exception. This exception is caught in the Apex method and rethrown to the LWC. The LWC then catches the error and calls an imperative Apex method to log the exception.

Demostration Setup

public with sharing class GoverLimitSimulatorController {
    
    @AuraEnabled(cacheable=true)
    public static List<Contact> getContacts() {
        List<Contact> contacts = new List<Contact>();
        try{
            String queryString = 'SELECT Id, FirstName, test, LastName, Email, Phone FROM Contact LIMIT 100';
            contacts = Database.query(queryString);
            //The below line throws an error as DML operation cannot be performed in a read-only transaction.
            //SystemLogUtils.insertSystemLogs(SystemLogUtils.LogLevel.Info, queryString, '', 'GoverLimitSimulatorController.getContacts', queryString, 'Governor Limit Errors App', 'Simulator for DML One Exception');
        }catch(Exception e) {
            //SystemLogUtils.insertSystemLogs(SystemLogUtils.LogLevel.Error, e.getMessage(), '', 'GoverLimitSimulatorController.getContacts', e.getMessage(), 'Governor Limit Errors App', 'Simulator for DML One Exception');
            System.debug('DML operation cannot be performed in a read-only transaction.'+e.getMessage());   
            throw new AuraHandledException('Error fetching contacts: ' + e.getMessage());
        }
        

        return contacts;    
    }

    @AuraEnabled
    public static void logFromLWC(String exceptionMessage, String source, String stackTrace, String applicationName, String moduleName){
        System_Log__c systemLog = SystemLogUtils.prepareLog(SystemLogUtils.LogLevel.Error, exceptionMessage , null, source, stackTrace,applicationName,moduleName);
        SystemLogUtils.insertSystemLogs(systemLog);
    }
    
}
import { LightningElement, wire } from 'lwc';
import getContacts from '@salesforce/apex/GoverLimitSimulatorController.getContacts';
import logFromLWC from '@salesforce/apex/GoverLimitSimulatorController.logFromLWC';


const COLUMNS = [
    { label: 'First Name', fieldName: 'FirstName' },
    { label: 'Last Name', fieldName: 'LastName' },
    { label: 'Email', fieldName: 'Email', type: 'email' },
    { label: 'Phone', fieldName: 'Phone', type: 'phone' }
];

export default class DmlOneErrorSimulator extends LightningElement {
    columns = COLUMNS;
    contacts;
    error;

    @wire(getContacts)
    wiredContacts({ data, error }) {
        if (data) {
            this.contacts = data;
            this.error = undefined;
        } else if (error) {
            this.error = error;
            logFromLWC({
                exceptionMessage: error.body ? error.body.message : 'No Message available',
                source: 'dmlOneErrorSimulator LWC Component',
                stackTrace: error.body ? error.body.stack : 'No stack trace available',
                applicationName: 'Governor Limit Simulator',
                moduleName: 'DML One Error Simulator'
            });
            this.contacts = undefined;
        }
    }
}

Using Queueable Apex

  • Queueable Apex is a more advanced and flexible way to run asynchronous Apex processes compared to @future methods, and is the preferred approach for handling tasks that involve:
  • Queueable jobs execute in the background in their own isolated thread, ensuring that the main Apex logic is not delayed. Like future methods, these jobs are processed when system resources become available.
  • Key Advantages of Queueable Apex over Future methods
    • Job Identification:
      • The System.enqueueJob() method returns a job ID.
      • This ID can be used to monitor job status in the Salesforce UI (Apex Jobs page) or by querying the AsyncApexJob object.
    • Support for Complex Data Types:
      • Unlike future methods, queueable classes can accept and process member variables of non-primitive types (e.g., sObjects, lists, and custom Apex types).
    • Job Chaining:
      • Queueable jobs can be chained by starting a new job from within the currently executing job.
      • Useful for scenarios that require sequential execution or task dependency handling.
    • Transaction Finalizers:
      • Finalizers can be attached to a queueable job to take post-execution actions.
      • For example, failed jobs can be automatically retried (up to five times) using finalizers
  • Platform Limits for Queueable Jobs
    • Job Chaining:
      • Maximum of 50 chained jobs per transaction.
    • Job Submission:
      • Up to 50 queueable jobs can be enqueued in a single synchronous transaction.
      • Only 1 job can be enqueued from within an asynchronous transaction (e.g., batch job).
    • Execution Limits:
      • CPU Time: 60 seconds documented, 65 seconds enforced.
      • Heap Size: 12 MB per job.
    • Daily Limits:
      • The total number of queueable jobs per 24-hour period depends on your Salesforce edition.
  • Best Practices for Logging with Queueable Apex
    • Before using queueable jobs, ensure your implementation stays within platform limits.
    • Bulkify your logging operations:

Demonstration Setup

  • A bulkified queueable job has been implemented to log info, warning, and exception messages efficiently.
  • If the log count exceeds 200 records, a chained job is automatically triggered to process the remaining entries.
public class SystemLogsQueueable implements Queueable {

    private List<System_Log__c> logs;

    public SystemLogsQueueable(List<System_Log__c> logsToInsert) {
        this.logs = new List<System_Log__c>();
        if (logsToInsert != null) {
            this.logs.addAll(logsToInsert);
        }
    }

    public void execute(QueueableContext context) {
        Integer batchSize = 200;

        // Split logs safely
        List<System_Log__c> currentBatch = new List<System_Log__c>();
        List<System_Log__c> remainingBatch = new List<System_Log__c>();

        for (Integer i = 0; i < logs.size(); i++) {
            if (i < batchSize) {
                currentBatch.add(logs[i]);
            } else {
                remainingBatch.add(logs[i]);
            }
        }

        // Insert current batch
        if (!currentBatch.isEmpty()) {
            try {
                insert currentBatch;
            } catch (Exception e) {
                System.debug('Failed to insert logs: ' + e.getMessage());
            }
        }

        // Chain next batch if needed
        if (!remainingBatch.isEmpty()) {
            System.enqueueJob(new SystemLogsQueueable(remainingBatch));
        }
    }
}
public with sharing class GoverLimitSimulatorController {
    
    @AuraEnabled(cacheable=true)
    public static List<Contact> getContacts() {
        List<Contact> contacts = new List<Contact>();
        List<System_Log__c> logs = new List<System_Log__c>();
        try{
            String queryString = 'SELECT Id, FirstName, LastName, Email, Phone FROM Contact LIMIT 100';
            contacts = Database.query(queryString);
            //The below line throws an error as DML operation cannot be performed in a read-only transaction.
            logs.add(SystemLogUtils.prepareLog(SystemLogUtils.LogLevel.Info, queryString, '', 'GoverLimitSimulatorController.getContacts', 'Queueable Job', 'Governor Limit Errors App', 'Simulator for DML One Exception'));
        }catch(Exception e) {
            logs.add(SystemLogUtils.prepareLog(SystemLogUtils.LogLevel.Error, e.getMessage(), '', 'GoverLimitSimulatorController.getContacts', e.getMessage(), 'Governor Limit Errors App', 'Simulator for DML One Exception'));
            System.debug('DML operation cannot be performed in a read-only transaction.'+e.getMessage()); 
            if(logs.size() > 0){
                System.enqueueJob(new SystemLogsQueueable(logs));
            }
            return null;
            //throw new AuraHandledException('Error fetching contacts: ' + e.getMessage());
        }
        if(logs.size() > 0){
                System.enqueueJob(new SystemLogsQueueable(logs));
        }

        return contacts;    
    }

    @AuraEnabled
    public static void logFromLWC(String exceptionMessage, String source, String stackTrace, String applicationName, String moduleName){
        System_Log__c systemLog = SystemLogUtils.prepareLog(SystemLogUtils.LogLevel.Error, exceptionMessage , null, source, stackTrace,applicationName,moduleName);
        SystemLogUtils.insertSystemLogs(systemLog);
    }
    
}

Using Platform Events

  • Platform Events represent Salesforce’s native support for event-driven architecture, enabling loosely coupled communication between different parts of a system.
  • Platform events—and their associated triggers and flows—are effective alternatives to asynchronous Apex methods such as @future and Queueable. They are especially beneficial when:
    • The operation is lightweight and does not involve callouts.
    • You need to trigger off-platform processing or integrate with external systems.
    • You want to decouple components and build scalable, reactive systems.
  • Events are published using “publish after commit”, ensuring they are only dispatched after the database transaction is successfully committed. These events:
    • Are delivered in the order in which they are published.
    • Can be bulk-processed by platform event triggers or flows in a synchronous context.
    • Are subject to synchronous governor limits, so it’s important to size batches appropriately to avoid exceeding limits.

Demonstration Setup

  • A custom platform event, LogEvent__e, was created to carry logging data. Its structure mirrors that of the custom object System_Log__c.
  • An Apex class was developed to publish platform events programmatically, encapsulating log messages into event records and dispatching them to the event bus.
  • Created a Platform Event trigger as a subscriber that processes these published events and creates the System Logs
  • Updated the Actual class to publish the events instead of queueable jobs.
public class SystemLogsPublisher {
    public static void publishLogs(List<System_Log__c> logs) {
        List<Log_Event__e> events = new List<Log_Event__e>();

        for (System_Log__c log : logs) {
            events.add(new Log_Event__e(
                Message__c = log.Message__c,
                Level__c = log.Level__c,
                Source__c = 'ApexClass',
                Timestamp__c = System.now()
            ));
        }

        if (!events.isEmpty()) {
            Database.SaveResult[] results = EventBus.publish(events);
            System.debug('Published ' + results.size() + ' log events');
        }
    }
}
trigger LogEventTrigger on LogEvent__e (after insert) {
    List<System_Log__c> logs = new List<System_Log__c>();
    
    for (LogEvent__e event : Trigger.new) {
        logs.add(new System_Log__c(
            Log_Level__c = event.Log_Level__c,
            Message__c = event.Message__c,
            Record_Id__c = event.Record_Id__c,
            Source__c = event.Source__c,
            Stack_Trace__c = event.Stack_Trace__c,
            Application__c = event.Application__c,
            Module__c = event.Module__c
        ));
    }
    
    if (!logs.isEmpty()) {
        insert logs;
    }
}
public with sharing class GoverLimitSimulatorController {
    
    @AuraEnabled(cacheable=true)
    public static List<Contact> getContacts() {
        List<Contact> contacts = new List<Contact>();
        List<System_Log__c> logs = new List<System_Log__c>();
      
        try{
            String queryString = 'SELECT Id, FirstName, LastName, Email, Phone FROM Contact LIMIT 100';
            contacts = Database.query(queryString);
            logs.add(SystemLogUtils.prepareLog(SystemLogUtils.LogLevel.Info, queryString, '', 'GoverLimitSimulatorController.getContacts', 'Platform Event', 'Governor Limit Errors App', 'Simulator for DML One Exception'));
        }catch(Exception e) {
            
            logs.add(SystemLogUtils.prepareLog(SystemLogUtils.LogLevel.Error, e.getMessage(), '', 'GoverLimitSimulatorController.getContacts', e.getMessage(), 'Governor Limit Errors App', 'Simulator for DML One Exception'));
            System.debug('DML operation cannot be performed in a read-only transaction.'+e.getMessage()); 
            if (!logs.isEmpty()) {
                SystemLogsPublisher.publishLogs(logs);
            }
            return null;
            //throw new AuraHandledException('Error fetching contacts: ' + e.getMessage());
        }
        if (!logs.isEmpty()) {
                SystemLogsPublisher.publishLogs(logs);
        }

        return contacts;    
    }

    @AuraEnabled
    public static void logFromLWC(String exceptionMessage, String source, String stackTrace, String applicationName, String moduleName){
        System_Log__c systemLog = SystemLogUtils.prepareLog(SystemLogUtils.LogLevel.Error, exceptionMessage , null, source, stackTrace,applicationName,moduleName);
        SystemLogUtils.insertSystemLogs(systemLog);
    }
}

I have demonstrated this in my youtube channel. Please refer here 


References

Architects Guide for Asynchronous Processing

Leave a Reply

Your email address will not be published. Required fields are marked *