Skip to main content

Introduction to "Tiger" - a simple Salesforce trigger framework

It is often tricky when it comes to automations around Salesforce DMLs. There are various tools of building automations in Salesforce including workflow rules, process builders, flows and apex triggers. Overall, apex trigger is the most difficult tool to use which requires deep coding knowledge, while on the flip side of the coin, it is the most powerful tool which enables things that with other tools are impossible or not elegant. However the challenges of doing apex triggers are not trivial though:
  • The order of triggers' execution is not guaranteed (so is workflow rules, process builders and flows). It is the developer's job to control the execution if the order matters.
  • Recursive execution - triggers are executed twice when there are workflows, flows or process builders that have field updates on the same SObject. Trigger executions could be recursive till timeout if there are inappropriate DMLs in the code.
  • It is not uncommon that some or all of the triggers need to be bypassible to certain people like the ETL or Integration users. And there needs to be elegant and flexible ways of doing that.
  • When the triggers are developed by different teams either because the old teams implemented parts are gone, or there are multiple teams working simultaneously, the approaches of building triggers often lack of consistency which makes the code hard to maintain and scale.
  • It becomes much trickier when different business units/geographic share the same SObjects(like the Sales, Service & Marketing are all using Account and Contact etc.) but having diversified processes. Architects and developers often strive to design and build flexible & scalable trigger code, which I think is one of the most challenging missions in Salesforce implementations.

In the interest of "easing" these challenges, I've coded an object-oriented trigger framework called Tiger(abbreviation of "Trigger"), and wanted to use this article to share it with you.

The source code can be found here - https://github.com/odie-tang/sfdc/blob/master/force-app/main/default/classes/Tiger.cls.

1. The class diagram of the framework.

There are 7 event based interfaces, which all exend the same interface Actioner. There are 2 additional interfaces that are optional - Recursive and Skipable. And there is the Tiger framework that defines and processes these interfaces. The blue color part are the concrete actioner classes to be implemented by the developers, and rest is provided by the framework. Let us dive into the details by writing some code for a few simplified real-life requirements.

2. So what does it take to create a bulkified trigger class using Tiger?

We have a simplified requirement - "when a User record is created, create a Contact with the Record Type 'Employee', copy the User's First Name, Last Name, Email and Phone, and populate the custom Contact field Employee_User__c with the User's Id."

And we are going to write an Apex class implementing the interface Tiger.AfterInsert:


    public interface AfterInsert extends Actioner{
        boolean inScopeAfterInsert(SObject newRecord);
        void processAfterInsert(SObject[] scope);
    }

Here is the class UserTgEmployeeManager:

public without sharing class UserTgEmployeeManager 
		implements Tiger.AfterInsert{

    static final Id employeeRecordTypeId = Schema.SObjectType.Contact.getRecordTypeInfosByDeveloperName().get('Employee').getRecordTypeId();

    public boolean inScopeAfterInsert(SObject newRecord){

        return true;
    }

    public void processAfterInsert(SObject[] scope){

        createContacts((User[])scope);
    }

    private void createContacts(User[] users){

        Contact[] contacts = new Contact[]{};

        for(User user : users){

            contacts.add(createContact(user));
        }

        insert contacts;
    }

    private Contact createContact(User user){

        Contact contact = new Contact();
        contact.Employee_User__c = user.Id;
        contact.RecordTypeId = employeeRecordTypeId;
        contact.Email = user.Email;
        contact.FirstName = user.FirstName;
        contact.LastName = user.LastName;
        contact.Phone = user.Phone;

        return contact;
    }
}
  

The interface has two methods to be implemented. The first method inScopeAfterInsert is checking each record in trigger.new to see if the record is in scope for the later action. Notice the parameter here is newRecord meaning it comes from trigger.new. In the BeforeDelete & AfterDelete interfaces, the inScope's parameter is oldRecord which originates from trigger.old. In our case, we need a Contact to be created every time when a User is created, so it always returns true. Then all the in-scope records are passed by the framework to the next method processAfterInsert, where the bulkified logic of creating Contacts is implemented.

It is important that an Actioner class does not have to consider all 7 trigger events every time. It only focuses on what is needed, by specifying the corresponding event interface(s) to implement, in which case it is the "AfterInsert".

Also, the event based interfaces do not take the standard trigger's context variables like the trigger.new or trigger.newMap as parameters in its methods. This is because these trigger variables are static contextual variables that can be referenced in any apex classes invoked by triggers. Passing them as parameters separately is not necessary, adding complexity, and introducing potential bugs (think about what if the upper context passes a wrong list of SObjects instead of the expected trigger.new?).

The checking of "inScope" makes the "process" part simple by only focusing on what's in the "scope". The event based interfaces clearly set up the boundaries, where each actioner has its own "scope" for its own event(s). Narrowing the "scope" makes the logic "focused" & "simple".

3. The class will not execute by itself so far. So how can the trigger be aware of and execute it?

I need to create an apex trigger listening to the trigger events and add the actioners to the execution:

