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 (
startmethod): Ingests the metadata record usinggetInstance()or SOQL, sanitizes inputs, binds Datetime variables, and returns a dynamicDatabase.QueryLocator. - Bulk Execution Tier (
executemethod): Deletes records in chunks using partial-success DML (Database.delete(scope, false)) and captures error logs for monitoring.
- Custom Metadata Object:
Delete_Records_Setting__mdt(Object_Name__c, Start_Date__c, End_Date__c, Is_Active__c). - Batch Pattern:
Database.Batchable<sObject>withDatabase.Statefulto 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.
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
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
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.
- Avoid Global Modifiers: Use
public with sharingfor 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, useDatabase.emptyRecycleBin(scope)inside theexecute()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_ROWexceptions.
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.