Back to insights

Apex Architecture

Bulkified Apex can still be the wrong architecture

JSBC Labs8 min read

Bulkification is the entry ticket

Most Apex reviews begin with two sensible checks: is there SOQL inside a loop, and is DML performed one record at a time? Salesforce teaches collection-based trigger processing because the same code may receive one record from the user interface or a full record set from an API, automation or data load. Passing those checks matters. It does not prove that the transaction is well designed.

A trigger can query once, update one list and still exhaust resources when several automations compose around it. It can load far more rows than the business operation needs, lock records in inconsistent order, hide partial failures or enqueue work faster than the org can process it. Bulkification prevents a class of implementation defects. Architecture decides whether the total workload is bounded, recoverable and observable.

Design for the transaction you actually have

Governor limits apply to the whole Apex transaction, not to each class that participates in it. A service that consumes a modest number of queries in isolation may be expensive when record-triggered Flow, managed packages, duplicate rules and other triggers share the same save. The relevant budget is the complete execution path produced by a realistic business operation.

Map that path before optimising an individual method. Identify entry points, maximum records, related-record fan-out, queries, rows, writes, callouts, CPU-heavy calculations and automation that follows each write. Then reserve headroom for growth and org-level composition. Code that succeeds only because today's neighbouring automation is quiet has not been engineered to a stable operating boundary.

Make collection semantics part of the contract

A bulk-safe trigger handler is useful only if the services beneath it also accept collections. Methods that take one record ID encourage callers to loop, repeat queries and scatter transaction control. Prefer business-shaped contracts that accept a set of candidate records, gather required data once, calculate outcomes in memory and return an explicit plan or result for the complete request.

The return contract should distinguish records that need no change, records ready to write and records rejected by a business rule. That structure lets the caller coordinate writes, surface meaningful errors and test mixed batches. A collection parameter alone is not enough; the method must define maximum expected volume, duplicate handling, ordering assumptions and whether one invalid item should fail the entire operation.

Control amplification, not only statement count

One query can still be dangerous. If 200 parent records each have thousands of children, a single relationship query may retrieve an unbounded population and consume rows, heap and CPU. Likewise, one update statement can touch records whose automation launches another wave of reads and writes. The number of statements is a weak measure when each statement can amplify work dramatically.

Start with the smallest business projection. Filter by changed fields and qualifying states before querying related data. Select only fields used by the rule, aggregate when detail is unnecessary and split genuinely large populations into a processing model designed for them. Record volume, relationship fan-out and automation density belong in acceptance criteria, not as surprises discovered during a production data load.

Coordinate writes at one deliberate boundary

Service methods that perform DML whenever they finish a calculation are convenient locally but expensive globally. The caller loses the ability to combine writes, order them consistently, choose all-or-none behaviour or explain which item failed. Recursive automation also becomes harder to reason about because each nested service can create another save cycle without a shared view of the intended unit of work.

Where the use case permits, separate decision logic from persistence. Let services contribute proposed inserts, updates or deletes to a transaction-level coordinator, then execute the smallest ordered set of writes. Use Database methods with deliberate partial-success handling only when the business process can tolerate independent outcomes. If atomicity is required, fail clearly and avoid catching an exception merely to leave incomplete state behind.

Security is part of bulk behaviour

Bulk safety says nothing about access safety. Salesforce's current guidance states that Apex at API version 67.0 and later runs database operations in user mode by default, while version 66.0 and earlier defaults to system mode; record-sharing behaviour also depends on explicit or inherited class declarations. A mature org can therefore contain different assumptions at once, especially across older services and managed code.

Declare whether each service acts for the current user, for a controlled integration identity or as privileged domain logic, and set sharing and database access modes explicitly. Apply that decision consistently across every item in the collection. In mixed batches, one inaccessible record must not expose data through an aggregate result, error message or side effect on another record. Least privilege and bulk processing need one design.

Use asynchronous processing as a boundary, not an escape hatch

Queueable or Batch Apex can move appropriate work out of the user's transaction and provide a fresh set of execution limits. That does not make capacity infinite. Salesforce's architecture guidance warns that asynchronous processing has no guaranteed service level, is governed by flow control and fair usage, and can be delayed when finite platform resources are busy.

Move work asynchronously when the user does not require the outcome before continuing, when the workload needs an independent retry boundary or when a large population must be processed in controlled chunks. Pass durable identifiers rather than oversized snapshots, make jobs idempotent and record completion or failure. Do not enqueue one job per record or use a queue merely to postpone a transaction whose volume remains unbounded.

Test the limit shape, not just the happy path

A useful Apex test creates the largest realistic trigger batch and gives each parent realistic related data. It mixes qualifying and non-qualifying records, exercises security differences, causes one validation failure and verifies the intended atomic or partial-success outcome. It also includes entry points such as APIs and scheduled processing that may produce data shapes the user interface never creates.

Measure important budgets with the Limits methods where that evidence helps detect regression, but avoid tests coupled to every incidental query count. Assert business outcomes, bounded growth and the absence of per-record work. Add operational telemetry for job age, repeated retries, failure populations and processing duration. A limit exception is already late evidence; the goal is to see workload drift before the platform stops the transaction.

The JSBC Labs view

Production Apex should be bulkified, but that is the beginning of the review. Design against the complete transaction, make services collection-native, bound relationship fan-out, coordinate persistence, declare execution security and choose asynchronous boundaries from business latency and recovery requirements. Each decision reduces a different kind of failure; none can be replaced by moving a query outside a loop.

The most useful question is not whether a class can process 200 records. Ask whether the business operation has a predictable cost as data, automation and integrations grow—and whether the team can recover when one part fails. Apex becomes durable architecture when its unit of work is explicit, its resource demand is bounded and its outcome can be proven.

Official references

Continue reading