Fix Governor Limits with a Scalable Apex Bulkification Pattern

You write a trigger. It works perfectly in your sandbox β fast saves, no errors, tests passing.
So you deploy.
A few weeks later, a data load runs in production. Hundreds of records hit your trigger at once.
And then it happens:
System.LimitException: Too many SOQL queries: 101
It didn't fail because your logic was wrong. It failed because it didn't scale. And there's a subtler problem too: if two contacts share the same Account, each one updates it separately β your final count is wrong even before you hit the limit.
π§© Problem Statement
Salesforce processes up to 200 records per transaction, but governor limits apply to the entire batch.
In a single transaction, all automation shares the same limits β your trigger isn't alone.
| Limit | Cap |
|---|---|
| SOQL Queries | 100 |
| DML Statements | 150 |
This common pattern breaks instantly:
// β Anti-pattern β do not use in production
for (Contact con : Trigger.new) {
Account acc = [SELECT Id, Total_Contacts__c FROM Account WHERE Id = :con.AccountId];
acc.Total_Contacts__c++;
update acc;
}
This pattern scales linearly with record count β and governor limits do not.
π Works for 1 record, fails at scale.
π‘ The Solution: The CQAW Pattern
Collect β Query β Aggregate β Write
CQAW = a constant-cost execution pattern for Apex transactions.
This isn't just a pattern β it's a deterministic way to control governor limits.
The result:
1 SOQL query
1 DML operation
Correct results at any batch size
βοΈ Prerequisites
API version 59+
Basic Apex + SOQL knowledge
VS Code + Salesforce CLI (optional)
π Deep Dive
1οΈβ£ Collect IDs
Set<Id> accountIds = new Set<Id>();
for (Contact con : Trigger.new) {
if (con.AccountId != null) {
accountIds.add(con.AccountId);
}
}
Set<Id> deduplicates automatically β 50 contacts sharing one Account produce exactly one ID. Always guard against null; it wonβt throw an error but can lead to silent issues downstream.
2οΈβ£ Query Once
Map<Id, Account> accountMap = new Map<Id, Account>(
[SELECT Id, Total_Contacts__c FROM Account WHERE Id IN :accountIds]
);
Map<Id, SObject> gives O(1) lookup access β no repeated queries, no loop scans. This is the backbone of bulkified Apex.
3οΈβ£ Aggregate in Memory
Map<Id, Integer> counts = new Map<Id, Integer>();
for (Contact con : Trigger.new) {
if (con.AccountId != null) {
counts.put(con.AccountId,
(counts.containsKey(con.AccountId) ? counts.get(con.AccountId) : 0) + 1
);
}
}
Pure in-memory computation. This ensures correct totals across the entire batch before writing anything to the database.
4οΈβ£ Write Once
List<Account> updates = new List<Account>();
for (Id accId : counts.keySet()) {
Account acc = accountMap.get(accId);
if (acc != null) {
acc.Total_Contacts__c =
(acc.Total_Contacts__c == null ? 0 : acc.Total_Contacts__c)
+ counts.get(accId);
updates.add(acc);
}
}
if (!updates.isEmpty()) {
update updates;
}
One DML call regardless of batch size β preventing unnecessary DML and keeping intent explicit.
π Handle Updates Too
When a Contact changes Account, only recalculate whatβs necessary:
Contact oldCon = oldMap.get(con.Id);
if (con.AccountId != oldCon.AccountId) {
// Recalculate for both old and new AccountId
}
This avoids unnecessary processing and keeps updates efficient.
β Why Not Use Roll-Up Summary Fields?
Roll-Up Summary fields:
Only work on Master-Detail relationships
Donβt support complex logic
CQAW works with Lookup relationships and custom aggregation rules β making it far more flexible.
Keep Logic Out of the Trigger
The trigger routes. The handler works.
// ContactTrigger.trigger β routing only
trigger ContactTrigger on Contact (after insert, after update) {
if (Trigger.isAfter && Trigger.isInsert) {
ContactTriggerHandler.afterInsert(Trigger.new);
}
if (Trigger.isAfter && Trigger.isUpdate) {
ContactTriggerHandler .afterUpdate(Trigger.new, Trigger.oldMap);
}
}
// ContactTriggerHandler.cls β CQAW logic
public with sharing class ContactTriggerHandler {
public static void afterInsert(List<Contact> newContacts) {
updateAccountContactCount(newContacts);
}
public static void afterUpdate(List<Contact> newContacts, Map<Id, Contact> oldMap) {
List<Contact> changed = new List<Contact>();
for (Contact con : newContacts) {
Contact oldCon = oldMap.get(con.Id);
if (con.AccountId != oldCon.AccountId) {
changed.add(con);
}
}
updateAccountContactCount(changed);
}
private static void updateAccountContactCount(List<Contact> contacts){
// CQAW implementation
}
}
This separation makes the logic reusable across Batch Apex, Queueables, and services β not just triggers.
π Architecture Overview
| Component | Role |
|---|---|
ContactTrigger |
Entry point β delegates by context |
ContactTriggerHandler |
Executes CQAW pattern |
_Test class |
Validates bulk behavior |
π§ͺ Test It at Scale
@isTest
static void testBulkInsert() {
Account acc = new Account(Name = 'Test Account');
insert acc;
List<Contact> contacts = new List<Contact>();
for (Integer i = 0; i < 200; i++) {
contacts.add(new Contact(LastName = 'Test ' + i, AccountId = acc.Id));
}
Test.startTest();
insert contacts;
Test.stopTest();
Account result = [SELECT Total_Contacts__c FROM Account WHERE Id = :acc.Id];
System.assertEquals(200, result.Total_Contacts__c, 'Should count all records');
}
Always test with 200 records, not just one.
π Real-World Applications
CI/CD pipelines β Catch governor limit failures before production
Data migrations β Safe handling of Bulk API operations
Enterprise orgs β Shared limits require efficient code
Reusable services β Works in Batch, Queueable, and Flow contexts
β Key Takeaways
Design for 200 records, not 1
Never use SOQL or DML inside loops
Use Map + Set for efficient data handling
Aggregate first, then write once
Keep logic in handlers, not triggers
Test for scale, not just correctness
π Resources
Quick check:
Would your current trigger survive a 10,000-record data load?
If you're not 100% sure β it's already a risk.
Whatβs the toughest governor limit issue youβve hit? Drop it below π




