Trigger Lib

This post was NOT written by AI.
I only used AI to help refine my grammar because I am not a native English speaker.


Introduction

A major part of most Salesforce projects is automation. Automation in Salesforce can be achieved via Flows, Apex Triggers and periodically running jobs. I’m not the biggest fan of Salesforce Flows, which are extremely impractical in the AI era because they block AI’s potential and have a lot of limitations. I’ve described all the problems with Flows in my Salesforce Flow Considerations post. Since Flows have many problems, Apex Triggers are, in my opinion, the only good choice. However, Apex Triggers are not perfect and come with the following problems:

  • Apex Triggers can become messy and hard to understand
  • Messy Apex Triggers can cause System.LimitException: Too many SOQL queries: 101
  • Messy Apex Triggers can cause System.LimitException: Too many DML statements: 151
  • Consultants and Admins don’t know how to code
  • AI Agents build logic in triggers without thinking about the system as a whole, which can cause many bugs and a lack of transaction control
  • It’s not easy to add new automation to the existing structure
  • There is no out-of-the-box functionality to disable triggers
  • Multiple triggers on one object run in no fixed order
  • Recursion
  • Trigger logic can only be tested with DML
  • There is no out-of-the-box error-handling strategy

This is why developers usually use Apex Trigger Frameworks, which address at least part of these issues.

Trigger Frameworks Issue

In the AI era, developers use AI to produce code faster and faster without thinking about the system as a whole. You’re supposed to be more productive and just deliver, right? But Salesforce governor limits are shared by everything that runs in the same transaction. You can write or generate the best code in the world, but if another team’s code in that transaction runs a query in a loop, your logic breaks too.

Apex Trigger Frameworks should help here, and there are plenty of them on the "market". I’ve used many of them on my projects, and they worked fine. However, I haven’t found one that gives you the tools to optimize the transaction and avoid Too many SOQL queries, Too many DML statements or long-running automations.

This is why I created Trigger Lib.

Trigger Lib

Trigger Lib is part of Apex Fluently, a collection of modern open-source Salesforce libraries.

Unlike other frameworks, it gives you a strict way to write Apex handlers: one small, focused class per concern, with one role in a trigger context. It also has built-in SOQL query bulkification and uses a Unit of Work to reduce the number of DML statements.

The Trigger Lib follows the Framework-Driven Development principles:

  • Idiot-proof (AI-proof)
  • Optimized for code and limits
  • Quality by design

Let me show you how it works and why it’s worth considering for your project.

Architecture

Trigger Lib is NOT a metadata-driven framework. I believe custom metadata only makes code more complicated, as I described in The Hidden Cost of Configurable Code. That doesn’t mean you can’t use custom metadata, though. You still can!

To run handlers registered in custom metadata records, call the runFromMetadata() method. Its name makes it explicit that the code runs based on metadata records.

trigger ContactTrigger on Contact(before insert, after insert, before update, after update, before delete, after delete, after undelete) {
    TriggerOrchestrator.runFromMetadata();
}

The other option, which needs no custom metadata records, is an orchestrator: a class that lists all the handlers.

trigger ContactTrigger on Contact(before insert, after insert, before update, after update, before delete, after delete, after undelete) {
    TriggerOrchestrator.run(new ContactTriggerOrchestrator());
}

With an orchestrator, custom metadata is optional. You only use it to switch handlers off, and the framework works without any metadata records.

Trigger Lib has three levels:

  1. Trigger – where you start the framework with run(orchestrator) or runFromMetadata().
  2. Orchestrator or custom metadata – where all handlers and their order are defined.
  3. Handler – a small, focused class. Instead of one generic handler type, each handler has one of four roles:
    • Populators exist only in before insert and before update, and set fields on the records being saved.
    • Validators exist in the before contexts and reject invalid records.
    • Writers exist in the after contexts and before delete, and create, update or delete related records.
    • Dispatchers exist in the after contexts and start async jobs, publish events or send emails.
