Standard Salesforce Field History tracking is a powerful tool for auditing changes, but standard related lists offer limited flexibility for custom layouts, dynamic embedding, or multi-object audit screens. By creating a generic Visualforce component backed by dynamic Apex and Schema describes, you can render clean, real-time audit trails for any custom object in your org.
Invoice__History), converts API names to friendly field labels, and formats the change audit in a Lightning-style data table.
1. Prerequisites: Enabling Field History Tracking
Before querying history records in Apex, verify that history tracking is activated for your target object:
- Go to Setup > Object Manager > [Your Custom Object].
- Click Edit and ensure Track Field History is enabled.
- Click Fields & Relationships > Set History Tracking, and check off up to 20 standard or custom fields you need to monitor.
<ObjectName>__History table will not exist in the database, causing dynamic SOQL queries to throw an invalid object type error.
2. JSON Data Wrapper Class (HistoryWrapper.cls)
This helper class provides strongly typed data structures for parsing raw query results and history record attributes:
public with sharing class HistoryWrapper {
public WrapperClass_attributes attributes;
public String CreatedById;
public DateTime CreatedDate;
public String Field;
public Boolean IsDeleted;
public String NewValue;
public String OldValue;
public String Id;
public WrapperClass_CreatedBy CreatedBy;
public static List<HistoryWrapper> parse(String jsonString) {
return (List<HistoryWrapper>) JSON.deserialize(jsonString, List<HistoryWrapper>.class);
}
public class WrapperClass_attributes {
public String type;
public String url;
}
public class WrapperClass_CreatedBy {
public WrapperClass_attributes attributes;
public String Name;
public String FirstName;
public String LastName;
public String Id;
}
}
3. Dynamic Apex Controller (HistoryController.cls)
The controller dynamically inspects the current record ID, determines the SObject type, inspects field metadata via Schema.getGlobalDescribe(), queries the history table, and builds formatted audit entries:
public with sharing class HistoryController {
public List<HistoryEntry> recordsHistory { get; set; }
public String objectLabel { get; set; }
public HistoryController() {
recordsHistory = new List<HistoryEntry>();
Id parentId = ApexPages.currentPage().getParameters().get('id');
if (parentId != null) {
String sObjName = parentId.getSObjectType().getDescribe().getName();
objectLabel = parentId.getSObjectType().getDescribe().getLabel();
Map<String, Schema.SObjectType> schemaMap = Schema.getGlobalDescribe();
Schema.SObjectType objSchema = schemaMap.get(sObjName);
Map<String, Schema.SObjectField> fieldMap = objSchema.getDescribe().fields.getMap();
// Derive history table name for custom objects
String historyObjName = sObjName.endsWith('__c') ? sObjName.replace('__c', '__History') : sObjName + 'History';
if (schemaMap.containsKey(historyObjName)) {
String query = 'SELECT CreatedBy.Name, CreatedBy.FirstName, CreatedBy.LastName, ' +
'CreatedDate, Field, IsDeleted, NewValue, OldValue ' +
'FROM ' + String.escapeSingleQuotes(historyObjName) + ' ' +
'WHERE ParentId = :parentId ' +
'AND Field NOT IN (\'locked\', \'unlocked\') ' +
'ORDER BY CreatedDate DESC LIMIT 200';
List<HistoryWrapper> parsedList = HistoryWrapper.parse(JSON.serialize(Database.query(query)));
if (parsedList != null && !parsedList.isEmpty()) {
for (HistoryWrapper historyItem : parsedList) {
if (historyItem.Field == 'created') {
recordsHistory.add(new HistoryEntry(
historyItem.CreatedBy != null ? historyItem.CreatedBy.Name : 'System',
historyItem.CreatedDate,
'Created',
'',
''
));
} else if (fieldMap.containsKey(historyItem.Field)) {
String fieldLabel = fieldMap.get(historyItem.Field).getDescribe().getLabel();
recordsHistory.add(new HistoryEntry(
historyItem.CreatedBy != null ? historyItem.CreatedBy.Name : 'System',
historyItem.CreatedDate,
fieldLabel,
String.valueOf(historyItem.NewValue),
String.valueOf(historyItem.OldValue)
));
}
}
}
}
}
}
// Inner UI Wrapper Model
public class HistoryEntry {
public String userName { get; set; }
public String dateValue { get; set; }
public String fieldLabel { get; set; }
public String newValue { get; set; }
public String oldValue { get; set; }
public HistoryEntry(String user, DateTime dt, String field, String nVal, String oVal) {
this.userName = user;
this.dateValue = dt != null ? dt.format() : '';
this.fieldLabel = field;
this.newValue = (nVal != null && nVal != 'null') ? nVal : '';
this.oldValue = (oVal != null && oVal != 'null') ? oVal : '';
}
}
}
- Step 1: Apex parses the
idparameter from the page URL and determines the base SObject type. - Step 2: Schema describe fetches field API names and their human-readable labels.
- Step 3: Dynamic SOQL retrieves raw history rows, converting API names into localized field labels for display.
4. Visualforce Component Markup (CustomCodeHistory.component)
Create a reusable Visualforce component styled with the Salesforce Lightning Design System (SLDS):
<apex:component controller="HistoryController">
<div class="slds-scope">
<div class="slds-section slds-is-open">
<h3 class="slds-section__title slds-theme_shade">
<span class="slds-truncate slds-p-horizontal_small" title="Field History">
{!objectLabel} Field History
</span>
</h3>
<div class="slds-section__content">
<apex:outputPanel rendered="{!recordsHistory.size > 0}" layout="none">
<table class="slds-table slds-table_cell-buffer slds-table_bordered slds-table_striped">
<thead>
<tr class="slds-line-height_reset">
<th scope="col"><div class="slds-truncate" title="Modified By">Modified By</div></th>
<th scope="col"><div class="slds-truncate" title="Modified Date">Modified Date</div></th>
<th scope="col"><div class="slds-truncate" title="Field">Field</div></th>
<th scope="col"><div class="slds-truncate" title="New Value">New Value</div></th>
<th scope="col"><div class="slds-truncate" title="Old Value">Old Value</div></th>
</tr>
</thead>
<tbody>
<apex:repeat value="{!recordsHistory}" var="item">
<tr class="slds-hint-parent">
<td><div class="slds-truncate">{!item.userName}</div></td>
<td><div class="slds-truncate">{!item.dateValue}</div></td>
<td><div class="slds-truncate">{!item.fieldLabel}</div></td>
<td><div class="slds-truncate">{!item.newValue}</div></td>
<td><div class="slds-truncate">{!item.oldValue}</div></td>
</tr>
</apex:repeat>
</tbody>
</table>
</apex:outputPanel>
<apex:outputPanel rendered="{!recordsHistory.size == 0}" layout="none">
<div class="slds-notify slds-notify_alert" role="alert">
<h2>No history tracking records found for this entry.</h2>
</div>
</apex:outputPanel>
</div>
</div>
</div>
</apex:component>
5. Embedding in a Visualforce Page
To display the history tracking table, reference your component inside a hosting Visualforce page:
<apex:page lightningStylesheets="true">
<apex:slds />
<!-- Renders the custom generic history component -->
<c:CustomCodeHistory />
</apex:page>
6. Architecture & Platform Considerations
- Tracking Limits: Standard history tracks up to 20 fields per custom or standard object (expandable to 60 with Field Audit Trail).
- Retention Window: Standard field history data is retained for up to 18 months via the UI and 24 months via API.
- Performance Safeguards: Always apply
LIMITclauses and bind parameters (:parentId) to protect against SOQL injection and heap exhaustion.
Summary
Building a generic field history component eliminates the need to write separate history pages for every object in your org. By combining dynamic SOQL with Schema metadata describes and SLDS data tables, you achieve a unified audit trail UI that automatically adapts to any record ID passed to it.