đź› ️ Custom Field Change Tracking in Salesforce – Overcoming Limitations of Field History Tracking

Salesforce is powerful, but like every platform, it has limitations. One such limitation is related to Field History Tracking—you can only track up to 20 fields per object, and even then, certain field types (like long text areas) aren’t supported.

I recently came across this limitation during a project where tracking every update to Task fields was critical for compliance and reporting. So, I designed a custom field tracking solution using Apex Triggers and a custom object. In this post, I’ll walk you through how I built it and how you can implement it too.


❗ Why Custom Tracking?

Out-of-the-box Salesforce lets you:

  • Track up to 20 fields per object.

  • Track changes on limited data types (excludes Long Text Area, Rich Text, etc.)

  • View changes only in the standard history related list.

But in my case, I needed to:

  • Track more than 20 fields.

  • Track long text fields like Description.

  • Store change history in a separate custom object.

  • Restrict deletion of historical records.


🏗️ The Custom Setup

✅ Custom Object: Task_History_Tracking__c

I created a custom object with fields like:

  • Field_Changed__c

  • Old_Value__c / New_Value__c

  • Old_Text__c / New_Text__c

  • Modified_By__c

  • Task_Id__c, Task_Name__c, Case__c

  • Task Update Time – Formula to show how long ago the update was made


đź’» Apex Code

trigger TriggerOnTaskHistoryObject on Task_History_Tracking__c (before delete) {
    switch on Trigger.operationType {
        when BEFORE_DELETE {
            TriggerOnTaskHistoryHandler.preventToDeleteRecords(trigger.old);
        }
    }
}

public class TriggerOnTaskHistoryHandler {
    public static void preventToDeleteRecords(List<Task_History_Tracking__c> oldTaskHistory) {
        for(Task_History_Tracking__c THT : oldTaskHistory){
            THT.addError(System.Label.Task_history_error_message);
        }
    }
}

//----------------------------------------------------------------------------------------

trigger TriggerOnTaskObject on Task(before insert,before update,after update) {
    switch on Trigger.operationType {
        when AFTER_UPDATE {
            TriggerOnTaskObjectHandler.createTaskHistoryTrackingRecords(Trigger.oldMap, Trigger.new);
        }
    }
}

public static void createTaskHistoryTrackingRecords(Map<Id, Task> oldMap, List<Task> newTask) {
        List<Task_History_Tracking__c> THTList = new List<Task_History_Tracking__c>();
        Set<String> ownerIds = new Set<String>();
        Map<String,String> fieldMap = new Map<String,String> {
            'Subject' => 'Subject',
                        'OwnerId' => 'Assigned To ID',
                        'CC_Due_Date__c' => 'CC Due Date',
                        'Status' => 'Status',
                        'Description' => 'Comments',
                        'LastModifiedById' => 'Last Modified By'
                };
           
                for(Task objTas : newTask) {
                        if(objTas.OwnerId != oldMap.get(objTas.Id).OwnerId) {
                ownerIds.add(objTas.OwnerId);
                ownerIds.add(oldMap.get(objTas.Id).OwnerId);
                        }
            if(objTas.LastModifiedById != oldMap.get(objTas.Id).LastModifiedById) {
                ownerIds.add(objTas.LastModifiedById);
                ownerIds.add(oldMap.get(objTas.Id).LastModifiedById);
            }
                }

        Set<Id> userIds = new Set<Id>();
        Set<Id> queueIds = new Set<Id>();
        for(String theId : ownerIds) {
            if(theId.startsWith('005')) {
                userIds.add(theId);
            }
            else if(theId.startsWith('00G')) {
                queueIds.add(theId);
            }
        }

        Map<Id, User> userMap = new Map<Id, User>([SELECT Name FROM User WHERE Id IN :ownerIds]);
        Map<Id, Group> groupMap = new Map<Id, Group>([SELECT Name FROM Group WHERE Id IN :queueIds]);
       
        for(Task objTas : newTask) {
            if(
                (objTas.Subject != oldMap.get(objTas.Id).Subject) ||
                (objTas.OwnerId != oldMap.get(objTas.Id).OwnerId) ||
                (objTas.CC_Due_Date__c != oldMap.get(objTas.Id).CC_Due_Date__c) ||
                                (objTas.Status != oldMap.get(objTas.Id).Status) ||
                (objTas.Description != oldMap.get(objTas.Id).Description) ||
                (objTas.LastModifiedById != oldMap.get(objTas.Id).LastModifiedById)
                        ) {
                   for(String fieldNames : fieldMap.keySet()) {
                       if(objTas.get(fieldNames) != NULL && String.valueOf(objTas.get(fieldNames)) != String.valueOf(oldMap.get(objTas.Id).get(fieldNames))) {
                           Task_History_Tracking__c THT = new Task_History_Tracking__c();
                           THT.Field_Changed__c = fieldMap.get(fieldNames);
                           THT.Modified_By__c = objTas.LastModifiedById;
                           THT.Task_Id__c = objTas.Id;
                           THT.Task_Name__c = objTas.subject;
                           THT.Task_Created_DateTime__c = objTas.CreatedDate;

                           if(fieldNames != 'Description') {
                               if(fieldNames != 'OwnerId' && fieldNames != 'LastModifiedById') {
                                   THT.Old_Value__c = String.valueOf(oldMap.get(objTas.Id).get(fieldNames));
                                   THT.New_Value__c = String.valueOf(objTas.get(fieldNames));
                               }
                               else {
                                   if(userMap.containsKey(String.valueOf(oldMap.get(objTas.Id).get(fieldNames)))) {
                                       THT.Old_Value__c = userMap.get(String.valueOf(oldMap.get(objTas.Id).get(fieldNames))).Name;
                                   }
                                   if(userMap.containsKey(String.valueOf(objTas.get(fieldNames)))) {
                                       THT.New_Value__c = userMap.get(String.valueOf(objTas.get(fieldNames))).Name;
                                   }

                                   if(groupMap.containsKey(String.valueOf(oldMap.get(objTas.Id).get(fieldNames)))) {
                                       THT.Old_Value__c = groupMap.get(String.valueOf(oldMap.get(objTas.Id).get(fieldNames))).Name;
                                   }
                                   if(groupMap.containsKey(String.valueOf(objTas.get(fieldNames)))) {
                                       THT.New_Value__c = groupMap.get(String.valueOf(objTas.get(fieldNames))).Name;
                                   }
                               }
                           }
                           else {
                               THT.New_Text__c = objTas.Description;
                               THT.Old_Text__c = oldMap.get(objTas.Id).Description;
                           }
                           if(objTas.WhatId != null && objTas.WhatId.getSObjectType() == Case.SObjectType){
                               THT.Case__c = objTas.WhatId;
                           }

                           THTList.add(THT);
                       }
                   }
               }
        }
          insert THTList;
}


🔚 Wrapping Up

With this custom tracking setup, I was able to:

  • Track unlimited fields (including long text)

  • Maintain a detailed history log

  • Protect audit data from deletion

  • Customize tracking across related objects

If you're hitting Salesforce’s 20-field tracking limit or need to track rich/complex data, try this solution!

Comments