Apex Programming Guide: Triggers, Testing and Best Practices

SALESFORCE Aug 19, 2026 0 comments 10 Minutes Read
Vaibhav Sharma By Vaibhav Sharma
Apex Programming Guide: Triggers, Testing and Best Practices
Last updated: 19 August

Apex is easy to learn and hard to write well, as the syntax takes about a week while the constraints take a quarter.

Most Apex problems in production aren’t syntax errors; they’re code that worked perfectly against ten records and collapsed against fifty thousand, or a trigger that fires twice because nobody added a recursion guard, or a class that passes 78% coverage while asserting nothing at all.

This guide covers the language and then the parts that actually break.

Quick Answer: Apex is Salesforce’s server-side language: Java-like syntax running inside hard per-transaction limits. Bulkify everything and assume 200 records. One trigger per object, delegating to a handler. Use Queueable as your default async option. Enforce security with WITH USER_MODE rather than manual FLS checks. And treat 75% coverage as a deployment gate not a quality target.

What Is Apex?

Apex is Salesforce’s proprietary, strongly typed, object-oriented programming language. It runs on Salesforce servers, executes in the context of a database transaction, and has SOQL and DML built directly into the syntax rather than bolted on through a driver.

You reach for it when the declarative tools can’t express what you need – complex bulk processing, callouts with retry logic, recursion control, transactional rollback, or logic that has to be unit-tested to a standard Flow can’t reach. See Flow vs Apex for where that line sits.

Apex Looks Like Java. It Doesn’t Behave Like Java.

Comparison of Java and Apex across resource limits, threading, static state, data access, security, testing, and deployment

Comparison of Java and Apex across resource limits, threading, static state, data access, security, testing, and deployment

The three that catch experienced developers hardest:

  • Static State Resets Every Transaction: In Java, a static variable lives as long as the application. In Apex it’s wiped when the transaction ends. That sounds limiting until you realise it’s exactly what makes the static recursion guard pattern work.
  • There Are No Threads: Asynchronous work means Queueable, Batch, Future, or Scheduled – four specific mechanisms with different rules, not a thread pool you control.
  • Governor Limits Aren’t Performance Guidance: They’re hard caps. Exceed one and the entire transaction rolls back immediately. There’s no degradation, no warning, and no partial success.

Full reference: Salesforce governor limits

Apex Syntax Essentials

public with sharing class AccountService 

 

 

Four things in that snippet worth naming:

  • With sharing: Enforces the running user’s record access. Make it your default. without sharing is a deliberate, documented exception – never a habit.
  • Collections over singletons: List, Set and Map are the working vocabulary of Apex. A Map<Id, SObject> is the single most useful structure you’ll write.
  • DML outside the loop: One update on a collection, not 200 updates inside a loop.
  • The isEmpty() guard: DML on an empty list still consumes a statement against your limit.

Triggers and the Trigger Handler Framework

A trigger is Apex that fires on a database event – before or after insert, update, delete, or undelete.

 

That’s the whole trigger, and the logic belongs elsewhere.

Four-layer trigger framework showing trigger for routing, handler for event decisions and recursion guard, service for business logic and selector for all SOQL

Four-layer trigger framework showing trigger for routing, handler for event decisions and recursion guard, service for business logic and selector for all SOQL

Why One Trigger Per Object Matters: If you have three triggers on Account, Salesforce decides the order they execute in and doesn’t tell you. You get non-deterministic behaviour that appears only under specific conditions. One trigger, one handler, explicit ordering.

The recursion guard, which lives in the handler:

 

This works precisely because static state resets between transactions. Without a guard, an update inside a trigger re-fires the trigger, and you get cascading updates that hit governor limits in ways that are genuinely difficult to trace.

  • Context Variables You’ll Use Constantly: Trigger.new, Trigger.old, Trigger.newMap, Trigger.oldMap, plus isBefore, isAfter, isInsert, isUpdate.

Before or after? Before, for modifying the record that fired the trigger – no DML needed; the values are written as part of the same save. After for related records and for anything needing the record ID on insert.

Bulkification: The Single Most Important Habit

This is the difference between code that works and code that works in production.

 

  • The Pattern Never Changes: Collect IDs into a Set, query once outside the loop into a Map, loop again to apply. Learn it once, apply it everywhere.
  • Never Assume One Record: Trigger.new[0] is a bug waiting for a data load. A trigger receives up to 200 records per batch, and Data Loader will find that out for you.

Related: SOQL and SOSL guide

The Order of Execution

When a record saves, Salesforce runs a defined sequence – and misunderstanding it causes the most confusing bugs in the platform.

