← Back to blog

A Trigger Framework That Actually Scales

·5 min read

I've lost count of the number of Salesforce orgs I've walked into where the first thing I see is a dozen triggers on the Account object, each doing something slightly different, some of them fighting each other. It's the most predictable kind of tech debt on the platform, and it's almost entirely preventable.

Over the years I've settled on a trigger framework that I bring to every engagement. It's not revolutionary — if you've been around the ecosystem you've seen variations of it. But the details matter, and I want to walk through the specific choices I make and why.

One Trigger Per Object, No Exceptions

This is the hill I'll die on. Every object gets exactly one trigger, and that trigger does nothing except delegate to a handler class. The trigger file itself should be five to ten lines long. If I see business logic in a trigger file during code review, it's an instant rejection.

The reason isn't just aesthetics. Salesforce doesn't guarantee trigger execution order when you have multiple triggers on the same object. I've debugged production issues that came down to two triggers on Opportunity running in an order nobody expected. One trigger per object eliminates the problem entirely.

The trigger looks something like this: it checks which context we're in (before insert, after update, etc.) and calls the corresponding method on the handler. Simple, predictable, boring. That's the point.

The Handler Pattern

Each trigger has a corresponding handler class that implements a common interface. The interface defines methods for every trigger context — beforeInsert, afterInsert, beforeUpdate, afterUpdate, and so on. Not every handler needs to implement every method, but the structure is consistent.

I use a base class with virtual methods that do nothing by default. Handlers extend it and override only the contexts they care about. This means adding a new piece of logic for after-update on Contact is: open the handler, add your code in the afterUpdate method, done. No wiring, no registration, no confusion about where things go.

Custom Metadata for Bypass Controls

This is where it gets practical. I create a custom metadata type — usually called Trigger_Setting__mdt — with fields for the object name, a boolean to disable the trigger, and optionally a field for which user or profile should be bypassed.

Why custom metadata instead of a custom setting or a hierarchy custom setting? Because custom metadata deploys with your source. It's in version control. You can include it in your CI/CD pipeline. Custom settings are data, and they don't travel with your deployments unless you script them separately. For something as critical as trigger bypass controls, I want it in the metadata.

In practice this means an admin can disable the Account trigger for a data migration by flipping a metadata record, deploying it, running the migration, and flipping it back. No code changes, no deployments of Apex, fully auditable.

Recursion Guards

Every handler gets a static set that tracks which record IDs have already been processed in the current transaction. Before processing a record, the handler checks the set. After processing, it adds the ID.

I've seen people use a simple static boolean for this, and it works until it doesn't. The boolean approach blocks the entire trigger from firing a second time, which breaks legitimate re-entry scenarios — like when a workflow field update causes the trigger to re-fire. The set-based approach is more granular: it prevents the same record from being processed twice, but allows other records to proceed.

Transaction-Level Caching

This one is less about the framework and more about what I bake into the handler base class. I keep a static map that handlers can use to cache SOQL query results within the transaction. If the before-insert handler queries a set of related records, the after-insert handler can reuse those results without hitting SOQL limits again.

It's a small thing, but in orgs with complex automation chains it makes a measurable difference. I've seen it cut SOQL usage by 30-40% in triggers that run during bulk operations.

Bulkification Is Non-Negotiable

Every method in the handler receives the full list (or map) of records from Trigger.new or Trigger.oldMap. There are no single-record methods. If someone writes a handler method that assumes it'll only ever process one record, it gets refactored before it merges.

I enforce this through code review, but also structurally. The base class signature takes List<SObject> parameters. You'd have to go out of your way to process records one at a time. Making the right thing easy and the wrong thing hard — that's the goal.

When the Framework Isn't the Answer

I'll be honest — not everything belongs in a trigger. Over the past few years I've moved a lot of logic into record-triggered Flows, especially for straightforward field updates and record creation. The trigger framework is for the complex stuff: multi-object orchestration, integration callouts, heavy computation, things where you need the control that Apex gives you.

The framework is a safety net, not a mandate. If a Flow does the job cleanly and the admin team can maintain it, that's a better outcome than an Apex handler that only I understand.

At the end of the day, a trigger framework is plumbing. Nobody gets excited about plumbing. But when it works well, everything built on top of it just flows — and when it's missing, every new feature becomes a debugging adventure. I'll take boring and reliable every time.

Liked this?

Get one Salesforce insight per week. No spam.