// level 1
trigger ContactTrigger on Contact(before insert, after insert, before update, after update, before delete, after delete, after undelete) {
    TriggerOrchestrator.run(new ContactTriggerOrchestrator());
    // or
    TriggerOrchestrator.runFromMetadata();
}
// level 2
public with sharing class ContactTriggerOrchestrator implements TriggerOrchestrator.BeforeInsert {
    public List<BeforeInsert.Handler> beforeInsertHandlers() {
        return new List<BeforeInsert.Handler>{
            new ContactEmailNormalizationPopulator(),
            // ...
        };
    }
}

// or

// custom metadata records
// level 3
public with sharing class ContactEmailNormalizationPopulator implements BeforeInsert.Populator {
    public Boolean populateOnBeforeInsertWhen(TriggerTypes.InsertRecord record) {
        return record.isNotBlank(Contact.Email);
    }

    public void populateOnBeforeInsert(TriggerTypes.InsertRecord record) {
        Contact newContact = (Contact) record.getNewSObject();
        record.put(Contact.Email, newContact.Email.trim().toLowerCase());
    }
}

Besides the four roles, Trigger Lib has add-on interfaces for query bulkification, bypassing, recursion control, swallowing exceptions and more. Depending on the context, you can add:

  • ParentQuery
  • PriorParentQuery
  • RelatedQuery
  • OwnUnitOfWork
  • Bypassable
  • RecursionGuard
  • Finalizer
  • ContinueOnError

As I mentioned, Trigger Lib follows the Framework-Driven Development principles, which make code easy to work with for both developers and AI Agents.

First of all, the code is "Idiot-proof (AI-proof)". Each interface exists only in the contexts where it fits, and it forces you to implement exactly the methods it needs. Neither you nor the AI has to remember all the methods: just pick one role and as many add-ons as you need.

public class AfterInsert {
    // Roles - pick one

    public interface Writer extends Handler {
        Boolean writeOnAfterInsertWhen(TriggerTypes.InsertRecord record);
        void writeOnAfterInsert(TriggerTypes.InsertRecord record, TriggerTypes.UnitOfWork unitOfWork);
    }

    public interface Dispatcher extends Handler {
        Boolean dispatchOnAfterInsertWhen(TriggerTypes.InsertRecord record);
        void dispatchOnAfterInsert(TriggerTypes.InsertRecords records);
    }

    // Add-ons - pick as many as you need

    public interface ParentQuery {
        Map<SObjectField, TriggerTypes.ParentFields> queryParentsOnAfterInsert();
    }

    public interface RelatedQuery {
        Map<String, AfterInsert.RecordsProvider> queryRelatedOnAfterInsert();
    }

    public interface RecordsProvider {
        List<SObject> query(TriggerTypes.InsertRecords records);
        String keyOf(SObject row);
    }

    public interface OwnUnitOfWork {
        DML.Committable ownUnitOfWorkOnAfterInsert();
    }

    public interface Bypassable {
        Boolean bypassOnAfterInsertWhen();
    }

    public interface Finalizer {
        void finalizeOnAfterInsert(TriggerTypes.InsertRecords records);
    }

    public interface ContinueOnError {}

    // ...
}

This approach gives you "Quality by design". Instead of handler classes with hundreds or thousands of lines, you end up with small classes of up to 100 lines (I’ve never seen a longer one with Trigger Lib), each focused on one goal: populating, validating, writing or dispatching.

Finally, Trigger Lib is "Optimized for code and limits". It helps with the Salesforce limits that AI or inexperienced developers tend to hit. DML in before insert and before update throws a runtime error, and handlers never need SOQL or DML per record: ParentQuery and RelatedQuery load the data in bulk, and Writers register DML on a Unit of Work that commits it in bulk. I’ll cover this in more detail below. The goal is that neither you nor the AI has to worry about limits or the existing logic, because the framework handles them for you.

Populators

Populators are a kind of handler that only makes sense in the Before Insert and Before Update contexts. They are used to set field values and can be created per field or per group of fields.

public interface Populator extends BeforeInsert.Handler {
    Boolean populateOnBeforeInsertWhen(TriggerTypes.InsertRecord record);
    void populateOnBeforeInsert(TriggerTypes.InsertRecord record);
}

public interface Populator extends BeforeUpdate.Handler {
    Boolean populateOnBeforeUpdateWhen(TriggerTypes.UpdateRecord record);
    void populateOnBeforeUpdate(TriggerTypes.UpdateRecord record);
}