The simplified order: system validation → before triggers → custom validation rules → duplicate rules → record saved but not committedafter triggers → assignment rules → auto-response rules → workflow rulesescalation rulesrecord-triggered Flows (after-save) → roll-up summaries → criteria-based sharing → commit.

Two Important Consequences:

Validation rules run after before-triggers, so a before-trigger can set a value that a validation rule then rejects.

Workflow field updates re-fire before and after triggers, which is one of the main reasons recursion guards exist.

If a field is being set by something and you can’t find what, the order of execution is where to look – and the culprit is frequently a Flow rather than Apex. See Salesforce Flow.

Asynchronous Apex

Which Asynchronous Apex do you need?

Comparison of Future, Queueable, Batch and Scheduled Apex showing what each is for and when to use it

  • Use Queueable by default: It accepts sObjects and complex types as parameters, returns a job ID you can monitor, and chains to the next job. @future is the older approach with primitive-only parameters and no chaining – largely superseded.
  • Use Batch for volume: Up to 50 million records, processed in chunks of 200, with fresh governor limits per chunk. That last part is why Batch is the answer to “I need to process everything.”
  • Use Scheduled to trigger Batch: Scheduled Apex is a timer, not a processor – the pattern is a Schedulable class that enqueues a Batch job.

public class AccountUpdateQueueable implements Queueable,

 

One rule that catches people: you cannot make a callout directly from a trigger. Callouts must be asynchronous – use Queueable with Database.AllowsCallouts.

Exception Handling

 

Catch specific exception types, not bare Exception. DmlException, QueryException, CalloutException, NullPointerException each tell you something different.

  • Never swallow silently: An empty catch block converts a loud failure into a silent data problem, which is strictly worse.
  • Log with context: record IDs, the operation, the user. “Update failed” tells the next developer nothing.

Custom exceptions are just a class extending Exception, and they make failures readable:

public class AccountServiceException extends Exception {}

Database.update(records, false) performs a partial save, letting good records through while returning per-record results for the failures. Useful in bulk contexts where one bad record shouldn’t fail 199 good ones.

Security: What Most Apex Guides Still Get Wrong

Apex runs in system context by default. It ignores the running user’s object permissions, field-level security, and sharing rules unless you tell it not to.

The modern approach is one clause:

 

WITH USER_MODE enforces object permissions, field-level security, and sharing rules in a single clause. It’s clearer and more complete than the older WITH SECURITY_ENFORCED, which covered CRUD and FLS but not sharing.

For DML, use the AccessLevel parameter:

 

For stripping inaccessible fields rather than throwing:

 

Class-Level Sharing: with sharing respects record access, without sharing ignores it, inherited sharing takes the caller’s context. Default to with sharing and document any exception.

Related: Salesforce compliance and data security

Testing Beyond the 75% Rule

Salesforce requires 75% Apex coverage to deploy to production. That’s a gate, not a quality measure – coverage records which lines executed, not whether your assertions proved anything. A test that runs your code and asserts nothing passes.

 

What matters more than coverage:

  • @testSetup builds shared data once per class rather than per method
  • A test data factory keeps setup consistent and maintainable
  • 200+ records in bulk tests, because that’s what production sends
  • Negative tests for validation failures and permission denials
  • Test.startTest() / stopTest() resets governor limits and forces async work to complete
  • Callout mocks via HttpCalloutMock, so your tests don’t depend on someone else’s uptime
  • Never SeeAllData=true. It makes tests dependent on org data, and they’ll fail in a fresh sandbox.
  • Assert class methods with meaningful messages – the newer Assert.areEqual() is preferred over System.assertEquals()

Build Better With Salesforce Development Expertise

Get expert support for Apex development, modernization, testing, and complex Salesforce engineering needs.

Contact Us Now →

Apex Anti-Patterns

  1. SOQL or DML Inside A Loop – The most common cause of production failure
  2. Multiple Triggers On One Object – Non-deterministic execution order
  3. Logic In The Trigger Body – Untestable in isolation
  4. No Recursion Guard – Cascading updates that are hard to trace
  5. Trigger.new[0] – Assuming a single record
  6. Hardcoded IDs – Works in the sandbox, breaks in production
  7. Without Sharing As A Default – A security decision made by accident
  8. Empty Catch Blocks – Silent failure, the worst kind
  9. God Classes – One class doing everything, testable by nobody
  10. Chasing Coverage Instead of writing assertions
  11. SeeAllData=true – Tests that depend on org data
  12. Ignoring Null Returns From Get/Query Results – A query that finds nothing returns an empty list, not an error

