Prepare for your Salesforce Platform Developer I & II interviews with real questions covering Apex fundamentals, triggers, SOQL, async patterns, and best practices.
Apex is a strongly typed, object-oriented programming language developed by Salesforce that runs on the Force.com platform. It lets developers execute flow and transaction control statements in conjunction with calls to the Force.com API. Apex syntax is similar to Java — it supports classes, interfaces, inheritance, generics, exception handling, and annotations. Key uses: Apex triggers, web services, batch processing, scheduled jobs, and custom controllers.
Governor limits are runtime limits enforced by the Salesforce platform to ensure no single tenant monopolises shared resources on the multi-tenant architecture. Key per-transaction limits:
// Per synchronous transaction SOQL queries : 100 DML statements : 150 SOQL rows returned : 50,000 Heap size : 6 MB (async: 12 MB) CPU time : 10,000 ms HTTP callouts : 100 Future calls : 50
Apex supports primitive types, sObject types, collection types, and special types:
// Primitives Integer i = 42; Long l = 9999999999L; Decimal d = 3.14; Double db = 2.718; Boolean b = true; String s = 'Hello'; Date dt = Date.today(); DateTime dtt = DateTime.now(); Id recordId = '001xx000003GYk1'; Blob b64 = Blob.valueOf('data'); // Collections List<Account> accounts = new List<Account>(); Set<Id> idSet = new Set<Id>(); Map<Id, Account> accMap = new Map<Id, Account>();
A class is a blueprint for objects and can contain method implementations, fields, constructors, and inner classes. An interface defines a contract — only method signatures, no implementations. Classes implement interfaces using implements. Apex does not support multiple class inheritance but a class can implement multiple interfaces.
public interface Notifiable { void sendNotification(String message); } public class EmailNotifier implements Notifiable { public void sendNotification(String message) { // send email implementation } }
Static members belong to the class itself — they are shared across all instances and persist for the lifetime of the transaction. Instance members belong to a specific object instance. In Apex triggers, static variables are commonly used as recursive guards because they retain their value across trigger invocations within the same transaction.
public class TriggerHelper { // Static — shared across all calls in this transaction public static Boolean hasRun = false; // Instance — each new TriggerHelper() gets its own public Integer instanceCount = 0; }
Apex has four access modifiers: private (default for class members — accessible only within the class), protected (accessible within the class and its subclasses), public (accessible within the same namespace), global (accessible from any Apex code, including external packages). Classes themselves must be public or global to be visible outside the file.
An sObject (Salesforce Object) is any object that can be stored in the Salesforce database. It is the generic type for all Salesforce records. Standard sObjects include Account, Contact, Lead, Opportunity. Custom sObjects end in __c. You can use the generic SObject type or specific types like Account:
Account acc = new Account(Name = 'Acme', Industry = 'Technology'); SObject generic = acc; // generic reference String name = (String) generic.get('Name'); // dynamic field access
List: ordered collection, allows duplicates, index-based access (list.get(0)). Set: unordered collection, no duplicates — useful for membership checks. Map: key-value pairs, keys are unique — commonly used to map IDs to records for efficient lookup instead of nested loops.
// Map for O(1) lookup instead of nested loops Map<Id, Account> accMap = new Map<Id, Account>( [SELECT Id, Name FROM Account WHERE Id IN :accountIds] ); for (Contact c : contacts) { Account parent = accMap.get(c.AccountId); // O(1) }
Mixed DML error occurs when you perform DML on setup objects (User, Group, GroupMember, etc.) and non-setup objects in the same transaction. Solution: use @future to isolate one of the DML operations in a separate transaction.
public class UserHelper { @future public static void assignPermSet(Id userId, String psName) { PermissionSet ps = [SELECT Id FROM PermissionSet WHERE Name = :psName LIMIT 1]; insert new PermissionSetAssignment( AssigneeId = userId, PermissionSetId = ps.Id); } }
with sharing enforces the current user's sharing rules — SOQL queries and DML only act on records the user has access to. without sharing bypasses sharing rules and accesses all records. Default is without sharing if not specified. Best practice is to always explicitly declare sharing mode.
public with sharing class AccountService { // Respects user's sharing rules public List<Account> getUserAccounts() { return [SELECT Id, Name FROM Account]; } }
An Apex trigger is code that executes before or after specific database events on Salesforce records — insert, update, delete, and undelete. Triggers follow the syntax: trigger TriggerName on ObjectName (events) { body }. Best practice: one trigger per object that delegates to a handler class.
Before triggers fire before the record is written to the database. They are used to validate or modify field values on the records being inserted/updated — changes to Trigger.new records take effect automatically without an additional DML call. Record IDs are NOT yet available in before insert triggers.
After triggers fire after the record is saved. Record IDs are available. They are used to create/update related records (child records, lookup updates, cascade logic). You must use explicit DML for any changes.
Trigger.new // List of new records (insert, update, undelete) Trigger.old // List of old records (update, delete) Trigger.newMap // Map<Id, SObject> of new records Trigger.oldMap // Map<Id, SObject> of old records Trigger.isInsert // true on insert Trigger.isUpdate // true on update Trigger.isDelete // true on delete Trigger.isUndelete// true on undelete Trigger.isBefore // true in before context Trigger.isAfter // true in after context Trigger.size // number of records in this batch (max 200)
Trigger.new is null in delete triggers. Trigger.old is null in insert triggers. Always check before accessing.Triggers can fire with up to 200 records in a single call (e.g., when a data loader imports 10,000 records, the trigger fires ~50 times with 200 records each). Placing SOQL or DML inside a loop multiplies those operations by the record count — 200 SOQL queries would immediately hit the 100-query governor limit.
// ❌ Wrong — SOQL inside loop for (Account acc : Trigger.new) { List<Contact> contacts = [SELECT Id FROM Contact WHERE AccountId = :acc.Id]; // 💣 } // ✅ Correct — single SOQL outside loop Set<Id> accountIds = Trigger.newMap.keySet(); Map<Id, List<Contact>> contactMap = new Map<Id, List<Contact>>(); for (Contact c : [SELECT Id, AccountId FROM Contact WHERE AccountId IN :accountIds]) { if (!contactMap.containsKey(c.AccountId)) contactMap.put(c.AccountId, new List<Contact>()); contactMap.get(c.AccountId).add(c); }
A recursive trigger is one that re-fires itself. For example: an after insert trigger updates the record, which fires the after update trigger, which updates again — an infinite loop. Prevention: use a static Boolean guard that is set to true on first execution and checked before proceeding.
public class TriggerHelper { public static Boolean hasRun = false; } trigger AccountTrigger on Account (after insert) { if (TriggerHelper.hasRun) return; TriggerHelper.hasRun = true; // trigger logic here }
The trigger handler pattern delegates all logic from the trigger to a separate class. The trigger itself is kept to 3–5 lines. Benefits: testable in isolation, easier to maintain, prevents multiple triggers competing on the same object.
trigger AccountTrigger on Account (before insert, after update) { AccountTriggerHandler handler = new AccountTriggerHandler(); if (Trigger.isBefore && Trigger.isInsert) handler.onBeforeInsert(Trigger.new); if (Trigger.isAfter && Trigger.isUpdate) handler.onAfterUpdate(Trigger.new, Trigger.oldMap); }
Technically yes, but it is a best practice anti-pattern. The order of execution for multiple triggers on the same object is not guaranteed. This makes logic unpredictable and untestable. Best practice: one trigger per object, delegating to a handler class that organises all event-specific logic.
addError() on a record or field prevents the DML operation and returns a user-friendly error message. It is used in before triggers to block invalid records from being saved.
trigger ContactValidation on Contact (before insert, before update) { for (Contact c : Trigger.new) { if (c.Email == null) { c.Email.addError('Email is required for all contacts.'); } } }
Salesforce follows a specific order: 1. System validation → 2. Before triggers → 3. Custom validation rules → 4. Duplicate rules → 5. Record saved to database → 6. After triggers → 7. Assignment rules → 8. Auto-response rules → 9. Workflow rules → 10. Processes/Flows → 11. Escalation rules → 12. Entitlement rules → 13. Commit.
No — direct HTTP callouts from triggers are not allowed because callouts require a transaction to remain open while waiting for a response, which violates the synchronous trigger execution model. Use @future(callout=true) or Queueable Apex to make callouts asynchronously from a trigger.
SOQL (Salesforce Object Query Language) queries a single object and its relationships — like SQL SELECT. SOSL (Salesforce Object Search Language) searches text fields across multiple objects simultaneously — like a search engine. Use SOQL when you know the object; use SOSL when searching across objects by keyword.
// SOQL — single object [SELECT Id, Name FROM Account WHERE Industry = 'Technology'] // SOSL — across objects [FIND 'Acme*' IN ALL FIELDS RETURNING Account(Id, Name), Contact(Id, Name)]
insert account; // creates record update account; // updates existing record (Id required) upsert account; // insert if new, update if exists delete account; // moves to Recycle Bin undelete account; // restores from Recycle Bin merge master duplicates; // merges up to 3 records // Database methods — allow partial success Database.SaveResult[] results = Database.insert(records, false);
DML statements (insert list) fail the entire operation if any record fails — all-or-nothing. Database methods (Database.insert(list, false)) allow partial success — failed records are captured in SaveResult[] while successful records commit. Use Database methods when processing large batches where some failures are acceptable.
WITH SECURITY_ENFORCED enforces field-level and object-level security on a SOQL query. If the running user does not have read access to any field or object in the query, a System.QueryException is thrown. It is best practice to include it in service classes and controllers to prevent unauthorised data access.
[SELECT Id, Name, AnnualRevenue FROM Account WITH SECURITY_ENFORCED WHERE Industry = 'Technology' LIMIT 200]
SOQL supports two types of relationship queries: Child-to-parent uses dot notation to traverse up the relationship. Parent-to-child uses a subquery with the child's relationship name (plural for standard, __r for custom).
// Child-to-parent (Contact → Account) [SELECT Id, Name, Account.Name, Account.Industry FROM Contact] // Parent-to-child (Account → Contacts) [SELECT Id, Name, (SELECT Id, Name FROM Contacts) FROM Account]
A SOQL for loop processes query results in batches of 200 records at a time, avoiding heap size limits when querying large data sets. Use it when you expect more than a few thousand records.
// Standard — loads all records into heap at once List<Account> accs = [SELECT Id FROM Account]; // SOQL for loop — streams in batches of 200 for (List<Account> batch : [SELECT Id FROM Account]) { // process batch — heap stays low }
COUNT() // number of records COUNT(fieldName) // non-null values in field SUM(field) // sum of numeric field AVG(field) // average MIN(field) // minimum MAX(field) // maximum // Access via AggregateResult for (AggregateResult ar : [ SELECT Industry, COUNT(Id) cnt FROM Account GROUP BY Industry]) { System.debug(ar.get('Industry') + ': ' + ar.get('cnt')); }
Dynamic SOQL builds query strings at runtime using Database.query(String). Use it when fields, objects, or conditions are unknown at compile time (e.g., user-configurable filters). Always sanitise string inputs with String.escapeSingleQuotes() to prevent SOQL injection.
String obj = 'Account'; String field = 'Industry'; String val = String.escapeSingleQuotes(userInput); // sanitise! String q = 'SELECT Id, Name FROM ' + obj + ' WHERE ' + field + ' = \'' + val + '\''; List<SObject> results = Database.query(q);
@future runs a static method asynchronously in a separate thread. Use cases: HTTP callouts from triggers (requires @future(callout=true)), mixed DML operations. Limitations: no return value, parameters must be primitive (no sObjects), cannot be called from another @future.
@future(callout=true) public static void syncToExternal(List<Id> recordIds) { HttpRequest req = new HttpRequest(); // make callout }
Queueable Apex is a more powerful async pattern. Key differences: can accept non-primitive parameters (sObjects, custom classes), returns a job ID (AsyncApexJob), supports chaining (calling another Queueable from execute()), and can be monitored. Implement Queueable and call System.enqueueJob(new MyJob()).
public class ProcessAccountsJob implements Queueable { private List<Account> accounts; public ProcessAccountsJob(List<Account> accs) { accounts = accs; } public void execute(QueueableContext ctx) { // process accounts System.enqueueJob(new NextJob()); // chain } }
Batch Apex processes millions of records in chunks. Governor limits reset per execute() batch. Use it when you need to process more records than a single synchronous transaction allows. Implement Database.Batchable<SObject> with start(), execute(), and finish() methods. Default batch size is 200; maximum is 2,000.
By default, batch classes are stateless — instance variables reset between execute() calls. Implementing Database.Stateful allows instance variable values to persist across batches. Use it when you need to count totals, accumulate errors, or track cross-batch state.
global class SummaryBatch implements Database.Batchable<SObject>, Database.Stateful { global Integer totalProcessed = 0; // persists across batches global void execute(Database.BatchableContext bc, List<SObject> scope) { totalProcessed += scope.size(); } }
Schedulable Apex runs on a cron-based schedule. Implement the Schedulable interface and its execute(SchedulableContext ctx) method. Schedule with System.schedule('JobName', cronExpression, new MyJob()). Most commonly used to launch batch jobs on a schedule.
global class DailyCleanupScheduler implements Schedulable { global void execute(SchedulableContext ctx) { Database.executeBatch(new CleanupBatch(), 200); } } // Schedule: every day at 2am System.schedule('Daily Cleanup', '0 0 2 * * ?', new DailyCleanupScheduler());
Synchronous Apex executes in the current user thread — governor limits apply immediately and the user waits for completion. Asynchronous Apex (@future, Queueable, Batch, Schedulable) runs in a separate thread, often with higher limits (12 MB heap vs 6 MB, 200 SOQL queries vs 100). Use async when: making HTTP callouts from triggers, processing large data volumes, or performing mixed DML.
In production, you can chain up to unlimited Queueable jobs — each execute() can enqueue one child. In test context, only one level of chaining is allowed (the enqueue call is accepted but the child job does not execute within the test unless you use additional Test.startTest()/stopTest() blocks). The Apex flex queue can hold up to 100 Queueable jobs.
Platform Events are Salesforce's publish-subscribe messaging system. A publisher fires EventBus.publish(new My_Event__e(...)) and any subscriber trigger (trigger on My_Event__e (after insert)) receives it asynchronously. Unlike standard triggers, platform event triggers run in their own transaction and can be used for cross-org or cross-system integration.
Salesforce requires a minimum of 75% code coverage across all Apex classes and triggers before you can deploy to production. Individual classes/triggers do not have their own minimum — it is the overall average. Best practice: aim for 90%+ and ensure tests cover positive paths, negative paths, and edge cases — not just inflating coverage.
Test.startTest() resets all governor limit counters so the code under test has a fresh set. Test.stopTest() forces all async operations (@future, Queueable, Batch) to execute synchronously before the next assertion. Always place assertions after Test.stopTest() when testing async code.
@isTest static void testBatchJob() { insert testData; Test.startTest(); Database.executeBatch(new MyBatchJob(), 200); Test.stopTest(); // batch runs synchronously here System.assertEquals(10, [SELECT COUNT() FROM Account].getSize()); }
@TestSetup marks a method that runs once before any test method in the class. The data it creates is rolled back and re-inserted fresh for each test method, ensuring test isolation. Use it to avoid duplicating test data creation across multiple test methods.
@isTest class AccountServiceTest { @TestSetup static void makeData() { insert new Account(Name = 'Test Account', Industry = 'Technology'); } @isTest static void testMethod1() { Account a = [SELECT Id FROM Account LIMIT 1]; // fresh copy } }
Callouts cannot be made in test context. Create a mock class implementing HttpCalloutMock and register it with Test.setMock(HttpCalloutMock.class, new MyMock()) before the callout code runs.
@isTest global class MockHttpResponse implements HttpCalloutMock { global HTTPResponse respond(HTTPRequest req) { HTTPResponse res = new HTTPResponse(); res.setStatusCode(200); res.setBody('{"status":"ok"}'); return res; } } @isTest static void testCallout() { Test.setMock(HttpCalloutMock.class, new MockHttpResponse()); Test.startTest(); MyCalloutClass.doCallout(); Test.stopTest(); }
By default, test classes run in isolation — they cannot see org data. The only exceptions are metadata (custom settings, custom metadata types) and data explicitly created in the test or @TestSetup. To access org data in tests, use @isTest(SeeAllData=true) — but this is strongly discouraged because it creates test dependencies on live data.
System.assertEquals(expected, actual); System.assertEquals(expected, actual, 'message'); // with message System.assertNotEquals(expected, actual); System.assert(condition); System.assert(condition, 'failure message'); // API v57+ (more readable) Assert.areEqual(expected, actual); Assert.isTrue(condition, 'message'); Assert.isNotNull(value);
@isTest static void testNullInput() { try { MyService.processAccount(null); System.assert(false, 'Expected exception was not thrown'); } catch (IllegalArgumentException e) { System.assert(e.getMessage().contains('required')); } }
null means the variable has no value assigned. An empty string ('') or empty list (new List<Account>()) is a valid object with zero length. Calling methods on a null reference throws NullPointerException. Always null-check before accessing properties or methods on objects that could be null.
try { update accounts; } catch (DmlException e) { for (Integer i = 0; i < e.getNumDml(); i++) { System.debug(e.getDmlMessage(i)); } } catch (Exception e) { System.debug('Unexpected: ' + e.getMessage()); } finally { // always runs }
Custom exceptions extend the built-in Exception class. They allow you to throw meaningful, domain-specific errors. The class name must end in Exception.
public class InvalidInputException extends Exception {} public static void processAmount(Decimal amount) { if (amount < 0) throw new InvalidInputException('Amount cannot be negative'); }
The Service Layer pattern separates business logic from triggers and controllers into dedicated with sharing classes. Benefits: reusable across triggers, controllers, and batch jobs; testable in isolation; enforces consistent security. A service class contains static methods operating on collections of records.
public with sharing class AccountService { public static void updateAnnualRevenue(List<Account> accounts) { // bulk-safe business logic here update accounts; } }
Hardcoded Salesforce IDs are org-specific and will fail in any other org (sandbox, production, scratch org). Alternatives: query by name (WHERE Name = 'My Queue'), use Custom Metadata Type records, use Custom Labels, or use Custom Settings. Custom Metadata is the preferred approach as it is deployable.
A savepoint marks a position in a transaction. Database.rollback(sp) undoes all DML performed after the savepoint was set. Useful for partial transaction rollback when a later operation fails and you need to undo earlier work cleanly.
Savepoint sp = Database.setSavepoint(); try { insert accountList; insert contactList; // if this fails... } catch (DmlException e) { Database.rollback(sp); // ...accounts also rolled back }
Reading answers is just the first step. Solidify your knowledge by writing real Apex code in our browser-based editor — no org needed.
Write your first bulk-safe trigger with a handler class.
MediumImplement a static boolean guard to prevent recursion.
MediumBuild a Database.Batchable class to archive old records.
HardChain Queueable jobs with HTTP callouts.