Each populator has two methods:

  1. populateOn{Context}When(record) – this method qualifies records; only records that meet the criteria are passed to the populateOn{Context} method. It’s the equivalent of an if statement inside a for loop. SOQL and DML statements are blocked here. Additional records can be queried only through add-on interfaces.
  2. populateOn{Context}(record) – this method contains the automation; this is where we set field values. The method is executed only for qualified records. SOQL and DML statements are blocked here. Additional records can be queried only through add-on interfaces.

Examples

public with sharing class ContactLeadSourcePopulator implements BeforeInsert.Populator {
    public Boolean populateOnBeforeInsertWhen(TriggerTypes.InsertRecord record) {
        return record.isBlank(Contact.LeadSource);
    }

    public void populateOnBeforeInsert(TriggerTypes.InsertRecord record) {
        record.put(Contact.LeadSource, 'Web');
    }
}
public with sharing class ContactDescriptionPopulator implements BeforeInsert.Populator, BeforeInsert.ParentQuery {
    public Map<SObjectField, TriggerTypes.ParentFields> queryParentsOnBeforeInsert() {
        return new Map<SObjectField, TriggerTypes.ParentFields>{ 
            Contact.AccountId => TriggerTypes.ParentFields.with(Account.Name) 
        };
    }

    public Boolean populateOnBeforeInsertWhen(TriggerTypes.InsertRecord record) {
        return record.getNewParent(Contact.AccountId) != null;
    }

    public void populateOnBeforeInsert(TriggerTypes.InsertRecord record) {
        Account parentAccount = (Account) record.getNewParent(Contact.AccountId);
        record.put(Contact.Description, parentAccount.Name);
    }
}

Validators

Validation rules don’t cover everything, so you need to use the .addError(...) method. Validators are handlers that can be used to validate records. They exist in the Before Insert, Before Update and Before Delete contexts; in Before Delete, they block the deletion.

public interface Validator extends BeforeInsert.Handler {
    Boolean addErrorOnBeforeInsertWhen(TriggerTypes.InsertRecord record);
    void addErrorOnBeforeInsert(TriggerTypes.RejectableInsertRecord record);
}

public interface Validator extends BeforeUpdate.Handler {
    Boolean addErrorOnBeforeUpdateWhen(TriggerTypes.UpdateRecord record);
    void addErrorOnBeforeUpdate(TriggerTypes.RejectableUpdateRecord record);
}

public interface Validator extends BeforeDelete.Handler {
    Boolean addErrorOnBeforeDeleteWhen(TriggerTypes.DeleteRecord record);
    void addErrorOnBeforeDelete(TriggerTypes.RejectableDeleteRecord record);
}

Similarly to populators, they consist of two methods:

  1. addErrorOn{Context}When(record) – this method qualifies records; only records that meet the criteria are passed to the addErrorOn{Context} method. It’s the equivalent of an if statement inside a for loop. SOQL and DML statements are blocked here. Additional records can be queried only through add-on interfaces.
  2. addErrorOn{Context}(record) – this method sets a validation error, either on a field or at the record level. The method is executed only for qualified records. SOQL and DML statements are blocked here. Additional records can be queried only through add-on interfaces.

If a validator qualifies a record but doesn’t add an error to it, the library throws an exception.

Examples

public with sharing class ContactEmailValidator implements BeforeInsert.Validator {
    public Boolean addErrorOnBeforeInsertWhen(TriggerTypes.InsertRecord record) {
        return record.isBlank(Contact.Email);
    }

    public void addErrorOnBeforeInsert(TriggerTypes.RejectableInsertRecord record) {
        record.addError(Contact.Email, 'Email is required.');
    }
}
public with sharing class OpportunityWinAmountValidator implements BeforeUpdate.Validator {
    public Boolean addErrorOnBeforeUpdateWhen(TriggerTypes.UpdateRecord record) {
        return record.isChangedTo(Opportunity.StageName, 'Closed Won') && (record.isNull(Opportunity.Amount) || record.lessThanOrEqualTo(Opportunity.Amount, 0));
    }

    public void addErrorOnBeforeUpdate(TriggerTypes.RejectableUpdateRecord record) {
        record.addError(Opportunity.Amount, 'Set a positive Amount before moving this Opportunity to Closed Won.');
    }
}

