Skip to main content

Command Palette

Search for a command to run...

Fix Governor Limits with a Scalable Apex Bulkification Pattern

Updated
β€’6 min read
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 πŸ‘‡

More from this blog

Kingsley Mgbams

18 posts

This publication focuses on practical Salesforce development, sharing real-world experiences, best practices, and lessons learned along the way.