Loading...

Apex Trigger Anti-Patterns and the One-Trigger-Per-Object Pattern

Multiple triggers on the same object fire in unpredictable order, recurse against themselves, and breach governor limits during data loads. The fix is a deliberate handler pattern that every Salesforce org should adopt before the second trigger ships.

Apex Trigger Anti-Patterns and the One-Trigger-Per-Object Pattern

A Salesforce org has been running for three years. Five developers have come and gone. The Account object has eleven active triggers. Three of them call each other transitively. Two recurse. A data load that worked fine last quarter now hits governor limits halfway through. Nobody on the current team can confidently say what the trigger logic does end to end.

This is the most common Apex anti-pattern in production Salesforce orgs. The fix is a deliberate handler pattern that every org should adopt before the second trigger on any object ships. Sapota's Salesforce team treats trigger architecture as a first-week deliverable on every engagement.

The problem with multiple triggers per object

Salesforce allows multiple triggers on the same object. The order in which they execute is not guaranteed. Two triggers that both manipulate the same field can produce different results depending on which fires first. Salesforce documentation has stated this for over a decade, yet new orgs still ship with multiple triggers on the same object.

The failure modes:

Non-deterministic ordering. Trigger A sets Status__c = 'Pending' based on one rule. Trigger B sets Status__c = 'Active' based on another. Whichever runs second wins. The order can change after a deployment, a metadata refactor, or sometimes for no obvious reason.

Cascading triggers and recursion. Trigger A on Account updates a related Contact. The Contact update fires Trigger B on Contact. Trigger B updates the Account back. Trigger A fires again. Governor limits hit. The transaction rolls back.

Diffused responsibility. New logic gets added as new triggers because that is the path of least resistance. The org accumulates triggers nobody owns. Bug fixes that should be one-line changes turn into archaeology projects.

The one-trigger-per-object pattern

The canonical fix: one and only one trigger per object. That trigger is a thin shell. All logic lives in a handler class. The handler routes by event (before insert, after update, etc.) and orchestrates the business logic.

A typical structure:

trigger AccountTrigger on Account (before insert, before update, before delete, after insert, after update, after delete, after undelete) { new AccountTriggerHandler().run(); }

The trigger itself has one line. Everything else is in AccountTriggerHandler. The handler reads Trigger.operationType to dispatch:

public class AccountTriggerHandler { public void run() { if (Trigger.isBefore && Trigger.isInsert) beforeInsert(); else if (Trigger.isBefore && Trigger.isUpdate) beforeUpdate(); else if (Trigger.isAfter && Trigger.isInsert) afterInsert(); // ... and so on } `` private void beforeInsert() { // Each business-rule module is a separate method or // delegate class. new AccountValidationService().validate(Trigger.new); new AccountDefaultingService().applyDefaults(Trigger.new); } }

Now there is one entry point per object. New logic gets added as new service methods, not new triggers. Code review enforces this.

Recursion control

Even with one trigger, recursion happens when the trigger updates records that fire the same trigger again. A common case: an after-update trigger that updates the same Account in a different context, causing the trigger to fire on the updated record, causing another update, and so on.

The standard fix is a recursion guard:

public class AccountTriggerHandler { private static Boolean alreadyRun = false; `` public void run() { if (alreadyRun) return; alreadyRun = true; // ... dispatch logic } }

This is the simplest form. It stops all re-entry. For nuanced cases (where some operations should re-fire and others should not), the guard becomes a set of record IDs already processed:

private static Set processedIds = new Set();

The handler skips records already in processedIds. New records or context changes still flow through.

The discipline is to add recursion guards proactively, not after the first production incident.

Bulkification inside the handler

A trigger fires once per DML operation, with up to 200 records in Trigger.new. Handler code that loops record-by-record and queries inside the loop hits governor limits at the 200-record boundary or sooner.

Bulkified handler pattern:

private void beforeUpdate() { // Collect all parent IDs first. Set parentIds = new Set(); for (Account a : (List) Trigger.new) { if (a.ParentId != null) parentIds.add(a.ParentId); } ` // Query once for all parents.` ` Map parents = new Map(` ` [SELECT Id, Industry, Type FROM Account WHERE Id IN :parentIds]` ` );` // Now loop and apply logic using the prefetched map. for (Account a : (List) Trigger.new) { if (a.ParentId != null && parents.containsKey(a.ParentId)) { Account parent = parents.get(a.ParentId); // ... business logic ... } } }

The shape: collect IDs, query once, build a map, iterate. Repeat for each related object the handler needs.

Other rules that hold up

A few patterns that consistently produce maintainable trigger code:

No DML inside the loop. Build the result collection first, do the DML in one statement at the end. Same principle as no SOQL inside the loop, different operation.

Selective trigger contexts. A handler that uses only before-insert and after-update should not list every event in the trigger declaration. List only what is used. Cleaner code review, faster onboarding.

Service-class delegates for complex logic. When a handler method grows past 50 lines, extract a service class. The handler routes; the service does the work. Test the service independently.

Test coverage of every dispatch branch. Each Trigger.isBefore && Trigger.isInsert branch needs at least one test. The dispatcher is the riskiest part of the architecture; cover it explicitly.

No business logic in the trigger itself. The trigger file is one line. Anything more belongs in the handler.

Refactoring a legacy multi-trigger org

When inheriting an org with the eleven-trigger problem, the migration:

  1. Inventory the existing triggers. Document what each one does. Most will overlap in scope.
  2. Identify dependencies. Which triggers update which fields. Which call each other.
  3. Design the single trigger plus handler. Map every piece of existing logic to a handler method or service class.
  4. Migrate one trigger at a time. Disable the old, enable the new path, verify behavior in sandbox.
  5. Run regression tests after each migration. Apex tests plus UI smoke tests.
  6. Delete the old triggers once the handler is proven.

Realistic timeline for a busy org: four to six weeks. Mostly testing time, not refactoring time. The refactor itself is mechanical; the validation that nothing broke is the work.

What good trigger architecture looks like

A Salesforce org with healthy trigger architecture:

  • One trigger per object, file under 5 lines each.
  • One handler class per object, organized by event type with delegate services for complex logic.
  • Static recursion guard on every handler, with documented use cases.
  • Bulkified queries and DML, no per-record SOQL.
  • Apex tests covering every dispatch branch plus business logic.
  • Documentation map of trigger-to-handler-to-service for each object.
  • No "trigger zoo" of 5+ triggers on any single object.

Sapota's Salesforce team holds the Platform Developer I credential and has refactored multi-trigger orgs into one-trigger-per-object architectures on production engagements. The pattern is well-understood; the discipline to apply it across an entire org is what separates teams that ship reliably from teams that fight governor limits monthly.


Inheriting a multi-trigger Salesforce org or building a new one? Sapota's Salesforce team handles trigger architecture, handler design, and legacy refactors on production engagements. Get in touch ->

See our full platform services for the stack we cover.

Contact Us Now

Share Your Story

We build trust by delivering what we promise – the first time and every time!

We'd love to hear your vision. Our IT experts will reach out to you during business hours to discuss making it happen.

WHY CHOOSE US

"Collaborate, Elevate, Celebrate where Associates - Create Project Excellence"

SapotaCorp beyond the IT industry standard, we are

  • Certificated
  • Assured quality
  • Extra maintenance

Tell us about your project

close