Writers

The best practice is to run DML statements in the after contexts. Writers exist in all four after contexts (After Insert, After Update, After Delete and After Undelete) and in Before Delete.

Each DML statement in Salesforce fires all automations on that object (flows and triggers), and each automation consumes SOQL query and DML statement limits. This is why we should reduce the number of DML statements in triggers. Trigger Lib provides a unit of work out of the box: each writer adds records to it, and by default the library commits them all at once at the end of each trigger chunk. Under the hood we use DML Lib, which builds a dependency graph, groups records into buckets and executes them in the correct order.

public interface Writer extends AfterInsert.Handler {
    Boolean writeOnAfterInsertWhen(TriggerTypes.InsertRecord record);
    void writeOnAfterInsert(TriggerTypes.InsertRecord record, TriggerTypes.UnitOfWork unitOfWork);
}

public interface Writer extends AfterUpdate.Handler {
    Boolean writeOnAfterUpdateWhen(TriggerTypes.UpdateRecord record);
    void writeOnAfterUpdate(TriggerTypes.UpdateRecord record, TriggerTypes.UnitOfWork unitOfWork);
}

Like populators and validators, writers consist of two methods:

  1. writeOn{Context}When(record) – this method qualifies records; only records that meet the criteria are passed to the writeOn{Context} method. It’s the equivalent of an if statement inside a for loop. SOQL statements are blocked here. Additional records can be queried only through add-on interfaces.
  2. writeOn{Context}(record, unitOfWork) – this method adds records to the unit of work instead of running DML statements. The method is executed only for qualified records. SOQL statements are blocked here. Additional records can be queried only through add-on interfaces.

Examples

public with sharing class ContactTaskWriter implements AfterInsert.Writer {
    public Boolean writeOnAfterInsertWhen(TriggerTypes.InsertRecord record) {
        return record.isNotNull(Contact.AccountId);
    }

    public void writeOnAfterInsert(TriggerTypes.InsertRecord record, TriggerTypes.UnitOfWork unitOfWork) {
        unitOfWork.toInsert(new Task(WhatId = ((Contact) record.getNewSObject()).AccountId, Subject = 'Review contact'));
    }
}

Dispatchers

A dispatcher is a kind of handler used to enqueue a Queueable or publish events. Dispatchers exist in all four after contexts. The dispatchOn{Context} method gets all qualified records at once, as TriggerTypes.InsertRecords, TriggerTypes.UpdateRecords, TriggerTypes.DeleteRecords or TriggerTypes.UndeleteRecords. These types have bulkification methods like getIds(), getIdsOf(field) and getValuesOf(field). If you need the individual records, call getRecords().

public interface Dispatcher extends AfterInsert.Handler {
    Boolean dispatchOnAfterInsertWhen(TriggerTypes.InsertRecord record);
    void dispatchOnAfterInsert(TriggerTypes.InsertRecords records);
}

public interface Dispatcher extends AfterUpdate.Handler {
    Boolean dispatchOnAfterUpdateWhen(TriggerTypes.UpdateRecord record);
    void dispatchOnAfterUpdate(TriggerTypes.UpdateRecords records);
}

Like the other roles, dispatchers consist of two methods:

  1. dispatchOn{Context}When(record) – this method qualifies records; only records that meet the criteria are passed to the dispatchOn{Context} method. It’s the equivalent of an if statement inside a for loop. SOQL statements are blocked here. Additional records can be queried only through add-on interfaces.
  2. dispatchOn{Context}(records) – this method gets all qualified records at once, behind an interface with bulkification methods, so you don’t need to loop over the records to retrieve values. It’s executed only when at least one record qualifies.

dispatchOn{Context} runs once per trigger chunk of up to 200 records, so when 1,000 records are updated, the example below enqueues 5 jobs. Because it doesn’t run per record, SOQL isn’t blocked there. DML isn’t blocked either, but it runs immediately, outside the unit of work, so leave record changes to writers.

Examples

