Apex Developer Guide

The full development lifecycle — plan, develop, build, test, debug, deploy — with real-world examples throughout.

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 unified sf commands 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 with sf --version.
  • Step 4: Create Your First Project
    sf project generate --name MyFirstProject
    cd MyFirstProject
    This scaffolds the standard source-driven folder structure (force-app/main/default) where your Apex classes will live.
  • Step 5: Authorize Your Org
    From the project folder, run:
    sf org login web --alias DevOrg --set-default
    A browser opens for login. Once done, your org is connected — VS Code and the CLI deploy to it by default.
  • Step 6: Create, Deploy, and Test a Class
    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
    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.

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 packages
    • Private – accessible only within the same class
    • Local – 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-else blocks; use switch or early returns if possible.
  • Always handle the else case 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

  • switch in Apex supports only one when else block, and it must come last.
  • You can match multiple values in a single when block: when 'A', 'B' { ... }.
  • Only scalar types like String, Integer, Enum are allowed in switch conditions.

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 for or while loops; 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 break or continue cautiously — 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 private or public. This protects internal data.
  • Inheritance: One class can reuse another class’s code using the extends keyword.
  • 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_MODE to 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 older WITH SECURITY_ENFORCED clause 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 Database methods for better control and error tracking.
  • Never perform DML operations inside for loops.
  • Group logic before executing DML to reduce statements.
  • Use try-catch for 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 allOrNone flag
  • Use external IDs for intelligent upsert operations

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 feedback
  • Database.update() – Update records and monitor individual success/failure
  • Database.delete() – Remove records while skipping invalid ones
  • Database.upsert() – Insert or update intelligently based on Id or External Id
  • Database.undelete() – Restore soft-deleted records from Recycle Bin
  • Database.merge() – Merge duplicate records (only for Leads, Contacts, Accounts)
  • Database.convertLead() – Convert leads into Contacts/Accounts/Opportunities
  • Database.rollback() – Revert changes to a savepoint
  • Database.query() / Database.queryWithBinds() – Run dynamic SOQL; queryWithBinds takes 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 = false to prevent one bad record from ruining the batch.
  • Use isSuccess() and getErrors() for clean error management.
  • Log errors to a custom object or platform event for traceability in production.
  • Avoid writing Database.* calls inside for loops. Collect records, then execute once.
  • Use SavePoint and Database.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 database
  • Trigger.isAfter – True if the trigger runs after the record is saved
  • Trigger.isInsert – True if the operation is an insert
  • Trigger.isUpdate – True if the operation is an update
  • Trigger.isDelete – True if the operation is a delete
  • Trigger.isUndelete – True if records are being restored from Recycle Bin
  • Trigger.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 static and return void
  • 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.Stateful to 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-catch to 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 @testSetup for 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() and Test.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 Assert Class:
    The dedicated Assert class is the recommended way to verify results. It reads better and adds purpose-built methods that System.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
  • 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 exceptions
    • WARN – Potential issues
    • INFO – 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) with System.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__c object from your catch blocks 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 LoggingLevel for 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
  • Use Assertions to Validate Outcomes:
    • Prefer the modern Assert class: Assert.areEqual(), Assert.isTrue(), Assert.isNotNull(), Assert.fail() (the older System.assertEquals() family still works)
    • Assert actual vs expected results to ensure correctness — a test without assertions proves nothing, even at 100% coverage
  • 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

EnvironmentWhat it isUse it for
Developer Edition orgFree, permanent personal org (signup)Learning, prototypes, this guide's examples
Scratch orgDisposable org (1–30 days) created from a config file by the CLISource-driven team development, CI pipelines, testing features in isolation
Developer / Developer Pro sandboxCopy of production's metadata (no data / more storage)Day-to-day project development and unit testing
Partial Copy sandboxMetadata + a sample of production dataQA and integration testing with realistic data
Full sandboxComplete replica of productionUAT, 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:

RequirementRight toolWhy
Field defaults, simple record updates, guided screensFlowFaster to build, admins can maintain it
Complex branching across many objects, reusable service logicApex (often invoked from Flow via @InvocableMethod)Version-controlled, unit-testable, easier to debug at scale
Processing thousands–millions of recordsBatch Apex / Apex CursorsFlow hits element/iteration limits; Apex async is built for volume
Callouts with complex payloads, retries, error handlingApex (Queueable + Finalizer)Full control over serialization, timeouts, and recovery
Same-transaction validation with custom logicApex trigger + addError()Blocks the save atomically
Logic an admin will tweak monthly (e.g., assignment rules)Flow or Custom Metadata–driven ApexKeep 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 insert for 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.oldMap stage-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

ExceptionThrown when…Typical fix
DmlExceptioninsert/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 violationsAssign to a List and check isEmpty(); check getInaccessibleFields() in user mode
NullPointerExceptionDereferencing a null (acc.Name when acc is null)Guard with the safe navigation operator acc?.Name
ListException / MathExceptionIndex out of bounds / divide by zeroBounds-check; validate divisors
CalloutExceptionHTTP callout timeout, or callout attempted after DML in the same transactionRetry via Queueable; reorder logic so callouts happen before DML
System.LimitExceptionA governor limit is exceededCannot 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-friendly addError().
  • Catch the most specific type first: DmlException before Exception.
  • 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

BehaviorPreviouslyNewest API versions
SOQL / SOSL / DML / Database methodsSystem mode unless you opt into user modeUser mode by default; system mode is an explicit opt-in (AccessLevel.SYSTEM_MODE)
Class with no sharing keywordActs as without sharingDefaults to with sharing
WITH SECURITY_ENFORCEDSupportedRemoved — use WITH USER_MODE
TriggersAlways 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)

LimitSynchronousAsynchronous
SOQL queries100200
Records retrieved by SOQL50,000
Records retrieved by Database.getQueryLocator (Batch scope)50,000,000
SOSL searches20
DML statements150
Records processed by DML10,000
CPU time10,000 ms60,000 ms
Heap size6 MB12 MB
HTTP callouts100 (max ~120s cumulative timeout)
@future calls500 from another future/batch context
Jobs enqueued via System.enqueueJob501 per Queueable execution (chaining)
sendEmail method calls10

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 for loops (for (Account a : [SELECT ...])) to stream records in chunks instead of one giant list; use transient where applicable.
  • Cache repeated reads: static variables within a transaction; Platform Cache across transactions.
  • Remember: LimitException can'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

MethodBest forNotes
Salesforce CLI (sf project deploy)Developers; the backbone of CI/CDSource-driven, scriptable, supports validate-only runs
DevOps CenterTeams wanting Git-based pipelines without building CI themselvesFree Salesforce tool; work items, review stages, click-to-promote backed by GitHub
Change SetsAdmin-led orgs, sandbox → production onlyPoint-and-click but manual and hard to repeat; no version control
Unlocked PackagesModular architectures, versioned internal appssf package version create; installs like a product
Managed PackagesISVs distributing on AppExchangeIP 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.schedule runs 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

  • Assert class — modern test assertions (Test Classes).
  • Database.queryWithBinds() — injection-safe dynamic SOQL (SOQL).
  • User-mode operationsWITH 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

WhenHeadline Apex changes
Earlier updatesCompression namespace GA (zip/unzip) · FormulaEval GA (runtime formula evaluation)
Earlier updatesFormulaEval template mode (merge-field syntax in string formulas)
Recent updatesApexDoc — 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 updatesApex Cursors GA (up to 50M rows, forward/backward paging) · build Agentforce agent actions from annotated Apex classes via generated OpenAPI specs
Latest updatesUser 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 with WITH USER_MODE (better errors, checks WHERE clauses, handles polymorphic fields).
  • Find classes with no sharing keyword that silently relied on without sharing behavior → declare without sharing explicitly 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_MODE before 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