Contact Count On Account Using Apex Triggers in 4 Easy Steps, In Salesforce, Accounts naturally display a related list of Contacts along with a standard count. But what if you need that count as a field for custom reporting, automation, or to drive dynamic UI components? In this post, we’ll walk you through building a custom Contact Count field on the Account object that updates automatically via Apex triggers. We’ll explain the key code segments, testing strategies, and why this approach offers unique advantages—even when Salesforce provides a standard count in the related list.
Why Build a Custom Contact Count Field?
Even though Salesforce automatically shows the number of Contacts in a related list, there are compelling reasons to create your own field:
Data Integrity:
With a custom solution, you can enforce read-only access so that users never inadvertently modify the count.
Direct Reporting and Formula Integration:
A custom field can be referenced directly in formula fields, workflows, and reports without additional aggregation logic.
Performance Efficiency:
By pre-calculating and storing the count, you avoid runtime overhead and extra queries, especially important in complex business processes.
Enhanced Automation:
Use the field in downstream processes (such as Process Builder, Flow, or even additional triggers) without needing to re-compute the value.
Improved User Interface:
Placing the count directly on the Account record page makes it more prominent and accessible for users, improving overall usability.
Implementation Overview of Contact Count On Account Using Apex Triggers in 4 Easy Steps
Our solution uses a combination of:
Comprehensive Testing: Ensures 100% code coverage and validates all trigger contexts (insert, update, delete, undelete).
A Lightweight Trigger: Delegates processing to a handler class.
A Robust Handler Class: Implements the logic to update the count in a bulkified and defensive manner.
Step 1: The Trigger – Keeping It Lean and Mean
Our trigger is designed to capture all relevant DML events on the Contact object, and then delegate the processing to a handler class. This approach minimizes logic within the trigger and makes it easier to manage.
Trigger Code Snippet:
//Contact Count On Account Using Apex Triggers in 4 Easy Steps
trigger ContactTrigger on Contact (after insert, after update, after delete, after undelete) {
//Contact Count On Account Using Apex Triggers in 4 Easy Steps
if (Trigger.isAfter) {
if (Trigger.isInsert || Trigger.isUndelete) {
ContactTriggerHandler.handleAfterInsertUpdate(Trigger.new);
}
if (Trigger.isUpdate) {
// Call the handler with both new and old records for update events.
ContactTriggerHandler.handleAfterUpdate(Trigger.new, Trigger.oldMap);
}
if (Trigger.isDelete) {
ContactTriggerHandler.handleAfterDelete(Trigger.old);
}
}
}
//Contact Count On Account Using Apex Triggers in 4 Easy Steps
Key Points:
- Delegation: All business logic is passed to the
ContactTriggerHandler
class. - Bulkification: The trigger processes lists of records (e.g.,
Trigger.new
) rather than single records. - Context Awareness: The trigger distinguishes between different DML events, ensuring the appropriate handler method is called.
Step 2: The Handler Class – Bulkification & Defensive Programming
The handler class encapsulates all the logic to update the custom Contact Count field on the Account object. It’s designed to be bulk-safe and checks for empty inputs before processing, ensuring efficiency and stability.
Handler Class Code Snippet:
//Contact Count On Account Using Apex Triggers in 4 Easy Steps
public class ContactTriggerHandler {
/**
* Handles after insert and undelete events.
* @param newContacts List of new or undeleted Contact records.
*/
//Contact Count On Account Using Apex Triggers in 4 Easy Steps
public static void handleAfterInsertUpdate(List<Contact> newContacts) {
if(newContacts == null || newContacts.isEmpty()){
return;
}
Set<Id> accountIds = new Set<Id>();
for(Contact con : newContacts){
if(con.AccountId != null){
accountIds.add(con.AccountId);
}
}
if(!accountIds.isEmpty()){
updateAccountContactCount(accountIds);
}
}
/**
* Handles after update events.
* This method ensures that if a Contact’s AccountId has changed,
* both the new and old Account IDs are processed.
* @param newContacts List of updated Contact records.
* @param oldMap Map of old Contact records before update.
*/
//Contact Count On Account Using Apex Triggers in 4 Easy Steps
public static void handleAfterUpdate(List<Contact> newContacts, Map<Id, Contact> oldMap) {
if(newContacts == null || newContacts.isEmpty()){
return;
}
Set<Id> accountIds = new Set<Id>();
// Add Account IDs from new records.
for(Contact con : newContacts){
if(con.AccountId != null){
accountIds.add(con.AccountId);
}
}
// Also add Account IDs from old records.
if(oldMap != null){
for(Contact oldCon : oldMap.values()){
if(oldCon.AccountId != null){
accountIds.add(oldCon.AccountId);
}
}
}
if(!accountIds.isEmpty()){
updateAccountContactCount(accountIds);
}
}
/**
* Handles after delete events.
* @param oldContacts List of Contact records that were deleted.
*/
//Contact Count On Account Using Apex Triggers in 4 Easy Steps
public static void handleAfterDelete(List<Contact> oldContacts) {
if(oldContacts == null || oldContacts.isEmpty()){
return;
}
Set<Id> accountIds = new Set<Id>();
for(Contact con : oldContacts){
if(con.AccountId != null){
accountIds.add(con.AccountId);
}
}
if(!accountIds.isEmpty()){
updateAccountContactCount(accountIds);
}
}
/**
* Private helper method to update the Contact_Count__c field on Accounts.
* @param accountIds Set of Account IDs to update.
*/
//Contact Count On Account Using Apex Triggers in 4 Easy Steps
private static void updateAccountContactCount(Set<Id> accountIds) {
// Query Accounts with their related Contacts.
List<Account> accountsToUpdate = [
SELECT Id, Contact_Count__c, (SELECT Id FROM Contacts)
FROM Account
WHERE Id IN :accountIds
];
// Update each Account's Contact_Count__c.
for(Account acc : accountsToUpdate){
Integer contactCount = (acc.Contacts != null) ? acc.Contacts.size() : 0;
acc.Contact_Count__c = contactCount;
}
if(!accountsToUpdate.isEmpty()){
update accountsToUpdate;
}
}
}
//Contact Count On Account Using Apex Triggers in 4 Easy Steps
Key Concepts in the Handler:
- Bulk Processing: The logic gathers all Account IDs from the contact records and performs a single SOQL query for efficiency.
- Defensive Checks: The methods return early if no contacts are present, preventing unnecessary processing.
- Reusability: The
updateAccountContactCount
method is used by all handler methods to update the custom field. - Bulk Query: Retrieves Accounts along with their Contacts in one go.
- Defensive Check: The code safely handles cases when there are no Contacts.
Step 3: Comprehensive Testing for Reliability
A robust test class is key to validating our solution and ensuring 100% code coverage. Our tests simulate all relevant trigger contexts and edge cases, including:
- Insert and Undelete:
Validate that inserting a new Contact correctly updates the count, and that deleting and then undeleting a Contact restores the count. - Update:
Ensure that when a Contact’s Account association changes, both the old and new Accounts reflect the correct count. - Delete:
Confirm that deleting a Contact updates the count to zero. - Edge Cases:
Test handler methods with empty lists to validate defensive programming.
Test Class Code Snippet:
//Contact Count On Account Using Apex Triggers in 4 Easy Steps
@isTest
private class ContactTriggerTest {
/**
* Test the insert and undelete scenarios.
*/
//Contact Count On Account Using Apex Triggers in 4 Easy Steps
@isTest
static void testInsertAndUndelete() {
// Create an Account record.
Account acc = new Account(Name = 'Test Account');
insert acc;
// Create and insert a new Contact associated with the Account.
Contact con = new Contact(FirstName = 'John', LastName = 'Doe', AccountId = acc.Id);
insert con;
// Verify that the Account's Contact_Count__c is updated to 1.
Account updatedAcc = [SELECT Id, Contact_Count__c FROM Account WHERE Id = :acc.Id];
System.assertEquals(1, updatedAcc.Contact_Count__c, 'After insert, count should be 1');
//Contact Count On Account Using Apex Triggers in 4 Easy Steps
// Combine delete and undelete in one Test.startTest/stopTest block.
Test.startTest();
// Delete the Contact.
delete con;
// Query the Account after deletion.
updatedAcc = [SELECT Id, Contact_Count__c FROM Account WHERE Id = :acc.Id];
System.assertEquals(0, updatedAcc.Contact_Count__c, 'After delete, count should be 0');
// Query the deleted Contact from the recycle bin using ALL ROWS.
Contact deletedCon = [SELECT Id FROM Contact WHERE Id = :con.Id ALL ROWS];
// Undelete the Contact.
undelete deletedCon;
Test.stopTest();
// Re-query the Account and verify the count is updated to 1 after undelete.
updatedAcc = [SELECT Id, Contact_Count__c FROM Account WHERE Id = :acc.Id];
System.assertEquals(1, updatedAcc.Contact_Count__c, 'After undelete, count should be 1');
}
//Contact Count On Account Using Apex Triggers in 4 Easy Steps
/**
* Test the update scenario where the Contact's Account changes.
*/
//Contact Count On Account Using Apex Triggers in 4 Easy Steps
@isTest
static void testUpdate() {
// Create two Account records.
Account acc1 = new Account(Name = 'Account 1');
Account acc2 = new Account(Name = 'Account 2');
insert new List<Account>{ acc1, acc2 };
// Insert a Contact associated with acc1.
Contact con = new Contact(FirstName = 'Jane', LastName = 'Doe', AccountId = acc1.Id);
insert con;
// Verify initial count on acc1.
Account updatedAcc1 = [SELECT Id, Contact_Count__c FROM Account WHERE Id = :acc1.Id];
System.assertEquals(1, updatedAcc1.Contact_Count__c, 'Before update, acc1 count should be 1');
// Change the Contact's Account association from acc1 to acc2.
con.AccountId = acc2.Id;
Test.startTest();
update con;
Test.stopTest();
// Verify that acc1's count is now 0 and acc2's count is 1.
updatedAcc1 = [SELECT Id, Contact_Count__c FROM Account WHERE Id = :acc1.Id];
Account updatedAcc2 = [SELECT Id, Contact_Count__c FROM Account WHERE Id = :acc2.Id];
System.assertEquals(0, updatedAcc1.Contact_Count__c, 'After update, acc1 count should be 0');
System.assertEquals(1, updatedAcc2.Contact_Count__c, 'After update, acc2 count should be 1');
}
//Contact Count On Account Using Apex Triggers in 4 Easy Steps
/**
* Test the delete scenario.
*/
@isTest
static void testDelete() {
// Create an Account and a Contact.
Account acc = new Account(Name = 'Account for Delete Test');
insert acc;
Contact con = new Contact(FirstName = 'Delete', LastName = 'Me', AccountId = acc.Id);
insert con;
// Verify the Account's count is 1.
Account updatedAcc = [SELECT Id, Contact_Count__c FROM Account WHERE Id = :acc.Id];
System.assertEquals(1, updatedAcc.Contact_Count__c, 'Before delete, count should be 1');
// Delete the Contact.
Test.startTest();
delete con;
Test.stopTest();
// Verify the Account's count is updated to 0.
updatedAcc = [SELECT Id, Contact_Count__c FROM Account WHERE Id = :acc.Id];
System.assertEquals(0, updatedAcc.Contact_Count__c, 'After delete, count should be 0');
}
@isTest
static void testHandlerWithEmptyInputs() {
Test.startTest();
// Test handleAfterInsertUpdate with an empty list.
ContactTriggerHandler.handleAfterInsertUpdate(new List<Contact>());
// Test handleAfterUpdate with empty new list and empty oldMap.
ContactTriggerHandler.handleAfterUpdate(new List<Contact>(), new Map<Id, Contact>());
// Test handleAfterDelete with an empty list.
ContactTriggerHandler.handleAfterDelete(new List<Contact>());
Test.stopTest();
}
}
//Contact Count On Account Using Apex Triggers in 4 Easy Steps
Test Class Highlights:
- Comprehensive Context Coverage:
Each trigger event (insert, update, delete, undelete) is covered to ensure the handler responds correctly. - Edge-Case Validation:
ThetestHandlerWithEmptyInputs
method verifies that our handler safely handles empty lists without errors. - Bulkification and Realistic Scenarios:
By simulating account reassignments and deletion/undelete scenarios, we confirm that our solution works in bulk and in real-world scenarios.
Step 4: Enforcing Read-Only Access
Since the Contact Count field is managed entirely by automation, we want to ensure that users cannot edit it manually. We implement this through:
- Field-Level Security:
- Mark the field as read-only for all profiles that should not change it.
- Page Layout Configuration:
- In the Account page layout, set the field to read-only so that the edit (pencil) icon is not actionable.
- Optional Validation Rule:
- A rule such as
ISCHANGED(Contact_Count__c)
can be added to prevent manual edits via the API or other means.
- A rule such as
Even if the UI shows an edit icon (a known limitation in some Lightning configurations), the field remains protected.
Below are the actual implementation screenshots
On details tab of any account will see Contact Count field:

Here we can see only 3 contacts exists and our Trigger working as expected:

Below we Deleted one contact from the list:

Here is the screenshot of post deletion of contact it shows our Trigger working as expected:

Advantages Recap
Even though Salesforce already shows a related list count, here’s why our custom solution shines:
- Direct Accessibility: The field can be directly referenced in other objects, formulas, or reports.
- Automation Ready: Since it’s a field on the Account, you can build further automation (like roll-ups or conditional logic) based on its value.
- Performance Boost: With a pre-calculated count, you reduce the overhead of counting child records during runtime.
- Enhanced User Experience: Custom UI components and record pages can display this field prominently, ensuring end-users see the most relevant data at a glance.
Conclusion
Contact Count On Account Using Apex Triggers in 4 Easy Steps using best practices of trigger design, bulkification, and field security, our solution not only automates the contact count but also opens up new avenues for reporting and automation. This small customization illustrates the power and flexibility of Salesforce when you tailor it to your business needs.
Feel free to leave a comment or reach out if you have questions about this implementation!