public with sharing class ContactSyncDispatcher implements AfterUpdate.Dispatcher {
    public Boolean dispatchOnAfterUpdateWhen(TriggerTypes.UpdateRecord record) {
        return record.isChanged(Contact.Email);
    }

    public void dispatchOnAfterUpdate(TriggerTypes.UpdateRecords records) {
        System.enqueueJob(new ContactSyncJob(records.getIds()));
    }

    public class ContactSyncJob implements Queueable {
        private Set<Id> recordIds;

        public ContactSyncJob(Set<Id> recordIds) {
            this.recordIds = recordIds;
        }

        public void execute(QueueableContext context) {
            // logic here
        }
    }
}

Bulkify SOQLs

I did an alalysis of SOQL queries for thousands of trigger handlers across different projects I used to work on. Here are the results:

Group %
Parent Queries 31%
Config Queries (metadata, running user, profiles, territory setup) 23%
Siblings (other children of the same parent) 12%
Children 11%
Via an earlier query’s results 10%
Matched by value 9%
Trigger record re-queried 3%
Unrelated records 2%

Usage percentage of each query type can be different for different projects, but we more or less the order would be always the same.

My conclusion is that majority of queries (over 50%) are parent queries and configs (usually custom metadata). Such queries can be shared accross different handlers. Parent queries usually do not need complex conditions. They are just quried based on triggering record id.
Another big group are siblings and children queries, however such queries usually have complex where conditions and are not easy to reuse between different handlers.

The goal of Trigger Lib is that you or AI shouldn’t think about the limits – the framework is responsilbe for that. No more shared context classes or services with cached queried results. Such approaches are paintful when it comes to updates. Your change can affect other parts of the system, not mention about merge conflicts. Trigger Lib solves it in a different way – you are resoponsible only for you own handler. We have 3 ways to query records.

1. Parent Queries

When we have 20 handlers, each handler query 2-3 different parents, you end up with 40-60 queries of of 100 queries available in the transaction, not mentioning that any DML can execute other automation which also consume the same limit.

In Trigger Lib you cannot make SOQL queries by your own, you or AI agents needs to follow specific way for queries which ensures that queries are bulkified as much as possible. The only thing you need to do is implement ParentQuery interface.

public interface ParentQuery {
   Map<SObjectField, TriggerTypes.ParentFields> queryParentsOn{Context}();
}
public with sharing class ContactPopulator implements BeforeInsert.Populator, BeforeInsert.ParentQuery {
    public Map<SObjectField, TriggerTypes.ParentFields> queryParentsOnBeforeInsert() {
        return new Map<SObjectField, TriggerTypes.ParentFields>{ 
            Contact.AccountId => TriggerTypes.ParentFields.with(Account.Name, Account.Industry) ,
            // other relationships here
        };
    }

    public Boolean populateOnBeforeInsertWhen(TriggerTypes.InsertRecord record) {
        return record.getNewParent(Contact.AccountId) != null;
    }

    public void populateOnBeforeInsert(TriggerTypes.InsertRecord record) {
        Account parentAccount = (Account) record.getNewParent(Contact.AccountId);
       // logic here
    }
}

Your handler specify what related fields are needed by map of relationship (Contact.AccountId) to fields TriggerTypes.ParentFields.with(Account.Name, Account.Industry). You can specify more relationships.
Each handler do the same. Trigger Lib gather or relationships requests, gather unique fields and make queries for parent records. Later you just call record.getNewParent(relationshipField) and done! You have related record.

There is also a few optimalization that make it even more awesome:

  • queries are only for qualified records – if there is no record that qualifies query is not made
  • queries are only made when getNewParent(relationship) is called – qualify method can getNewParent(relationship), but when conditions in Apex are executed one by one, if it’s known that condition won’t be met, the rest of the condition is not check eg. (record.Type == 'A' AND record.getNewParent(relationship).Type == 'B') in this example when record.Type won’t be 'A' then record.getNewParent(relationship).Type won’t be executed at all. Trigger Lib won’t make a query till getNewParent is not executed.
  • single query in after context – in after context instead of query parent one-by-one we make a single query using parent relationship query SELECT Parent.Name, Parent.CreatedBy.Name FROM TriggeringRecordName. In this way, no matter how many parent object the handlers requests, only signle query is made.

2. Config queries

To query configs you can either add query to the handler’s concructor. This will work whemn you don’t need any additional conditions or you query custom metadata records.