//It's a good practice to include all trigger events in the trigger class
trigger UserTiger on User (before insert, after insert, before update, after update) {

    Tiger.Actioner[] actioners = new Tiger.Actioner[]{

        new UserTgEmployeeManager()
    };

    new Tiger(actioners).fire();
}
    
    

As mentioned earlier that all the event interfaces including this Tiger.AfterInsert extend the same interface Tiger.Actioner. That's right, in this framework, any logic that handles a trigger event is an Actioner.

public interface Actioner{}    

The code in the trigger itself is almost static, all that's different is adding actioners to the Tiger's constructor in the order that is desired.

4. Can one actioner class take care of multiple events?

Yeah, sure. Let's do this by adding a little bit more requirement - "when a user's First Name, Last Name, Email, or Phone is changed, update the corresponding Contact to reflect the changes." Now we just need to add the implementation of the interface Tiger.AfterUpdate to the previous class.

    public interface AfterUpdate extends Actioner{
        boolean inScopeAfterUpdate(SObject newRecord);
        void processAfterUpdate(SObject[] scope);
    }       

Which becomes :

public without sharing class UserTgEmployeeManager 
    implements Tiger.AfterInsert, Tiger.AfterUpdate{

    static final Id employeeRecordTypeId = Schema.SObjectType.Contact.getRecordTypeInfosByDeveloperName().get('Employee').getRecordTypeId();

    public boolean inScopeAfterInsert(SObject newRecord){

        return true;
    }

    public void processAfterInsert(SObject[] scope){

        createContacts((User[])scope);
    }

    private void createContacts(User[] users){

        Contact[] contacts = new Contact[]{};

        for(User user : users){

            contacts.add(createContact(user));
        }

        insert contacts;
    }

    private Contact createContact(User user){

        Contact contact = new Contact();
        contact.Employee_User__c = user.Id;
        contact.RecordTypeId = employeeRecordTypeId;
        contact.Email = user.Email;
        contact.FirstName = user.FirstName;
        contact.LastName = user.LastName;
        contact.Phone = user.Phone;

        return contact;
    }


    public boolean inScopeAfterUpdate(SObject newRecord){
        /*
        * The UtilTrigger is a util class for triggers 
        * https://github.com/odie-tang/sfdc/blob/develop/base/force-app/main/default/classes/UtilTrigger.cls
        */
        return UtilTrigger.anyFieldChanged(
        	newRecord, new String[]{'FirstName', 'LastName', 'Email', 'Phone'});
    }

    public void processAfterUpdate(SObject[] scope){

        User[] usersWithEmployeeContacts = 
        			[SELECT Id, FirstName, LastName, Email, Phone, 
                    		(SELECT Id FROM Employee_Contacts__r) 
                            FROM USER WHERE Id IN: scope];

        Contact[] contacts = new Contact[]{};

        for(User user : usersWithEmployeeContacts){

            for(Contact contact : user.Employee_Contacts__r){

                contact.FirstName = user.FirstName;
                contact.LastName = user.LastName;
                contact.Email = user.Email;
                contact.Phone = user.Phone;
            }

            contacts.addAll(user.Employee_Contacts__r);
        }

        update contacts;
    }

}

Now we have an actioner that is executable in both AfterInsert & AfterUpdate triggers, but it is well contained, which is serving the purpose of managing employee Contacts from the User perspective.

5. What does it look like if we need to add multiple actioners to the Tiger's execution?

Okay. Let's take a look by implementing our new requirement - "when a User is deactivated, update all his/her owned Accounts to be owned by a static User whose name is 'Tiger Master'."

Here is the new code:

public without sharing class UserTgAccountsOwnerDeactivator 
        implements Tiger.AfterUpdate{
            
    public boolean inScopeAfterUpdate(SObject newRecord){
        /*
        * The UtilTrigger is a util class for triggers 
        * https://github.com/odie-tang/sfdc/blob/develop/base/force-app/main/default/classes/UtilTrigger.cls
        */
        return UtilTrigger.fieldChangedTo(newRecord, 'IsActive', false);
    }

    public void processAfterUpdate(SObject[] scope){

        User defaultOwner = [SELECT Id FROM User WHERE Name = 'Tiger Master'];

        Account[] accounts = [SELECT Id FROM Account WHERE OwnerId IN: scope];

        for(Account account : accounts){

            account.OwnerId = defaultOwner.Id;
        }

        update accounts;
    }
}

And adding it to the Tiger

//make sure all the types of trigger events are included in the trigger
trigger UserTiger on User (before insert, after insert, before update, after update) {

    Tiger.Actioner[] actioners = new Tiger.Actioner[]{

        new UserTgEmployeeManager(),
        new UserTgAccountsOwnerDeactivator()
    };

    new Tiger(actioners).fire();
}

So, just keep implementing your new actioners following the event interfaces and make sure they are added to the Tiger in the right order.

6. Is it possible to bypass trigger executions for certain users with this framework?

Sure! The Tiger uses custom permissions to do the bypassing. There are certain rules already pre-built in the framework so that just following the conventions would do you the magic.

