Apex • Interview • Salesforce •

Advanced Apex Trigger Framework – Build Scalable, Reusable & Maintainable Triggers

khumed Khatib 5 min read

Advanced Salesforce Development Series | Article 3

Advanced Apex Trigger Framework – Build Scalable, Reusable & Maintainable Triggers



Introduction

If you have worked on a Salesforce project for some time, you have probably seen triggers that contain hundreds of lines of code.

Initially, the trigger might handle only one requirement. A few months later, the business asks for additional validations, related record updates, notifications, integrations, and other automation.

Developers keep adding conditions to the same trigger.

Eventually, it becomes difficult to understand which logic executes first, where a particular operation happens, and why updating one record triggers another process.

The problem is not that Apex triggers are bad. The problem is how we organize the logic inside them.

An Apex Trigger Framework helps us structure trigger logic into reusable, manageable classes.

In this article, we will build a practical Trigger Framework from scratch. We will understand how to handle different trigger events, separate business logic, manage recursion, and write test classes.

Table of Contents

1. Why Do We Need an Apex Trigger Framework?

Let's consider a simple requirement.

Whenever an Account is created or updated, Salesforce needs to perform some business operations:

  • Validate Account information before saving.
  • Perform additional processing after Account creation.
  • Detect changes in Account fields.
  • Update related Contact records when Account information changes.
  • Prevent unnecessary recursive processing.

We could write all this logic inside one trigger.

trigger AccountTrigger on Account (
    before insert,
    before update,
    after insert,
    after update,
    before delete,
    after delete
) {

    if (Trigger.isBefore && Trigger.isInsert) {
        // Validation logic
    }

    if (Trigger.isBefore && Trigger.isUpdate) {
        // Update validation
    }

    if (Trigger.isAfter && Trigger.isInsert) {
        // Related record processing
    }

    if (Trigger.isAfter && Trigger.isUpdate) {
        // Update related records
    }
}

This works, but imagine adding another 10 business requirements. The trigger becomes difficult to maintain.

Instead, we can keep the trigger responsible only for identifying the event and delegating execution to a handler class.

2. What Is an Apex Trigger Framework?

An Apex Trigger Framework is a structured approach to managing trigger execution.

Instead of putting all business logic inside a trigger, we divide the implementation into different classes.

Salesforce DML Operation
          |
          v
     Apex Trigger
          |
          v
    Trigger Handler
          |
          v
    Service Layer
          |
          v
    Selector / Utility
          |
          v
       Database
Component Responsibility
Trigger Entry point for trigger execution.
Trigger Handler Identifies and dispatches trigger events.
Service Class Contains business operations.
Selector Class Handles reusable SOQL queries.
Utility Class Contains common reusable methods.
Test Class Validates application behavior.

You do not necessarily need every layer in every project. For smaller requirements, a trigger, handler, and service class may be sufficient.

3. Understand Salesforce Trigger Events

Salesforce supports the following trigger events:

Trigger Event Execution
before insert Before new records are saved.
before update Before existing records are updated.
before delete Before records are deleted.
after insert After new records are saved.
after update After existing records are updated.
after delete After records are deleted.
after undelete After deleted records are restored.

Before Triggers

Use before triggers when you need to validate or modify the records being saved.

  • Set default field values.
  • Validate business conditions.
  • Calculate values before saving.
  • Prevent invalid updates.

You can directly modify Trigger.new records in before triggers. You do not need another DML operation to save those changes.

After Triggers

Use after triggers when you need the saved record IDs or want to perform operations involving other records.

  • Create related records.
  • Update related Contacts.
  • Publish events.
  • Initiate other business operations.
Important: Trigger.new is read-only in after triggers. Use it to access the saved records, but do not attempt to modify those records directly.

4. Create the Base Trigger Handler

Now let's start building our framework.

Create an Apex class named TriggerHandler.

public virtual class TriggerHandler {

    public void run() {

        switch on Trigger.operationType {

            when BEFORE_INSERT {
                beforeInsert();
            }

            when BEFORE_UPDATE {
                beforeUpdate();
            }

            when BEFORE_DELETE {
                beforeDelete();
            }

            when AFTER_INSERT {
                afterInsert();
            }

            when AFTER_UPDATE {
                afterUpdate();
            }

            when AFTER_DELETE {
                afterDelete();
            }

            when AFTER_UNDELETE {
                afterUndelete();
            }

            when else {
                // No operation to execute
            }
        }
    }

    protected virtual void beforeInsert() {}

    protected virtual void beforeUpdate() {}

    protected virtual void beforeDelete() {}

    protected virtual void afterInsert() {}

    protected virtual void afterUpdate() {}

    protected virtual void afterDelete() {}

    protected virtual void afterUndelete() {}
}