public with sharing class ContactPopulator implements BeforeInsert.Populator {
    private static MySetting__c setting = [SELECT Id FROM MySetting__c];

    public Boolean populateOnBeforeInsertWhen(TriggerTypes.InsertRecord record) {
        return //... ;
    }

    public void populateOnBeforeInsert(TriggerTypes.InsertRecord record) {
        // logic here
    }
}

However when you need to pull configs based on trigger records you need the RelatedQuery interface. I described it below.

3. Siblings, Children, etc.

To query any other records you need to use the RelatedQuery interface.
When you do it, you need to specify key and provider class in queryRelatedOn{Context} method. To use the queried records you need to call record.getRelated('YourKey').getAllWhereKeyEquals(...).

The query is made only when code reach a place where record.getRelated('YourKey') is called, otherwise no queries are issued.

If you need to make the same query for related records in the few places, you only need to create a shared RecordsProvider class, otherwise to keep everything in one place keep the providers as inner classes.

public with sharing class AccountFormerCustomerCaseWriter implements AfterUpdate.Writer, AfterUpdate.RelatedQuery {
    public Map<String, AfterUpdate.RecordsProvider> queryRelatedOnAfterUpdate() {
        return new Map<String, AfterUpdate.RecordsProvider>{ 'openCases' => new OpenCases() };
    }

    public Boolean writeOnAfterUpdateWhen(TriggerTypes.UpdateRecord record) {
        return record.isChangedTo(Account.Type, 'Former Customer');
    }

    public void writeOnAfterUpdate(TriggerTypes.UpdateRecord record, TriggerTypes.UnitOfWork unitOfWork) {
        for (SObject row : record.getRelated('openCases').getAllWhereKeyEquals(record.getId())) {
            unitOfWork.toUpdate(new Case(Id = row.Id, Status = 'Closed'));
        }
    }

    private with sharing class OpenCases implements AfterUpdate.RecordsProvider {
        public List<SObject> query(TriggerTypes.UpdateRecords records) {
            return [SELECT Id, AccountId FROM Case WHERE AccountId IN :records.getIds() AND IsClosed = FALSE];
        }

        public String keyOf(SObject row) {
            return ((Case) row).AccountId;
        }
    }
}

Bulkify DMLs

Continue On Error

You don’t need to wrap your handler logic with try-catch, we do it for you and log errors via Logger interface. Thrown error stops transaction and revert back all changes that has been made so far, however that behavior can be change. Trigger lib provides a marker interface called ContinueOnError, this one log an error, but transaction is continued event when the logic fails. Default behavior follows the standard way of how triggers works, this is why ContinueOnError must be added explicitly as a quick feedback for developer/AI that transaction will be continued even when logic fails.

Example

public with sharing class ContactPopulator implements BeforeUpdate.Populator, BeforeUpdate.ContinueOnError {
    public Boolean populateOnBeforeUpdateWhen(TriggerTypes.UpdateRecord record) {
        return record.isChanged(Contact.Email);
    }

    public void populateOnBeforeUpdate(TriggerTypes.UpdateRecord record) {
        // logic that failes here
    }
}

Logger

You don’t need to wrap you logic with try-catch. We already do it for you.

What happens when error occures?
You need to implement TriggerOrchestrator.Logger to log erorrs.

public interface Logger {
    void log(TriggerOrchestrator.Error error);
    void flush();
}

In this way no matter what logging framework do you use you can connect it with the Trigger Lib. Logger has to methods log and flush. The log method is called every time an error occures and the flush() is called at the end of main chunk.

Compare the the other frameworks when developer is responsible for saving logs, and usuually save logs is called in every handlers catch – trigger lib calls in as minimum as possible.

public with sharing class TriggerErrorLogger implements TriggerOrchestrator.Logger {
    public void log(TriggerOrchestrator.Error error) {
        // log the errors
            error.getHandlerName(),
            String.valueOf(error.getOperation()),
            String.valueOf(error.getSObjectType()),
            String.join(new List<Id>(error.getRecordIds()), ','),
            error.getException().getMessage(),
            error.getException().getStackTraceString()
    }

    public void flush() {
        // save the errors
    }
}

