LearnFrenzy
Sign in Take a free test

Apex Triggers · Lesson 26 of 41

Trigger Handler Pattern: One Trigger per Object

6 min read Updated Nov 8, 2026 Saurabh Samir

Trigger code that lives in the trigger body works for a while. Then a second developer adds a second trigger on the same object, nobody can say which runs first (the platform doesn't guarantee an order), logic can't be unit-tested in isolation, and a data fix can't be run without the triggers firing. The trigger handler pattern fixes all of that, and it's what employers expect to see.

In this lesson

  • Why one trigger per object
  • A small reusable handler base class
  • A real handler for Contact
  • Stopping recursion
  • Bypassing triggers for data loads

The layers

  1. Triggerone per object, one line: new ContactTriggerHandler().run();
  2. Handlerroutes each event to a method: beforeInsert, afterUpdate…
  3. Services / domain classesthe actual business logic, bulkified and unit-testable

The base class

// TriggerHandler.cls
public virtual class TriggerHandler {
    private static Set<String> bypassed = new Set<String>();

    public static void bypass(String handlerName) { bypassed.add(handlerName); }
    public static void clearBypass(String handlerName) { bypassed.remove(handlerName); }

    public void run() {
        String name = String.valueOf(this).substringBefore(':');      // the handler's class name
        if (!Trigger.isExecuting || bypassed.contains(name)) return;

        switch on Trigger.operationType {
            when BEFORE_INSERT { beforeInsert(Trigger.new); }
            when BEFORE_UPDATE { beforeUpdate(Trigger.new, Trigger.oldMap); }
            when BEFORE_DELETE { beforeDelete(Trigger.oldMap); }
            when AFTER_INSERT { afterInsert(Trigger.newMap); }
            when AFTER_UPDATE { afterUpdate(Trigger.newMap, Trigger.oldMap); }
            when AFTER_DELETE { afterDelete(Trigger.oldMap); }
            when AFTER_UNDELETE { afterUndelete(Trigger.newMap); }
        }
    }

    // subclasses override only what they need
    protected virtual void beforeInsert(List<SObject> newRecords) {}
    protected virtual void beforeUpdate(List<SObject> newRecords, Map<Id, SObject> oldMap) {}
    protected virtual void beforeDelete(Map<Id, SObject> oldMap) {}
    protected virtual void afterInsert(Map<Id, SObject> newMap) {}
    protected virtual void afterUpdate(Map<Id, SObject> newMap, Map<Id, SObject> oldMap) {}
    protected virtual void afterDelete(Map<Id, SObject> oldMap) {}
    protected virtual void afterUndelete(Map<Id, SObject> newMap) {}
}

Open-source frameworks (Kevin O'Hara's trigger handler, fflib's domain layer, Apex Trigger Actions Framework) follow the same idea with more features. This small version shows every moving part.

The trigger and the handler

// ContactTrigger.trigger
trigger ContactTrigger on Contact (before insert, before update, after insert, after update) {
    new ContactTriggerHandler().run();
}
// ContactTriggerHandler.cls
public with sharing class ContactTriggerHandler extends TriggerHandler {
    private static Set<Id> processedAfterUpdate = new Set<Id>();     // recursion guard for this transaction

    protected override void beforeInsert(List<SObject> newRecords) {
        normalise((List<Contact>) newRecords);
    }

    protected override void beforeUpdate(List<SObject> newRecords, Map<Id, SObject> oldMap) {
        normalise((List<Contact>) newRecords);
    }

    protected override void afterUpdate(Map<Id, SObject> newMap, Map<Id, SObject> oldMap) {
        List<Contact> toStamp = new List<Contact>();
        for (Contact c : (List<Contact>) newMap.values()) {
            if (processedAfterUpdate.contains(c.Id)) continue;          // already handled in this transaction
            processedAfterUpdate.add(c.Id);
            Contact old = (Contact) oldMap.get(c.Id);
            if (c.Email != old.Email) {
                toStamp.add(new Contact(Id = c.Id, Description = 'Email changed from ' + old.Email));
            }
        }
        System.debug('afterUpdate: ' + newMap.size() + ' record(s), ' + toStamp.size() + ' to stamp');
        if (!toStamp.isEmpty()) update toStamp;                         // fires the trigger again…
    }

    private static void normalise(List<Contact> contacts) {
        for (Contact c : contacts) {
            if (c.Email != null) c.Email = c.Email.trim().toLowerCase();
            if (c.FirstName != null) c.FirstName = c.FirstName.trim().capitalize();
        }
    }
}
Contact c = new Contact(FirstName = '  asha ', LastName = 'Verma', Email = ' Asha@Acme.EXAMPLE ');
insert c;
Contact saved = [SELECT FirstName, Email FROM Contact WHERE Id = :c.Id];
System.debug('Normalised: ' + saved.FirstName + ' <' + saved.Email + '>');

c.Email = 'asha.verma@acme.example';
update c;
System.debug([SELECT Description FROM Contact WHERE Id = :c.Id].Description);

Debug log

Normalised: Asha <asha@acme.example>
afterUpdate: 1 record(s), 1 to stamp
afterUpdate: 1 record(s), 0 to stamp
Email changed from asha@acme.example

The handler's own update toStamp fired the trigger a second time – that's the second "afterUpdate" line. The static processedAfterUpdate set made the second run skip the records it had already handled, so there was no infinite loop and no repeated work. Without the guard, the second run would compare against the already-changed email and could keep re-triggering.

Don't use a single static Boolean for recursion

The popular static Boolean firstRun = true trick breaks with large data loads: the platform calls the trigger once per chunk of 200 records in the same transaction, so a Boolean set by chunk 1 silently skips chunks 2, 3… A set of processed Ids only skips records that were really handled.

Bypassing a handler

Data migrations and some integrations need to save records without running automation. The static bypass switch on the base class makes that a one-liner – and because statics live for one transaction, it can't "stick" for other users:

TriggerHandler.bypass('ContactTriggerHandler');
insert new Contact(FirstName = '  raw ', LastName = 'Import', Email = ' RAW@DATA.EXAMPLE ');
TriggerHandler.clearBypass('ContactTriggerHandler');

Contact raw = [SELECT FirstName, Email FROM Contact WHERE LastName = 'Import'];
System.debug('FirstName saved as "' + raw.FirstName + '" – the handler would have made it "Raw"');

Debug log

FirstName saved as "raw" – the handler would have made it "Raw"

Notice that the email still came out lower-case and trimmed even with the handler bypassed: Salesforce itself trims text fields and lower-cases email addresses on save. The first name is the visible proof – the handler's capitalisation didn't run.

Production frameworks often add an org-level switch too – a Custom Metadata or hierarchy Custom Setting checked in run() – so admins can disable a handler for a user (the integration user, say) without a deployment.

Practice

Try it yourself

  1. Add before-delete logic to ContactTriggerHandler that prevents deleting contacts whose Title is 'CEO'.

    Show answer

    Add before delete to the trigger's events and override: protected override void beforeDelete(Map<Id, SObject> oldMap) { for (Contact c : (List<Contact>) oldMap.values()) { if (c.Title == 'CEO') c.addError('CEO contacts cannot be deleted.'); } }

  2. Why put the logic in a handler instead of the trigger body?

    Show answer

    Predictable order (one trigger per object), logic you can unit-test and reuse, a single place to add recursion control and bypasses, and much easier maintenance.

Quick check

Why one trigger per object?

The order of several triggers on one object isn't guaranteed.

What's the best recursion guard?

A static Set of processed record Ids – not a single static Boolean.

How long does a static bypass last?

Only for the current transaction.

Summary

  • One trigger per object, containing a single call to its handler.
  • A base class routes events; handlers override only what they need.
  • Guard recursion with a static Set of Ids.
  • Provide a bypass for data loads and integrations.

Finished this lesson?

Mark it complete to track your progress, then carry on with the next lesson.

Next lesson

Search LearnFrenzy

Esc

Quick links