The TriggerHandler class acts as the base class for our object-specific trigger handlers.

The run() method checks Trigger.operationType and calls the corresponding method.

For example, if an Account is inserted, Salesforce executes the before-insert and after-insert methods in separate trigger invocations.

The methods are declared virtual, so child classes can override them with their own implementations.

5. Create the Account Trigger

Now create the Account trigger.

Trigger Name: AccountTrigger

trigger AccountTrigger on Account (
    before insert,
    before update,
    before delete,
    after insert,
    after update,
    after delete,
    after undelete
) {

    new AccountTriggerHandler().run();

}

That's it. Our trigger contains only one line of actual execution logic.

We do not need to add business conditions inside the trigger.

Whenever an Account trigger event occurs, Salesforce creates the handler and calls its run() method.

6. Create the Account Trigger Handler

Create another Apex class named AccountTriggerHandler.

This class extends our base TriggerHandler class.

public with sharing class AccountTriggerHandler
    extends TriggerHandler {

    protected override void beforeInsert() {

        List<Account> accounts =
            (List<Account>) Trigger.new;

        validateAccounts(accounts);
    }

    protected override void beforeUpdate() {

        List<Account> accounts =
            (List<Account>) Trigger.new;

        validateAccounts(accounts);
    }

    protected override void afterInsert() {

        List<Account> accounts =
            (List<Account>) Trigger.new;

        AccountService.processNewAccounts(accounts);
    }

    protected override void afterUpdate() {

        List<Account> accounts =
            (List<Account>) Trigger.new;

        AccountService.processUpdatedAccounts(
            accounts,
            Trigger.oldMap
        );
    }

    private void validateAccounts(
        List<Account> accounts
    ) {

        for (Account acc : accounts) {

            if (String.isBlank(acc.Name)) {

                acc.addError(
                    'Account Name is required.'
                );
            }
        }
    }
}

Understand the implementation

beforeInsert()

This method executes before new Account records are inserted. We use Trigger.new to access the records and validate them.

beforeUpdate()

This method executes before existing Account records are updated. We reuse the same validation method instead of writing the same logic twice.

afterInsert()

This method delegates the processing to AccountService.

afterUpdate()

This method passes the new Account records and old record map to the service class.

Why do we need Trigger.oldMap? Because when an Account is updated, we often need to compare its previous values with the current values.

7. Understand Trigger Context Variables

Trigger context variables provide information about the records currently being processed.

Variable Description
Trigger.new New versions of records.
Trigger.old Previous versions of records.
Trigger.newMap Map of new records using record IDs.
Trigger.oldMap Map of old records using record IDs.
Trigger.isInsert Identifies an insert operation.
Trigger.isUpdate Identifies an update operation.
Trigger.isDelete Identifies a delete operation.
Trigger.isBefore Identifies before-trigger execution.
Trigger.isAfter Identifies after-trigger execution.
Trigger.size Number of records in the current trigger batch.
Trigger.operationType Identifies the exact trigger operation.

One important point: Trigger.newMap and Trigger.oldMap are not available in every trigger context.

For example, Trigger.newMap is not available in before insert because the records do not yet have Salesforce IDs.

8. Separate Business Logic into a Service Class

Our handler should not become another large class containing all business operations.

Let's create a service class named AccountService.

For our practical example, we will implement the following requirement:

Business Requirement: Whenever the Account Phone number changes, update the Phone number of its related Contacts.
public with sharing class AccountService {

    public static void processNewAccounts(
        List<Account> accounts
    ) {

        System.debug(
            'Processing newly created Accounts: '
            + accounts.size()
        );

        // Additional after-insert business logic
        // can be implemented here.
    }

    public static void processUpdatedAccounts(
        List<Account> accounts,
        Map<Id, SObject> oldMap
    ) {

        Set<Id> accountIds = new Set<Id>();

        Map<Id, String> accountPhoneMap =
            new Map<Id, String>();

        for (Account acc : accounts) {

            Account oldAccount =
                (Account) oldMap.get(acc.Id);

            if (acc.Phone != oldAccount.Phone) {

                accountIds.add(acc.Id);

                accountPhoneMap.put(
                    acc.Id,
                    acc.Phone
                );
            }
        }

        if (accountIds.isEmpty()) {
            return;
        }

        List<Contact> contactsToUpdate =
            new List<Contact>();

        for (Contact con : [
            SELECT Id, AccountId, Phone
            FROM Contact
            WHERE AccountId IN :accountIds
        ]) {

            String newPhone =
                accountPhoneMap.get(con.AccountId);

            if (con.Phone != newPhone) {

                con.Phone = newPhone;

                contactsToUpdate.add(con);
            }
        }

        if (!contactsToUpdate.isEmpty()) {

            update contactsToUpdate;
        }
    }
}