Bypass

Do you use feature flags and would like to deploy code as inactive?
Would you like to skip the some handler?
Do you need to bypass all triggers during data migration?
Do you need to bypass triggers in unit tests?

All of it and much more is possible using the Trigger Lib.

Trigger Lib has three differen mechanizms of bypassing the code.

  • Custom metadata config
  • Bypassable interface
  • TriggerOrchestrator.bypass() method

Custom Metadata

Unlinke other trigger frameworks, custom metadata is NOT required for Trigger Lib. However you can still use it to disable triggers on production or to override handlers settings. It can be very useful when you need to deploy inactive handler to prod, the only thing you need to do is to have custom metadata record with Bypass__c = true`

Bypassable interface

Another mechanism is bypassable interface:

public interface Bypassable {
    Boolean bypassOn{Context}When();
}

The bypass interface can be useful when you use feature flags, or have very specific conditions that based on org config. When the bypassOn{Context}When returns true, the whole handler is skipped without checking if any records qualifies.
To use it you only needs to implement the interface which will force you to implement bypassOn{Context}When method.

public with sharing class ContactReachabilityValidator implements BeforeInsert.Validator, BeforeInsert.Bypassable {
    public static Boolean isBypassed = false;

    public Boolean bypassOnBeforeInsertWhen() {
        return ContactReachabilityValidator.isBypassed || System.isBatch();
    }

    public Boolean addErrorOnBeforeInsertWhen(TriggerTypes.InsertRecord record) {
        return //...;
    }

    public void addErrorOnBeforeInsert(TriggerTypes.RejectableInsertRecord record) {
        // ...
    }
}

bypass method

You can bypass triggers in code in a few different ways. You can bypass all handlers for the given SObjectType, you can bypass specific orchestrator, or specific handler. You can even bypass everything by simply calling TriggerOrchestrator.bypass().all().

public interface Bypassable {
    Bypassable sObject(SObjectType sObjectType);
    Bypassable orchestrator(System.Type orchestrator);
    Bypassable handler(System.Type handler);
    void all();
    void clear();
}
TriggerOrchestrator.bypass()
    .sObject(Account.SObjectType)
    .handler(ContactFollowUpTaskWriter.class);

// operation here

TriggerOrchestrator.bypass().clear();

Recursion Control

Recursion control has only sense in update context as this is the place where we can end up with infinite loop. You can defined how many times can your handler can be executed for the same record before the error will be thrown.

You need to implement RecursionGuard interface and add maxRunsPerRecordOn{Context} methods which has to return depth.

By default the depth level is equal 3.

public interface RecursionGuard {
    Integer maxRunsPerRecordOnAfterUpdate();
}
public with sharing class ContactWriter implements AfterUpdate.Writer, AfterUpdate.RecursionGuard {
    public Integer maxRunsPerRecordOnAfterUpdate() {
        return 1;
    }

    public Boolean writeOnAfterUpdateWhen(TriggerTypes.UpdateRecord record) {
        return // ...;
    }

    public void writeOnAfterUpdate(TriggerTypes.UpdateRecord record, TriggerTypes.UnitOfWork unitOfWork) {
        // ...
    }
}
Piotr Gajek
Piotr Gajek
Senior Salesforce Developer
Technical Architect and Full-stack Salesforce Developer. He started his adventure with Salesforce in 2017. Clean code lover and thoughtful solutions enthusiast.

You might also like

SOQL Lib

SOQL Lib

The SOQL Lib provides functional constructs for SOQL queries in Apex. Use the SOQL Lib as a Selector Layer on your project.

Piotr Gajek
Piotr Gajek

Senior Salesforce Developer

Apex Queueable Processing Framework

Apex Queueable Processing Framework

Struggling with “Too many queueable jobs” errors in Salesforce? This guide introduces AsyncLib, a framework that manages Apex asynchronous processing with queueable chains. It helps overcome strict governor limits by chaining jobs using Salesforce’s Finalizer interface. AsyncLib simplifies async execution, error handling, and scheduling for Queueable, Batchable, and Schedulable jobs—no more complex workarounds or manual tracking.

Mateusz Babiaczyk
Mateusz Babiaczyk

Salesforce Certified Technical Architect