Building Scalable Apex Triggers: A Step-by-Step Framework for Real-World Salesforce Automation

 

Apex Triggers in Action — Building a Scalable Trigger Framework Step‑by‑Step



Salesforce triggers are a powerful tool — they let you react to database events and enforce business logic in real time. But without discipline, triggers can quickly become tangled, hard to maintain, and difficult to test. In this post, we’ll walk through how to architect scalable trigger frameworks that hold up in real‑world enterprise environments.


Why Triggers Need a Framework

At their core, Apex triggers fire before or after DML events like insert, update, delete, and undelete. Simple logic works fine for small customizations, but in larger orgs with complex automation and multiple teams working together, triggers must be:

  • Bulk‑ready — capable of processing hundreds or thousands of records without hitting governor limits.

  • Maintainable — logic is organized cleanly, easy to read and update.

  • Testable — easy to write and run unit tests with predictable behavior.

  • Decoupled — business rules don’t live in giant trigger bodies.

A trigger framework solves these by abstracting logic into reusable patterns.


The Core Principles of a Good Trigger Framework

Before we dive into structure, here are the principles that guide our design:

1. One Trigger Per Object

Having multiple triggers on the same object leads to unpredictable execution order. With one trigger, we control order and avoid hard‑to‑trace bugs.

2. Use Handler Classes

Trigger logic belongs in handler classes, not the trigger body. This improves readability and separates concerns.

3. Bulkification Everywhere

Every handler method must work on collections. Avoid SOQL or DML inside loops — always use maps and lists.

4. Context‑Safe Logic

Different trigger contexts (before insert, after update, etc.) require different handling. A framework routes to correct methods without clutter.


Blueprint: The Scalable Trigger Framework

Here’s what our framework looks like:

📌 Trigger Shell

The actual trigger file is minimal:

trigger AccountTrigger on Account (before insert, before update, after insert, after update) {
    new TriggerDispatcher(new AccountTriggerHandler()).run();
}

This delegates all logic to a dispatcher and a handler class — a clean separation of responsibilities.


📌 Trigger Dispatcher

The dispatcher identifies which trigger event occurred and calls the relevant handler method:

public with sharing class TriggerDispatcher {
    private TriggerHandler handler;

    public TriggerDispatcher(TriggerHandler handler) {
        this.handler = handler;
    }

    public void run() {
        if(Trigger.isBefore) {
            if(Trigger.isInsert) handler.beforeInsert(Trigger.new);
            if(Trigger.isUpdate) handler.beforeUpdate(Trigger.new, Trigger.oldMap);
        }
        if(Trigger.isAfter) {
            if(Trigger.isInsert) handler.afterInsert(Trigger.new);
            if(Trigger.isUpdate) handler.afterUpdate(Trigger.new, Trigger.oldMap);
        }
    }
}

This class helps keep our trigger logic consistent and extensible.


📌 Abstract Trigger Handler

Define a base class with stubs for each event:

public abstract class TriggerHandler {
    public virtual void beforeInsert(List<SObject> newRecords) {}
    public virtual void beforeUpdate(List<SObject> newRecords, Map<Id, SObject> oldMap) {}
    public virtual void afterInsert(List<SObject> newRecords) {}
    public virtual void afterUpdate(List<SObject> newRecords, Map<Id, SObject> oldMap) {}
}

This lets each concrete handler implement only the events it cares about.


📌 Concrete Handler Example

Here’s a handler for Account:

public with sharing class AccountTriggerHandler extends TriggerHandler {

    public override void beforeInsert(List<SObject> newRecords) {
        List<Account> accounts = (List<Account>) newRecords;
        for(Account acc : accounts) {
            if(String.isBlank(acc.Industry)) {
                acc.Industry = 'Unknown';
            }
        }
    }

    public override void afterInsert(List<SObject> newRecords) {
        List<Account> accounts = (List<Account>) newRecords;
        // Example: send welcome email using async operations
        MyAsyncEmailService.sendWelcomeEmails(accounts);
    }
}

This keeps logic focused, readable, and testable.


Recursion Control

Triggers can inadvertently reinvoke themselves when handler logic performs DML. A common pattern to prevent recursion is:

public class TriggerExecutionLock {
    private static Set<String> executed = new Set<String>();

    public static Boolean isFirstRun(String key) {
        if(executed.contains(key)) return false;
        executed.add(key);
        return true;
    }
}

Use like:

if(TriggerExecutionLock.isFirstRun('AccountBeforeInsert')) {
    // …logic…
}

This prevents repeat execution within the same context.


Unit Tests That Matter

Testing is essential in a scalable framework. Write tests that:

  • Cover different contexts (before insert, after update, etc.)

  • Use bulk test data

  • Assert correct logic, not just code coverage

Example test:

@IsTest
static void testBeforeInsertDefaultsIndustry() {
    Account a = new Account(Name='Test Co.');
    insert a;
    a = [SELECT Industry FROM Account WHERE Id=:a.Id];
    System.assertEquals('Unknown', a.Industry);
}

Real‑World Considerations

1. Async Processing

Logic not required in the current transaction (e.g., sending emails, callouts) should use @future, Queueable, or Platform Events.

2. Exception Handling

Gracefully handle errors with custom exceptions and meaningful logs. Don’t let unhandled exceptions block unrelated automation.

3. Shared Utility Services

Common operations (logging, error formatting, batch helper methods) should live in reusable utility classes.


Summary — Why This Framework Works

GoalHow the Framework Helps
Predictable behaviorSingle trigger + dispatcher
MaintainabilityHandler classes, clear separation
Bulk safetyCollection‑based methods
ScalabilityContext routing, recursion control
TestabilityFocused logic with robust test patterns

What’s Next?

Once your triggers follow this structure, you’ll be able to:

  • onboard new team members faster,

  • support cross‑functional automation,

  • ship changes confidently,

  • and scale Salesforce implementations for the long term.


Post a Comment

Previous Post Next Post