Need Salesforce Developers for Your Team?

Add experienced Salesforce developers to your team for ongoing development, technical debt, and project delivery.

Hire Salesforce Developers →

 

Apex in 2026: Invocable Methods and Agentforce

Apex is increasingly the backend for declarative and AI features rather than the front line.

Invocable methods expose Apex to Flow and now to Agentforce as custom agent actions:

 

That single annotation makes your business logic callable from a Flow, from an Agentforce agent action, and from an LWC without rewriting it. It’s the reason the service-layer pattern matters more now than it did five years ago: logic in a service class is reusable everywhere; logic in a trigger body isn’t.

Related: What is Agentforce · Lightning Web Components

 

Where to Go Next

For the queries inside your Apex, see the SOQL and SOSL guide. For the limits shaping every design decision, see governor limits.

For when to use code at all, Flow vs Apex and configuration vs customization.

For the front end calling your Apex, Lightning Web Components.

For deploying it safely, Salesforce DevOps and sandboxes.

The complete Salesforce development guide is the hub.

When the Apex Is the Problem

There’s A Recognisable State an Org Reaches: Nobody wants to touch the Apex. Coverage sits just above 75%. Changes break things nobody predicted. The person who wrote it left in 2022.

That’s remediation work, and it’s routine for our team. Our Salesforce Development Services can help map what the code actually does, add meaningful test coverage, restructure triggers into a proper framework, and leave your team with code they can safely maintain and extend.

We’re a certified Salesforce Consulting Partner with developers and architects across the US, UK, Australia, Canada, UAE, and India.

Have an Apex Challenge to Solve?

Tell us what you’re working on and our Salesforce experts will help you determine the right next step.

Contact Our Salesforce Experts →

FAQs

Salesforce’s proprietary server-side programming language – strongly typed, object-oriented, with Java-like syntax and SOQL and DML built into the language. It runs on Salesforce servers inside strict per-transaction governor limits and handles logic the declarative tools can’t express.

The syntax is similar, but the behaviour isn’t. Apex has hard per-transaction resource limits, no threading, static state that resets each transaction, built-in database access, a runtime-enforced sharing model and mandatory test coverage before deployment.

Create one trigger per object containing no logic, just a call to a handler class. Put event routing and the recursion guard in the handler, business logic in a service class, and SOQL in a selector class. Always write for 200 records, never one.

Writing code that processes collections rather than individual records. Collect IDs into a Set, query once outside the loop into a Map, then loop to apply. Queries or DML inside a loop are the most common cause of governor limit failures in production.

Queueable handles chainable asynchronous work with sObject parameters and a monitorable job Id – the default choice for async. Batch processes up to 50 million records in chunks of 200, with fresh governor limits per chunk, making it the option for large data volumes.

Because your code runs on shared infrastructure and Salesforce needs assurance it won’t destabilise the platform during upgrades. It’s a deployment gate, not a quality measure – coverage records which lines ran, not whether the assertions proved anything meaningful.

Add WITH USER_MODE to your SOQL, which enforces object permissions, field-level security, and sharing rules in one clause. For DML, use Database.insert(records, AccessLevel.USER_MODE). Security.stripInaccessible() removes inaccessible fields rather than throwing an exception.

A static Boolean in the trigger handler that prevents the trigger from re-firing when its own updates trigger it again. It works because static variables reset at the end of each transaction, so the guard is fresh for every new save.

Not directly. Callouts must be asynchronous when initiated from a trigger – use a Queueable class implementing Database.AllowsCallouts, enqueued from the trigger handler.

Vaibhav Sharma

Vaibhav Sharma

A Salesforce specialist with over 15 years in the field, Vaibhav Sharma has built his career at the intersection of CRM strategy and enterprise transformation. As the driving force behind Salesforce innovation at DianApps, he architects solutions across Sales Cloud, Service Cloud, and Experience Cloud that turn fragmented customer data into unified growth engines. His work spans a 360-degree perspective in BFSI, healthcare, and retail, where he's known for engineering platforms that don't just go live, they move revenue and retention numbers. Vaibhav's edge lies in reading where Salesforce is headed next, from AI-powered automation, AgentExchange to Agentforce, and translating that foresight into roadmaps enterprise leaders can act on today.

Leave a Comment

Your email address will not be published. Required fields are marked *

Get a free Quote

You will receive a reply in 2 min and your idea is completely safe with us.

9 + 9 = ?
  • In just 2 mins you will get a response
  • Your idea is 100% protected by our Non Disclosure Agreement
Add us as a preferred source on Google »

Looking for something specific?