Salesforce Platform Developer II (PDII) - Fact Sheet¶
Quick Reference¶
Cost: $200 USD ($100 retake) Format: 60 multiple-choice / multi-select (proctored, single exam as of 2026) Duration: 120 minutes Passing: 70% Prereq: Active PDI credential
Credential page Apex Developer Guide Apex Reference Guide LWC Developer Guide Integration Patterns Guide REST API Developer Guide
Governor limits (must memorize cold)¶
| Limit | Sync | Async |
|---|---|---|
| SOQL queries | 100 | 200 |
| SOQL rows returned | 50,000 | 50,000 |
| SOSL queries | 20 | 20 |
| DML statements | 150 | 150 |
| DML rows | 10,000 | 10,000 |
| CPU time | 10,000 ms | 60,000 ms |
| Heap size | 6 MB | 12 MB |
| HTTP callouts | 100 | 100 |
| Callout total time | 120 sec | 120 sec |
| Future calls per txn | 50 | n/a |
| Queueable jobs per txn | 50 | 1 (chained from queueable: 1 in test, otherwise 50) |
| Queueable depth | unlimited prod / 5 chained in test | n/a |
| Batch concurrent jobs | 5 | n/a |
| Async daily executions | 250,000 (or 200x user licenses, whichever is greater) | n/a |
EventBus.publish events | per-txn limit applies; subscribers run async | n/a |
| Email invocations | 10 (sendEmail) | n/a |
Bulkification = staying within these limits despite mass operations. Every PDII trigger/handler answer must respect bulk patterns.
Apex class structure (advanced)¶
public with sharing class OpportunityService {
@TestVisible private static Boolean bypassRecursion = false;
public interface IOpportunityRepo {
List<Opportunity> findByStage(String stage);
}
public class StageChangeException extends Exception {}
private final IOpportunityRepo repo;
public OpportunityService(IOpportunityRepo repo) {
this.repo = repo;
}
public void closeWon(Set<Id> oppIds) {
List<Opportunity> opps = repo.findByStage('Negotiation');
// ... business logic
}
}
Sharing keywords¶
| Keyword | Behavior |
|---|---|
with sharing | Enforces running user's record-level sharing |
without sharing | Bypasses sharing (system context) |
inherited sharing | Inherits caller's mode; safer default for utility classes |
Triggers default to without sharing unless declared in a class with explicit sharing. Apex REST endpoints execute as the calling user.
WITH SECURITY_ENFORCED and Security.stripInaccessible¶
List<Account> accs = [SELECT Id, Name, AnnualRevenue FROM Account WITH SECURITY_ENFORCED];
SObjectAccessDecision decision = Security.stripInaccessible(
AccessType.READABLE, [SELECT Id, Name, AnnualRevenue FROM Account]);
List<Account> safeList = (List<Account>) decision.getRecords();
WITH SECURITY_ENFORCED throws if any field is not accessible. stripInaccessible removes inaccessible fields silently and returns a sanitized list.
SOQL (advanced)¶
Selective filters and indexed fields¶
A query is "selective" when filtering reduces the candidate set below the org-specific threshold. Indexed fields:
Id,Name(auto-indexed)OwnerId,LookupFields,MasterDetailExternal Id(custom flag - this creates a custom index)Uniquefields- Audit fields (
CreatedDate,LastModifiedDate,SystemModstamp) - Custom indexes via Salesforce support
Selectivity threshold: 10% (under 333K records) or 5% (over 333K). Standard indexes ~30%. Custom indexes ~10%.
Skinny tables¶
Salesforce-managed denormalized table for an object with up to 100 fields. Improves report and list view performance for tables with millions of records. Created via Salesforce support; visible to admins as a tuning option, not directly queryable.
Bind variables and dynamic SOQL¶
String name = 'Acme%';
List<Account> accs = [SELECT Id FROM Account WHERE Name LIKE :name];
// Dynamic
String soql = 'SELECT Id FROM Account WHERE Name LIKE :name';
List<Account> dyn = Database.query(soql);
// Sanitize when concatenating
String safe = String.escapeSingleQuotes(userInput);
Query plan¶
Use Developer Console -> Query Plan tool to see leading operation type (Index, TableScan, Other). Optimize until LeadingOperationType = "Index".
Cursors and chunking¶
For >50K rows in a single transaction, use Batch Apex with Database.QueryLocator (50M record cap) or Database.getQueryLocator in a Queueable that re-queues itself.
SOSL¶
List<List<sObject>> results = [
FIND :searchTerm IN ALL FIELDS
RETURNING Account(Id, Name WHERE Industry='Technology'),
Contact(Id, LastName)
LIMIT 100
];
- 20 SOSL queries per transaction
- 2,000 records returned
- Use SOSL when searching across multiple objects with full-text terms; SOQL for filtered single-object queries
Async Apex - decision matrix¶
| Tool | When to use | Args | Chaining | Callouts |
|---|---|---|---|---|
@future | Fire-and-forget, sync triggers needing callouts | Primitives only | No | @future(callout=true) |
| Queueable | Pass complex types, chain jobs | Any type via constructor | Yes (50/txn, depth limited in test only) | Database.AllowsCallouts |
| Batchable | Process millions of records | sObject scope, custom Iterable | Via finish() | Database.AllowsCallouts |
| Schedulable | Run on cron schedule | none | Yes (often launches Batch) | n/a |
| Platform Events | Pub/sub event-driven | event sObject | Via subscriber triggers | n/a |
| Change Data Capture | React to record CRUD | CDC event | Via triggers | n/a |
Future¶
public class CalloutHelper {
@future(callout=true)
public static void postToService(Set<Id> ids, String url) {
Http h = new Http();
HttpRequest req = new HttpRequest();
req.setEndpoint(url);
req.setMethod('POST');
HttpResponse res = h.send(req);
if (res.getStatusCode() != 200) {
System.debug(LoggingLevel.ERROR, 'Callout failed: ' + res.getStatusCode());
}
}
}
Constraints: static, void, primitive args (no sObjects), can't be called from another @future or from Batch.
Queueable with chaining + callouts¶
public class SyncOpportunitiesQueueable implements Queueable, Database.AllowsCallouts {
private List<Id> oppIds;
private Integer offset;
public SyncOpportunitiesQueueable(List<Id> oppIds, Integer offset) {
this.oppIds = oppIds;
this.offset = offset;
}
public void execute(QueueableContext ctx) {
List<Id> chunk = new List<Id>();
Integer end = Math.min(offset + 50, oppIds.size());
for (Integer i = offset; i < end; i++) chunk.add(oppIds[i]);
// ... do callout for chunk
if (end < oppIds.size() && !Test.isRunningTest()) {
System.enqueueJob(new SyncOpportunitiesQueueable(oppIds, end));
}
}
}
Test mode chains max 5 deep. Production has no documented chain limit (use stack control to avoid runaway).
Batch Apex (stateful)¶
public class RevenueRecalcBatch implements Database.Batchable<sObject>, Database.Stateful {
public Integer processed = 0;
public List<Id> failedIds = new List<Id>();
public Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator('SELECT Id, AnnualRevenue FROM Account WHERE Active__c = true');
}
public void execute(Database.BatchableContext bc, List<Account> scope) {
for (Account a : scope) {
try {
a.AnnualRevenue = (a.AnnualRevenue == null ? 0 : a.AnnualRevenue) * 1.05;
} catch (Exception e) {
failedIds.add(a.Id);
}
}
Database.SaveResult[] results = Database.update(scope, false);
for (Database.SaveResult r : results) {
if (r.isSuccess()) processed++;
else failedIds.add(r.getId());
}
}
public void finish(Database.BatchableContext bc) {
// Optional: chain another batch, send email, log
}
}
// Run:
Id jobId = Database.executeBatch(new RevenueRecalcBatch(), 200);
Database.Stateful preserves instance variables across batch executions; otherwise instance state resets per chunk.
Schedulable¶
public class NightlyRecalcScheduler implements Schedulable {
public void execute(SchedulableContext ctx) {
Database.executeBatch(new RevenueRecalcBatch(), 200);
}
}
// CRON: 'seconds minutes hours dayOfMonth month dayOfWeek [year]'
String cron = '0 0 2 * * ?'; // every day 02:00
System.schedule('Nightly Recalc', cron, new NightlyRecalcScheduler());
Integration patterns (35% domain)¶
REST API endpoint in Apex¶
@RestResource(urlMapping='/orders/*')
global with sharing class OrderResource {
@HttpGet
global static Order__c doGet() {
RestRequest req = RestContext.request;
Id orderId = req.requestURI.substringAfterLast('/');
return [SELECT Id, Status__c FROM Order__c WHERE Id = :orderId];
}
@HttpPost
global static Id doPost(String name, Decimal total) {
Order__c o = new Order__c(Name=name, Total__c=total);
insert o;
return o.Id;
}
}
URL: /services/apexrest/orders/<id>. Methods supported: @HttpGet, @HttpPost, @HttpPut, @HttpPatch, @HttpDelete.
SOAP / WSDL2Apex¶
sf force apex generate webservice --wsdl path/to/service.wsdl
Generates Apex stub classes. Less common in modern integrations; REST + Named Credentials preferred.
Named Credentials¶
Endpoint + auth (OAuth, named principal, per-user OAuth) configured in setup. Reference in code:
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:My_Named_Cred/path');
req.setMethod('GET');
HttpResponse res = new Http().send(req);
Auth headers are auto-injected. Avoid hardcoding tokens.
External Services¶
Declarative integration. Import OpenAPI / JSON schema, Salesforce generates invocable actions usable in Flow without code.
Platform Events¶
// Define: Order_Placed__e (custom event with fields)
Order_Placed__e evt = new Order_Placed__e(Order_Id__c = ord.Id, Total__c = ord.Total__c);
Database.SaveResult sr = EventBus.publish(evt);
if (!sr.isSuccess()) {
for (Database.Error err : sr.getErrors()) System.debug(err.getMessage());
}
Change Data Capture (CDC)¶
System publishes events when records are created/updated/deleted/undeleted on subscribed objects. Subscribe via:
- Apex trigger on
__ChangeEvent(e.g.,AccountChangeEvent) - Pub/Sub API (gRPC) for external subscribers
- Streaming API
- LWC via empApi
- Flow
Outbound Messaging¶
Workflow-based SOAP message sent on record change. Largely replaced by Platform Events / external API callouts.
Pub/Sub API¶
Modern gRPC-based API for high-throughput streaming of Platform Events and CDC. External clients subscribe with replay support. Replaces older CometD-based Streaming API.
Integration pattern decision matrix¶
| Need | Pattern |
|---|---|
| External system pushes data into Salesforce | REST API / Apex REST / Bulk API |
| Salesforce pushes data to external system | Outbound Messaging / Apex Callout (Queueable + Named Cred) |
| Real-time bi-directional event stream | Platform Events / CDC / Pub/Sub API |
| Process millions of records nightly | Bulk API 2.0 |
| Trigger external workflow from record change | Platform Event published from trigger |
| Mash up external data inline on a record | External Objects (OData) / Salesforce Connect |
Lightning Web Components¶
Lifecycle hooks¶
| Hook | When |
|---|---|
constructor() | Component instantiation; no DOM yet, no @api props |
connectedCallback() | Element inserted into DOM; @api props available |
renderedCallback() | After every render (use a flag to run only once) |
disconnectedCallback() | Element removed from DOM; cleanup subscriptions |
errorCallback(error, stack) | Captures errors from descendant components |
Reactivity¶
import { LightningElement, api, wire, track } from 'lwc';
import getOpps from '@salesforce/apex/OppController.getByStage';
export default class OppList extends LightningElement {
@api recordId;
stage = 'Negotiation';
@wire(getOpps, { stage: '$stage' })
wiredOpps; // wiredOpps.data, wiredOpps.error
handleStageChange(e) { this.stage = e.detail.value; } // re-fires wire
}
$propName syntax in @wire config makes the wire reactive to property changes. Primitive properties are reactive automatically. Objects/arrays only deep-react if reassigned (or use @track).
Imperative Apex¶
import getOpps from '@salesforce/apex/OppController.getByStage';
async load() {
try {
this.opps = await getOpps({ stage: this.stage });
} catch (e) { this.error = e.body.message; }
}
Use imperative when: action-triggered (button click), needs DML, needs to await before render.
Events¶
// Child fires
this.dispatchEvent(new CustomEvent('selected', {
detail: { id: this.recordId },
bubbles: true, // bubble up DOM
composed: true // cross shadow boundary
}));
// Parent listens
<c-child onselected={handleSelected}></c-child>
For sibling/distant components, use Lightning Message Service:
import { publish, subscribe, MessageContext } from 'lightning/messageService';
import ORDER_CHANNEL from '@salesforce/messageChannel/OrderChannel__c';
@wire(MessageContext) messageContext;
connectedCallback() {
this.sub = subscribe(this.messageContext, ORDER_CHANNEL, (msg) => this.handle(msg));
}
Lightning Data Service (LDS)¶
Component-level CRUD without Apex; cached, no governor cost:
import { getRecord, updateRecord, createRecord } from 'lightning/uiRecordApi';
import NAME_FIELD from '@salesforce/schema/Account.Name';
@wire(getRecord, { recordId: '$recordId', fields: [NAME_FIELD] }) account;
async save() {
await updateRecord({ fields: { Id: this.recordId, Name: 'New' }});
}
@AuraEnabled flavors¶
| Annotation | Use |
|---|---|
@AuraEnabled | Imperative call from LWC; DML allowed |
@AuraEnabled(cacheable=true) | Wire-compatible; client-side cache; no DML |
@AuraEnabled(continuation=true) | Long-running callout (>5s) returning a Continuation |
Apex Test patterns¶
Test class with Stub API mock¶
@isTest
private class OpportunityServiceTest {
private class MockRepo implements OpportunityService.IOpportunityRepo {
public List<Opportunity> findByStage(String stage) {
return new List<Opportunity>{
new Opportunity(Id='006000000000001AAA', StageName=stage)
};
}
}
@TestSetup
static void setup() {
Account a = new Account(Name='Acme');
insert a;
}
@isTest
static void closeWon_marksOpps() {
OpportunityService svc = new OpportunityService(new MockRepo());
Test.startTest();
svc.closeWon(new Set<Id>{ '006000000000001AAA' });
Test.stopTest();
// assertions...
}
}
Stub API for mocking concrete classes¶
public class StubExample implements System.StubProvider {
public Object handleMethodCall(Object stubbed, String method, Type returnType,
List<Type> paramTypes, List<String> paramNames,
List<Object> args) {
if (method == 'getName') return 'mocked';
return null;
}
}
@isTest
static void testWithStub() {
AccountService svc = (AccountService) Test.createStub(AccountService.class, new StubExample());
System.assertEquals('mocked', svc.getName());
}
Stub API allows mocking concrete classes without an interface (cannot stub static methods).
HttpCalloutMock¶
public class MockResponse implements HttpCalloutMock {
public HttpResponse respond(HttpRequest req) {
HttpResponse r = new HttpResponse();
r.setStatusCode(200);
r.setBody('{"status":"ok"}');
return r;
}
}
@isTest
static void testCallout() {
Test.setMock(HttpCalloutMock.class, new MockResponse());
Test.startTest();
String result = MyService.callExternal();
Test.stopTest();
System.assertEquals('ok', result);
}
For multi-callout flows, use MultiStaticResourceCalloutMock or a custom mock that switches by URL.
Test data factory¶
@isTest
public class TestDataFactory {
public static List<Account> createAccounts(Integer n) {
List<Account> accs = new List<Account>();
for (Integer i = 0; i < n; i++) accs.add(new Account(Name='Test ' + i));
insert accs;
return accs;
}
}
Centralize test data creation; avoid seeAllData=true.
Deployment¶
| Tool | Use |
|---|---|
| Change Sets | UI sandbox-to-sandbox / sandbox-to-prod; slow, limited metadata |
Salesforce CLI (sf project deploy start) | Source-driven; works with git |
| Metadata API | Programmatic deployment (CI/CD) |
| 1GP packages | Legacy managed packages (single namespace) |
| 2GP packages | Modern; track package versions in source control; supported by SFDX |
| Unlocked packages | 2GP without namespace; for internal modular dev |
| DevOps Center | Salesforce-native pipeline UI on top of source control |
Test execution levels¶
RunSpecifiedTests- explicit list, fastestRunLocalTests- all tests in org except managed packages (default for prod)RunAllTestsInOrg- everything
Highest-yield PDII facts¶
- Bulkify everything - never SOQL/DML in a loop; always handle 200 records.
- Queueable > @future - pass complex objects, chain, callouts via
Database.AllowsCallouts. - Batch for >50K rows - QueryLocator handles 50M;
Database.Statefulpreserves state. - Recursion guards - static class flag pattern is the canonical answer.
- Trigger handlers - one trigger per object, dispatcher to handler class.
- Mixed DML error - setup objects (User, Group, etc.) can't be inserted with non-setup objects in the same txn; use
System.runAsor async. - Stub API + dependency injection - pass interfaces into services for testability.
Test.startTest()/Test.stopTest()- resets governor limits; forces async to run.- Selective queries - filter on indexed fields; check Query Plan tool.
- Named Credentials - never hardcode endpoints/tokens; reference
callout:My_Cred/path. - LWC
@wirereactivity -$propNameconfig syntax re-fires on property change. - Platform Events vs CDC - Platform Events for custom messages; CDC for record-level diffs.
@AuraEnabled(cacheable=true)- cacheable, read-only, wire-compatible; no DML.- Lightning Message Service - cross-component / cross-DOM comms (Aura/Visualforce/LWC).
Limits.getQueries()/Limits.getDmlStatements()- check limits at runtime.- Dynamic Apex -
Schema.getGlobalDescribe()/getDescribe()for schema-aware code. Database.insert(records, false)- allows partial success; returnsDatabase.SaveResult[].addError()in before-trigger context for graceful validation.- 75% test coverage required for prod deploy; assertions required for "real" coverage.
- 2GP packaging - modern packaging tied to source control via SFDX.