Detect only changed records

We compare the new and old Account Phone values.

if (acc.Phone != oldAccount.Phone) {
    accountIds.add(acc.Id);
}

We do not want to process every Account whenever any field is updated.

For example, if only the Account Description changes, there is no reason to update related Contact phone numbers.

Use Set and Map

We use a Set to collect Account IDs and a Map to store the new phone numbers. This helps us avoid unnecessary queries and nested loops.

Query and DML outside the loop

We query all related Contacts in a single SOQL statement. We collect the changed Contacts and update them together.

9. Understand Recursion in Apex Triggers

Recursion is one of the common challenges in Salesforce trigger development.

Let's understand it with an example:

  1. An Account is updated.
  2. Its after-update trigger executes.
  3. The trigger handler calls a service class.
  4. The service class updates another Account.
  5. The Account trigger executes again.

This can result in repeated processing if the code is not designed carefully.

Static recursion guard

A common approach is to use a static variable to control repeated execution.

Create a class named AccountRecursionControl.

public class AccountRecursionControl {

    private static Set<Id> processedAccountIds =
        new Set<Id>();

    public static Boolean shouldProcess(Id accountId) {

        if (processedAccountIds.contains(accountId)) {
            return false;
        }

        processedAccountIds.add(accountId);

        return true;
    }
}

Now use this guard inside the Account service loop.

for (Account acc : accounts) {

    if (!AccountRecursionControl.shouldProcess(acc.Id)) {
        continue;
    }

    Account oldAccount =
        (Account) oldMap.get(acc.Id);

    if (acc.Phone != oldAccount.Phone) {

        accountIds.add(acc.Id);

        accountPhoneMap.put(
            acc.Id,
            acc.Phone
        );
    }
}

The static Set exists for the lifetime of the Apex transaction. Once an Account ID is added, another invocation in that transaction will not process that same ID through this guard.

Important: Do not blindly add a static Boolean that disables the entire trigger after its first execution. A transaction may legitimately process different groups of records or execute the same event more than once.

A static Set is more targeted, but it also needs careful design. In this example, an Account can only be processed once per transaction. If the business requirement allows multiple legitimate updates to the same Account in one transaction, this guard could suppress valid work.

Recursion is not always caused by the same trigger. Record-triggered Flows, other triggers, and related-record updates can all participate in the execution chain.

The best approach is to make operations idempotent wherever possible: running the same operation again should not create duplicate or unnecessary changes.

10. Why Bulkification Is Important

A trigger framework does not automatically make code bulkified.

You can have a well-structured framework and still hit governor limits if the service classes are poorly written.

Incorrect implementation

for (Account acc : accounts) {

    List<Contact> contacts = [
        SELECT Id, Phone
        FROM Contact
        WHERE AccountId = :acc.Id
    ];

    for (Contact con : contacts) {

        con.Phone = acc.Phone;

        update con;
    }
}

There are two problems:

  • SOQL is executed inside a loop.
  • DML is executed inside a loop.

Better implementation

Set<Id> accountIds = new Set<Id>();

for (Account acc : accounts) {
    accountIds.add(acc.Id);
}

List<Contact> contacts = [
    SELECT Id, AccountId, Phone
    FROM Contact
    WHERE AccountId IN :accountIds
];

List<Contact> contactsToUpdate =
    new List<Contact>();

// Apply the required changes.

if (!contactsToUpdate.isEmpty()) {
    update contactsToUpdate;
}

Bulkification must be considered across the entire execution chain, including handlers, services, utilities, and Flows.

11. Understand Trigger Execution Order

A Trigger Framework helps organize your own Apex code, but it does not replace Salesforce's platform execution order.

Salesforce has a defined order of execution involving:

  • Before-save record-triggered Flows.
  • Before Apex triggers.
  • Validation rules.
  • Record saving.
  • After Apex triggers.
  • Assignment and other applicable automation.
  • After-save Flows.
  • Further updates and recursive processing where applicable.

The exact execution path depends on the type of operation and automation configured in the org.