There are 2 levels control of bypassing -

  • The first one is at the context level, which mutes the entire tiger instance. The name of this type of Custom Permissions follows the convention "SKIP_TIGER_" + sObjectName + ("_" + context) (Basically if there are multiple Tiger instances for a particular SObject, each Tiger instance should have it's own unique context. We will explain the context in detail later. By default, it is null). For example, a Tiger instance in the Account trigger without defining any "context", the users with "SKIP_TIGER_Account" custom permission will bypass all the actioners of that Tiger instance.
  • The second level is controlled in each actioner class. Actioners that implement the interface - Tiger.Skipable will be bypassed as long as the user is granted the Custom Permission whose name is defined in the method customPermission(). For instance, as a developer if you want your actioner to be skipable for the Custom Permission "DATA_LOADER", return "DATA_LOADER" in the customPermission() method. Then the System Admin can create the Custom Permission with the name "DATA_LOADER" and assign it to appropriate users.
  • global interface Skipable{
    	String customPermission();
    }
      

In general, we should be mindful of granting users the bypass permissions at the context level as it's likely that some required automations will be muted unintentionally.

One useful use case of the context level bypassing would be - if there is a batch job running to calculate the aggregated values at the Account level from its child records, the job is only dealing with aggregations instead of firing any automations. It would be very convenient to mute the entire Tiger instance for the running user using the "context" level bypassing and avoid unnecessary trigger executions.

7. Is it possible to not allow context level bypassing?

Sure. Before invoking the fire() method, make sure to disallow it by calling the method - disallowSkipTiger(). You probably need this feature if you are a package developer and don't want your automations to be bypassed in the installing orgs. Note: this does not affect the class level bypassing via the Tiger.Skipable interface.

8. Why should the context be unique?

Giving a unique context name to each Tiger instance allows multi-Tiger instances on the same SObject. Typically the multi-Tigers-per-SObject situation happens when you have multiple teams working on the same SObjects' triggers independently. (Note: you can skip this section if you don't have multi-Tigers-per-SObject situation in your org.)

From the "bypassing" point of view, you don't want one team's Tiger to be muted because of the user having the custom permission that skips the other Tigers. The following 2 examples should give some clarity on why there could be multiple Tiger instances, and why keeping "context" unique is very important.

  • As a global company doing business over 2 geographic regions - NA & APAC, your company has 1 Salesforce org but 2 distributed dev teams building different automations on some shared SObjects like Account and Contact etc. You'd like each region's users to run the triggers built by their own dev team, not the other's. So as a technical architect, you set up a baseline to have 2 individual triggers on each common SObject, eg. on Account, they are AccountTigerNa.trigger, AccountTigerApac.trigger having their corresponding Tiger instances with the context "ACCOUNT_NA" & "ACCOUNT_APAC" respectively. Then you create and assign the Custom Permssion "SKIP_TIGER_ACCOUNT_NA" to all APAC users and "SKIP_TIGER_ACCOUNT_APAC" to all NA users , so that the APAC users won't be executing the NA specific automations and vice versa.
  • Another use case would be, if you are a package developer who is using the Tiger framework, you don't want your customers who use the same framework accidentally bypassing your triggers where both of you have the same context. So you name your context special, like "PackageName__Account" and make a good documentation of it.

Also there is another reason to keep the uniqueness of the context - the framework uses context to prevent recursive execution which we will talk about in the next part.

9. So how to avoid trigger actioners running recursively?

Do nothing! The framework takes care of it. In the default mode, it does not run recursions and so actioners only execute once for each implemented trigger event. Now what if you need to run an actioner recursively on purpose? It's easy, just make your class implement the interface Tiger.Recursive!

Behind the scene, the Tiger's context is marked as "already run" for a particular SObject's trigger event after execution. The next time when the same event is executed again from another Tiger instance, if the context is "already run" and it is not Recursive, the actioner will be muted.

That is to say it's critical to make the uniqueness of the "context" per Tiger per SObject, as if it is not, the Tigers with the same "context" will be muted after the first one is run within the same transaction. Since most of the cases, your org only needs 1 Tiger instance for each SObject, you don't have to worry about the uniqueness in those cases.

10. Passing class names to the constructors.

Two of the Tiger's constructors also take class names as parameter of type String[], which makes it possible to define your actioner class names in a Custom Metadata Type, and pass them dynamically. This would require some extra code to retrieve the configurations from the CMT, and pass to the Tiger based on the trigger's context. It might not be necessary of doing dynamic actioners, but certainly it is possible via the constructors taking class names.

11. Imperfection

A little bit of imperfection of this framework that I feel is, all the interfaces deal with the general SObject instead of concrete SObject types. In the actioner classes, I have to cast the parameters explicitly to a concrete SObject type in order to access its fields at the "compile" time. At this moment, Salesforce has not opened Generics to custom apex classes. It would be really nice if Generics becomes available!

The End

With this interface driven, single class Trigger framework, it is simple to develop modularized actioner classes, with easy control over the execution order, clear boundaries between each other, de-recursivebility and bypassbility etc. It supports multiple dev teams working simultaneously on their own Trigger logics independently.

Comments