Language Overview
Apex is Salesforce’s native programming language used to write custom business logic, automate workflows, and integrate with external systems — all directly within the Salesforce platform. It’s similar to Java in syntax, but purpose-built for Salesforce's multitenant, cloud-based environment.
Why Do We Need Apex?
- Declarative tools fall short: When Flow can't handle complex logic like nested loops, heavy multi-object transactions, or sophisticated error handling, Apex is the go-to — Flow and Apex are the two automation tools on the platform today.
- Complex custom automation: Create custom triggers to react to DML events such as insert, update, delete — for example, updating child records when a parent is modified.
- API integrations: Use Apex to call out to external systems using REST or SOAP services — e.g., push Opportunity data to an ERP system on record close.
- Batch and Scheduled Jobs: For large data volume processing or recurring tasks like monthly reports or cleanup jobs.
Features of Apex
- Database-Aware: Use SOQL/SOSL to query Salesforce records, and DML to create, update, or delete them.
- Event-Driven: React to changes using Triggers that fire before or after data changes.
- Asynchronous Support: Perform long-running tasks using future methods, Queueable, Batch Apex, or Schedulable classes.
- Reusable Code: Create Apex classes and methods that can be called from triggers, Flows, or Lightning Components.
- Secure and Scoped: All code runs with org-specific governor limits to avoid abuse in a shared environment.
Design Considerations Before You Build
- Start Simple: Could this be done with Flow or a Validation Rule instead? Always prefer declarative tools first.
- One Trigger per Object: Use handler classes to keep trigger logic clean and maintainable.
- Bulkify Always: Code must handle multiple records. Avoid queries or DML inside loops.
- Think of Governor Limits: E.g., max 100 SOQL queries per transaction. Optimize your code accordingly.
- Use Frameworks (Optional): For large orgs, consider Apex trigger frameworks like TDTM or fflib.
// Simple Apex Class: Count Contacts per Account
public class AccountProcessor {
public static void countContacts(List<Id> accountIds) {
// Fetch related contacts using a subquery
List<Account> accounts = [SELECT Id, (SELECT Id FROM Contacts)
FROM Account WHERE Id IN :accountIds];
for(Account acc : accounts) {
acc.Number_of_Contacts__c = acc.Contacts.size(); // Custom field
}
update accounts; // Persist updates
}
}
Development Tools
To write and deploy Apex code, Salesforce offers various tools for different skill levels. Whether you're building enterprise-grade apps or learning to code, the tools below will support your workflow.
Primary Development Tools
-
Visual Studio Code with the Salesforce Extension Pack (recommended):
- Recommended for: Every serious Apex developer — this is Salesforce's officially supported IDE
- Syntax highlighting, IntelliSense/code completion, Apex test execution, the Apex Replay Debugger, and deployment via the Salesforce CLI
- Built for source-driven projects, Git version control, and CI/CD pipelines
- Scenario: Build a Lightning Web Component and Apex class, push to a scratch org, run tests — all from the terminal
-
Salesforce CLI (
sf):- The command-line backbone of modern Salesforce development — creates projects and scratch orgs, deploys/retrieves metadata, runs tests, and tails debug logs
- Note: the old
sfdx-style commands (sfdx force:...) are retired; use the unifiedsfcommands shown below
-
Code Builder (browser-based):
- A full VS Code environment that runs in your browser — same extensions and CLI, zero local install
- Recommended for: Locked-down laptops, quick fixes from any machine, or trying Salesforce development without setup
-
Developer Console (built into Salesforce UI):
- Recommended for: Quick anonymous-Apex scripts, ad-hoc SOQL queries, and inspecting debug logs
- Includes SOQL Query Editor, Debug Logs viewer, Test Runner, and Logs Inspector — fine for spot checks, but not for real project work
- Scenario: Debug a trigger failure by running test methods and inspecting logs directly
-
Agentforce for Developers (AI assistance):
- Salesforce's AI coding assistant inside VS Code / Code Builder — generates Apex from natural-language prompts, explains code, and suggests test cases. Always review generated code against the best practices in this guide.
Developer Environment Setup
-
Step 1: Sign up for a Salesforce Developer Org
Create a free Salesforce Developer account at developer.salesforce.com/signup. This gives you your own playground with full API access and Apex support. -
Step 2: Install Visual Studio Code (VS Code)
Download from code.visualstudio.com and install the Salesforce Extension Pack from the Extensions marketplace. -
Step 3: Install Salesforce CLI
Download from developer.salesforce.com/tools/salesforcecli. The CLI is required for creating projects, authorizing orgs, running tests, and deploying from VS Code. Verify the install withsf --version. -
Step 4: Create Your First Project
This scaffolds the standard source-driven folder structure (sf project generate --name MyFirstProject cd MyFirstProjectforce-app/main/default) where your Apex classes will live. -
Step 5: Authorize Your Org
From the project folder, run:A browser opens for login. Once done, your org is connected — VS Code and the CLI deploy to it by default.sf org login web --alias DevOrg --set-default -
Step 6: Create, Deploy, and Test a Class
That's the core loop of Apex development: write locally → deploy to a dev org → run tests. Everything else in this guide builds on it.sf apex generate class --name AccountProcessor --output-dir force-app/main/default/classes sf project deploy start --source-dir force-app sf apex run test --class-names AccountProcessorTest --result-format human --code-coverage
Data Types in Apex
Apex supports a variety of data types to help you manage different business logic needs. These include: primitive types (basic numeric/text values), sObjects (Salesforce records), collections (Lists, Maps, Sets), and custom classes.
Primitive Data Types (With Examples)
-
Integer: Whole numbers between -2,147,483,648 and 2,147,483,647
Integer totalLeads = 250;
Integer remaining = totalLeads - 50; -
Double: Decimal values with floating point precision
Double temperature = 36.75;
Double discount = 12.5 / 100; -
Long: Larger whole numbers than Integer, useful for large counters or timestamps
Long pageViews = 34567890123L;
Long userId = System.now().getTime();// Timestamp -
Decimal: High-precision decimal values ideal for currency
Decimal invoiceTotal = 1999.99;
Decimal tax = invoiceTotal * 0.18;
System.debug('Final Price: ' + (invoiceTotal + tax)); -
String: Text data enclosed in single quotes
String company = 'Acme Corporation';
String greeting = 'Hello ' + company + '!'; -
Boolean: Logical true/false values
Boolean isLoggedIn = true;
if (isLoggedIn) { System.debug('Welcome back!'); } -
Date, Datetime, Time: Useful for scheduling, time tracking, and date math
Date today = Date.today();
Datetime now = Datetime.now();
Time alarm = Time.newInstance(8, 30, 0, 0);
Date endDate = today.addDays(30); -
ID: Represents a Salesforce record's unique identifier (15 or 18 chars)
Id contactId = '0038Z00001ABCxY';
Contact c = [SELECT Name FROM Contact WHERE Id = :contactId]; -
Blob: Binary large objects—used for file content, images, encryption
Blob fileBody = Blob.valueOf('PDF content here');
String base64 = EncodingUtil.base64Encode(fileBody); -
Enum: Define your own fixed set of values for better control
public enum Status { Draft, Submitted, Approved }
Status current = Status.Submitted;
if (current == Status.Submitted) { System.debug('Pending review'); }
Variables in Apex
Variables in Apex are named containers used to store values that may change as your program runs. Think of them as labeled boxes that temporarily hold data. Each variable must be declared with a data type (such as String, Integer, or a custom object like Account), and the variable's accessibility depends on where it is defined—called its scope.
Key Concepts
-
Declaration: You must declare variables with a data type. Optionally, you can assign an initial value.
String name = 'Salesforce';
Boolean isActive = true;
Integer quantity; -
Scope: Determines where a variable can be accessed in your code:
Global / Public– accessible across classes or even from external packagesPrivate– accessible only within the same classLocal– declared within methods or code blocks and accessible only there
-
Reassignment: You can update a variable's value during execution.
Integer count = 1;
count = count + 1;
Types of Variable Scope (Examples)
-
1. Instance Variable (Class-level, used across multiple methods):
public class Cart {
Integer totalItems = 0;
public void addItem() {
totalItems++;
}
public Integer getTotalItems() {
return totalItems;
}
} -
2. Local Variable (Declared inside a method):
public void calculateTax() {
Decimal price = 100.0;
Decimal taxRate = 0.18;
Decimal tax = price * taxRate;
System.debug('Total Tax: ' + tax);
} -
3. Static Variable (Shared across all instances of the class):
public class Invoice {
public static Integer globalCounter = 0;
public Invoice() {
globalCounter++;
}
public static Integer getInvoiceCount() {
return globalCounter;
}
}Use case: Track total number of invoices created system-wide.
-
4. Final Variable (Read-only after initial assignment):
public void showLimit() {
final Integer MAX_LIMIT = 500;
// MAX_LIMIT = 600; // Compile-time error
System.debug('Limit is: ' + MAX_LIMIT);
} -
5. Global Variable (Rarely used; exposed to managed packages):
global class Library {
global String version = 'v1.0';
global void showVersion() {
System.debug(version);
}
}
- Example: Lead Assignment Tracker
// Class to track number of leads assigned to a user
public class LeadAssigner {
private Integer leadCount = 0;
public void assignLead(String repName) {
leadCount++;
System.debug('Assigned lead #' + leadCount + ' to: ' + repName);
}
}
// Usage:
LeadAssigner la = new LeadAssigner();
la.assignLead('Priya'); // Assigned lead #1 to: Priya
la.assignLead('Amit'); // Assigned lead #2 to: Amit
In this example, leadCount is a private instance variable. It retains its value across multiple method calls on the same object, which makes it perfect for tracking cumulative actions like assignments.
Bonus Example: Using Static Variable to Track All Leads Globally
public class GlobalLeadTracker {
public static Integer totalLeads = 0;
public static void addLead() {
totalLeads++;
System.debug('Total Leads in System: ' + totalLeads);
}
}
// Usage:
GlobalLeadTracker.addLead(); // Total Leads in System: 1
GlobalLeadTracker.addLead(); // Total Leads in System: 2
A static variable like totalLeads is useful when you want a count that applies across all users and sessions—like system-wide tracking of records.
Understanding where and how to define your variables in Apex is key to writing clean, maintainable, and efficient logic. Use the right variable scope to avoid bugs, unnecessary memory usage, and improve code readability.
Collections in Apex
Collections in Apex are powerful structures that let you store and manage multiple values together. There are three main types of collections: List, Set, and Map. These are commonly used in queries, loops, batch processing, and business logic.
1. List (Ordered Collection, Allows Duplicates)
A List is an ordered collection of elements. It allows duplicates and preserves insertion order. Think of it as a dynamic array that can grow or shrink as needed.
Useful List Methods:
.add(), .addAll(). .remove(index), .size(), .isEmpty(), .clear(), .contains()
// List Example: Managing a list of fruits
List fruits = new List{'Apple', 'Mango', 'Apple'};
// .add(): Add an item
fruits.add('Banana');
// .remove(index): Remove item at specific index
fruits.remove(1); // Removes 'Mango'
// .size(): Get number of elements
System.debug('Total fruits: ' + fruits.size()); // 3
// .isEmpty(): Check if list is empty
System.debug(fruits.isEmpty()); // false
// .clear(): Remove all elements
fruits.clear();
// .contains(): Check if a value exists
fruits.add('Orange');
System.debug(fruits.contains('Orange')); // true
// Access by index
System.debug(fruits[0]); // Orange
// Loop through list
for (String f : fruits) {
System.debug('Fruit: ' + f);
}
2. Set (Unordered, No Duplicates)
A Set is an unordered collection that ensures all elements are unique. Use it when duplicates are not allowed.
Useful Set Methods:
.add(), .contains(), .remove(), .size(), .isEmpty(), .clear()
// Set Example: Unique user IDs
Set userIds = new Set{101, 102, 102, 103}; // 102 appears only once
// .add(): Add value
userIds.add(104);
// .contains(): Check existence
System.debug(userIds.contains(101)); // true
// .remove(): Remove value
userIds.remove(102); // 102 removed
// .size(): Number of unique elements
System.debug('Total IDs: ' + userIds.size()); // 3
// .isEmpty(): Check if set is empty
System.debug(userIds.isEmpty()); // false
// .clear(): Remove all elements
userIds.clear();
System.debug('Now empty? ' + userIds.isEmpty()); // true
3. Map (Key-Value Pair)
A Map holds data in key-value pairs. Keys must be unique. Use it when you need fast access to a value using a specific key (like a dictionary).
Useful Map Methods:
.put(), .get(key), .containsKey(), .keySet(), .values(), .size(), .remove(key), .clear()
// Map Example: Track stock quantities
Map stock = new Map();
// .put(): Add or update key-value pair
stock.put('Apples', 100);
stock.put('Bananas', 50);
// .get(): Get value using key
System.debug('Apples in stock: ' + stock.get('Apples')); // 100
// .containsKey(): Check if key exists
System.debug(stock.containsKey('Bananas')); // true
// .keySet(): Get all keys
for (String item : stock.keySet()) {
System.debug('Item: ' + item);
}
// .values(): Get all values
for (Integer qty : stock.values()) {
System.debug('Quantity: ' + qty);
}
// .size(): Number of key-value pairs
System.debug('Total items: ' + stock.size()); // 2
// .remove(): Delete a key
stock.remove('Apples');
// .clear(): Remove everything
stock.clear();
System.debug('Is empty now? ' + stock.isEmpty()); // true
Combined - Use Case
Here's how you can use all three collections to assign leads to sales reps based on region.
// Step 1: Get unique regions using Set
Set regions = new Set();
for (Lead l : [SELECT Region__c FROM Lead WHERE CreatedDate = LAST_N_DAYS:30]) {
if (l.Region__c != null) {
regions.add(l.Region__c);
}
}
// Step 2: Assign reps to regions using Map
Map repByRegion = new Map{
'East' => 'Kunal',
'West' => 'Sara',
'North' => 'Amit'
};
// Step 3: Store output in a List
List assignments = new List();
// Step 4: Loop through regions and prepare message
for (String r : regions) {
if (repByRegion.containsKey(r)) {
String msg = 'Assign leads in ' + r + ' to ' + repByRegion.get(r);
assignments.add(msg);
System.debug(msg);
} else {
String msg = 'No rep assigned for region: ' + r;
assignments.add(msg);
System.debug(msg);
}
}
Conditional Statements in Apex
Conditional statements allow your Apex code to make decisions and take different actions based on specific conditions. These include if, if-else, else if, and switch statements.
They are essential when building logic flows, validations, and branching behavior in triggers, classes, and controllers.
Using if and if-else
The if statement checks whether a condition is true. You can extend it using else and else if blocks to handle alternative logic paths.
Integer score = 85;
if (score >= 90) {
System.debug('Grade: A');
} else if (score >= 75) {
System.debug('Grade: B');
} else if (score >= 60) {
System.debug('Grade: C');
} else {
System.debug('Grade: F');
}
// Output: Grade: B
Example: Opportunity Discount Check
Let’s say you want to apply a different message based on the discount given on an Opportunity record:
Decimal discount = 20;
if (discount > 30) {
System.debug('High discount - Needs Manager Approval');
} else if (discount > 10) {
System.debug('Medium discount - Auto-approved');
} else {
System.debug('Low discount - No approval required');
}
// Output: Medium discount - Auto-approved
Best Practice Tips for If-Else
- Avoid deeply nested
if-elseblocks; useswitchor early returns if possible. - Always handle the
elsecase to prevent unexpected behavior. - Use comparison operators like
==,!=,<,>=, etc. correctly.
Using switch Statement
The switch statement is useful when you want to compare a single variable against multiple possible values.
This improves readability over long chains of if-else if.
String paymentMode = 'UPI';
switch on paymentMode {
when 'CreditCard' {
System.debug('Processing Credit Card payment');
}
when 'UPI' {
System.debug('Processing UPI payment');
}
when 'NetBanking' {
System.debug('Processing Net Banking payment');
}
when else {
System.debug('Unknown payment method');
}
}
// Output: Processing UPI payment
Example: Support Case Priority Handling
Suppose you're handling support cases and want to log different messages based on priority levels:
String casePriority = 'High';
switch on casePriority {
when 'Low' {
System.debug('Log and assign to tier-1 support');
}
when 'Medium' {
System.debug('Notify supervisor and assign to tier-2 support');
}
when 'High' {
System.debug('Escalate immediately to tier-3');
}
when else {
System.debug('Unknown priority. Please review the record.');
}
}
// Output: Escalate immediately to tier-3
Switch Statement Tips
switchin Apex supports only onewhen elseblock, and it must come last.- You can match multiple values in a single
whenblock:when 'A', 'B' { ... }. - Only scalar types like
String,Integer,Enumare allowed inswitchconditions.
Example: Enum with Switch
Apex Enums are often used in switch for better readability. Here's an example using a custom enum:
public enum UserType { CUSTOMER, PARTNER, EMPLOYEE }
UserType type = UserType.PARTNER;
switch on type {
when CUSTOMER {
System.debug('Provide self-service portal access');
}
when PARTNER {
System.debug('Provide partner community access');
}
when EMPLOYEE {
System.debug('Provide internal access');
}
}
// Output: Provide partner community access
Summary
Conditional logic is at the heart of any business application. Apex offers both flexible if-else chains and clean switch statements for controlling flow.
Use them wisely to keep your logic readable and efficient. For complex or repetitive conditions, consider moving logic into reusable methods or handlers.
Loops in Apex
Loops in Apex allow you to execute a block of code repeatedly, either for a known number of times or while a certain condition remains true. Apex supports several types of loops including for, while, and do-while.
Loops are commonly used for bulk processing records, iterating through lists, and performing repetitive operations.
1. For Loop (Traditional)
A traditional for loop lets you iterate a specific number of times using an index variable.
// Print numbers 1 to 5
for (Integer i = 1; i <= 5; i++) {
System.debug('Number: ' + i);
}
2. For-Each Loop (List or Set)
This loop is used when iterating through collections like List, Set, or Map values.
// List of lead names
List<String> leadNames = new List<String>{'Anil', 'Priya', 'Ravi'};
for (String name : leadNames) {
System.debug('Lead Name: ' + name);
}
3. For Loop with SOQL (Inline Query Loop)
This special loop allows direct iteration over records from a SOQL query. It's memory efficient and best used with large datasets.
// Efficient way to loop over Accounts
for (Account acc : [SELECT Id, Name FROM Account WHERE CreatedDate = LAST_N_DAYS:30]) {
System.debug('Account: ' + acc.Name);
}
Best Practices for SOQL in Loops
- Do not put SOQL inside traditional
fororwhileloops; it can hit governor limits. - Use
for (sObject : [SOQL])directly when possible. - Always use selective filters in your query to avoid pulling too much data.
4. While Loop
A while loop runs as long as a condition is true. It checks the condition before every iteration.
Integer count = 0;
while (count < 3) {
System.debug('Count is: ' + count);
count++;
}
5. Do-While Loop
A do-while loop runs the code block once before checking the condition. It's useful when you need the code to run at least once regardless of the condition.
Integer number = 0;
do {
System.debug('Number is: ' + number);
number++;
} while (number < 3);
Example: Update Low-Value Opportunities
Let’s use a loop to iterate through Opportunity records and update the Stage if the amount is too low:
List<Opportunity> oppList = [SELECT Id, Amount, StageName FROM Opportunity WHERE StageName != 'Closed Won'];
for (Opportunity opp : oppList) {
if (opp.Amount < 5000) {
opp.StageName = 'Closed Lost';
}
}
update oppList;
Example: Bulk Lead Email Logging
Suppose you want to log emails for a list of leads, using a for loop:
List<Lead> leads = [SELECT Id, Name FROM Lead WHERE CreatedDate = LAST_N_DAYS:7];
List<Task> emailTasks = new List<Task>();
for (Lead l : leads) {
Task t = new Task(
Subject = 'Follow-up Email',
WhatId = l.Id,
Status = 'Not Started',
Priority = 'Normal'
);
emailTasks.add(t);
}
insert emailTasks;
Looping Tips and Governor Limits
- Apex has a limit of 100 SOQL queries and 150 DML operations per transaction — never put DML or SOQL inside a loop!
- Always bulkify your logic by processing collections instead of individual records.
- Use
breakorcontinuecautiously — they can make logic harder to follow.
Summary
Apex loops are essential for processing data efficiently in Salesforce. From traditional for loops to SOQL-based iterations, each loop has its ideal use case.
To write scalable and governor-limit-safe code, always use loops with best practices in mind—especially when working with large datasets or automation logic.
Classes and Objects in Apex
In Apex, classes and objects are the building blocks of object-oriented programming. A class is a blueprint that defines properties (variables) and behaviors (methods), while an object is a specific instance of that class. This model helps you build modular, reusable, and maintainable code.
Defining a Class
A basic class in Apex contains fields, constructors, and methods. Here's a simple example:
public class MyCar {
public String model;
// Constructor to initialize the model
public MyCar(String model) {
this.model = model;
}
// Method to simulate driving the car
public void drive() {
System.debug(model + ' is driving');
}
}
Creating and Using an Object
You can create an object from the class using the new keyword:
MyCar car1 = new MyCar('Mahindra XUV 700');
car1.drive(); // Output: Mahindra XUV 700 is driving
Another Example: Employee Class
Let’s create a class to represent an employee with a method to calculate annual salary:
public class Employee {
public String name;
public Decimal monthlySalary;
public Employee(String name, Decimal monthlySalary) {
this.name = name;
this.monthlySalary = monthlySalary;
}
public Decimal calculateAnnualSalary() {
return monthlySalary * 12;
}
}
// Usage
Employee emp = new Employee('Ravi Kumar', 50000);
System.debug(emp.name + '\'s annual salary is ₹' + emp.calculateAnnualSalary());
Understanding Object-Oriented Concepts
Apex supports key OOP principles:
- Encapsulation: Restrict access to class members using access modifiers like
privateorpublic. This protects internal data. - Inheritance: One class can reuse another class’s code using the
extendskeyword. - Polymorphism: Different classes can define their own version of the same method (overriding behavior).
Example: Inheritance & Polymorphism
// Parent class
public virtual class Animal {
public virtual void speak() {
System.debug('Animal speaks');
}
}
// Subclass 1
public class Dog extends Animal {
public override void speak() {
System.debug('Dog barks');
}
}
// Subclass 2
public class Cat extends Animal {
public override void speak() {
System.debug('Cat meows');
}
}
// Usage
Animal a1 = new Dog();
Animal a2 = new Cat();
a1.speak(); // Output: Dog barks
a2.speak(); // Output: Cat meows
Access Modifiers Recap
- public: Visible everywhere in your org's Apex code.
- private: Visible only inside the same class.
- protected: Visible in the same class and any subclasses.
- global: Visible across namespaces (used for managed packages).
Static vs Instance Members
- Static Members: Shared across all objects. Called using class name.
- Instance Members: Specific to each object instance.
public class Counter {
public static Integer totalCount = 0;
public Integer individualCount = 0;
public void increment() {
totalCount++;
individualCount++;
}
}
// Usage
Counter c1 = new Counter();
Counter c2 = new Counter();
c1.increment();
c2.increment();
System.debug('Total count: ' + Counter.totalCount); // 2
System.debug('C1 count: ' + c1.individualCount); // 1
System.debug('C2 count: ' + c2.individualCount); // 1
With Sharing, Without Sharing, Inherited Sharing
Sharing rules in Apex control whether the class respects the record-level sharing rules of the current user. By default, Apex classes run in **system mode**, ignoring sharing rules. You can enforce or inherit them like this:
- with sharing: Enforces sharing rules for the user running the code.
- without sharing: Ignores sharing rules. Full access to all records.
- inherited sharing: Inherits the sharing settings of the class that called it.
// Class with enforced sharing rules
public with sharing class LeadHandler {
public static List getMyLeads() {
return [SELECT Name FROM Lead]; // Only leads user has access to
}
}
// Class without sharing (full access)
public without sharing class AdminUtility {
public static void deleteAllLeads() {
delete [SELECT Id FROM Lead]; // Ignores user sharing, can delete all
}
}
// Class that inherits caller's context
public inherited sharing class ServiceDispatcher {
public static void route() {
// Uses the sharing mode of the caller class
}
}
Best Practice: Always use with sharing when working with user-facing logic, especially in Lightning or Apex Controllers. Use inherited sharing for utility or domain classes to maintain context safety.
Summary
Apex classes and objects make it easy to organize business logic using OOP principles. Whether you're building reusable utilities or modeling - entities like Orders, Employees, or Vehicles, mastering classes is foundational to writing robust Apex code. Don’t forget to consider sharing context when building secure and scalable applications.
SOQL (Salesforce Object Query Language)
SOQL is used in Apex to query Salesforce records. It’s similar to SQL but designed specifically for Salesforce's data model.
You use it to retrieve data from standard or custom objects like Account, Contact, Opportunity, and any custom objects you create.
With SOQL, you can fetch only the fields you need, apply filters, sort records, and even join related objects in one query.
Basic Syntax
In Apex, you write SOQL queries inside square brackets [ ] and assign the result to a List of SObjects.
You typically use a SELECT clause to pick fields and a FROM clause to specify the object.
// Get Accounts with Industry populated
List<Account> accs = [SELECT Id, Name FROM Account WHERE Industry != NULL LIMIT 10];
Explanation: Fetches 10 Accounts where Industry is not null.
Here’s another example fetching Contacts created in the last 15 days:
List<Contact> recentContacts =
[SELECT Id, FirstName, LastName FROM Contact
WHERE CreatedDate = LAST_N_DAYS:15];
Filter and Sort Data
You can use WHERE to filter records, AND/OR for combining conditions, ORDER BY to sort, and LIKE for pattern matching.
// High-value closed Opportunities
List<Opportunity> opps =
[SELECT Id, Name, Amount FROM Opportunity
WHERE StageName = 'Closed Won' AND Amount > 100000
ORDER BY Amount DESC];
Example using LIKE and multiple filters:
// Get Accounts with name starting with 'Tata'
List<Account> tataAccounts =
[SELECT Id, Name FROM Account
WHERE Name LIKE 'Tata%' AND Industry = 'Manufacturing'];
Working with Related Objects (Relationship Queries)
SOQL supports object relationships:
Child-to-Parent (Dot Notation): Access fields from parent objects
Parent-to-Child (Subquery): Access related child records via subqueries
// Child to Parent: Contact to Account
List<Contact> contacts =
[SELECT Id, FirstName, LastName, Account.Name FROM Contact];
// Parent to Child: Account with related Contacts
List<Account> accounts =
[SELECT Id, Name, (SELECT FirstName, Email FROM Contacts) FROM Account];
Extra Example: Opportunities and their related Owner names:
List<Opportunity> opps =
[SELECT Id, Name, Owner.Name FROM Opportunity WHERE Amount > 50000];
Aggregate Functions
Use COUNT(), SUM(), AVG(), MIN(), MAX() to calculate summaries. Combine with GROUP BY and HAVING to filter groups.
// Count of Opportunities by Stage
AggregateResult[] results =
[SELECT StageName, COUNT(Id) total FROM Opportunity GROUP BY StageName];
for (AggregateResult ar : results) {
System.debug(ar.get('StageName') + ': ' + ar.get('total'));
}
Example: Total Sales per Sales Rep
// Sum of Opportunity Amount grouped by Owner
AggregateResult[] salesByRep =
[SELECT Owner.Name, SUM(Amount) totalSales FROM Opportunity
WHERE StageName = 'Closed Won' GROUP BY Owner.Name];
for (AggregateResult ar : salesByRep) {
System.debug(ar.get('Owner.Name') + ': ₹' + ar.get('totalSales'));
}
Dynamic SOQL
Dynamic SOQL allows building queries at runtime. Useful in generic Apex code or admin utilities.
// Modern, injection-safe dynamic SOQL: Database.queryWithBinds
// Values are passed as a bind map — never concatenated into the string.
String companyFilter = 'Infosys'; // imagine this came from user input
Map<String, Object> binds = new Map<String, Object>{ 'comp' => companyFilter };
List<Lead> leads = Database.queryWithBinds(
'SELECT Id, Name FROM Lead WHERE Company = :comp',
binds,
AccessLevel.USER_MODE // also enforces the running user's FLS + sharing
);
Another Example: Dynamically fetch Accounts by Industry
String ind = 'Energy';
List<Account> accs = Database.queryWithBinds(
'SELECT Id, Name FROM Account WHERE Industry = :ind',
new Map<String, Object>{ 'ind' => ind },
AccessLevel.USER_MODE
);
Security Note: Never build queries by concatenating user input ('... WHERE Name = \'' + value + '\'') — that's a SOQL injection hole. Use Database.queryWithBinds() as shown above; if you must concatenate (e.g., a dynamic field name), validate it against Schema.describe results and wrap values with String.escapeSingleQuotes().
Best Practices
- Use SELECT fields you actually need — avoid SELECT *
- Use LIMIT to restrict result size and improve performance
- Always filter with WHERE to avoid querying unnecessary data
- Never write SOQL inside loops — bulkify your logic
- Index filter fields when dealing with large data volumes
- Add
WITH USER_MODEto queries that serve user-facing features — it enforces the running user's field-level security and sharing (e.g.,[SELECT Id FROM Account WITH USER_MODE]). Note: the olderWITH SECURITY_ENFORCEDclause is retired in the newest API versions — see the Apex Security section.
SOQL Examples
// Get top 5 Accounts created this month
List<Account> topAccounts =
[SELECT Id, Name FROM Account
WHERE CreatedDate = THIS_MONTH
ORDER BY CreatedDate DESC LIMIT 5];
// Get all Contacts in Mumbai
List<Contact> mumbaiContacts =
[SELECT Id, FirstName, City FROM Contact
WHERE MailingCity = 'Mumbai'];
// Count Leads grouped by Company
AggregateResult[] leadsByCompany =
[SELECT Company, COUNT(Id) FROM Lead GROUP BY Company];
SOQL is one of the most powerful tools in Apex. Mastering it means faster queries, scalable code, and more efficient data retrieval across your Salesforce org.
SOSL (Salesforce Object Search Language)
SOSL is used to search text across multiple objects and fields in a single query. Unlike SOQL, which targets one object at a time, SOSL is powerful for global keyword searches. It is ideal when you're unsure where the data resides or want to implement a global search bar.
Basic Syntax
SOSL queries return a list of lists. Each inner list contains records of a specific SObject type. The structure allows you to search and retrieve data from multiple objects at once.
// Search all fields for 'Smith' across Contact and Account
List<List<SObject>> searchResults =
[FIND 'Smith*' IN ALL FIELDS
RETURNING Contact(Id, FirstName, LastName),
Account(Id, Name)];
This retrieves any Contact or Account record where any field starts with "Smith".
How to Access Results
List<Contact> contactsFound = (List<Contact>) searchResults[0];
List<Account> accountsFound = (List<Account>) searchResults[1];
System.debug('Found ' + contactsFound.size() + ' Contacts');
System.debug('Found ' + accountsFound.size() + ' Accounts');
Each list is indexed in the order the SObjects appear in the RETURNING clause.
Advanced: Specify Fields or Objects
// Search only in Name fields of Contact and Lead
List<List<SObject>> results =
[FIND 'Kumar' IN Name Fields
RETURNING Contact(Id, Name),
Lead(Id, Company, Email)];
This improves performance by restricting the search scope to Name fields only.
// Example on custom object
List<List<SObject>> results =
[FIND 'Tech' IN ALL FIELDS
RETURNING Product__c(Id, Name, Category__c)];
This searches all fields in the custom object Product__c for the keyword "Tech".
Dynamic SOSL
You can use dynamic SOSL when building queries at runtime, especially in Lightning Components or Apex Controllers.
String keyword = 'Sharma*';
String dynamicSOSL = 'FIND \'' + keyword + '\' IN ALL FIELDS RETURNING Contact(Id, Name)';
List<List<SObject>> results = Search.query(dynamicSOSL);
Always escape and validate input to avoid SOSL injection attacks.
Best Use Cases
- Implementing a global search feature across Contacts, Accounts, and Leads.
- Searching when you don't know the exact object or field the keyword resides in.
- Customer support portals or Lightning quick find components.
- Mass search scenarios like import deduplication, auditing, or reconciliation processes.
SOQL vs SOSL
- SOQL: Query records from a single object or related objects with filters.
- SOSL: Perform full-text search across multiple objects and fields.
- SOQL is used when object and field are known; SOSL is used for broader discovery.
- SOQL supports aggregations, joins, and relationships; SOSL supports fast keyword-based search.
Example
// Global search in a service portal
String searchTerm = 'David';
List<List<SObject>> matches =
[FIND :searchTerm IN ALL FIELDS
RETURNING Contact(Id, FirstName, LastName),
Case(Id, Subject),
Lead(Id, Name, Company)];
Ideal for search bars where the user could be a Contact, a Lead, or referenced in a Case.
DML Statements in Apex
DML (Data Manipulation Language) in Apex is used to perform operations like inserting, updating, deleting, or restoring records in Salesforce. These statements interact directly with the database and are subject to governor limits. Understanding and optimizing your DML usage is critical for scalable and efficient Apex development.
1. insert – Create New Records
Use the insert statement to add one or more new records into the database.
// Insert a single Account
Account acc = new Account(Name = 'Cloud Inc');
insert acc;
// Insert multiple Contacts
List<Contact> contacts = new List<Contact>{
new Contact(FirstName='Riya', LastName='Sen', Email='riya@cloud.com'),
new Contact(FirstName='Anil', LastName='Patel', Email='anil@cloud.com')
};
insert contacts;
// Insert a custom object record
Student__c student = new Student__c(Name='Ajay Sharma', Grade__c='10th');
insert student;
2. update – Modify Existing Records
Use update to modify values in existing records. Records must already exist in the database (i.e., they have valid Ids).
// Update the Account name
acc.Name = 'Cloud Technologies';
update acc;
// Update multiple Contacts
for (Contact con : contacts) {
con.Phone = '9999999999';
con.Title = 'Manager';
}
update contacts;
// Example: Mark Opportunities as Closed Lost
List<Opportunity> opps = [SELECT Id, StageName FROM Opportunity WHERE StageName != 'Closed Won'];
for (Opportunity opp : opps) {
opp.StageName = 'Closed Lost';
}
update opps;
3. delete – Remove Records
Use delete to remove records from Salesforce. Deleted records are soft deleted (go to the Recycle Bin) unless they are hard deleted by system-level users.
// Delete a single Account
delete acc;
// Delete multiple Contacts
delete contacts;
// Delete all leads created by a specific user
List<Lead> userLeads = [SELECT Id FROM Lead WHERE CreatedById = :UserInfo.getUserId()];
delete userLeads;
4. upsert – Insert or Update Based on Id or External Id
upsert is a powerful operation that inserts a record if it doesn’t exist, or updates it if it does. You can upsert using either the record Id or an external Id field defined in Salesforce.
// Using record Id
Account acc2 = new Account(Id = acc.Id, Name = 'Cloud Hub');
upsert acc2;
// Using external Id
Contact c = new Contact(FirstName='Deepak', LastName='Joshi');
c.External_Id__c = 'EXT123'; // Custom External Id field
upsert c External_Id__c;
// Bulk upsert
List<Student__c> students = new List<Student__c>{
new Student__c(External_Ref__c='STU001', Name='Rahul'),
new Student__c(External_Ref__c='STU002', Name='Priya')
};
upsert students External_Ref__c;
5. undelete – Restore Deleted Records
Use undelete to recover records from the Recycle Bin. It works within 15 days after deletion.
// Undelete previously deleted Contacts
undelete contacts;
// Restore deleted leads
List<Lead> deletedLeads = [SELECT Id, Name FROM Lead WHERE IsDeleted = TRUE ALL ROWS];
undelete deletedLeads;
Using DML with Error Handling
To avoid runtime exceptions due to failed DML operations, use Database methods such as Database.insert(), Database.update(), and so on. These methods return detailed results and give you more control over partial success.
// Safe DML using Database class
Database.SaveResult result = Database.insert(acc, false);
if (!result.isSuccess()) {
for (Database.Error err : result.getErrors()) {
System.debug('Insert failed: ' + err.getMessage());
}
}
Governor Limits for DML
- Maximum DML statements per transaction: 150
- Maximum records processed per DML: 10,000
- Combine records into a single DML where possible to optimize performance
Best Practices
- Always bulkify your code. Use Lists to avoid hitting limits.
- Use
Databasemethods for better control and error tracking. - Never perform DML operations inside
forloops. - Group logic before executing DML to reduce statements.
- Use
try-catchfor exception safety when required.
Database Methods in Apex
The Database class in Apex offers advanced control over DML operations. Unlike traditional DML statements (insert, update, delete), these methods allow you to:
- Handle partial success without halting the entire execution
- Capture and log detailed errors for each record
- Choose whether to rollback changes using the
allOrNoneflag - Use external IDs for intelligent
upsertoperations
Why Use Database Methods?
Database methods are ideal for bulk operations or when you want graceful error handling. For instance, when inserting 500 student records from a form, you wouldn’t want the whole process to fail due to one invalid entry. Instead, you can allow partial success and fix only the problematic data.
Example: Insert with Partial Success
This example inserts multiple Accounts, with some intentionally invalid, and handles each result individually.
// One valid, one invalid Account (Name is required)
List<Account> accList = new List<Account>{
new Account(Name = 'Cloud Factory'),
new Account()
};
Database.SaveResult[] results = Database.insert(accList, false);
for (Database.SaveResult r : results) {
if (r.isSuccess()) {
System.debug('Inserted: ' + r.getId());
} else {
for (Database.Error err : r.getErrors()) {
System.debug('Insert Error: ' + err.getMessage());
}
}
}
Update with Error Handling
When updating customer records, some may have missing fields. You can prevent one failure from stopping all updates.
// Updating patient records in a hospital system
List<Account> accsToUpdate = [SELECT Id, Name FROM Account LIMIT 2];
accsToUpdate[0].Name = null; // Invalid
accsToUpdate[1].Name = 'Valid Update';
Database.SaveResult[] updResults = Database.update(accsToUpdate, false);
for (Database.SaveResult r : updResults) {
if (r.isSuccess()) {
System.debug('Updated: ' + r.getId());
} else {
for (Database.Error err : r.getErrors()) {
System.debug('Update Error: ' + err.getMessage());
}
}
}
Upsert Using External Id
External IDs let you integrate data from outside systems. If a matching External ID exists, Salesforce updates it; otherwise, it creates a new record.
// Use case: Syncing student records from an external portal
Contact c = new Contact(FirstName = 'Rahul', LastName = 'Mehra');
c.External_Id__c = 'STU-1023';
Database.UpsertResult result = Database.upsert(c, 'External_Id__c', false);
if (result.isSuccess()) {
System.debug('Upserted Contact: ' + result.getId());
} else {
for (Database.Error err : result.getErrors()) {
System.debug('Upsert Error: ' + err.getMessage());
}
}
Delete with Partial Handling
Instead of failing the entire delete operation, partial handling allows you to skip invalid records and delete what’s valid.
List<Account> accsToDelete = [SELECT Id FROM Account LIMIT 2];
accsToDelete.add(new Account()); // Invalid record (no Id)
Database.DeleteResult[] delResults = Database.delete(accsToDelete, false);
for (Database.DeleteResult r : delResults) {
if (r.isSuccess()) {
System.debug('Deleted: ' + r.getId());
} else {
for (Database.Error err : r.getErrors()) {
System.debug('Delete Error: ' + err.getMessage());
}
}
}
Available Database Methods
Database.insert()– Safe insertion with error feedbackDatabase.update()– Update records and monitor individual success/failureDatabase.delete()– Remove records while skipping invalid onesDatabase.upsert()– Insert or update intelligently based on Id or External IdDatabase.undelete()– Restore soft-deleted records from Recycle BinDatabase.merge()– Merge duplicate records (only for Leads, Contacts, Accounts)Database.convertLead()– Convert leads into Contacts/Accounts/OpportunitiesDatabase.rollback()– Revert changes to a savepointDatabase.query()/Database.queryWithBinds()– Run dynamic SOQL;queryWithBindstakes a bind-variable map (injection-safe)Database.countQuery()/Database.getQueryLocator()– Count results / feed Batch Apex with up to 50 million records
Modern tip — AccessLevel: Every method above accepts an optional AccessLevel argument, e.g. Database.insert(records, false, AccessLevel.USER_MODE), which makes the operation respect the running user's object permissions, field-level security, and sharing. In the newest API versions, user mode is the default for new code — see Apex Security.
Best Practices
- Always set
allOrNone = falseto prevent one bad record from ruining the batch. - Use
isSuccess()andgetErrors()for clean error management. - Log errors to a custom object or platform event for traceability in production.
- Avoid writing
Database.*calls insideforloops. Collect records, then execute once. - Use
SavePointandDatabase.rollback()when you need transactional control.
Trigger
Apex Triggers are like little behind-the-scenes assistants — they quietly react to database changes and automate tasks without anyone pressing a button. These events include inserting, updating, deleting, or even undeleting records.
Triggers help enforce business rules, auto-fill fields, create related records, or send alerts the moment something changes in your data. They keep your Salesforce org smart and responsive.
Trigger Syntax Explained
Here's the basic structure of any trigger:
trigger <triggerName> on <ObjectName> (<triggerEvents>) {
// Your trigger logic here
}
- triggerName: Any name you choose (e.g., AccountTrigger)
- ObjectName: The object it runs on (e.g., Account, Contact)
- triggerEvents: Events like before insert, after update, etc.
Basic Trigger Template
A single trigger can listen to multiple events, like this:
trigger AccountTrigger on Account (
before insert, before update, before delete,
after insert, after update, after delete, after undelete
) {
if (Trigger.isBefore && Trigger.isInsert) {
// Set default values
}
if (Trigger.isAfter && Trigger.isUpdate) {
// Notify user of updates
}
if (Trigger.isBefore && Trigger.isDelete) {
// Stop deletion if business rule fails
}
if (Trigger.isAfter && Trigger.isDelete) {
// Log deleted account
}
if (Trigger.isAfter && Trigger.isUndelete) {
// Alert team that record was restored
}
}
Examples
- Auto-fill Fields: Assign a default Rating to Accounts
- Duplicate Prevention: Stop duplicate Contacts by checking email/phone
- Auto-Creation: Automatically create a Case when Opportunity is Closed Lost
- Sync Records: Update Contacts if their Account name changes
- Clean-Up: Delete custom child records when parent is deleted
Trigger Best Practice Example
trigger ContactTrigger on Contact (before insert, after update) {
if (Trigger.isBefore && Trigger.isInsert) {
for (Contact con : Trigger.new) {
if (con.FirstName == null) {
con.FirstName = 'Guest';
}
}
}
if (Trigger.isAfter && Trigger.isUpdate) {
for (Contact con : Trigger.new) {
System.debug('Updated Contact ID: ' + con.Id);
}
}
}
Example: Auto-Assign Rating
Assign a default Rating of "Warm" to new Accounts without one:
trigger AccountTrigger on Account (before insert) {
for(Account acc : Trigger.new) {
if(acc.Rating == null) {
acc.Rating = 'Warm';
}
}
}
Example: Prevent Duplicate Contacts
Prevent insertion of Contacts with duplicate Email or Phone (skip nulls):
trigger ContactTrigger on Contact (before insert) {
Set<String> emailSet = new Set<String>();
Set<String> phoneSet = new Set<String>();
for (Contact con : Trigger.new) {
if (con.Email != null) emailSet.add(con.Email.toLowerCase());
if (con.Phone != null) phoneSet.add(con.Phone);
}
List<Contact> existing = [SELECT Id, Email, Phone FROM Contact
WHERE Email IN :emailSet OR Phone IN :phoneSet];
for (Contact con : Trigger.new) {
for (Contact exist : existing) {
if ((exist.Email != null && exist.Email.equalsIgnoreCase(con.Email)) ||
(exist.Phone != null && exist.Phone == con.Phone)) {
con.addError('Duplicate Contact found with same Email or Phone.');
}
}
}
}
Example: Auto-Create Case on Opportunity Closed Lost
When an Opportunity is marked Closed Lost, auto-create a Case to follow-up:
trigger OpportunityTrigger on Opportunity (after update) {
List<Case> caseList = new List<Case>();
for (Opportunity opp : Trigger.new) {
Opportunity oldOpp = Trigger.oldMap.get(opp.Id);
if (opp.StageName == 'Closed Lost' && oldOpp.StageName != 'Closed Lost') {
caseList.add(new Case(
Subject = 'Follow-up for Lost Deal',
Description = 'Auto-created due to lost opportunity: ' + opp.Name,
Status = 'New',
Origin = 'Salesforce',
AccountId = opp.AccountId
));
}
}
if (!caseList.isEmpty()) insert caseList;
}
Example: Sync Contacts on Account Name Change
If an Account's name changes, reflect it in related Contact's Description field:
trigger AccountTrigger on Account (after update) {
Set<Id> changedAccIds = new Set<Id>();
for (Account acc : Trigger.new) {
Account oldAcc = Trigger.oldMap.get(acc.Id);
if (acc.Name != oldAcc.Name) {
changedAccIds.add(acc.Id);
}
}
List<Contact> contactsToUpdate = new List<Contact>();
for (Contact con : [SELECT Id, AccountId, Description FROM Contact
WHERE AccountId IN :changedAccIds]) {
con.Description = 'Updated due to Account name change';
contactsToUpdate.add(con);
}
if (!contactsToUpdate.isEmpty()) update contactsToUpdate;
}
Example: Delete Child Records When Parent is Deleted
When a custom parent record (like Project__c) is deleted, also delete its Task__c child records:
trigger ProjectTrigger on Project__c (after delete) {
List<Task__c> tasksToDelete = [SELECT Id FROM Task__c
WHERE Project__c IN :Trigger.oldMap.keySet()];
if (!tasksToDelete.isEmpty()) delete tasksToDelete;
}
Why Use Triggers Instead of Flow?
- More Control: You can write complex conditions and logic in code
- Cross-Object Logic: Easily update or read related records
- Error Handling: Add try-catch blocks to manage failures gracefully
- Flexibility: Ideal for dynamic logic, queries, and multiple DML operations
Trigger Context Variables
Apex provides built-in context variables inside triggers to help you identify when and how the trigger is executing. These variables give you access to the records and their states — so you can write logic that behaves differently in each scenario.
Trigger.isBefore– True if the trigger runs before the record is saved to the databaseTrigger.isAfter– True if the trigger runs after the record is savedTrigger.isInsert– True if the operation is an insertTrigger.isUpdate– True if the operation is an updateTrigger.isDelete– True if the operation is a deleteTrigger.isUndelete– True if records are being restored from Recycle BinTrigger.new– List of the new record values (available in insert, update, undelete)Trigger.old– List of the old record values (available in update, delete)Trigger.newMap– A map of new records by ID (useful in update and after insert)Trigger.oldMap– A map of old records by ID (useful in update and delete)
Example: Detect Field Changes
Let’s compare the new and old values to detect if the Rating field has changed:
trigger AccountTrigger on Account (before update) {
for (Account acc : Trigger.new) {
Account oldAcc = Trigger.oldMap.get(acc.Id);
if (acc.Rating != oldAcc.Rating) {
System.debug('Rating changed from ' + oldAcc.Rating + ' to ' + acc.Rating);
}
}
}
Example: Alert on Email Change
Track when a Contact's email address is updated:
trigger ContactTrigger on Contact (after update) {
for (Contact con : Trigger.new) {
Contact oldCon = Trigger.oldMap.get(con.Id);
if (con.Email != oldCon.Email) {
System.debug('Email changed for ' + con.LastName +
' from ' + oldCon.Email + ' to ' + con.Email);
}
}
}
Example: Prevent Deletion Based on Condition
Stop deletion of Accounts that are marked as Type = "Customer - Direct":
trigger AccountTrigger on Account (before delete) {
for (Account acc : Trigger.old) {
if (acc.Type == 'Customer - Direct') {
acc.addError('You cannot delete a direct customer account.');
}
}
}
Trigger Best Practices
Triggers can be powerful, but without structure, they quickly become unmanageable. These best practices help you write clean, efficient, and scalable triggers — so your Salesforce org doesn’t spiral into chaos as it grows.
Key Best Practices
- One Trigger Per Object: Avoid multiple triggers on the same object. It keeps execution order predictable and easier to debug.
- Use Trigger Handler Classes: Move business logic to a separate Apex class. This makes code modular, reusable, and testable.
- Bulkify Your Code: Always write your code to handle 200+ records at once — even if today you're dealing with one. Triggers are always bulk.
- Avoid SOQL/DML Inside Loops: Doing DML or queries inside loops can cause governor limit exceptions. Use collections like Lists, Sets, and Maps outside the loop.
- Use Maps for Efficient Lookups: When matching or comparing records, Maps save CPU time and reduce SOQL calls.
- Use Context Variables Carefully:
Use
Trigger.isInsert,Trigger.isBefore, etc., to organize logic for different operations cleanly. - Avoid Recursive Updates: Prevent your trigger from calling itself again and again using static variables or flags.
- Test for Bulk and Edge Cases: Write test methods that simulate real-world scenarios: bulk updates, missing fields, and limit conditions.
Trigger Handler Example
Here’s how to move logic to a separate handler class. It keeps the trigger lean and business logic isolated — making it easy to manage and test.
// Handler class
public class AccountTriggerHandler {
public static void handleBeforeInsert(List<Account> newList) {
for (Account acc : newList) {
if (acc.Rating == null) {
acc.Rating = 'Warm';
}
}
}
public static void handleAfterUpdate(List<Account> newList, Map<Id, Account> oldMap) {
for (Account acc : newList) {
Account oldAcc = oldMap.get(acc.Id);
if (acc.Rating != oldAcc.Rating) {
System.debug('Rating changed from ' + oldAcc.Rating + ' to ' + acc.Rating);
}
}
}
}
// Trigger file
trigger AccountTrigger on Account (before insert, after update) {
if (Trigger.isBefore && Trigger.isInsert) {
AccountTriggerHandler.handleBeforeInsert(Trigger.new);
}
if (Trigger.isAfter && Trigger.isUpdate) {
AccountTriggerHandler.handleAfterUpdate(Trigger.new, Trigger.oldMap);
}
}
Asynchronous Apex Overview
Asynchronous Apex lets you execute operations that take a long time to process—like external callouts, complex calculations, or massive DML updates—without blocking the user's screen or hitting strict synchronous governor limits. It helps you scale efficiently and keep the user experience smooth.
Why Use Asynchronous Apex?
- Work around governor limits: Async methods get higher CPU time (up to 60 seconds) and separate limits.
- Enable callouts from triggers: You can't do HTTP callouts directly in a trigger—use async instead.
- Handle mixed DML operations: Async Apex separates setup and non-setup objects, avoiding mixed DML errors.
- Process large volumes of data: Batch Apex lets you process millions of records without timing out.
- Run tasks on schedule: Scheduled Apex runs code at set intervals (daily, weekly, monthly, etc.).
Async Apex Options
- @future methods: For lightweight, fire-and-forget jobs like API calls or basic updates.
- Queueable Apex: Better than @future—supports complex data types, chaining, and retry logic.
- Batch Apex: Splits large data sets into chunks and processes them asynchronously. Ideal for data-heavy jobs.
- Scheduled Apex: Schedules your Apex logic to run at specific times using cron expressions.
How the Async Queue Works
When you enqueue an async job, Salesforce adds it to a queue. The job waits until resources are available, then runs in the background. This guarantees fair distribution and reliability, even during high-load periods.
Use Cases
- @future: Send a welcome email or push data to an ERP after a record is inserted.
- Queueable: Assign leads based on custom rules, then notify sales reps in a chained job.
- Batch Apex: Clean up unused records weekly, enrich leads from an external system, or calculate loyalty points.
- Scheduled Apex: Run monthly billing, deactivate stale users nightly, or sync product inventory daily.
Choosing the right async tool depends on your needs. Start with Queueable for flexibility, Batch for large data, and Scheduled when time-based automation is key.
Future Methods
Future methods in Apex allow you to perform time-consuming or resource-heavy operations asynchronously. Instead of forcing users to wait while the backend completes a task, you can push that work into the background, creating smoother and faster user experiences.
You define future methods using the @future annotation. These methods are best suited for tasks like making HTTP callouts, running background updates, and avoiding mixed DML errors.
Key Characteristics
- Run asynchronously in a separate thread after the current transaction finishes
- Must be declared
staticand returnvoid - Can only accept primitive types, collections of primitives, or objects that implement
Serializable - Cannot be chained or nested (i.e., one future method cannot call another)
- Support callouts when annotated as
@future(callout=true) - Do not guarantee execution order or exact timing
Basic Syntax
public class FutureExample {
@future
public static void logAccountName(String accId) {
Account acc = [SELECT Name FROM Account WHERE Id = :accId];
System.debug('Account Name: ' + acc.Name);
}
}
Example 1: Callout to External API
Future methods support HTTP callouts, which are often needed for integrating with third-party services.
public class CalloutHandler {
@future(callout=true)
public static void fetchDataFromAPI(String url) {
Http http = new Http();
HttpRequest req = new HttpRequest();
req.setEndpoint(url);
req.setMethod('GET');
HttpResponse res = http.send(req);
System.debug('Response: ' + res.getBody());
}
}
Example 2: Assigning Default Values to Accounts
Assign a default value to account ratings in bulk without blocking the transaction.
public class AccountProcessor {
@future
public static void assignDefaultRating(List<Id> accountIds) {
List<Account> accountsToUpdate = [SELECT Id, Rating FROM Account WHERE Id IN :accountIds];
for(Account acc : accountsToUpdate) {
if(acc.Rating == null) {
acc.Rating = 'Warm';
}
}
update accountsToUpdate;
}
}
Example 3: Logging Trigger-Based Activities
Calling a future method from a trigger ensures you're not blocked by limits during data creation.
trigger AccountTrigger on Account (after insert) {
for(Account acc : Trigger.new) {
FutureExample.logAccountName(acc.Id);
}
}
Example 4: Logging Custom Events for Analytics
Use a future method to insert a custom log entry into an Analytics object without delaying a user transaction.
public class AnalyticsLogger {
@future
public static void logEvent(String eventType, String details) {
Event__c evt = new Event__c(Event_Type__c = eventType, Details__c = details);
insert evt;
}
}
Best Practices
- Don't call from loops—collect data first, then pass it in bulk
- Stay within 50 future calls per transaction
- Use Queueable Apex when you need to pass complex types or chain jobs
- Don't rely on immediate execution—jobs may be delayed
- Avoid nesting or chaining future methods
Common Use Cases
- Send welcome or verification emails after user registration
- Make HTTP callouts to external APIs (e.g., update user info in marketing tool)
- Process DML updates or inserts that must happen outside the original transaction
- Log analytics or audit data post user actions
- Run background enrichment based on third-party data sources
- Update related child records (e.g., auto-close tasks on parent record status change)
In conclusion, future methods are great for lightweight asynchronous tasks. If you find yourself needing more flexibility—like chaining jobs or working with complex input—it's better to use Queueable Apex.
Batch Apex
Batch Apex allows you to process thousands to millions of records in an asynchronous and scalable way. It’s perfect for long-running jobs like cleaning up data, performing mass updates, or integrating with external systems. Since each batch runs in its own transaction, it protects against hitting governor limits and enables better error recovery.
To use Batch Apex, your class must implement the Database.Batchable<SObject> interface, which includes three critical methods:
- start: Prepares the data to be processed.
- execute: Performs the logic on each batch of records.
- finish: Executes post-processing logic (like logging or triggering another job).
Batch Class Structure
global class AccountUpdateBatch implements Database.Batchable<sObject> {
global Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator('SELECT Id, Name FROM Account');
}
global void execute(Database.BatchableContext bc, List<Account> scope) {
for(Account acc : scope) {
acc.Name += ' - Updated';
}
update scope;
}
global void finish(Database.BatchableContext bc) {
System.debug('Batch job finished');
}
}
Executing a Batch
Instantiate your batch class and call Database.executeBatch(). You can optionally specify a batch size to control how many records are processed per chunk.
// Run the batch with 200 records per batch
AccountUpdateBatch batch = new AccountUpdateBatch();
Database.executeBatch(batch, 200);
Example 1: Deactivating Old Contacts
Suppose you need to deactivate contacts not updated in over 5 years. Batch Apex helps you do this without breaching limits.
global class ContactCleanupBatch implements Database.Batchable<sObject> {
global Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator([
SELECT Id, IsActive__c FROM Contact WHERE LastModifiedDate < LAST_N_YEARS:5
]);
}
global void execute(Database.BatchableContext bc, List<Contact> scope) {
for(Contact c : scope) {
c.IsActive__c = false;
}
update scope;
}
global void finish(Database.BatchableContext bc) {
System.debug('Inactive contacts updated.');
}
}
Example 2: Archiving Closed Opportunities
This example marks closed opportunities older than 2 years as archived by setting a custom field.
global class OpportunityArchiver implements Database.Batchable<sObject> {
global Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator([
SELECT Id, Archived__c FROM Opportunity
WHERE StageName = 'Closed Won' AND CloseDate < LAST_N_YEARS:2
]);
}
global void execute(Database.BatchableContext bc, List<Opportunity> scope) {
for(Opportunity opp : scope) {
opp.Archived__c = true;
}
update scope;
}
global void finish(Database.BatchableContext bc) {
System.debug('Old opportunities archived.');
}
}
Example 3: Stateful Batch for Aggregation
You can track running totals or shared values using the Database.Stateful interface.
global class LeadCounterBatch implements Database.Batchable<sObject>, Database.Stateful {
public Integer totalCount = 0;
global Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator('SELECT Id FROM Lead');
}
global void execute(Database.BatchableContext bc, List<Lead> scope) {
totalCount += scope.size();
}
global void finish(Database.BatchableContext bc) {
System.debug('Total leads processed: ' + totalCount);
}
}
Best Practices
- Keep logic in
execute()efficient and bulk-safe - Do not use future or queueable calls inside
execute() - Send alerts or logs in the
finish()method - Use
Database.Statefulto preserve state across batches - Chain multiple batches using
finish()for dependent jobs
Common Use Cases
- Mass updating or cleaning records
- Deactivating stale users or contacts
- Archiving opportunities or cases
- Sending time-based summary emails
- Performing asynchronous integrations
Batch Apex is your go-to solution for scalable, robust data processing in Salesforce. Whether you're cleaning data, performing audits, or syncing external systems, Batch Apex gives you the control and power to handle it efficiently within platform limits.
Queueable Apex
Queueable Apex is a powerful tool in Salesforce for running asynchronous operations. It bridges the gap between Future methods and Batch Apex by offering more flexibility, such as passing complex data types, chaining jobs, and handling exceptions elegantly. It's ideal for background tasks that need finer control, multiple steps, or processing medium-sized data sets.
To use it, implement the Queueable interface and call System.enqueueJob(). Each job runs in a separate transaction, making it robust for operations that might exceed synchronous limits.
Key Features
- Supports passing complex data structures (Lists, Maps, custom objects)
- Allows chaining multiple jobs with sequential execution
- Returns Job ID for status monitoring
- Better exception handling using
try-catch - Each chained job runs in a new transaction
Basic Queueable Example
public class AccountQueueable implements Queueable {
private List<Account> accounts;
public AccountQueueable(List<Account> accountsToProcess) {
this.accounts = accountsToProcess;
}
public void execute(QueueableContext context) {
for(Account acc : accounts) {
acc.Name += ' - Queued';
}
update accounts;
// Optional: Chain another job
if(accounts.size() > 100) {
System.enqueueJob(new NotifyUserQueueable());
}
}
}
Enqueueing the Job
List<Account> accs = [SELECT Id, Name FROM Account WHERE CreatedDate = TODAY];
System.enqueueJob(new AccountQueueable(accs));
Use Case 1: Logging Lead Activity and Sending Notifications
Suppose you want to create tasks for a list of leads and send a summary email afterward. Doing this synchronously could cause limits. Queueable Apex makes it seamless.
public class LeadActivityLogger implements Queueable {
private List<Lead> leads;
public LeadActivityLogger(List<Lead> leads) {
this.leads = leads;
}
public void execute(QueueableContext context) {
List<Task> tasks = new List<Task>();
for(Lead l : leads) {
tasks.add(new Task(
WhatId = l.Id,
Subject = 'Initial Follow-up',
Status = 'Not Started'
));
}
insert tasks;
System.enqueueJob(new NotifyManagerQueueable());
}
}
Use Case 2: Archiving Opportunities with Chaining
Imagine a scenario where you want to archive closed-lost opportunities and then log a summary. Queueable Apex lets you chain these jobs neatly.
public class ArchiveOpportunitiesQueueable implements Queueable {
public void execute(QueueableContext context) {
List<Opportunity> opps = [SELECT Id, StageName FROM Opportunity WHERE StageName = 'Closed Lost'];
for(Opportunity opp : opps) {
opp.StageName = 'Archived';
}
update opps;
// Chain job to log operation
System.enqueueJob(new LogArchivalJob(opps.size()));
}
}
Use Case 3: Splitting Large Tasks Across Jobs
You can use queueables to process very large lists by splitting them into manageable parts.
public class SplitTaskProcessor implements Queueable {
List<Task> tasks;
public SplitTaskProcessor(List<Task> tasks) {
this.tasks = tasks;
}
public void execute(QueueableContext context) {
Integer batchSize = 100;
for(Integer i = 0; i < tasks.size(); i += batchSize) {
List<Task> batch = tasks.subList(i, Math.min(i + batchSize, tasks.size()));
System.enqueueJob(new TaskUpdater(batch));
}
}
}
Best Practices
- You can enqueue up to 50 queueable jobs per transaction with
System.enqueueJob; a running queueable can chain only one child job, and Developer/Trial Edition orgs cap chain depth at 5 - Avoid using synchronous-only methods like
getContent()inside queueables - Use
try-catchto log errors or retry failed jobs - Return and track Job IDs for monitoring via the Apex Jobs UI
- Break large logic into smaller jobs using chaining or queuing
Common Scenarios
- Mass email logging or notification dispatching
- Background processing after a DML operation or user interaction
- Chained logic like updating, then summarizing, then notifying
- Callouts to external services (via chained jobs)
- Cleaning up or modifying related records after a primary update
Queueable Apex offers a perfect balance of power and ease of use. It’s ideal for tasks too complex for Future methods but not large enough for Batch Apex. With chaining, complex workflows become modular and manageable.
Scheduled Apex
Scheduled Apex allows you to execute Apex code at specific times—either once or on a recurring schedule. Think of it like setting an alarm for your code. It’s perfect for automating routine tasks such as nightly data cleanups, regular email reminders, syncing with external systems, or triggering batch jobs at off-peak hours.
To use Scheduled Apex, implement the Schedulable interface and define your logic in the execute() method. You then schedule it using a CRON expression with System.schedule().
Scheduling Syntax
global class ScheduledAccountUpdate implements Schedulable {
global void execute(SchedulableContext sc) {
// Business logic to run on schedule
AccountUpdateBatch batch = new AccountUpdateBatch();
Database.executeBatch(batch);
}
}
Scheduling a Job
Here's how to schedule the job to run daily at 2 AM:
// Schedule to run every day at 2 AM
String cronExp = '0 0 2 * * ?';
System.schedule('Daily Account Update', cronExp, new ScheduledAccountUpdate());
CRON Expression Format
- Seconds (0–59)
- Minutes (0–59)
- Hours (0–23)
- Day of Month (1–31)
- Month (1–12 or JAN–DEC)
- Day of Week (1–7 or SUN–SAT)
- Year (optional)
Example 1: Clean Up Old Tasks Weekly
global class WeeklyTaskCleaner implements Schedulable {
global void execute(SchedulableContext context) {
List<Task> oldTasks = [SELECT Id FROM Task WHERE CreatedDate < LAST_N_DAYS:30];
delete oldTasks;
}
}
// Schedule every Sunday at 1 AM
String cron = '0 0 1 ? * SUN';
System.schedule('Weekly Task Cleanup', cron, new WeeklyTaskCleaner());
Example 2: Monthly Data Archival
global class MonthlyDataArchiver implements Schedulable {
global void execute(SchedulableContext context) {
List<Opportunity> closed = [SELECT Id FROM Opportunity WHERE StageName = 'Closed Won' AND ClosedDate < LAST_N_DAYS:90];
// Archive logic here
}
}
// Schedule on the 1st day of every month at 3 AM
String cron = '0 0 3 1 * ?';
System.schedule('Monthly Data Archival', cron, new MonthlyDataArchiver());
Example 3: Daily Email Reminder to Sales Team
global class DailyEmailReminder implements Schedulable {
global void execute(SchedulableContext context) {
Messaging.SingleEmailMessage mail = new Messaging.SingleEmailMessage();
mail.setToAddresses(new String[]{'sales@example.com'});
mail.setSubject('Daily Follow-up Reminder');
mail.setPlainTextBody('Don’t forget to follow up with your leads today!');
Messaging.sendEmail(new Messaging.SingleEmailMessage[]{mail});
}
}
// Schedule every day at 8 AM
System.schedule('Daily Sales Email', '0 0 8 * * ?', new DailyEmailReminder());
Best Practices
- Use Scheduled Apex for time-based automation or recurring logic
- Scheduled jobs count toward the limit of 100 active scheduled jobs per org
- Use
System.abortJob(jobId)to stop a job if needed - Combine with Batch Apex for processing large datasets
- Schedule jobs during off-peak hours to minimize performance impact
- Always test in a sandbox with istic data before scheduling in production
Common Use Cases
- Weekly or monthly data cleanup tasks
- Nightly or morning job scheduling for reporting and analytics
- Automated follow-up or reminder emails
- Regular synchronization with external services
- Triggering chained jobs like Queueables or Batches after certain intervals
Scheduled Apex acts as your automated assistant, reliably executing background jobs. When combined with Batch or Queueable Apex, it becomes a powerhouse for handling large volumes and multi-step processing—keeping your Salesforce org clean, efficient, and smartly automated.
Test Classes
Apex test classes are essential for validating your business logic and ensuring code stability across deployments. Salesforce enforces that at least 75% of your Apex code must be covered by tests before you can deploy it to production.
What Is a Test Class?
A test class is a special Apex class annotated with @isTest. It simulates scenarios in a controlled environment. Tests are isolated and do not affect your org’s actual data. They ensure your code behaves as expected and is resilient against edge cases and changes.
Test Class Structure
@isTest
private class AccountProcessorTest {
@testSetup
static void setup() {
Account testAcc = new Account(Name = 'Test Account');
insert testAcc;
}
@isTest
static void testContactCounting() {
Account testAcc = [SELECT Id FROM Account LIMIT 1];
List<Contact> testContacts = new List<Contact>();
for (Integer i = 0; i < 5; i++) {
testContacts.add(new Contact(
FirstName = 'Test',
LastName = 'User ' + i,
AccountId = testAcc.Id
));
}
insert testContacts;
Test.startTest();
AccountProcessor.countContacts(new List<Id>{testAcc.Id});
Test.stopTest();
Account updatedAcc = [SELECT Number_of_Contacts__c FROM Account WHERE Id = :testAcc.Id];
System.assertEquals(5, updatedAcc.Number_of_Contacts__c, 'Contact count should be 5');
}
}
Best Practices for Writing Test Classes
-
1. Use
@testSetupfor Common Data:
Pre-load reusable data once for all test methods.@testSetup static void setupTestData() { insert new Account(Name = 'Shared Test Account'); } -
2. Wrap Logic in
Test.startTest()andTest.stopTest():
Ensures async behavior and limits are measured properly.Test.startTest(); System.enqueueJob(new MyQueueableJob()); Test.stopTest(); -
3. Use a Test Data Factory:
Abstract test data creation to improve readability.@isTest public class TestDataFactory { public static Account createAccount(String name, Boolean doInsert) { Account acc = new Account(Name = name); if(doInsert) insert acc; return acc; } } -
4. Use
System.runAs()for Role/Profile Testing:
Simulate different user profiles or permission sets.User testUser = new User( Alias = 'tuser', Email='testuser@example.com', Username='testuser@example.com', ProfileId = [SELECT Id FROM Profile WHERE Name='Standard User' LIMIT 1].Id, TimeZoneSidKey='Asia/Kolkata', LocaleSidKey='en_US', EmailEncodingKey='UTF-8', LanguageLocaleKey='en_US' ); insert testUser; System.runAs(testUser) { MyLogic.runSomething(); } -
5. Always Assert Outcomes — Prefer the Modern
AssertClass:
The dedicatedAssertclass is the recommended way to verify results. It reads better and adds purpose-built methods thatSystem.assert()never had.// Modern (recommended) Assert.areEqual('Expected', actualValue, 'Mismatch in expected result'); Assert.isTrue(opps.isEmpty(), 'No opportunities should remain'); Assert.isNotNull(result, 'Callout result must not be null'); Assert.isInstanceOfType(ex, DmlException.class, 'Wrong exception type'); Assert.fail('Should have thrown an exception before this line'); // Legacy (still works, but older style) System.assertEquals('Expected', actualValue, 'Mismatch in expected result'); -
6. Test Bulk and Governor Limit Scenarios:
Validate performance and behavior at scale.List<Account> accounts = new List<Account>(); for (Integer i = 0; i < 200; i++) { accounts.add(new Account(Name = 'Bulk ' + i)); } insert accounts; Test.startTest(); MyTriggerHandler.handleBulkInsert(accounts); Test.stopTest(); - 7. Isolate Test Data from Org Data:
Don’t depend on existing org records; generate fresh test data every time. -
8. Test Error and Edge Cases:
Test both success and failure paths.try { MyLogic.divide(10, 0); System.assert(false, 'Exception expected'); } catch (Exception e) { System.assertEquals('Cannot divide by zero', e.getMessage()); } - 9. Chain Test Scenarios Together:
Use multiple test methods to simulate workflows (e.g., insert → update → delete). -
10. Include Tests for Triggers and Queueables:
Make sure automation paths are fully covered.@isTest static void testTriggerExecution() { List<Lead> leads = new List<Lead>(); for (Integer i = 0; i < 5; i++) { leads.add(new Lead(FirstName='Test', LastName='Lead ' + i, Company='TestCo')); } insert leads; System.assertEquals(5, [SELECT COUNT() FROM Lead]); }
Final Thoughts
Writing quality test classes is more than just hitting 75% coverage — it’s about confidence. Confidence that your code works under all conditions, for all users, and at scale. Make your tests modular, descriptive, and comprehensive.
Debug Logs
Debug logs are your behind-the-scenes lens into Apex execution. They help you understand what your code is doing, step-by-step — from variable values and logic branches to errors and governor limits. Whether you're chasing down a bug or optimizing performance, debug logs are your best friend.
Working with Debug Logs
-
How to Generate Logs:
- Add
System.debug()statements in your Apex classes, triggers, or test methods - Execute the logic through UI, API calls, Developer Console, or anonymous window
- Test classes also auto-generate logs when executed
- Add
-
Where to View Logs:
- Setup > Debug Logs: Add your user if not already listed. Salesforce keeps the latest 20 logs per user.
- Developer Console > Debug > View Log: Run the code and open logs in time.
- VS Code (Salesforce Extensions): Use the "SFDX: Get Apex Debug Logs" command, or stream logs live from the terminal with
sf apex tail log --color.
-
Log Levels:
ERROR– Critical issues and unhandled exceptionsWARN– Potential issuesINFO– General application flow (good for light monitoring)DEBUG– Developer-focused logs (used most often)FINE,FINER,FINEST– Ultra-detailed logs (can be very noisy)
Debugging Example
Here's a example to show how debug logs capture execution details in a list-processing scenario:
public class DebugExample {
public static void processAccounts(List<Account> accounts) {
System.debug('Accounts to process: ' + accounts.size());
for (Account acc : accounts) {
System.debug('Processing Account: ' + acc.Name);
try {
acc.Description = 'Processed at ' + System.now();
} catch (Exception e) {
System.debug(LoggingLevel.ERROR, 'Error: ' + e.getMessage());
}
}
System.debug('Finished processing all accounts');
}
}
Beyond Logs: The Modern Debugging Toolkit
- Apex Replay Debugger (free, in VS Code): Turn any debug log into a step-through debugging session — breakpoints, variable inspection, call stack — without a live connection. Run "SFDX: Turn On Apex Debug Log for Replay Debugger", reproduce the issue, fetch the log, then "SFDX: Launch Apex Replay Debugger with Current File". This is a major upgrade over reading raw logs.
- Checkpoints (Developer Console): Capture a heap dump at a specific line to inspect every variable in memory at that moment — no code changes needed.
- Anonymous Apex: Reproduce a bug in isolation by pasting the failing logic into Execute Anonymous (Developer Console or
sf apex run --file script.apex) withSystem.debug()probes. - ApexGuru (Scale Center, select editions): AI-driven analysis of your org's real Apex performance — flags hotspots like queries missing selective filters and inefficient loops in production, without you adding any instrumentation.
- Custom exception logging: For production, don't rely on debug logs (they expire and need traces enabled). Persist failures to a custom
Error_Log__cobject from yourcatchblocks or a Transaction Finalizer — see the Exception Handling section.
Best Practices for Using Debug Logs
- Don’t overuse
System.debug()in production: It can clutter logs and slightly affect performance. - Use meaningful debug messages: Instead of "inside loop", say "Looping through Account: " + acc.Name.
- Use
LoggingLevelfor structured outputs: It helps in filtering logs based on importance. - Leverage
Test.startTest()/stopTest()to isolate logs: Especially helpful when testing async jobs. - Download logs for analysis: Sometimes the Developer Console truncates large logs. Download for full insight.
Final Thoughts
Debug logs are more than just a developer tool — they’re a diagnostic microscope. Once you master reading them, even the trickiest bugs and bottlenecks become visible. Use them wisely to write cleaner, faster, and more reliable Apex.
Testing Best Practices
Writing tests isn’t just about hitting 75% code coverage — it’s about ensuring your code behaves correctly in every situation. A strong test suite builds confidence, prevents future bugs, and simplifies refactoring.
Key Testing Practices
-
Test All Scenarios:
- Cover both positive (happy path) and negative (error or edge cases)
- Include boundary conditions (e.g., empty lists, null values)
- Simulate bulk operations (e.g., inserting 200+ records)
-
Use a Test Data Factory:
- Create reusable utility classes to generate test data
- Reduces redundancy across multiple test classes
- Improves maintainability and test clarity
-
Isolate Test Logic:
- Always use
SeeAllData=false(default) to avoid relying on org data - Make tests independent and repeatable
- Always use
-
Use Assertions to Validate Outcomes:
- Prefer the modern
Assertclass:Assert.areEqual(),Assert.isTrue(),Assert.isNotNull(),Assert.fail()(the olderSystem.assertEquals()family still works) - Assert actual vs expected results to ensure correctness — a test without assertions proves nothing, even at 100% coverage
- Prefer the modern
-
Governor Limit Awareness:
- Test with istic and bulk data volumes
- Use
Limits.getDMLStatements(),Limits.getQueries()for limit testing - Ensure code doesn’t break under load
-
Use
System.runAs():- Simulate running code as a specific user/profile
- Useful for testing security, FLS, sharing rules
-
Test Exception Scenarios:
- Deliberately cause errors and ensure your code handles them
- Validate proper fallback or error messaging
Test Data Factory Example
@isTest
public class TestDataFactory {
public static Account createAccount(String name, Boolean doInsert) {
Account acc = new Account(
Name = name,
Industry = 'Software',
AnnualRevenue = 2000000
);
if (doInsert) insert acc;
return acc;
}
public static List<Contact> createContactsForAccount(Id accountId, Integer count, Boolean doInsert) {
List<Contact> contacts = new List<Contact>();
for (Integer i = 0; i < count; i++) {
contacts.add(new Contact(
FirstName = 'Test',
LastName = 'Contact ' + i,
Email = 'test' + i + '@mail.com',
AccountId = accountId
));
}
if (doInsert) insert contacts;
return contacts;
}
}
Final Thoughts
High-quality tests save time and headaches later. Use factories, simulate different users, test both good and bad inputs, and make sure your code gracefully handles edge cases. Testing is your safety net — make it strong.
Plan & Design: The Apex Development Lifecycle
Great Apex isn't written — it's planned. The official development lifecycle is: Plan → Develop (in a sandbox or scratch org) → Build & Deploy → Test → Debug → Release → Monitor. You never write Apex directly in production (Salesforce doesn't even allow editing Apex in a production org) — code is authored in an isolated environment and promoted forward. This section gives you the planning skills the rest of the guide builds on.
Step 1 — Pick the Right Environment
| Environment | What it is | Use it for |
|---|---|---|
| Developer Edition org | Free, permanent personal org (signup) | Learning, prototypes, this guide's examples |
| Scratch org | Disposable org (1–30 days) created from a config file by the CLI | Source-driven team development, CI pipelines, testing features in isolation |
| Developer / Developer Pro sandbox | Copy of production's metadata (no data / more storage) | Day-to-day project development and unit testing |
| Partial Copy sandbox | Metadata + a sample of production data | QA and integration testing with realistic data |
| Full sandbox | Complete replica of production | UAT, performance/load testing, staging final releases |
Step 2 — Decide: Flow or Apex?
Salesforce's guidance is declarative first — but knowing exactly where the line sits is an architect skill. Use this decision table:
| Requirement | Right tool | Why |
|---|---|---|
| Field defaults, simple record updates, guided screens | Flow | Faster to build, admins can maintain it |
| Complex branching across many objects, reusable service logic | Apex (often invoked from Flow via @InvocableMethod) | Version-controlled, unit-testable, easier to debug at scale |
| Processing thousands–millions of records | Batch Apex / Apex Cursors | Flow hits element/iteration limits; Apex async is built for volume |
| Callouts with complex payloads, retries, error handling | Apex (Queueable + Finalizer) | Full control over serialization, timeouts, and recovery |
| Same-transaction validation with custom logic | Apex trigger + addError() | Blocks the save atomically |
| Logic an admin will tweak monthly (e.g., assignment rules) | Flow or Custom Metadata–driven Apex | Keep the moving parts declarative |
Step 3 — Know the Order of Execution
Every record save runs a fixed pipeline. Designing without knowing it is how "my field update didn't fire my trigger logic" bugs happen. Condensed version:
- 1. Record loaded / system validation (required fields, formats)
- 2. Before-save record-triggered Flows (fast field updates)
- 3. Before triggers (your Apex — mutate records without extra DML)
- 4. Custom validation rules, then duplicate rules
- 5. Record saved to the database (not yet committed)
- 6. After triggers (your Apex — records now have Ids; read-only)
- 7. Assignment, auto-response, escalation rules
- 8. After-save record-triggered Flows, then entitlement rules
- 9. Roll-up summary fields recalculate (parent records re-save, firing their pipeline)
- 10. Sharing recalculation → commit → post-commit work (emails actually send, async jobs actually enqueue)
Two design consequences: (a) put field mutations in before triggers so you don't need a second DML; (b) anything async (future/Queueable) only runs if the whole transaction commits — a rollback silently cancels it. Full detail: Triggers and Order of Execution.
Step 4 — Budget Governor Limits Up Front
Treat each transaction like it has a bill to pay: 100 synchronous SOQL queries, 150 DML statements, 10 seconds of CPU. If a requirement says "when an Order is activated, price 500 line items against 3 pricing tables," sketch the query/DML count for 200 records at once (the trigger batch size) before writing a line of code. If the budget doesn't fit, the design moves to Asynchronous Apex. The full numbers live in the Governor Limits section.
Worked Example: Planning a Real Feature
Requirement: "When an Opportunity is Closed Won, automatically create a renewal Opportunity dated one year out, and notify the account team."
- Tool choice: record-triggered Flow could do it — but the team also wants dedupe logic (don't create a renewal if one exists) and unit tests, so: Apex trigger → handler class, with the notification via Flow subflow for admin editability.
- Bulk analysis: Data Loader may close 200 opps at once → one query for existing renewals (keyed by a Set of Ids), one
insertfor all new renewals. Budget: 1 SOQL + 1 DML. - Order of execution: creation goes in the after update trigger (needs the won opp's Id), guarded by
Trigger.oldMapstage-change detection so it fires exactly once. - Test plan: single win, 200-record bulk win, re-save of an already-won opp (no duplicate), user without create permission (error path).
- Deployment: feature branch → scratch org → PR with CI validation → sandbox UAT → production deploy with
RunLocalTests. (See Deploy & DevOps.)
That's the pattern for everything in this guide: decide the tool, budget the limits, place the logic in the execution pipeline, plan the tests, plan the deployment.
Sources: Apex Developer Guide · Getting Started with Apex
Exception Handling
Things go wrong: a required field is missing, a callout times out, a user lacks permission. Unhandled, any exception rolls back the entire transaction and shows the user an ugly error. Handled well, your code degrades gracefully, tells the user what happened, and leaves a trail you can debug.
try / catch / finally
public class OpportunityService {
public static void closeOpportunities(List<Opportunity> opps) {
try {
for (Opportunity opp : opps) {
opp.StageName = 'Closed Won';
}
update opps;
} catch (DmlException e) {
// Which record failed, and why?
for (Integer i = 0; i < e.getNumDml(); i++) {
System.debug(LoggingLevel.ERROR,
'Row ' + e.getDmlIndex(i) + ' failed: ' + e.getDmlMessage(i));
}
throw e; // rethrow after logging — don't swallow it
} finally {
// Runs whether or not an exception occurred (cleanup, logging)
System.debug('closeOpportunities finished at ' + System.now());
}
}
}
The Built-In Exception Types You'll Actually Meet
| Exception | Thrown when… | Typical fix |
|---|---|---|
DmlException | insert/update/delete fails (validation rule, required field, trigger addError) | Inspect getDmlMessage(i); use Database.insert(recs, false) for partial success |
QueryException | [SELECT ... LIMIT 1] assigned to a single sObject returns 0 or 2+ rows; or user-mode FLS violations | Assign to a List and check isEmpty(); check getInaccessibleFields() in user mode |
NullPointerException | Dereferencing a null (acc.Name when acc is null) | Guard with the safe navigation operator acc?.Name |
ListException / MathException | Index out of bounds / divide by zero | Bounds-check; validate divisors |
CalloutException | HTTP callout timeout, or callout attempted after DML in the same transaction | Retry via Queueable; reorder logic so callouts happen before DML |
System.LimitException | A governor limit is exceeded | Cannot be caught. Prevent it — see Governor Limits |
Custom Exceptions
Create domain-specific exceptions by extending Exception (the class name must end in Exception). They make service-layer contracts explicit:
public class InsufficientInventoryException extends Exception {}
public class OrderService {
public static void reserveStock(Id productId, Integer qty) {
Product_Stock__c stock = [SELECT Units_Available__c FROM Product_Stock__c
WHERE Product__c = :productId LIMIT 1];
if (stock.Units_Available__c < qty) {
throw new InsufficientInventoryException(
'Only ' + stock.Units_Available__c + ' units left, requested ' + qty);
}
stock.Units_Available__c -= qty;
update stock;
}
}
// Caller decides what "insufficient stock" means for its context:
try {
OrderService.reserveStock(prodId, 50);
} catch (InsufficientInventoryException e) {
// business error → friendly message, no scary stack trace
order.addError(e.getMessage());
}
Savepoints: Roll Back Part of a Transaction
Savepoint sp = Database.setSavepoint();
try {
insert invoice;
insert lineItems; // if this fails…
} catch (DmlException e) {
Database.rollback(sp); // …the invoice insert is undone too: all-or-nothing
throw new InvoiceException('Invoice creation failed: ' + e.getMessage());
}
Transaction Finalizers: Recover When Async Jobs Die
A Queueable that hits an uncatchable error (limit exception, timeout) normally just dies. Attach a Finalizer and Salesforce guarantees your recovery code runs afterward — in a fresh transaction — even then. This is the production-grade pattern for retries and error logging:
public class SyncOrdersJob implements Queueable, Database.AllowsCallouts {
public void execute(QueueableContext ctx) {
System.attachFinalizer(new SyncOrdersFinalizer()); // attach FIRST
// ... risky work: callouts, heavy DML ...
}
}
public class SyncOrdersFinalizer implements Finalizer {
public void execute(FinalizerContext ctx) {
if (ctx.getResult() == ParentJobResult.UNHANDLED_EXCEPTION) {
insert new Error_Log__c(
Source__c = 'SyncOrdersJob',
Message__c = ctx.getException().getMessage(),
Job_Id__c = ctx.getAsyncApexJobId()
);
// Optional: re-enqueue for retry (finalizers may enqueue 1 job;
// cap retries with a counter so you don't loop forever)
}
}
}
Best Practices
- Never swallow exceptions: an empty
catch {}hides bugs. Log it, rethrow it, or convert it into a user-friendlyaddError(). - Catch the most specific type first:
DmlExceptionbeforeException. - In triggers, prefer
record.addError()— it blocks just the offending records instead of throwing for the whole batch. - Persist production errors to a custom object (or Platform Event so the log survives rollback) — debug logs won't be enabled when a real user hits the bug.
- You can't catch
LimitException— design within limits instead.
Sources: Exceptions in Apex · Transaction Finalizers
Apex Security: Sharing, FLS & User Mode
A fact that surprises many new Apex developers: Apex historically ran in system mode — ignoring the running user's object permissions, field-level security (FLS), and sharing rules unless you explicitly enforced them. That era is ending: in the newest API versions, database operations in new code default to user mode. Understanding both worlds is now mandatory.
The Three Security Layers
- Object permissions (CRUD): can this user create/read/update/delete Accounts at all?
- Field-level security (FLS): can they see or edit this field?
- Sharing (record access): can they see this record? Controlled by org-wide defaults, roles, and sharing rules.
Sharing Keywords on Classes
// Respects the running user's record visibility in queries/DML
public with sharing class AccountDashboardController { ... }
// Deliberately sees ALL records (use for system jobs, roll-ups, dedupe)
public without sharing class DataCleanupService { ... }
// Adopts the caller's sharing context; standalone it acts as "with sharing".
// The recommended default for reusable utility classes.
public inherited sharing class QueryUtils { ... }
Recent change: a class with no sharing keyword used to behave as without sharing when entered directly. In the newest API versions, an omitted keyword defaults to with sharing — bypassing sharing now requires you to write without sharing on purpose. Best practice either way: always declare it explicitly.
User Mode: FLS + CRUD + Sharing in One Keyword
Sharing keywords only handle record visibility — they never enforced FLS or object permissions. User-mode operations enforce all three. Three ways to opt in:
// 1. In SOQL / SOSL
List<Account> accts = [SELECT Id, Name, AnnualRevenue
FROM Account
WITH USER_MODE];
// 2. In DML
Account a = new Account(Name = 'Falcon Corp');
insert as user a; // also: update as user / delete as user
// 3. In Database methods (works for dynamic SOQL too)
Database.insert(records, false, AccessLevel.USER_MODE);
List<SObject> rows = Database.queryWithBinds(query, binds, AccessLevel.USER_MODE);
If the user lacks access to a queried field, user mode throws a QueryException — and e.getInaccessibleFields() reports every offending field at once, which is why WITH USER_MODE replaced the older WITH SECURITY_ENFORCED clause (removed in the newest API versions; it failed one field at a time and didn't check WHERE clauses).
stripInaccessible: Filter Instead of Fail
Sometimes you don't want an exception — you want to silently drop the fields the user can't see (e.g., before returning records to an LWC):
List<Contact> contacts = [SELECT Id, Name, Email, SSN__c FROM Contact];
SObjectAccessDecision decision = Security.stripInaccessible(
AccessType.READABLE, contacts);
List<Contact> safe = decision.getRecords(); // SSN__c removed if not visible
System.debug(decision.getRemovedFields()); // audit what was stripped
The New Security Defaults
| Behavior | Previously | Newest API versions |
|---|---|---|
SOQL / SOSL / DML / Database methods | System mode unless you opt into user mode | User mode by default; system mode is an explicit opt-in (AccessLevel.SYSTEM_MODE) |
| Class with no sharing keyword | Acts as without sharing | Defaults to with sharing |
WITH SECURITY_ENFORCED | Supported | Removed — use WITH USER_MODE |
| Triggers | — | Always run in system mode; sharing/access-mode declarations on triggers are not permitted — put security-sensitive logic in handler classes |
Existing classes keep their old API version and old behavior — nothing breaks on release day. The changes apply as you compile code at the newest API versions. The Release Radar section has a migration checklist.
Best Practices
- Default to user mode for anything user-facing (controllers, invocable methods, REST endpoints). Reserve system mode for genuine system logic — integrations, roll-ups, cleanup jobs — and comment why.
- Declare sharing explicitly on every class, even where a default would cover you.
- Guard dynamic SOQL with
Database.queryWithBinds()— see the SOQL section. - Test both sides: use
System.runAs(restrictedUser)to prove your code blocks (or filters) what it should — see Testing Best Practices.
Sources: Apex Security & Sharing · Sharing Keywords · Developer release guide (official blog)
Governor Limits
Salesforce is multitenant — thousands of orgs share the same servers. Governor limits are the referee that stops one org's runaway loop from slowing everyone else down. They aren't an obstacle; they're a design constraint that, respected early, produces faster and more scalable code.
The Limits That Matter Most (Per Transaction)
| Limit | Synchronous | Asynchronous |
|---|---|---|
| SOQL queries | 100 | 200 |
| Records retrieved by SOQL | 50,000 | |
Records retrieved by Database.getQueryLocator (Batch scope) | 50,000,000 | |
| SOSL searches | 20 | |
| DML statements | 150 | |
| Records processed by DML | 10,000 | |
| CPU time | 10,000 ms | 60,000 ms |
| Heap size | 6 MB | 12 MB |
| HTTP callouts | 100 (max ~120s cumulative timeout) | |
@future calls | 50 | 0 from another future/batch context |
Jobs enqueued via System.enqueueJob | 50 | 1 per Queueable execution (chaining) |
sendEmail method calls | 10 | |
Numbers above are the long-standing per-transaction values — verify edge cases in the official Execution Governors and Limits reference. Recent releases also add elastic daily async limits (beta): jobs beyond your daily allowance throttle instead of failing.
Watch Your Consumption: The Limits Class
System.debug('SOQL used: ' + Limits.getQueries() + ' / ' + Limits.getLimitQueries());
System.debug('DML used: ' + Limits.getDmlStatements() + ' / ' + Limits.getLimitDmlStatements());
System.debug('CPU used: ' + Limits.getCpuTime() + ' / ' + Limits.getLimitCpuTime() + ' ms');
System.debug('Heap used: ' + Limits.getHeapSize() + ' / ' + Limits.getLimitHeapSize() + ' bytes');
// Real-world use: bail out to async before you blow the limit
if (Limits.getQueries() > Limits.getLimitQueries() - 10) {
System.enqueueJob(new FinishLaterJob(remainingWork));
}
Before / After: Fixing a Limit-Blowing Pattern
The classic failure — SOQL and DML inside a loop. With 200 trigger records this attempts 200 queries and 200 updates:
// BEFORE (anti-pattern): 1 query + 1 DML *per record* → LimitException at scale
for (Contact c : Trigger.new) {
Account a = [SELECT Industry FROM Account WHERE Id = :c.AccountId]; // 💥
c.Lead_Source_Category__c = a.Industry;
}
// AFTER (fixed): 1 query + 0 DML, no matter how many records
Set<Id> accountIds = new Set<Id>();
for (Contact c : Trigger.new) {
if (c.AccountId != null) accountIds.add(c.AccountId);
}
Map<Id, Account> accounts = new Map<Id, Account>(
[SELECT Industry FROM Account WHERE Id IN :accountIds]);
for (Contact c : Trigger.new) { // before-insert trigger: no DML needed
Account a = accounts.get(c.AccountId);
if (a != null) c.Lead_Source_Category__c = a.Industry;
}
Strategies When You're Near a Limit
- Bulkify first (Sets + Maps + single DML) — it fixes most limit problems.
- Query selectively: only needed fields, indexed WHERE filters, aggregate queries (
COUNT(),SUM()) instead of pulling rows to count them in Apex. - Move volume to async: Batch Apex or Apex Cursors for big data sets; Queueable for callouts — higher CPU/heap ceilings.
- Cut heap: use
SOQL forloops (for (Account a : [SELECT ...])) to stream records in chunks instead of one giant list; usetransientwhere applicable. - Cache repeated reads: static variables within a transaction; Platform Cache across transactions.
- Remember:
LimitExceptioncan't be caught — prevention is the only cure.
Sources: Execution Governors and Limits · Limits Class Reference
HTTP Callouts & Integrating External APIs
Apex can call any external REST or SOAP API — pushing orders to an ERP, pulling exchange rates, invoking an AI model. This section walks the full production pattern: authenticate with Named Credentials, call with Http, parse with typed wrapper classes, go async from triggers, and mock everything in tests.
Step 1 — Named Credentials (Never Hardcode Endpoints or Secrets)
A Named Credential (Setup → Named Credentials) stores the endpoint URL and handles authentication (API key, OAuth, JWT) for you. Your code references it as callout:CredentialName and never touches a password — which also means secrets don't leak into version control.
Step 2 — A GET Callout with Typed JSON Parsing
public class ExchangeRateService {
// Wrapper class mirrors the JSON: {"base":"USD","rates":{"INR":83.2,"EUR":0.92}}
public class RateResponse {
public String base;
public Map<String, Decimal> rates;
}
public static Decimal getRate(String currencyCode) {
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:Exchange_API/v1/latest?base=USD');
req.setMethod('GET');
req.setTimeout(20000); // ms; default 10s, max 120s
HttpResponse res = new Http().send(req);
if (res.getStatusCode() != 200) {
throw new CalloutException('Rate API returned ' + res.getStatusCode()
+ ': ' + res.getBody());
}
RateResponse parsed = (RateResponse) JSON.deserialize(
res.getBody(), RateResponse.class);
return parsed.rates.get(currencyCode);
}
}
Step 3 — A POST Callout (Sending Data Out)
public class ErpOrderPublisher {
public class OrderPayload {
public String orderNumber;
public Decimal amount;
public String currencyCode;
}
public static String publish(Order ord) {
OrderPayload p = new OrderPayload();
p.orderNumber = ord.OrderNumber;
p.amount = ord.TotalAmount;
p.currencyCode = 'USD';
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:ERP_System/api/orders');
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setBody(JSON.serialize(p));
HttpResponse res = new Http().send(req);
if (res.getStatusCode() != 201) {
throw new CalloutException('ERP rejected order: ' + res.getBody());
}
return res.getBody(); // e.g., the ERP's confirmation id
}
}
Step 4 — Callouts from Triggers Must Be Async
Triggers can't make synchronous callouts (the database transaction can't be held open waiting on an external server). The modern pattern is a Queueable with Database.AllowsCallouts — it beats @future(callout=true) because it takes full objects, chains, and supports Finalizers for retry logic:
// In the after-insert trigger handler:
System.enqueueJob(new PublishOrdersJob(Trigger.newMap.keySet()));
public class PublishOrdersJob implements Queueable, Database.AllowsCallouts {
private Set<Id> orderIds;
public PublishOrdersJob(Set<Id> orderIds) { this.orderIds = orderIds; }
public void execute(QueueableContext ctx) {
List<Order> toUpdate = new List<Order>();
for (Order ord : [SELECT Id, OrderNumber, TotalAmount FROM Order
WHERE Id IN :orderIds]) {
String erpId = ErpOrderPublisher.publish(ord); // callout(s) first…
toUpdate.add(new Order(Id = ord.Id, ERP_Reference__c = erpId));
}
update toUpdate; // …DML after. Never before.
}
}
Golden rule: within one transaction, all callouts must happen before any DML. A callout after uncommitted DML throws CalloutException: You have uncommitted work pending.
Step 5 — Testing Callouts with Mocks
Tests can't hit real endpoints — you simulate the response with HttpCalloutMock:
@isTest
public class ExchangeRateMock implements HttpCalloutMock {
public HttpResponse respond(HttpRequest req) {
HttpResponse res = new HttpResponse();
res.setStatusCode(200);
res.setHeader('Content-Type', 'application/json');
res.setBody('{"base":"USD","rates":{"INR":83.20,"EUR":0.92}}');
return res;
}
}
@isTest
private class ExchangeRateServiceTest {
@isTest
static void returnsRequestedRate() {
Test.setMock(HttpCalloutMock.class, new ExchangeRateMock());
Test.startTest();
Decimal inr = ExchangeRateService.getRate('INR');
Test.stopTest();
Assert.areEqual(83.20, inr, 'Should parse the INR rate from JSON');
}
}
Best Practices & Limits
- 100 callouts per transaction, cumulative maximum timeout ~120 seconds — set explicit
setTimeout()values and handle the slow-API case. - Named Credentials always — hardcoded URLs also require Remote Site Settings and rot quickly.
- Typed wrappers over
JSON.deserializeUntyped— compile-time safety and readable code (use untyped only for genuinely dynamic payloads). - Design for failure: check status codes, wrap in try/catch, log failures, retry via Queueable chaining or a Finalizer.
- Also in the toolbox: generate SOAP clients with WSDL2Apex; expose your own APIs with
@RestResource; use Platform Events / Change Data Capture for event-driven integrations instead of polling.
Sources: Invoking Callouts Using Apex · Named Credentials as Callout Endpoints
Build & Deploy: Moving Apex to Production
You can't edit Apex in production — every class and trigger arrives through a deployment, and Salesforce enforces quality at the door: your deployment's tests must pass with ≥75% overall coverage, and every trigger must have some coverage. This section covers the "build" stage of the lifecycle end-to-end.
Your Deployment Options
| Method | Best for | Notes |
|---|---|---|
Salesforce CLI (sf project deploy) | Developers; the backbone of CI/CD | Source-driven, scriptable, supports validate-only runs |
| DevOps Center | Teams wanting Git-based pipelines without building CI themselves | Free Salesforce tool; work items, review stages, click-to-promote backed by GitHub |
| Change Sets | Admin-led orgs, sandbox → production only | Point-and-click but manual and hard to repeat; no version control |
| Unlocked Packages | Modular architectures, versioned internal apps | sf package version create; installs like a product |
| Managed Packages | ISVs distributing on AppExchange | IP protection, upgrade paths, namespaces |
The Core CLI Workflow
# Pull metadata from the org into your project (and into Git)
sf project retrieve start --source-dir force-app
# Deploy to the default org
sf project deploy start --source-dir force-app
# Deploy to PRODUCTION with tests (what CI runs)
sf project deploy start --source-dir force-app \
--target-org Production --test-level RunLocalTests
# Validate WITHOUT deploying (safe rehearsal — do this before release night)
sf project deploy start --source-dir force-app \
--target-org Production --test-level RunLocalTests --dry-run
# A validated deployment can later be promoted instantly ("quick deploy")
sf project deploy quick --job-id <validated-deploy-id>
Test Levels Explained
NoTestRun— sandboxes only; production always runs tests.RunSpecifiedTests— you name the test classes; each Apex class you're deploying still needs 75% coverage from them. Fastest option for surgical production fixes.RunLocalTests— all tests except managed-package tests. The production default and the safe CI choice.RunAllTestsInOrg— includes managed package tests; slow, occasionally necessary.
A Minimal CI Pipeline (GitHub Actions)
Every pull request validates against a sandbox — reviewers see green checks instead of hoping:
# .github/workflows/validate.yml
name: Validate on PR
on: pull_request
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Salesforce CLI
run: npm install -g @salesforce/cli
- name: Authenticate (SFDX auth URL stored as a repo secret)
run: |
echo "${{ secrets.SFDX_AUTH_URL }}" > auth.txt
sf org login sfdx-url --sfdx-url-file auth.txt --alias CI --set-default
- name: Validate deployment + run tests
run: |
sf project deploy start --source-dir force-app \
--test-level RunLocalTests --dry-run --wait 30
Get the auth URL once with sf org display --verbose --target-org YourSandbox (the Sfdx Auth Url value) and save it as the SFDX_AUTH_URL secret. Rotate it like any credential.
Release-Day Checklist
- Validated (
--dry-run) against production earlier in the week → quick-deploy the validated job on release day. - Coverage comfortably above 75% — aim for 85%+ so one skipped test doesn't block you.
- Destructive changes (deleted classes/fields) handled via a destructive changes manifest — deploys don't delete by default.
- Post-deploy steps scripted: permission set assignments, custom settings/metadata values, scheduled job re-registration (
System.scheduleruns aren't deployed). - A rollback story: previous Git tag ready to redeploy, and data-impacting jobs disabled until verified.
Sources: Deploying Apex · Salesforce CLI · DevOps Center
Modern Apex Features You Should Be Using
Apex gets new language features every release, but most tutorials online still teach 2018-era code. Everything below is generally available today — using these features makes your code shorter, safer, and easier to review.
Safe Navigation (?.) and Null Coalescing (??)
// Old defensive boilerplate
String city;
if (con != null && con.Account != null && con.Account.BillingCity != null) {
city = con.Account.BillingCity;
} else {
city = 'Unknown';
}
// Modern: one line, same behavior
String city = con?.Account?.BillingCity ?? 'Unknown';
// ?. short-circuits to null instead of throwing NullPointerException
// ?? returns the left side unless it's null, then the right side
The UUID Class
// Generate RFC-4122 version-4 UUIDs natively — no more homemade generators
UUID correlationId = UUID.randomUUID();
req.setHeader('X-Correlation-Id', correlationId.toString());
Perfect for idempotency keys and correlating a Salesforce transaction with external-system logs. (If you read our session ID generator blog — this class replaces most hand-rolled approaches.)
DataWeave in Apex: Serious Data Transformation
MuleSoft's transformation language runs natively in Apex — ideal for CSV↔JSON work that's painful with string splitting. A .dwl script file in /dw becomes callable:
// force-app/main/default/dw/csvToContacts.dwl
// %dw 2.0
// input records application/csv
// output application/json
// ---
// records map (r) -> { FirstName: r.first_name, LastName: r.last_name, Email: r.email }
DataWeave.Script script = new DataWeaveScriptResource.csvToContacts();
DataWeave.Result result = script.execute(
new Map<String, Object>{ 'records' => csvFileBody });
List<Object> contacts = (List<Object>) JSON.deserializeUntyped(
(String) result.getValue());
Compression Namespace: Zip Files Natively (GA)
// Create a zip with multiple entries — previously impossible without JS hacks
Compression.ZipWriter writer = new Compression.ZipWriter();
writer.addEntry('report.csv', csvBlob);
writer.addEntry('summary.txt', Blob.valueOf('Q2 export'));
Blob zipped = writer.getArchive();
// Extract one
Compression.ZipReader reader = new Compression.ZipReader(zipped);
Blob csv = reader.getEntry('report.csv').getContent();
Mind the heap limit (6/12 MB) — test with your real file sizes.
FormulaEval: Evaluate Formulas at Runtime (GA)
// Evaluate an admin-configurable formula against a record — think
// custom scoring rules stored in Custom Metadata, changed without a deploy.
FormulaEval.FormulaInstance f = Formula.builder()
.withType(Opportunity.SObjectType)
.withReturnType(FormulaEval.FormulaReturnType.BOOLEAN)
.withFormula('Amount > 100000 && StageName = "Negotiation"')
.build();
Boolean isHotDeal = (Boolean) f.evaluate(oppRecord);
A newer update added template mode, so string formulas can use merge-field syntax (Hi {!Owner.FirstName}) instead of concatenation.
Apex Cursors: Huge Queries Without Batch Apex (GA)
Cursors page through up to 50 million rows from a fixed query snapshot, forward or backward — great for "process a lot, but with Queueable-style control" jobs and paginated APIs where Batch Apex is too rigid:
public class ArchiveTasksJob implements Queueable {
private Database.Cursor cursor;
private Integer position = 0;
private static final Integer PAGE = 2000;
public ArchiveTasksJob() {
this.cursor = Database.getCursor(
'SELECT Id FROM Task WHERE CreatedDate < LAST_N_YEARS:2');
}
public void execute(QueueableContext ctx) {
List<Task> page = (List<Task>) cursor.fetch(position, PAGE);
// ... archive/delete this page ...
position += page.size();
if (position < cursor.getNumRecords()) {
System.enqueueJob(this); // chain until done
}
}
}
Also in Your Toolbox
Assertclass — modern test assertions (Test Classes).Database.queryWithBinds()— injection-safe dynamic SOQL (SOQL).- User-mode operations —
WITH USER_MODE,insert as user,AccessLevel(Apex Security). - Transaction Finalizers — guaranteed async recovery (Exception Handling).
- Multiline strings &
String.template()— brand-new (Release Radar).
Sources: Apex Release Notes · Safe Navigation Operator · DataWeave in Apex
What's New in Apex
Salesforce ships three releases a year, and Apex has been moving fast. Here's the verified timeline of what changed, what's new in the latest releases, and the migration checklist to bring older code forward.
Timeline of Recent Apex Changes
| When | Headline Apex changes |
|---|---|
| Earlier updates | Compression namespace GA (zip/unzip) · FormulaEval GA (runtime formula evaluation) |
| Earlier updates | FormulaEval template mode (merge-field syntax in string formulas) |
| Recent updates | ApexDoc — official JavaDoc-style documentation standard (readable by tooling and AI agents) · abstract/override methods now require an explicit protected/public/global access modifier (API 65+) · Test Discovery API runs Apex + Flow tests in one run, viewable in Setup |
| Recent updates | Apex Cursors GA (up to 50M rows, forward/backward paging) · build Agentforce agent actions from annotated Apex classes via generated OpenAPI specs |
| Latest updates | User mode by default for SOQL/SOSL/DML/Database ops · omitted sharing keyword now defaults to with sharing · WITH SECURITY_ENFORCED removed · triggers always system mode (no sharing declarations) · multiline strings (''') and String.template() · @IntegrationTest (developer preview): real callouts + committed data in tests · elastic daily async limits (beta) · invocable action input types need visible no-arg constructors |
Try the New String Features
// Multiline strings: no more '\n' + concatenation chains for payloads
String emailBody = '''
Hi ${firstName},
Your order ${orderNumber} shipped today.
Track it here: ${trackingUrl}
''';
// String.template() interpolates ${...} from your variables
String rendered = String.template(emailBody);
// Heads-up: Datetime values render in GMT (yyyy-MM-dd HH:mm:ss) —
// format them yourself first if you need user-local time.
ApexDoc: Document Code the Standard Way
/**
* Reserves inventory for confirmed orders.
*
* @param productId the Product2 record to reserve against
* @param qty units requested; must be positive
* @return the remaining available units after reservation
* @throws InsufficientInventoryException if stock is too low
*/
public static Integer reserveStock(Id productId, Integer qty) { ... }
Familiar if you've used JavaDoc — and because it's now an official standard, documentation generators and AI assistants (including Agentforce for Developers) can consume it reliably.
Migration Checklist for the Newest API Versions
- Search for
WITH SECURITY_ENFORCED→ replace withWITH USER_MODE(better errors, checks WHERE clauses, handles polymorphic fields). - Find classes with no sharing keyword that silently relied on
without sharingbehavior → declarewithout sharingexplicitly where genuinely intended, with a comment saying why. - Audit DML/SOQL that must bypass user permissions (integration users, system jobs) → make it explicit with
AccessLevel.SYSTEM_MODEbefore upgrading the class's API version. - Remove sharing declarations from triggers and move security-sensitive logic into handler classes.
- Add no-arg constructors to custom types used as invocable action inputs.
- Abstract/override methods without access modifiers won't compile at newer API versions — add
protected/public/global. - Existing classes keep working on their old API versions — migrate deliberately, class by class, with tests green at each step.
Sources: Developer release guides (official blog) · Apex Release Notes