Multiple triggers on the same object and event do not provide a reliable execution order. This is one reason why maintaining a single trigger entry point per object is a useful development convention.

12. Write an Apex Test Class

A framework is not complete without proper testing.

Let's write a test class to verify our Account trigger and related Contact processing.

@IsTest
private class AccountTriggerHandlerTest {

    @IsTest
    static void testAccountInsert() {

        Account acc = new Account(
            Name = 'ABC Technologies',
            Phone = '9876543210'
        );

        Test.startTest();

        insert acc;

        Test.stopTest();

        Account savedAccount = [
            SELECT Id, Name, Phone
            FROM Account
            WHERE Id = :acc.Id
        ];

        System.assertEquals(
            'ABC Technologies',
            savedAccount.Name
        );
    }

    @IsTest
    static void testAccountPhoneUpdate() {

        Account acc = new Account(
            Name = 'ABC Technologies',
            Phone = '1111111111'
        );

        insert acc;

        Contact con = new Contact(
            LastName = 'Test Contact',
            AccountId = acc.Id,
            Phone = '1111111111'
        );

        insert con;

        Test.startTest();

        acc.Phone = '2222222222';

        update acc;

        Test.stopTest();

        Contact updatedContact = [
            SELECT Id, Phone
            FROM Contact
            WHERE Id = :con.Id
        ];

        System.assertEquals(
            '2222222222',
            updatedContact.Phone
        );
    }

    @IsTest
    static void testBulkAccountUpdate() {

        List<Account> accounts =
            new List<Account>();

        for (Integer i = 0; i < 10; i++) {

            accounts.add(
                new Account(
                    Name = 'Account ' + i,
                    Phone = '1111111111'
                )
            );
        }

        insert accounts;

        List<Contact> contacts =
            new List<Contact>();

        for (Account acc : accounts) {

            contacts.add(
                new Contact(
                    LastName = 'Contact ' + acc.Name,
                    AccountId = acc.Id,
                    Phone = acc.Phone
                )
            );
        }

        insert contacts;

        Test.startTest();

        for (Account acc : accounts) {
            acc.Phone = '9999999999';
        }

        update accounts;

        Test.stopTest();

        Set<Id> accountIds =
            new Map<Id, Account>(accounts).keySet();

        List<Contact> updatedContacts = [
            SELECT Id, Phone
            FROM Contact
            WHERE AccountId IN :accountIds
        ];

        System.assertEquals(
            10,
            updatedContacts.size()
        );

        for (Contact con : updatedContacts) {

            System.assertEquals(
                '9999999999',
                con.Phone
            );
        }
    }
}

This test class covers Account creation, related Contact synchronization, and bulk processing.

For a production implementation, extend the tests to cover Accounts with no related Contacts, unchanged Phone numbers, multiple Accounts with different Phone numbers, recursion guard behavior, failure handling, and security behavior.

Testing tip: Do not treat code coverage alone as proof of quality. The important thing is whether your tests verify the actual business requirements.

13. Common Mistakes When Building Trigger Frameworks

1. Putting business logic inside the trigger

The trigger should be a small entry point. Move business operations into handler and service classes.

2. Creating multiple triggers for the same object

Multiple triggers can make execution ordering difficult to control. Prefer one trigger per object and event routing through a handler.

3. Using a static Boolean for everything

A static Boolean can accidentally prevent valid processing of records later in the same transaction.

4. Performing SOQL or DML inside loops

Always consider the maximum number of records a trigger can receive.

5. Ignoring oldMap

Without comparing old and new values, developers often execute unnecessary business logic on every update.

6. Writing one enormous handler class

A handler should coordinate execution, not become the place where every business requirement lives.

7. Ignoring Flow automation

A well-designed Apex framework can still participate in recursive automation when record-triggered Flows update the same records.

8. Ignoring security

Using with sharing does not automatically enforce CRUD and field-level security. Choose the appropriate security model for queries and DML.

9. Overcomplicating the framework

Do not introduce factories, dependency injection, multiple interfaces, and abstract classes unless the project benefits from them.

14. How Does This Framework Help in Real Projects?

Imagine you are working on a Loan Management System.

Whenever an Opportunity representing a loan application is updated, the system needs to:

  • Validate application details.
  • Update related application records.
  • Synchronize customer information.
  • Create audit records.
  • Initiate integration processing.
  • Publish platform events when required.

Without a structured approach, all this logic can become mixed inside a single trigger.

With a framework, the responsibilities can be separated:

OpportunityTrigger
        |
        v
