Most Salesforce orgs do not fail because of one catastrophic decision. They fail through accumulation: a quick trigger here, a test written for coverage there, a batch job cloned from a blog post. Five years later every release is a negotiation with the org. The six disciplines below are what we look for first when we audit enterprise Apex — and what we install first when we take an org over. If you are still deciding whether code is the right tool for a given requirement, start with our decision framework for when to write Apex; this article assumes the answer was yes.
Bulkification and governor limits
Apex executes in a multitenant runtime, and Salesforce protects that runtime with per-transaction governor limits: caps on SOQL queries, DML statements, CPU time, heap size and callouts. The exact numbers vary by context and occasionally change — check the current Apex governor limits documentation before designing against them — but the engineering consequence does not change: your code shares a transaction with every trigger, flow and managed package that fires alongside it.
The discipline that follows is bulkification. Every trigger receives records in chunks of up to 200, and Data Loader jobs, integrations and Batch Apex hit that ceiling routinely. Code that queries or writes inside a loop works in a demo and dies in production.
// Anti-pattern: SOQL inside the loop — fails at scale
for (OpportunityLineItem line : Trigger.new) {
Product2 p = [SELECT Family FROM Product2 WHERE Id = :line.Product2Id];
}
// Bulk-safe: one query, keyed by Id, any batch size
Set<Id> productIds = new Set<Id>();
for (OpportunityLineItem line : Trigger.new) { productIds.add(line.Product2Id); }
Map<Id, Product2> products = new Map<Id, Product2>(
[SELECT Id, Family FROM Product2 WHERE Id IN :productIds]);
Three habits cover most of it. First, no SOQL or DML inside loops — collect IDs into sets, query once with an IN clause, write once per object type. Second, push filtering and aggregation into the query engine with selective WHERE clauses and aggregate functions instead of iterating in Apex. Third, instrument the boundaries: the Limits class tells you at runtime how close you are, and a debug log with limit usage per transaction is the cheapest performance review you will ever run. Treat any single code path that consumes more than half of a limit as a design smell, because the other half belongs to the packages and automation you do not control.
One trigger per object, always
Salesforce does not define the execution order of multiple triggers on the same object. Two triggers on Opportunity means non-deterministic behaviour by design, and the non-determinism compounds every time a new team member adds a third. The pattern that survives at enterprise scale is one trigger per object, containing no logic — a single statement that delegates to a handler class.
The handler earns its existence by doing four jobs. It routes each trigger context — before insert, after update and so on — to the right methods. It controls recursion, ideally by tracking processed record IDs per context rather than a crude static boolean that silently skips the second chunk of a 400-record load. It exposes a bypass switch, via custom setting or custom permission, so data migrations and integration users can load records without setting off every automation in the org. And it fixes the order in which domain logic runs, explicitly, in code someone can read.
Whether you adopt an open-source trigger framework or write a hundred-line abstract handler yourself matters less than consistency. Metadata-driven frameworks add configurability at the price of indirection; hand-rolled handlers are simpler but need discipline to stay uniform. Pick one approach for the org and enforce it in code review.
Separation of concerns: thin entry points, one service layer
A trigger handler is an entry point. So is an LWC controller, a REST resource, a Queueable and a scheduled job. The moment the same business rule lives in two entry points, the org has a defect waiting for a reproduction case. The fix is a service layer: business logic lives in service classes that own transaction boundaries, savepoints and error semantics, and every entry point is a thin adapter that validates input, calls the service and shapes the response.
Below the service, two supporting patterns keep database access sane. Selector classes centralise SOQL per object — one place that defines field lists, enforces sharing and user-mode access, and prevents the same query being written eleven slightly different ways. A unit of work collects pending DML across the transaction, resolves parent-child relationships and commits once, which is how you keep to a single DML statement per object type even when five services participate in one transaction.
The honest trade-off: layering has a cost, and a ten-user org with two triggers does not need a full enterprise pattern library. The threshold question is whether logic exists exactly once. When the same discount rule fires from the UI, the API and a nightly batch, produces the same result and is covered by the same tests, the layers have paid for themselves.
Async Apex: Queueable vs Batch vs Platform Events
Anything that does not need to happen inside the user's transaction should leave it — callouts, heavy recalculation, cross-object fan-out. Salesforce gives you several vehicles, and choosing by habit rather than by fit is a common failure mode.
Queueable Apex is the default for bounded, event-driven work. It accepts complex object state, chains follow-up jobs, and — paired with Transaction Finalizers — can implement real retry logic when a callout fails. Prefer it over @future methods, which take only primitives, cannot chain and are effectively legacy.
Batch Apex is for volume. Each execute scope receives a fresh set of governor limits, which is the only sustainable way to process millions of rows on-platform. The costs: startup latency, constrained concurrency across the org's batch jobs, and state that must be carried explicitly via Database.Stateful. A batch job that processes fifty records was almost always meant to be a Queueable.
Platform Events decouple publisher from subscriber. They shine when multiple consumers care about one business fact — an order was activated — or when an external system subscribes through CometD or the Pub/Sub API instead of being called synchronously. Design subscribers to be idempotent: delivery is at-least-once, and your handler will eventually see a replay. Event-driven design across system boundaries is a topic of its own; our guide to Salesforce integration patterns covers when events beat synchronous REST.
One rule ties these together: async is a contract, not an escape hatch. Every async path needs an answer for partial failure, retry and monitoring before it ships.
Tests that actually assert
The platform requires 75% code coverage to deploy. That number has done real damage, because teams optimise for it: tests that call a method, swallow every exception and assert nothing. Those tests are worse than none — they are green lights wired to nothing.
A test suite that earns trust has different properties. Every test asserts a specific outcome with the Assert class, including a message that explains the failure. Data is built through a shared factory, never with SeeAllData, so tests are deterministic in any org. Bulk paths are tested with 200 records, because that is how triggers actually run. Negative paths are tested deliberately: the exception type, the error message a user sees, the record that must not change. Permission-sensitive logic runs under System.runAs with a minimally privileged user, not as the admin executing the deployment. Callouts are mocked with HttpCalloutMock, and Test.startTest/stopTest brackets both reset limits and force queued async work to execute so its results can be asserted.
We treat assertions per test, not coverage percentage, as the number worth watching in review — our Salesforce code review guide describes the checklist we apply. Coverage tells you what ran. Assertions tell you what is true.
Deployment hygiene
Everything above survives only if the delivery pipeline protects it. The baseline for an enterprise org in 2026: source is mastered in version control in source-tracked project format, not in a sandbox someone is afraid to refresh. Every change moves by pull request, reviewed against a written standard. CI runs static analysis — Salesforce Code Analyzer with PMD rules, plus graph-based checks for CRUD/FLS enforcement and SOQL injection — and executes the relevant test suite before a human looks at the diff.
Deployments themselves stay small and boring. Validate against production ahead of the release window and use quick deploy, so the risky part happens while engineers are fresh. Destructive changes ship deliberately and separately. Permissions travel as permission sets in source, because profile drift between sandboxes is one of the most common causes of works-in-UAT failures. And every release has a rollback answer written down before it starts, even if the answer is redeploying the previous commit.
If your org carries years of accumulated exceptions to this — unmerged hotfixes, orgs that differ from the repository, tests skipped temporarily since 2023 — that is recoverable, but it is an engineering project of its own. Our work on technical debt rescue and a structured Salesforce architecture review are the usual entry points.
Frequently asked questions
Should every Salesforce object really have only one Apex trigger?
Yes. Salesforce does not guarantee execution order when multiple triggers exist on the same object, which makes behaviour non-deterministic as the org grows. A single trigger that delegates to a handler class gives you explicit ordering, recursion control and a bypass mechanism for data loads. Managed packages ship their own triggers, which you cannot consolidate — but every trigger you own should follow the one-trigger rule.
When should I use Queueable Apex instead of Batch Apex?
Use Queueable for bounded, event-driven work: a callout after DML, a chained follow-up job, or logic that needs complex object state. Use Batch Apex when you are processing data volumes that cannot fit within a single transaction's limits, because each batch scope receives a fresh set of governor limits. Many enterprise orgs use both — Queueables for near-real-time offloading, Batch for scheduled bulk processing.
Is 75% code coverage enough for enterprise Apex?
75% is the platform's deployment gate, not a quality target. Coverage measures which lines executed, not whether behaviour was verified. A serious test suite asserts outcomes, exercises bulk scenarios with 200 records, tests negative paths and permission boundaries, and runs on every deployment. Teams that chase coverage percentages usually end up with tests that pass while the business logic is broken.
Do Apex best practices still matter now that Flow is so capable?
Arguably more than ever. Flow is the right tool for many admin-owned rules, but orgs that push complex logic into dozens of flows on one object recreate the ordering and recursion problems trigger frameworks solved years ago. The discipline is tool-agnostic: one entry point per object, logic defined once, bulk-safe design, honest tests. Choosing between Flow and Apex deserves its own decision framework.
How do we introduce these practices into an org full of legacy Apex?
Incrementally. Establish a static-analysis baseline, get everything into version control, and route all new work through a single trigger handler and service layer. Refactor legacy code opportunistically — when a defect or feature already touches it — rather than attempting a big-bang rewrite. An architecture review helps sequence the work by risk: recursion-prone triggers and untested revenue logic usually come first.
Bring the hard part to a senior Salesforce engineering team.