---
title: "Trigger design that still works at volume"
url: "https://trailassembly.com/trigger-design-at-volume/"
topic: "Architecture"
published: "2026-09-13T06:49:14+00:00"
updated: "2026-10-06T13:54:13+00:00"
error_codes: "UNABLE_TO_LOCK_ROW"
---

# Trigger design that still works at volume

**The short version:** write every trigger as though it will always receive 200 records, because eventually it will. That means no SOQL inside a loop, no DML inside a loop, one handler per object, and a hard decision about what moves to async. The thing that breaks first at scale is almost never CPU time in your own code. It is the SOQL query limit, hit by someone who wrote a query per record and tested with one.

## The 200 is not negotiable

Triggers fire in batches of up to 200 records. The Data Loader default is 200. The Bulk API splits batches into 200-record chunks before handing them to Apex. An inbound integration, a mass reassignment from a list view: all of it arrives as 200.

The limits that matter are per-transaction, shared across your trigger, every other trigger on the object, and every package trigger you did not write:

- 100 SOQL queries

- 50,000 rows returned by SOQL

- 150 DML statements

- 10,000 rows processed by DML

- 10,000 ms CPU time synchronous, 60,000 ms asynchronous

- 6 MB heap synchronous, 12 MB asynchronous

A query per record hits the SOQL ceiling at 100 records. Half a Data Loader batch.

## The cost of a query per record, concretely

Here is the shape of the problem and the shape of the fix.

```apex
// Fails at 100 records
for (Opportunity o : Trigger.new) {
    Account a = [SELECT Id, Industry FROM Account WHERE Id = :o.AccountId];
    o.Industry__c = a.Industry;
}
```

```apex
// One query, any batch size
Set<Id> accountIds = new Set<Id>();
for (Opportunity o : Trigger.new) {
    if (o.AccountId != null) accountIds.add(o.AccountId);
}
Map<Id, Account> accounts = new Map<Id, Account>(
    [SELECT Id, Industry FROM Account WHERE Id IN :accountIds]
);
for (Opportunity o : Trigger.new) {
    Account a = accounts.get(o.AccountId);
    if (a != null) o.Industry__c = a.Industry;
}
```

Collect IDs, query once, build a map, loop again. Every [bulkification](https://trailassembly.com/flow-bulkification/) pattern is those four steps.

## What breaks first, in the order you will meet it

**SOQL queries, at 100.** The most common failure, and the easiest to spot in a debug log: the same query repeating.

**DML rows, at 10,000.** Usually a trigger updating children. Two hundred accounts with sixty contacts each is 12,000 rows and a dead transaction.

**CPU time, at 10 seconds.** Arrives later, rarely from one culprit. Your trigger plus three managed packages plus fourteen flows, and whichever tips it over gets the blame.

**Row locks.** Not a governor limit, and the hardest to reproduce. Two concurrent batches update children of one parent and a batch gets `UNABLE_TO_LOCK_ROW`. Sorting the input by parent ID fixes more of these than any code change.

## One trigger per object, and the handler does nothing clever

Multiple triggers on one object have no defined execution order, and the order can change. [One trigger per object](https://trailassembly.com/apex-trigger-frameworks/), containing no logic, delegating to a handler class:

```apex
trigger OpportunityTrigger on Opportunity (before insert, before update,
        after insert, after update) {
    new OpportunityTriggerHandler().run();
}
```

Recursion control belongs in the handler, held in a static, and it must be specific. A blanket “have I run before” flag skips legitimate second passes caused by a field update. Key the guard by record ID and operation.

## When to go asynchronous, and when not to

Async raises your CPU allowance and heap, and takes work out of the user’s save. It also makes failures invisible: a failed [Queueable](https://trailassembly.com/apex-choosing-async/) does not roll back the user’s transaction.

Move work async when it is a callout, when it touches an unrelated object, or when it can arrive a minute later. Keep it synchronous when the user must see the result immediately, or when a failure has to prevent the save.

Queueable is the default: it takes sObjects as arguments, it chains, and it is easy to test. `@future` takes only primitives and cannot be chained, so retire it from new code. A Queueable started from a trigger can enqueue one more job, so fan-out designs need [Batch Apex](https://trailassembly.com/apex-batch-that-scales/).

## Test at 200, not at 1

A test that inserts one record proves the code compiles. Insert 200 in at least one test per handler and the query-per-record bugs surface immediately, with the DML inside `Test.startTest()` so the counters reset.

## Frequently asked

**Does a before trigger avoid a DML statement?** Yes, for the record being saved. The same work in an after trigger means an explicit update and a full second pass through the [order of execution](https://trailassembly.com/order-of-execution/).

**How do flows share these limits?** Completely. Flows, triggers and package code draw on the same per-transaction pool. A flow with a Get Records element inside a loop fails the same way Apex does.

**One handler class or several?** One per object, split into private methods per operation. When it stops fitting on a screen, move the domain logic into service classes.

---

Source: https://trailassembly.com/trigger-design-at-volume/
Trail Assembly — independent Salesforce write-ups.
