Skip to main content

Metadata-Driven Record Deletion in Salesforce: Scalable Batch Apex Guide

In plain words: A Metadata-Driven Deletion Batch is a reusable Batch Apex job that purges outdated records based on configuration rules stored in Custom Metadata Types (CMDT). Instead of editing and deploying code whenever you need to clean up an object, administrators simply update metadata fields (such as target object name, date range, or active status), and the batch automatically executes the correct SOQL query.

Maintaining data hygiene and keeping storage consumption within platform limits often requires automated record cleanup. However, hardcoding object names, date filters, or WHERE clauses directly inside Apex classes creates ongoing maintenance debt. Every requirement change forces developers to edit code, write new unit tests, and coordinate production deployments. Coupling Batch Apex with Custom Metadata Types creates a declarative engine that lets teams manage data retention policies without touching code.

1. Architectural Blueprint: Decoupling Query Logic with Custom Metadata

The metadata-driven pattern separates configuration from execution across three core tiers:

  • Configuration Tier (Custom Metadata): Stores object API names, date boundaries, custom filter clauses, and active toggles in records of type Delete_Records_Setting__mdt.
  • Query Generation Tier (start method): Ingests the metadata record using getInstance() or SOQL, sanitizes inputs, binds Datetime variables, and returns a dynamic Database.QueryLocator.
  • Bulk Execution Tier (execute method): Deletes records in chunks using partial-success DML (Database.delete(scope, false)) and captures error logs for monitoring.
360 Metadata Deletion Architecture Card:
  • Custom Metadata Object: Delete_Records_Setting__mdt (Object_Name__c, Start_Date__c, End_Date__c, Is_Active__c).
  • Batch Pattern: Database.Batchable<sObject> with Database.Stateful to aggregate total deleted records.
  • SOQL Security: Strict object verification via Schema.getGlobalDescribe() and bind variables for date filters.
  • Error Resilience: Partial DML processing with Database.DeleteResult[] logging.

2. Custom Metadata Type Definition

Create a custom metadata type named Delete_Records_Setting__mdt with the following custom fields:

  • Object_Name__c: Text(80) — The API name of the target sObject (e.g., Contact, Log__c).
  • Start_Date__c: Date / Datetime — The beginning of the record creation window.
  • End_Date__c: Date / Datetime — The end of the record creation window.
  • Is_Active__c: Checkbox — Determines whether the batch job should process this setting.
Metadata XML Record Definition (Delete_Records_Setting.Contact_Purge.md-meta.xml)
<?xml version="1.0" encoding="UTF-8"?>
<CustomMetadata xmlns="http://soap.sforce.com/2006/04/metadata" 
                xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
                xmlns:xsd="http://www.w3.org/2001/XMLSchema">
    <label>Contact Purge Setting</label>
    <protected>false</protected>
    <values>
        <field>Object_Name__c</field>
        <value xsi:type="xsd:string">Contact</value>
    </values>
    <values>
        <field>Is_Active__c</field>
        <value xsi:type="xsd:boolean">true</value>
    </values>
    <values>
        <field>Start_Date__c</field>
        <value xsi:type="xsd:date">2023-01-01</value>
    </values>
    <values>
        <field>End_Date__c</field>
        <value xsi:type="xsd:date">2023-12-31</value>
    </values>
</CustomMetadata>

3. Production-Ready Batch Apex Implementation

The updated implementation below replaces string concatenation for dates with Apex SOQL bind variables, validates the target object against the platform schema to prevent runtime query crashes, and uses Database.Stateful to track accurate deletion metrics.