OpportunityTriggerHandler
        |
        +--------------------+
        |                    |
        v                    v
OpportunityService    OpportunityValidator
        |
        +--------------------+
        |          |         |
        v          v         v
 Application   Audit     Integration
 Service       Service   Service

The handler identifies what happened. The services execute the relevant business operations.

This is particularly useful when multiple developers are working on the same Salesforce application.

15. Advanced Improvements for Production Projects

The framework we built is a learning implementation. For a larger enterprise project, consider these additional improvements:

  • Handler execution controls: Support controlled bypass mechanisms for approved migration, data-loading, and maintenance scenarios.
  • Dependency injection: Substitute service dependencies during unit testing or support multiple processing strategies.
  • Selector classes: Centralize complex SOQL queries and reuse them across services.
  • Structured logging: Capture transaction details, affected record IDs, operation, and failure information.
  • Asynchronous processing: Use Queueable Apex or another suitable mechanism for work that does not need to complete inside the trigger transaction.
  • Transaction management: Define what should happen if one operation fails after another operation has already performed DML.
  • Security enforcement: Decide whether each operation should run in user mode or system mode.
  • Code quality: Use Salesforce Code Analyzer, PMD rules where applicable, unit tests, and CI/CD validation.

16. Hands-On Assignment: Build an Employee Management Trigger Framework

Now let's apply the same concepts to an Employee Management System.

Project: Employee Management Trigger Framework
Difficulty: Advanced
Objective: Build a reusable trigger architecture for employee-related business operations.

Create a custom object:

Employee__c

Create these fields:

Field API Name Type
Employee Name Name Text
Email Email__c Email
Mobile Mobile__c Phone
Department Department__c Picklist
Salary Salary__c Currency
Status Status__c Picklist
Manager Manager__c Lookup(Employee__c)

Business requirements

  1. Validate Employee Email before insert and update.
  2. Prevent duplicate employee email addresses.
  3. Set Employee Status to Active by default.
  4. When an Employee is deactivated, update their related business records according to company rules.
  5. Maintain an audit record whenever Salary changes.
  6. Prevent unauthorized salary modifications.
  7. Ensure the implementation supports bulk processing.
  8. Write test classes for all trigger events.

Required Apex classes

EmployeeTrigger
EmployeeTriggerHandler
EmployeeService
EmployeeValidator
EmployeeSelector
EmployeeTriggerTest

Advanced challenge

Add a Queueable Apex job that sends employee information to an external HR system after a successful transaction.

Your implementation should also consider:

  • Callout failures.
  • Retry handling.
  • Duplicate request prevention.
  • Logging.
  • Unit testing with HttpCalloutMock.

This assignment will give you practical experience with trigger architecture, service-layer design, security, and asynchronous processing.

Conclusion

Apex Trigger Framework is not about writing more code or creating more classes. It is about organizing code so that future changes do not become difficult.

A trigger should not be responsible for everything. It should identify the event and delegate the work to the appropriate handler.

Keep business logic in service classes, queries in selector classes when needed, and validation in dedicated components.

Most importantly, do not forget bulkification, security, recursion, transaction management, and proper testing.

The framework demonstrated in this article is a starting point. As your Salesforce application grows, you can extend it based on actual business requirements rather than introducing unnecessary complexity.

My suggestion: Take an existing trigger from your project and try restructuring it using this approach. You will quickly understand the difference between writing a trigger and designing a maintainable trigger architecture.


Frequently Asked Questions

What is an Apex Trigger Framework?

It is a structured design approach that separates trigger execution, event handling, and business logic into different Apex classes.

Why should we use one trigger per object?

It provides a single entry point for trigger execution and avoids depending on the unpredictable order of multiple triggers on the same object and event.

Does a Trigger Framework prevent recursion automatically?

No. A framework helps organize execution, but recursion must be managed through careful business logic, idempotency, and appropriate execution controls.

What is the difference between a Trigger and a Trigger Handler?

A trigger is the Salesforce event entry point. A trigger handler contains the logic that routes and processes the corresponding trigger events.

Can we call a Queueable Apex class from a trigger?

Yes. Queueable Apex can be enqueued from a trigger, subject to asynchronous execution limits and transaction considerations.

Is a Trigger Framework mandatory in Salesforce?

No. Salesforce does not require developers to use a specific framework. However, a well-designed handler pattern is useful for maintaining complex applications.


Keep learning with KhumedSFDC

Practical Salesforce Development | Apex | LWC | Integrations | Agentforce

```