XSCREENSAVER / 3D PIPES

[readonly] markdown buffer

Trigger Architecture Without a 40-Class Framework

Jul 11, 2026 · 7 min read

An Apex trigger can become unmaintainable in two opposite ways.

One is a 900-line trigger containing everything. The other is a dispatcher, factory, metadata registry, abstract handler, unit-of-work wrapper, and twelve one-method classes.

The first has no structure. The second can have more structure than problem.

Most orgs need something in the middle: one visible entry point, explicit routing, and services that describe the business operation.

Start with a boring trigger

The trigger should declare when it runs and forward context.

trigger OpportunityTrigger on Opportunity (
    before insert,
    before update,
    after insert,
    after update
) {
    OpportunityTriggerHandler.run(
        Trigger.operationType,
        Trigger.new,
        Trigger.oldMap
    );
}

It should not query, perform DML, send email, enqueue jobs, or calculate pricing.

The handler then makes ownership visible:

public class OpportunityTriggerHandler {
    public static void run(
        System.TriggerOperation operation,
        List<Opportunity> newRecords,
        Map<Id, Opportunity> oldRecordById
    ) {
        switch on operation {
            when BEFORE_INSERT {
                OpportunityDefaults.apply(newRecords);
            }
            when BEFORE_UPDATE {
                OpportunityValidation.validateChanges(
                    newRecords,
                    oldRecordById
                );
            }
            when AFTER_UPDATE {
                RenewalService.createRenewalsFor(
                    changedStageRecords(newRecords, oldRecordById)
                );
            }
        }
    }
}

This is deliberately unsurprising. A developer can open one class and see which service owns each context.

Salesforce's record-triggered automation guide recommends a governed primary entry point per object and choosing Flow, hybrid automation, or Apex according to the automation density. That principle matters more than adopting a fashionable framework.

Make the service do the real work

The handler understands trigger context. The service should understand the business operation.

RenewalService.createRenewalsFor(closedWonOpportunities) is a better API than OpportunityService.handleAfterUpdate(Trigger.new, Trigger.oldMap). The first can be reused from a repair job and tested without pretending a trigger is running.

The whole call path must also be bulk-safe. A loop-free trigger still fails if its service queries one record at a time.

public class OpportunityRollupService {
    public static void refreshAccounts(List<Opportunity> opportunities) {
        Set<Id> accountIds = new Set<Id>();

        for (Opportunity opportunity : opportunities) {
            if (opportunity.AccountId != null) {
                accountIds.add(opportunity.AccountId);
            }
        }

        if (accountIds.isEmpty()) {
            return;
        }

        // Query once and update Accounts as one collection.
    }
}

Bulkification is an end-to-end property, not something achieved by moving a query into a class named Service.

Use before context for changes to the same record so no extra DML is required. On update, pass only meaningful changes into expensive work. A trigger runs because something changed, not necessarily because the field your service cares about changed.

Prevent repeated work by design

A global static Boolean is usually too broad:

if (TriggerGuard.hasRun) {
    return;
}
TriggerGuard.hasRun = true;

It can suppress legitimate work during another trigger invocation and hides why recursion exists.

Prefer change-aware, idempotent code. Avoid unnecessary same-record DML. If a guard remains necessary, scope it to the record IDs and operation involved in the recursive path.

The best recursion defence is code that does nothing when the relevant state is already correct.

Flow and Apex need the same deliberate ownership. One trigger per object does not help if several record-triggered Flows independently react to the same change. Document which mechanism owns the automation, its ordering, and its cross-object effects.

The architecture should explain itself

Test services directly for collection behaviour, edge cases, errors, and permissions. Keep a smaller set of trigger integration tests proving that contexts route correctly, irrelevant changes do nothing, and bulk execution stays within limits.

A larger framework earns its place when the org genuinely needs metadata-controlled ordering, package extension points, consistent bypass controls, central instrumentation, or many teams contributing independent actions.

The test is simple. A new developer should be able to answer what runs, in what order, which class owns the rule, whether it works for 200 records, what prevents repeated work, and how it is tested.

If a framework makes those answers clearer, use it. If four straightforward classes make them clearer, use those.

The goal is understandable automation, not maximum architecture per trigger.