public with sharing class DynamicRecordDeleteBatch implements Database.Batchable<sObject>, Database.Stateful {

    private String settingDeveloperName;
    public Integer totalRecordsProcessed = 0;
    public Integer totalRecordsDeleted = 0;
    public Integer totalErrors = 0;

    public DynamicRecordDeleteBatch(String settingDevName) {
        this.settingDeveloperName = settingDevName;
    }

    public Database.QueryLocator start(Database.BatchableContext bc) {
        // Fetch metadata configuration using instance selector
        Delete_Records_Setting__mdt setting = Delete_Records_Setting__mdt.getInstance(this.settingDeveloperName);

        if (setting == null || !setting.Is_Active__c) {
            // Return empty query locator if inactive or missing
            return Database.getQueryLocator('SELECT Id FROM Account WHERE Id = NULL');
        }

        // Validate that the configured object exists in the org schema
        String objectName = String.escapeSingleQuotes(setting.Object_Name__c.trim());
        Map<String, Schema.SObjectType> globalDescribe = Schema.getGlobalDescribe();
        if (!globalDescribe.containsKey(objectName.toLowerCase())) {
            throw new IllegalArgumentException('Configured SObject does not exist: ' + objectName);
        }

        // Construct timezone-safe Datetime boundaries
        Datetime startDateTime = Datetime.newInstanceGmt(
            setting.Start_Date__c.year(), 
            setting.Start_Date__c.month(), 
            setting.Start_Date__c.day(), 
            0, 0, 0
        );
        Datetime endDateTime = Datetime.newInstanceGmt(
            setting.End_Date__c.year(), 
            setting.End_Date__c.month(), 
            setting.End_Date__c.day(), 
            23, 59, 59
        );

        // Build query using clean bind variables to prevent injection and format bugs
        String query = 'SELECT Id FROM ' + objectName + 
                       ' WHERE CreatedDate >= :startDateTime AND CreatedDate <= :endDateTime';

        return Database.getQueryLocator(query);
    }

    public void execute(Database.BatchableContext bc, List<sObject> scope) {
        this.totalRecordsProcessed += scope.size();

        // Perform partial-success delete operation
        Database.DeleteResult[] deleteResults = Database.delete(scope, false, AccessLevel.USER_MODE);

        for (Database.DeleteResult dr : deleteResults) {
            if (dr.isSuccess()) {
                this.totalRecordsDeleted++;
            } else {
                this.totalErrors++;
                for (Database.Error err : dr.getErrors()) {
                    System.debug(LoggingLevel.ERROR, 'Deletion Error: ' + err.getStatusCode() + ' - ' + err.getMessage());
                }
            }
        }
    }

    public void finish(Database.BatchableContext bc) {
        System.debug(LoggingLevel.INFO, 'Dynamic Purge Complete. Processed: ' + totalRecordsProcessed + 
                     ', Deleted: ' + totalRecordsDeleted + ', Failures: ' + totalErrors);
    }
}

4. Executing the Batch Job

Anonymous Apex Execution Pattern
Pass the DeveloperName of the Custom Metadata record when enqueuing the batch job:
// Instantiate the batch with the specific metadata setting
DynamicRecordDeleteBatch purgeJob = new DynamicRecordDeleteBatch('Contact_Purge');

// Execute with an optimal chunk size (e.g., 200 records)
Id batchJobId = Database.executeBatch(purgeJob, 200);
System.debug('Enqueued Dynamic Record Delete Batch ID: ' + batchJobId);

5. Common Traps & Platform Best Practices

SOQL Date Formatting Trap: String Concatenation with Timezone Offsets
Formatting Datetime objects manually as strings (e.g., Dt.format('yyyy-MM-dd\'T\'hh:mm:ss\'Z\'')) into a dynamic query string often breaks because of 12-hour vs 24-hour clock formatting (hh vs HH) and local user timezone skew. Always use native SOQL bind variables (:startDateTime), which handle SOQL date literals and timezone conversion automatically.
Core Rule: Validate sObject names against Schema.getGlobalDescribe() before querying, use Datetime bind variables instead of string concatenation, and enforce user-mode security on deletions.
  • Avoid Global Modifiers: Use public with sharing for batch classes unless you are packaging the class across managed namespaces.
  • Hard Delete vs. Soft Delete: Standard Database.delete() sends records to the Recycle Bin. If you need to permanently purge records to instantly free storage, use Database.emptyRecycleBin(scope) inside the execute() method.
  • Cascading Relationship Locks: When deleting records with hundreds of master-detail children, reduce the batch chunk size (e.g., to 50 or 100) to prevent UNABLE_TO_LOCK_ROW exceptions.

Summary

Building a metadata-driven Batch Apex deletion engine transforms record cleanup into a declarative, maintainable process. By driving target objects, date filters, and operational flags through Custom Metadata Types while using robust bind variables and partial-success deletion handling, organizations can safely automate data retention policies without repetitive code deployments.