This is a deep study guide for the Salesforce Certified Platform Developer II exam, the advanced developer credential that sits on top of Platform Developer. It follows the official exam outline section by section, with explanations, original diagrams, quick-reference tables, Apex and Lightning Web Components code, a worked example and 25 practice questions with answers and explanations.
Updated for 2026: checked against the current official exam guide, which still aligns questions to the Winter '24 release. The biggest change in the last year isn't the content: since October 1, 2025, you earn Platform Developer II by passing the proctored exam alone. The Advanced Apex Specialist superbadge is no longer required.
| Section | Weight | Approx. questions |
|---|---|---|
| Advanced Developer Fundamentals | 15% | ~9 |
| Process Automation, Logic, and Integration | 27% | ~16 |
| User Interface | 20% | ~12 |
| Testing, Debugging, and Deployment | 20% | ~12 |
| Performance | 18% | ~11 |
| Exam fact | Detail |
|---|---|
| Official name | Salesforce Certified Platform Developer II (often shortened to PDII or PD2) |
| Format | 60 multiple-choice/multiple-select questions, plus up to 5 unscored questions |
| Time | 120 minutes |
| Passing score | 70% (42 of 60) |
| Fee | US$200 to register, US$100 to retake, plus applicable taxes |
| Prerequisite | Salesforce Certified Platform Developer (formerly Platform Developer I) |
| Superbadges | Not required since October 1, 2025; the exam alone earns the credential |
| Release alignment | Winter '24, per the current exam guide |
| Delivery | Onsite at a testing center or online proctored; no reference materials allowed |
| Maintenance | One Platform Developer II maintenance module on Trailhead per year |
| Official resources | Exam guide, credential page and the official prep Trailmix |
Process Automation, Logic, and Integration is the biggest single section, but the other four are close together.
Contents
- Who this certification is for
- What changed for 2026
- How to use this guide
- The Apex transaction in one picture
- Advanced Developer Fundamentals (15%)
- Process Automation, Logic, and Integration (27%)
- User Interface (20%)
- Testing, Debugging, and Deployment (20%)
- Performance (18%)
- Deep dive: choosing the right asynchronous tool
- Worked example: an end-to-end order sync feature
- Hands-on checklist
- Common exam traps
- Flashcard terms
- Quick-reference cheat sheet
- Frequently asked questions
- Related study guides
Who this certification is for
Salesforce describes the candidate as a developer with 2 to 4 years of development experience, including at least 1 year designing, implementing and deploying on the Salesforce Platform. In plain terms: someone who has shipped real Apex and Lightning Web Components to production, been paged when a batch job failed, and had to explain why a trigger hit a governor limit.
The exam guide lists what that person can do. Read it as a skills checklist:
- Write Apex that scales to large data sets, with a good grasp of platform behaviors, limits and performance. The guide calls out data volumes of roughly 300,000 to 480,000 records.
- Design complex sharing models with declarative and programmatic tools, including Apex managed sharing.
- Build and modify user interfaces with Lightning web components, Aura components, advanced Visualforce, CSS and JavaScript, and tune Visualforce controller performance.
- Write SOAP and REST web services in Apex, and call out from Apex using SOAP and REST.
- Use asynchronous Apex: queueable, batch, schedulable and future methods.
- Handle errors and exceptions correctly in each programmatic context.
- Follow trigger best practices and design patterns, and design for reuse.
- Plan tests that prove quality (coverage, behavior, scalability, environment independence and security), including Jest tests for Lightning web components.
- Deploy with the right tool for the job and understand the development lifecycle.
- Surface Lightning web components and Aura components on Lightning pages, in Visualforce pages and in quick actions.
Who finds it hardest? Developers who learned on the job often know the "how" but not the "which one and why": queueable versus batch, WITH USER_MODE versus stripInaccessible, a wire adapter versus an imperative call. Developers who studied hard for Platform Developer often know the definitions but haven't debugged a real limit exception or written a callout mock. The exam rewards both kinds of knowledge, so this guide spends time on both.
What changed for 2026
- The exam alone earns the credential. Platform Developer II used to be two pieces: the proctored multiple-choice exam plus superbadges (most recently the Advanced Apex Specialist). Salesforce retired that superbadge requirement on October 1, 2025. Anyone who had passed the exam but not finished the superbadge was certified automatically. The superbadges still exist as optional practice, and the exam guide still recommends the Apex Specialist, Apex Callouts and Platform API superbadges.
- The prerequisite was renamed. Platform Developer I is now called Salesforce Certified Platform Developer. It's still the prerequisite, and my Platform Developer study guide covers it.
- No name change in July 2026. Salesforce renamed 16 certifications on July 24, 2026 (mostly consultant and accredited professional credentials). Platform Developer II kept its name.
- The outline is still the Winter '24 outline. Five sections, same weights. Features that arrived after Winter '24 are useful at work but aren't the focus of the exam.
- Legacy tools still appear. The outline still includes Visualforce and Aura alongside Lightning web components, so don't skip them. Workflow Rules and Process Builder reached end of support on December 31, 2025; design new automation with Flow and Apex, but recognize the old tools in order-of-execution questions.
| Name in older material | Current name or replacement |
|---|---|
| Platform Developer I (PD1) | Salesforce Certified Platform Developer |
sfdx force:... commands | The unified sf CLI (sf project deploy start, sf org create scratch) |
| Advanced Apex Specialist superbadge (required) | No longer required; exam only |
| Superbadge units | Superbadges (shorter, optional skill assessments) |
getRecordNotifyChange() | notifyRecordUpdateAvailable() in lightning/uiRecordApi |
| Named credentials with embedded auth | Named credentials plus external credentials |
How to use this guide
This is a scenario and code-reading exam. Many questions show an Apex class, a trigger, a test or a component and ask what's wrong, what happens, or what to change. Memorizing definitions isn't enough; you need to know which tool fits which requirement and what the platform actually does at run time.
- Get a free Developer Edition org or a Trailhead Playground, install the Salesforce CLI and VS Code, and build the examples in this guide yourself. Typing the code is what makes code-reading questions easy.
- Read one section at a time. Each ends with a key takeaway and practice questions.
- Read every answer explanation, including why the wrong answers are wrong. The distractors on this exam are usually real features used in the wrong place, or code that compiles but breaks at scale.
- In the last week, do the worked example, the hands-on checklist and the cheat sheet, and time yourself: 60 questions in 120 minutes is 2 minutes per question, which is generous until you hit a long code snippet.
If you want more hands-on Apex reps, the free Camp Apex practice site is a good warm-up. For the learning-science reasons behind this study order (retrieval practice, spacing, interleaving), read The Science of Studying for Salesforce Certifications.
A six-week plan for someone who already holds Platform Developer and writes code most weeks.
The Apex transaction in one picture
Almost every Platform Developer II question comes back to one idea: everything that happens because of one request runs in one transaction, with one set of governor limits, and it either commits together or rolls back together. Triggers, flows, validation rules, roll-up summaries and the Apex they call all share that transaction. Asynchronous work (queueable, future, batch, publish-after-commit events) starts after the commit, in its own transaction with its own limits.
Learn the save order, because order-of-execution questions are really questions about what can see what:
- Load the original record (or initialize a new one) and the new field values. For requests from the user interface, run system validation (required fields, field formats, maximum lengths).
- Run before-save record-triggered flows.
- Run before triggers.
- Run system validation again plus custom validation rules.
- Run duplicate rules.
- Save the record to the database, but don't commit.
- Run after triggers.
- Run assignment rules, auto-response rules and workflow rules. A workflow field update saves the record again and fires before and after update triggers one more time.
- Run escalation rules, then after-save record-triggered flows, then entitlement rules.
- Update roll-up summary fields and cross-object formulas on parents, which runs the parent through its own save.
- Evaluate criteria-based sharing.
- Commit everything.
- Run post-commit logic: send emails, start queued asynchronous Apex, and publish platform events configured to publish after commit.
One request, one transaction, one set of limits. Async work starts after the commit.
What this explains. Why a before trigger can change Trigger.new without DML but an after trigger can't. Why a workflow field update can make your update trigger run twice. Why a callout after DML fails with "uncommitted work pending". Why a queueable you enqueue in a trigger never runs if the transaction rolls back. And why a single insert of 200 records can blow the SOQL limit if any automation in the chain queries inside a loop.
| Limit (per transaction) | Synchronous | Asynchronous |
|---|---|---|
| SOQL queries | 100 | 200 |
| Records retrieved by SOQL | 50,000 | 50,000 |
| DML statements | 150 | 150 |
| Records processed by DML | 10,000 | 10,000 |
| CPU time | 10,000 ms | 60,000 ms |
| Heap size | 6 MB | 12 MB |
| Callouts (HTTP or web service) | 100 | 100 |
| Cumulative callout timeout | 120 seconds | 120 seconds |
System.enqueueJob calls | 50 | 1 |
| Future method calls | 50 | 50 from queueable; 0 from batch or future |
| SOSL queries | 20 | 20 |
The governor limits that show up most in PDII questions. Check the current Apex Developer Guide before you rely on a number at work.
Advanced Developer Fundamentals (15%)
This is the smallest section, but it's full of details that are easy to get wrong in code. The official objectives:
- Demonstrate knowledge of localization and multi-currency features and capabilities and how they affect coding.
- Given a scenario, justify using sharing objects and Apex managed sharing.
- Given a scenario, identify best practices for various types of custom metadata and custom settings, and how to implement required solutions.
Localization: let the platform format for the user. A global org has users with different locales (date and number formats), languages and currencies. The rule of thumb for the exam is simple: don't hard-code formats or text. Use the platform's locale-aware tools so every user sees their own format and language.
- Locale-aware formatting in Apex.
Date.format(),Datetime.format()andDecimal.format()use the context user's locale.UserInfo.getLocale(),UserInfo.getLanguage(),UserInfo.getTimeZone()andUserInfo.getDefaultCurrency()tell you about the running user. - Time zones. Datetime values are stored in GMT and displayed in the user's time zone.
Datetime.format()converts to the user's time zone;formatGmt()doesn't. Date-only fields have no time zone, which is why date math on Datetime fields near midnight causes bugs. - Translated text. Use custom labels for user-facing text (
System.Label.Order_Submittedin Apex,$Labelin Visualforce and Aura,import label from '@salesforce/label/c.Order_Submitted'in LWC) and translate them with the Translation Workbench. In SOQL,toLabel(Status)returns translated picklist labels. - LWC internationalization. Import
@salesforce/i18n/locale,@salesforce/i18n/currencyand friends, and use base components such aslightning-formatted-numberandlightning-formatted-date-time, which format for the user automatically.
Multi-currency: know where amounts are converted. When multiple currencies are enabled (a permanent change), every record with currency fields gets a CurrencyIsoCode field, and amounts are stored in the record's currency. The org has a corporate currency, and each user has a personal currency. In code:
SELECT Amount FROM Opportunityreturns the amount in the record's currency. Don't add amounts from records in different currencies without converting them.SELECT convertCurrency(Amount) FROM Opportunityreturns the amount converted to the user's currency, using the org's conversion rates.FORMAT(Amount)andFORMAT(convertCurrency(Amount))return locale-formatted strings for display.- With advanced currency management, dated exchange rates (
DatedConversionRate) apply to opportunities and related objects based on close date; other objects use the static rates (CurrencyType). - When you insert records, set
CurrencyIsoCodeexplicitly if the default (the running user's currency) isn't right. Integration users are a classic source of wrong currencies.
| Requirement | Tool |
|---|---|
| Show a date or number in the user's format | format() methods in Apex, FORMAT() in SOQL, lightning-formatted-* base components |
| Show translated picklist values | toLabel(field) in SOQL |
| Show translated messages and button text | Custom labels plus the Translation Workbench |
| Show an amount in the user's currency | convertCurrency(field) in SOQL |
| Find the running user's locale or currency | UserInfo methods; @salesforce/i18n/* in LWC |
| Historical exchange rates for opportunities | Advanced currency management (dated exchange rates) |
Sharing objects and Apex managed sharing. Declarative sharing (org-wide defaults, role hierarchy, sharing rules, teams, manual sharing) covers most requirements. Apex managed sharing is for access rules that are too dynamic or too complex for criteria-based sharing rules, such as "share each project with the users listed in its related assignment records" or "share with whoever is named in a lookup field, and update the share when the lookup changes."
Every object whose org-wide default is more restrictive than Public Read/Write has a share object: AccountShare, OpportunityShare, CaseShare, and Project__Share for a custom object called Project__c. A share record says: this user or group gets this access level to this record, for this reason.
| Share object field | Meaning |
|---|---|
ParentId | The record being shared (for standard objects the field name is object-specific, such as AccountId on AccountShare) |
UserOrGroupId | The user, public group, role group or queue that gets access |
AccessLevel | Read or Edit (All is reserved for owners and admins; standard objects use fields such as AccountAccessLevel) |
RowCause | Why the share exists: Owner, Manual, Rule, Team, or a custom Apex sharing reason |
Key facts the exam likes:
- Apex sharing reasons exist only for custom objects. You define them on the custom object (for example
Assigned_Team__c), and Apex refers to them asSchema.Project__Share.RowCause.Assigned_Team__c. - Shares with an Apex sharing reason survive an owner change. Shares with the
Manualrow cause are deleted when the record owner changes. On standard objects, Apex can only create shares withManualrow cause, so code must recalculate them after ownership changes. - Recalculation. You can attach an Apex class that implements
Database.Batchableto a custom object's Apex sharing reasons, so admins can rerun Apex managed sharing recalculation after org-wide defaults change. - Sharing only grants access. You can't use a share record to take away access granted elsewhere.
- Sharing keywords decide whether Apex respects sharing.
with sharingenforces record sharing for the running user,without sharingignores it, andinherited sharinguses the caller's mode (and runs aswith sharingwhen it's the entry point). Declare one on every class; it's a common code-review finding and a common exam distractor. - Sharing isn't CRUD or FLS.
with sharingcontrols which records a user sees, not which objects and fields. For object and field security, useWITH USER_MODEqueries,AccessLevel.USER_MODEDML orSecurity.stripInaccessible().
public with sharing class ProjectSharingService {
// Shares each project with the user named in its Delivery_Lead__c lookup.
public static void shareWithDeliveryLeads(List<Project__c> projects) {
List<Project__Share> shares = new List<Project__Share>();
for (Project__c p : projects) {
if (p.Delivery_Lead__c == null || p.Delivery_Lead__c == p.OwnerId) {
continue; // the owner already has full access
}
shares.add(new Project__Share(
ParentId = p.Id,
UserOrGroupId = p.Delivery_Lead__c,
AccessLevel = 'Edit',
RowCause = Schema.Project__Share.RowCause.Delivery_Lead__c
));
}
// allOrNone = false: one bad share (for example an inactive user)
// shouldn't block the rest. Inspect the results and log failures.
Database.SaveResult[] results = Database.insert(shares, false);
for (Integer i = 0; i < results.size(); i++) {
if (!results[i].isSuccess()) {
System.debug(LoggingLevel.WARN, 'Share failed for ' +
shares[i].ParentId + ': ' + results[i].getErrors()[0].getMessage());
}
}
}
}
A share record is just a row: who, what access, and why. The "why" decides what survives an owner change.
Custom metadata types versus custom settings. Both store configuration that code reads without hard-coding. The exam asks which one fits a requirement, and how to read it efficiently.
Custom metadata types (__mdt) | Custom settings (__c) | |
|---|---|---|
| Records are | Metadata | Data |
| Deploy records with change sets, packages, the CLI | Yes | No (definitions only; load records separately) |
| Read in Apex without using SOQL limits | getAll(), getInstance(); SOQL on them also doesn't count unless it selects long text area fields | getInstance(), getValues(), getOrgDefaults(), getAll() (cached) |
| Change records from Apex | Not with DML; use the Metadata namespace to deploy changes asynchronously | Yes, with normal DML |
| Per-user or per-profile values | No built-in hierarchy | Yes, with hierarchy custom settings |
| Relationships | Metadata relationship fields to other metadata, objects and fields | None |
Visible in tests without SeeAllData | Yes | No; create the records in the test |
| Best for | App configuration that moves between orgs: mappings, routing tables, feature switches, integration endpoints by environment | Values that change at run time or differ by user or profile: trigger bypass switches, counters, per-user preferences |
Two details to remember: getAll() and getInstance() on custom metadata return text fields truncated to 255 characters, so query with SOQL when you need a full long text area value (and that query counts against the SOQL limit). And list custom settings are still supported but Salesforce recommends custom metadata types for new list-style configuration.
// A hierarchy custom setting used as a per-user trigger bypass.
// Checks the user's value, then the profile's, then the org default.
Automation_Settings__c settings = Automation_Settings__c.getInstance();
if (settings != null && settings.Bypass_Triggers__c) {
return;
}
// A custom metadata type used as a routing table. No SOQL limit used.
Map<String, Region_Routing__mdt> routes = Region_Routing__mdt.getAll();
Region_Routing__mdt emea = Region_Routing__mdt.getInstance('EMEA');
String queueName = emea?.Queue_Developer_Name__c;
Ask two questions: does it need to deploy with the code, and does it differ by user?
Key takeaway: write code that adapts to the user (locale, language, currency, access) and to the org (configuration in custom metadata or custom settings), instead of hard-coding any of them.
Practice questions: Advanced Developer Fundamentals
Question 1. A global company has multi-currency enabled. A Lightning web component shows each sales rep a list of their open opportunities with amounts. Reps in Japan complain that some amounts show in euros. The rep's personal currency is JPY. What should the developer change in the Apex query?
- A. Use
SELECT Name, convertCurrency(Amount) FROM Opportunityso amounts return in the user's currency - B. Divide each amount by the corporate currency conversion rate in Apex
- C. Change each opportunity's
CurrencyIsoCodeto JPY with a batch job - D. Use
toLabel(Amount)in the query
Answer: A. convertCurrency() converts currency fields to the running user's currency using the org's rates. Why not the others: manual math (B) ignores dated rates and reinvents the platform; changing the record currency (C) alters data that belongs to the deal; toLabel() (D) translates picklist values, not amounts.
Question 2. A custom object Engagement__c has a Private org-wide default. Each engagement must be shared with edit access to the consultant named in its Lead_Consultant__c lookup, and the access must stay when the engagement's owner changes. Which approach meets the requirement?
- A. A criteria-based sharing rule on
Lead_Consultant__c - B. Manual sharing created by the record owner
- C. Apex managed sharing that inserts
Engagement__Sharerecords with a custom Apex sharing reason - D. Apex that inserts
Engagement__Sharerecords withRowCauseset toManual
Answer: C. Sharing rules can't target a user named in a lookup field, and only shares with an Apex sharing reason survive an owner change. Why not the others: criteria-based rules share with groups and roles, not the user in a field (A); manual shares depend on people and are removed on owner change (B, D).
Question 3. A managed integration needs a different API endpoint path and batch size in each org it's deployed to: sandbox, UAT and production. The values should move with the deployment, be readable in Apex without consuming SOQL limits, and be visible in Apex tests. What should the developer use?
- A. A list custom setting
- B. A custom metadata type
- C. A hierarchy custom setting
- D. Custom labels
Answer: B. Custom metadata records are metadata, so they deploy with the code, getInstance() doesn't use SOQL limits, and tests can read them without SeeAllData. Why not the others: custom setting records are data that must be loaded separately and aren't visible to tests by default (A, C); custom labels are for translatable text (D).
Question 4. A developer reviews this method, which runs when a user clicks a button on an Account page.
public class AccountSummaryController {
@AuraEnabled
public static List<Contact> getContacts(Id accountId) {
return [SELECT Id, Name, Email, Salary__c
FROM Contact WHERE AccountId = :accountId];
}
}
Some users can't see Salary__c on the page layout, but the component still displays it. Which two changes enforce the user's object and field permissions? Choose 2 answers.
- A. Add
with sharingto the class - B. Add
WITH USER_MODEto the query - C. Pass the query results through
Security.stripInaccessible(AccessType.READABLE, contacts)and returngetRecords() - D. Mark the method
cacheable=true
Answer: B and C. WITH USER_MODE enforces sharing, object permissions and field-level security for the query, and stripInaccessible() removes fields the user can't read. Why not the others: with sharing controls record visibility only, not field access (A); caching (D) has nothing to do with security.
Process Automation, Logic, and Integration (27%)
The largest section, and the one most likely to show you code. The official objectives:
- Given a scenario, identify the considerations of interactions between multiple processes, both declarative and programmatic.
- Given a scenario, propose and justify the optimal programmatic or declarative solution.
- Demonstrate knowledge of the best practices for writing Apex triggers.
- Describe and use the Apex features available for error handling and maintaining transactional integrity.
- Demonstrate how and where to use advanced keywords in a SOQL query structure.
- Analyze a set of requirements and determine the benefits of using asynchronous Apex coding.
- Given a scenario and requirements, identify the appropriate dynamic Apex feature to use in the solution.
- Given a scenario, identify the appropriate publish/subscribe logic for platform events.
- Given a scenario, apply the programmatic integration techniques and platform features for inbound and outbound communication.
Declarative and programmatic processes together. Real orgs have record-triggered flows, Apex triggers, validation rules and sometimes old workflow rules on the same object. They all run inside the same transaction (see the order of execution above), so they share limits and can trigger each other. Things to know:
- Before-save flows run before before triggers. If a flow and a trigger both set the same field before save, the trigger wins because it runs later.
- The order of multiple triggers on the same object and event isn't guaranteed. That's one reason for the one-trigger-per-object rule. Record-triggered flows have a trigger order setting (and the Flow Trigger Explorer shows it), but that orders flows among themselves, not against Apex.
- Recursion. An after-save flow or after trigger that updates the same record starts a new save of that record, which runs the triggers again. Workflow field updates re-fire update triggers once. Guard with static variables, or better, avoid re-updating the record by doing same-record updates before save.
- Mixed DML. You can't perform DML on setup objects (such as
User,PermissionSetAssignment,GroupMember) and non-setup objects (such asAccount) in the same transaction. Move one of them to a@futureor queueable method, or useSystem.runAs()in tests.
Choosing declarative or programmatic. The exam's default is "declarative first, code when needed." Flow is the right answer for same-record field updates (before-save), simple related-record updates, guided screens and approvals. Apex is the right answer when you need complex logic over large collections, callouts with custom error handling, transaction control (savepoints, partial success), very high volume, reusable libraries, or behavior Flow can't express efficiently. Invocable methods connect the two: an @InvocableMethod lets admins call your Apex from Flow, and it must be bulkified because Flow batches calls.
public with sharing class CreditCheckAction {
public class Request {
@InvocableVariable(required=true label='Account Id')
public Id accountId;
}
public class Result {
@InvocableVariable(label='Credit Approved')
public Boolean approved;
}
// One invocable method per class. Flow passes a List, one entry per
// flow interview, so the method must handle many requests at once.
@InvocableMethod(label='Check Credit' callout=true)
public static List<Result> check(List<Request> requests) {
List<Result> results = new List<Result>();
for (Request r : requests) {
Result res = new Result();
res.approved = CreditService.isApproved(r.accountId);
results.add(res);
}
return results;
}
}
Trigger best practices. PDII questions assume you know the rules and test whether you can spot code that breaks them:
- One trigger per object, with no logic in the trigger body. The trigger delegates to a handler class, which delegates to service classes. This gives you a predictable order and testable code.
- Bulkify everything. Triggers receive up to 200 records per chunk. Query once with a
Setof IDs, build aMap, loop in memory, and do DML on a list once. - Use the right context. Change fields on the triggering records in before triggers (no DML needed). Create or update related records, or anything that needs the record ID, in after triggers.
- Use
Trigger.operationType(aSystem.TriggerOperationenum) or the context booleans to route logic, andTrigger.oldMapto act only when relevant fields change. - Block bad data with
addError()on the record or a specific field. It rolls back the record's save and shows the message to the user. - Control recursion with a static flag or a static
Set<Id>of processed records, and offer a bypass (a hierarchy custom setting or a custom permission) for data loads.
trigger OpportunityTrigger on Opportunity (
before insert, before update, after insert, after update
) {
new OpportunityTriggerHandler().run();
}
public with sharing class OpportunityTriggerHandler {
private static Set<Id> processedIds = new Set<Id>();
public void run() {
if (Automation_Settings__c.getInstance().Bypass_Triggers__c) {
return;
}
switch on Trigger.operationType {
when BEFORE_INSERT, BEFORE_UPDATE {
OpportunityService.setDefaults((List<Opportunity>) Trigger.new);
}
when AFTER_UPDATE {
List<Opportunity> closedWon = new List<Opportunity>();
for (Opportunity opp : (List<Opportunity>) Trigger.new) {
Opportunity old = (Opportunity) Trigger.oldMap.get(opp.Id);
if (opp.IsWon && !old.IsWon && !processedIds.contains(opp.Id)) {
closedWon.add(opp);
processedIds.add(opp.Id);
}
}
if (!closedWon.isEmpty()) {
OpportunityService.createOnboardingProjects(closedWon);
}
}
}
}
}
The trigger routes, the handler decides, the service does the work.
Error handling and transactional integrity. Apex gives you several tools, and the exam asks which one fits:
| Tool | What it does | Use it when |
|---|---|---|
try / catch / finally | Catches exceptions so you can log, recover or rethrow | You can handle the failure meaningfully |
| Custom exceptions | public class OrderException extends Exception {} | You want a specific, catchable failure type in your own API |
Database.insert(records, false) | Partial success: good records save, failures return in Database.SaveResult | Bulk loads where one bad row shouldn't block the rest |
Database.setSavepoint() / Database.rollback(sp) | Rolls back DML done after the savepoint | Several dependent DML steps must succeed or fail together |
addError() | Fails a specific record (or field) in a trigger with a message | Validation that needs code |
AuraHandledException | Sends a readable message to a Lightning component | Returning errors from @AuraEnabled methods |
| Transaction finalizers | System.Finalizer attached to a queueable runs after the job, even if it fails | Logging, retries or cleanup for async work |
| Publish-immediately platform events | Event is published even if the transaction rolls back | Error logging that must survive a rollback |
A few behaviors trip people up. Limit exceptions (System.LimitException) can't be caught; the transaction just ends. Savepoints and rollbacks count as DML statements. After a rollback, record IDs set by an insert stay on the sObject variables, so reinserting the same instances fails; clone them or clear the IDs first. And an unhandled exception rolls back the whole transaction, including work done before it.
public with sharing class OrderService {
public class OrderException extends Exception {}
public static Id placeOrder(Order__c header, List<Order_Line__c> lines) {
Savepoint sp = Database.setSavepoint();
try {
insert header;
for (Order_Line__c line : lines) {
line.Order__c = header.Id;
}
insert lines;
return header.Id;
} catch (DmlException e) {
Database.rollback(sp);
header.Id = null; // the ID survives a rollback; clear it before any retry
throw new OrderException('Order could not be saved: ' + e.getDmlMessage(0));
}
}
}
Decide up front: all or nothing, best effort, or a checkpoint you can return to.
Advanced SOQL keywords. Know what each does and where it goes in the query:
| Keyword | What it does |
|---|---|
FOR UPDATE | Locks the returned rows for the rest of the transaction so other transactions can't change them; can't be used with ORDER BY |
WITH USER_MODE / WITH SYSTEM_MODE | Runs the query with the user's sharing, object and field permissions (or in system mode); recommended over WITH SECURITY_ENFORCED |
WITH SECURITY_ENFORCED | Throws an exception if the query references fields or objects the user can't access (older; doesn't enforce sharing) |
GROUP BY ROLLUP / GROUP BY CUBE | Adds subtotal rows (and cross-tab subtotals for CUBE); GROUPING() tells you which rows are subtotals |
HAVING | Filters on aggregate results, like WHERE for groups |
TYPEOF | Selects different fields depending on the type of a polymorphic field such as What or Owner |
| Semi-join / anti-join | WHERE Id IN (SELECT AccountId FROM Opportunity) and NOT IN to filter by related records |
ALL ROWS | Includes deleted and archived records (Apex only) |
FOR VIEW / FOR REFERENCE | Updates the LastViewedDate or LastReferencedDate of returned records |
OFFSET | Skips rows for paging; maximum offset 2,000 |
USING SCOPE | Filters by scope such as mine or team |
toLabel() / convertCurrency() / FORMAT() | Translated picklists, user's currency, locale formatting |
// Lock the account row while recalculating its credit, so two
// concurrent transactions can't both read the old balance.
Account acct = [SELECT Id, Credit_Used__c FROM Account
WHERE Id = :accountId FOR UPDATE];
// Pipeline totals with subtotals per stage and a grand total.
List<AggregateResult> rows = [
SELECT StageName, Type, SUM(Amount) total, GROUPING(Type) isSubtotal
FROM Opportunity
WHERE IsClosed = false
GROUP BY ROLLUP(StageName, Type)
];
// Polymorphic What field on Task.
List<Task> tasks = [
SELECT Subject,
TYPEOF What
WHEN Account THEN Name, Industry
WHEN Opportunity THEN Name, StageName
END
FROM Task WHERE OwnerId = :UserInfo.getUserId()
];
Asynchronous Apex. Async runs later, in its own transaction, with higher limits (200 SOQL queries, 60 seconds of CPU, 12 MB heap). It's how you make callouts from triggers, process millions of records and keep the user interface fast. The four tools:
| Tool | Best for | Key facts |
|---|---|---|
@future | Simple fire-and-forget work, such as a callout from a trigger or avoiding mixed DML | Static void method; primitive parameters only (pass IDs, not sObjects); no chaining; no job ID to monitor; can't be called from batch or another future method |
| Queueable | The default choice for async work that needs sObject or complex parameters, chaining or monitoring | System.enqueueJob() returns a job ID; one child job per queueable; implement Database.AllowsCallouts for callouts; attach a Finalizer for failures |
| Batch Apex | Processing large data volumes in chunks | start (a QueryLocator can return up to 50 million records), execute per scope (default 200, maximum 2,000), finish; Database.Stateful keeps instance variables between chunks; 5 batch jobs processing at once, more wait in the flex queue |
| Schedulable | Running something at a specific time or on a recurring schedule | Cron expression with System.schedule(); usually starts a batch or queueable; up to 100 scheduled Apex jobs per org |
public class SyncOrderJob implements Queueable, Database.AllowsCallouts {
private final List<Id> orderIds;
public SyncOrderJob(List<Id> orderIds) {
this.orderIds = orderIds;
}
public void execute(QueueableContext context) {
System.attachFinalizer(new SyncOrderFinalizer(orderIds));
List<Order__c> orders = [SELECT Id, Name, Total__c FROM Order__c
WHERE Id IN :orderIds WITH USER_MODE];
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:ERP_Orders/api/orders');
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setBody(JSON.serialize(orders));
req.setTimeout(20000);
HttpResponse res = new Http().send(req);
if (res.getStatusCode() != 201) {
throw new CalloutException('ERP returned ' + res.getStatusCode());
}
}
}
public class SyncOrderFinalizer implements Finalizer {
private final List<Id> orderIds;
public SyncOrderFinalizer(List<Id> orderIds) { this.orderIds = orderIds; }
public void execute(FinalizerContext ctx) {
if (ctx.getResult() == ParentJobResult.UNHANDLED_EXCEPTION) {
// Runs even though the job failed. Log it, or re-enqueue a limited retry.
System.debug(LoggingLevel.ERROR, 'Order sync failed: ' +
ctx.getException().getMessage());
}
}
}
Queueable is the default. Batch when the data is big. Schedule when time matters. Future only when it's simple.
Dynamic Apex. Dynamic Apex lets code work with objects and fields it doesn't know at compile time, which is how you build reusable components, configuration-driven logic and admin-friendly tools.
| Need | Dynamic Apex feature |
|---|---|
| List an object's fields, labels or picklist values | Schema.SObjectType.Account.fields.getMap(), DescribeFieldResult, getPicklistValues() |
| Describe objects by name | Schema.describeSObjects(new List<String>{ 'Account' }) (cheaper than Schema.getGlobalDescribe() when you know the names) |
| Find record type IDs without SOQL | Schema.SObjectType.Case.getRecordTypeInfosByDeveloperName().get('Support').getRecordTypeId() |
| Build a query at run time | Database.query(soql) with bind variables, or Database.queryWithBinds(soql, bindMap, AccessLevel.USER_MODE) |
| Read or set a field by name | record.get('Field__c'), record.put('Field__c', value), record.getSObject('Account') |
| Create an instance of a class by name | Type.forName('DiscountStrategyEU').newInstance() (strategy or factory pattern driven by custom metadata) |
| Check access dynamically | DescribeSObjectResult.isAccessible(), isUpdateable(); DescribeFieldResult.isAccessible() |
Prevent SOQL injection. Never concatenate user input into a dynamic query. Use bind variables (:searchTerm), Database.queryWithBinds(), or at minimum String.escapeSingleQuotes(), and check field names against the describe map before using them.
public with sharing class RecordSearch {
public static List<SObject> search(String objectName, String fieldName, String term) {
Schema.DescribeSObjectResult objDescribe =
Schema.describeSObjects(new List<String>{ objectName })[0];
Map<String, Schema.SObjectField> fields = objDescribe.fields.getMap();
if (!fields.containsKey(fieldName.toLowerCase())) {
throw new IllegalArgumentException('Unknown field: ' + fieldName);
}
String soql = 'SELECT Id, ' + fieldName + ' FROM ' + objDescribe.getName() +
' WHERE ' + fieldName + ' LIKE :pattern LIMIT 50';
return Database.queryWithBinds(
soql,
new Map<String, Object>{ 'pattern' => '%' + term + '%' },
AccessLevel.USER_MODE
);
}
}
Platform events: publish and subscribe. Platform events decouple the code that announces "something happened" from the code (or external system) that reacts. They're the answer when a question mentions near-real-time notification of several subscribers, loose coupling, an external system listening through the Pub/Sub API, or logging that must survive a rollback.
- Define an event (
Order_Shipped__e) with custom fields. Choose its publish behavior: Publish After Commit (the event is only published if the transaction commits; the right choice for "the order really shipped") or Publish Immediately (published right away, even if the transaction later rolls back; right for logging and monitoring). - Publish from Apex with
EventBus.publish(), which returnsDatabase.SaveResultobjects and counts against the DML statement limit. Flows, processes and external apps through APIs can publish too. - Subscribe with an Apex trigger (
after insertonly), a platform event-triggered flow, Lightning components (lightning/empApi), or external clients through the Pub/Sub API or CometD. - Platform event triggers run asynchronously as the Automated Process user by default (you can set a different running user in a
PlatformEventSubscriberConfig), in batches of up to 2,000 events. - Resume after failure. Call
EventBus.TriggerContext.currentContext().setResumeCheckpoint(replayId)after processing each event so a failure later in the batch resumes after the last good event. ThrowEventBus.RetryableExceptionto have the whole batch retried later; a trigger can retry up to nine times before it goes into an error state. - Change Data Capture is the related feature for record changes: Salesforce publishes change events (
AccountChangeEvent) automatically when records are created, updated, deleted or undeleted.
// Publisher (for example, in a service class after the shipment is saved)
List<Order_Shipped__e> events = new List<Order_Shipped__e>();
for (Order__c o : shippedOrders) {
events.add(new Order_Shipped__e(Order_Id__c = o.Id, Carrier__c = o.Carrier__c));
}
List<Database.SaveResult> results = EventBus.publish(events);
// Subscriber
trigger OrderShippedTrigger on Order_Shipped__e (after insert) {
List<Task> followUps = new List<Task>();
for (Order_Shipped__e evt : Trigger.new) {
followUps.add(new Task(
WhatId = evt.Order_Id__c,
Subject = 'Confirm delivery with customer'
));
EventBus.TriggerContext.currentContext().setResumeCheckpoint(evt.ReplayId);
}
insert followUps;
}
Publishers don't know who's listening. That's the point.
Integration: inbound and outbound. Know the Apex side of both directions.
| Direction | Technique | Key facts |
|---|---|---|
| Outbound REST | HttpRequest, Http.send(), HttpResponse | Use named credentials (callout:ERP_Orders/path) so endpoints and authentication live in setup, not code; 100 callouts and 120 seconds total per transaction; default timeout 10 seconds, maximum 120 |
| Outbound SOAP | WSDL2Apex generates a proxy class from a WSDL | Test with WebServiceMock; large or complex WSDLs may need editing |
| Outbound, declarative | External Services (OpenAPI spec to invocable actions), outbound messages, platform events | Less code for admins; flows can call the generated actions |
| Long-running callouts from UI | Continuation (Visualforce, Aura and LWC through an @AuraEnabled(continuation=true) method) | Frees the server thread while waiting; up to 3 callouts per continuation; timeout up to 120 seconds |
| Inbound REST | @RestResource(urlMapping='/orders/*') on a global class with @HttpGet, @HttpPost, @HttpPatch, @HttpPut, @HttpDelete | RestContext.request and RestContext.response; runs as the authenticated user |
| Inbound SOAP | webservice methods in a global class | Salesforce generates a WSDL for the class |
| Inbound, standard APIs | REST, SOAP, Bulk API 2.0, Composite, Pub/Sub API | Prefer standard APIs before writing a custom service; Bulk API 2.0 for large loads |
Callouts and DML don't mix in one order. If a transaction has done DML (uncommitted work) and then tries a callout, it fails with "You have uncommitted work pending. Please commit or rollback before calling out." Do the callout first, or move it to async (queueable with Database.AllowsCallouts, or @future(callout=true)). Triggers can't make synchronous callouts at all, for the same reason.
@RestResource(urlMapping='/orders/*')
global with sharing class OrderRestResource {
@HttpGet
global static Order__c getOrder() {
String orderNumber = RestContext.request.requestURI.substringAfterLast('/');
List<Order__c> orders = [SELECT Id, Name, Status__c, Total__c FROM Order__c
WHERE Name = :orderNumber WITH USER_MODE LIMIT 1];
if (orders.isEmpty()) {
RestContext.response.statusCode = 404;
return null;
}
return orders[0];
}
}
Outbound: named credentials and async. Inbound: standard APIs first, custom Apex services when you need custom logic.
Key takeaway: know the transaction (order of execution, limits, rollback), pick the lightest async tool that works, keep triggers thin and bulkified, and keep secrets and endpoints in named credentials.
Practice questions: Process Automation, Logic, and Integration
Question 5. When an Invoice__c record is set to Approved, the business wants it sent to an external billing system through a REST API. The status can change through the UI, a nightly data load of up to 50,000 records, or an approval process. What's the best design?
- A. Make the HTTP callout directly in an after update trigger
- B. In an after update trigger, collect the approved invoice IDs and enqueue a queueable job that implements
Database.AllowsCallouts, sending invoices in bulk - C. Use a
@future(callout=true)method called once per invoice inside the trigger loop - D. Use a scheduled job that runs every minute and checks every invoice
Answer: B. Triggers can't call out synchronously, and a bulkified queueable sends the records in one async job with callout permission and a job ID you can monitor. Why not the others: synchronous callouts aren't allowed in triggers (A); one future call per record breaks the 50-future-call limit on big loads (C); polling every invoice every minute wastes resources and isn't near real time for the approval (D).
Question 6. A developer writes this code in a service class.
Account acct = new Account(Name = 'Acme');
insert acct;
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:Credit_Bureau/check?name=Acme');
req.setMethod('GET');
HttpResponse res = new Http().send(req);
What happens when it runs in a synchronous transaction?
- A. The callout succeeds and the account is committed first
- B. A
CalloutExceptionis thrown because there's uncommitted work pending when the callout starts - C. The callout is automatically deferred until after the commit
- D. The insert is silently rolled back so the callout can run
Answer: B. A callout can't run after DML in the same transaction until that work is committed. Make the callout first, or move it to async. Why not the others: the account isn't committed until the transaction ends (A); Apex doesn't defer callouts automatically (C) or roll back DML to make room (D).
Question 7. A batch job recalculates commissions for 8 million opportunity records each night. The finish method must email a summary with the total number of records that failed validation across all chunks. What should the developer do?
- A. Store the running total in a static variable
- B. Implement
Database.Statefuland keep the failure count in an instance variable - C. Query the count of failures in the
finishmethod usingALL ROWS - D. Use a hierarchy custom setting updated in each
execute
Answer: B. Database.Stateful preserves instance variables between execute chunks, so the count is available in finish. Why not the others: static variables reset for each chunk's transaction (A); ALL ROWS returns deleted records and doesn't track validation failures (C); updating a setting in every chunk adds DML and lock contention for no benefit (D).
Question 8. A trigger on Case must look up the support tier for each case's account from a custom metadata type, then set Priority. Which trigger context and approach is most efficient?
- A. After insert; query the custom metadata in the loop and update the cases with DML
- B. Before insert; read the custom metadata once with
getAll(), build a map, and setPriorityonTrigger.newwithout DML - C. After insert; enqueue a queueable job to set priority
- D. Before insert; call
Database.update(Trigger.new)after setting the field
Answer: B. Same-record field changes belong in before triggers, where you edit Trigger.new directly, and getAll() reads custom metadata without SOQL limits. Why not the others: after-trigger DML on the same records and queries in a loop waste limits (A); async adds delay for a simple field (C); DML on Trigger.new in a before trigger throws an exception (D).
Question 9. An order management app must notify three subscribers when an order ships: an Apex trigger that creates a follow-up task, a flow that updates loyalty points, and an external warehouse system. Notifications must only go out if the shipment transaction commits. Which solution fits best?
- A. A platform event with Publish After Commit behavior, published with
EventBus.publish() - B. A platform event with Publish Immediately behavior
- C. Three separate callouts from the trigger
- D. An outbound message from a workflow rule
Answer: A. Platform events decouple multiple subscribers, including external systems, and Publish After Commit only publishes if the transaction succeeds. Why not the others: Publish Immediately publishes even if the transaction rolls back (B); callouts from a trigger aren't allowed synchronously and couple the systems (C); workflow rules are past end of support and outbound messages reach only one endpoint per message definition (D).
Question 10. A developer needs to offer a search component where an admin chooses the object and field to search in a configuration screen, and users type the search term. Which approach is secure and correct?
- A.
Database.query('SELECT Id FROM ' + obj + ' WHERE ' + field + ' = \'' + term + '\'') - B. Validate the object and field names against describe results, and pass the search term as a bind variable using
Database.queryWithBinds()withAccessLevel.USER_MODE - C. Hard-code a separate static query for every object
- D. Use SOSL with the term concatenated into the query string
Answer: B. Describe validation protects the object and field names, bind variables prevent injection of the user-entered term, and user mode enforces permissions. Why not the others: concatenating user input invites SOQL injection (A, D); static queries can't support admin-chosen objects (C).
Question 11. An Apex REST service at /services/apexrest/orders/* is called by an external system. When an order isn't found, the developer wants the caller to receive HTTP 404. How should the method set the status?
- A. Throw a
QueryException - B. Return a string that says "404"
- C. Set
RestContext.response.statusCode = 404before returning - D. Call
System.abortJob()
Answer: C. RestContext.response controls the HTTP response, including the status code. Why not the others: an unhandled exception returns a 500-level error with a stack message (A); a string body still returns 200 (B); abortJob stops scheduled or async jobs, not web requests (D).
User Interface (20%)
This section mixes Lightning web components, Aura and Visualforce, and it loves code snippets. The official objectives:
- Given requirements and code snippets for an LWC or Aura component and its Apex controller class, analyze and determine any changes.
- Identify the techniques for using Visualforce to perform actions, partial page refreshes, and asynchronous operations.
- Given a scenario, identify best practices for displaying errors in the user interface.
- Given a set of requirements, select the appropriate LWC, Aura component, or Visualforce solution and identify its benefits.
- Identify aspects of LWC or Aura components that can cause a component's markup to display in a responsive manner based on form factor.
- Given a scenario, implement the correct method to communicate events through Lightning Web Components or Aura components.
- Describe the purpose and benefit of static resources in Visualforce, Lightning Web Components, and Aura components.
Getting data into a Lightning web component. Choose the lightest tool that does the job:
| Need | Use | Why |
|---|---|---|
| Read or edit one record's fields with standard layout and security | lightning-record-form, lightning-record-view-form, lightning-record-edit-form | Lightning Data Service (LDS) handles caching, sharing and FLS with no Apex |
| Read a record or picklist values in JavaScript | @wire(getRecord), getPicklistValues, getObjectInfo from lightning/ui*Api | LDS cache shared across components; updates automatically |
| Create, update or delete one record in JavaScript | createRecord, updateRecord, deleteRecord | LDS keeps every component in sync |
| Custom query, aggregate or joined data that the UI API can't provide | @wire an Apex method marked @AuraEnabled(cacheable=true) | Reactive, cached and re-runs when $ parameters change |
| DML, callouts or an action the user triggers | Call an Apex method imperatively (it returns a promise) | @wire only works with cacheable methods, and cacheable methods can't do DML |
Wired Apex rules worth memorizing. A wired method must be @AuraEnabled(cacheable=true) and must not perform DML. Reactive parameters use the $ prefix ({ accountId: '$recordId' }). Wired results are cached, so after you change data with imperative Apex, call refreshApex() on the provisioned value to refresh it. If the change happened outside LDS (in Apex), call notifyRecordUpdateAvailable(recordIds) from lightning/uiRecordApi so LDS-based components reload those records. Errors from Apex arrive in error.body.message.
import { LightningElement, api, wire } from 'lwc';
import { refreshApex } from '@salesforce/apex';
import { ShowToastEvent } from 'lightning/platformShowToastEvent';
import getOpenCases from '@salesforce/apex/CaseController.getOpenCases';
import closeCases from '@salesforce/apex/CaseController.closeCases';
export default class OpenCases extends LightningElement {
@api recordId;
wiredResult;
cases;
error;
@wire(getOpenCases, { accountId: '$recordId' })
wiredCases(result) {
this.wiredResult = result; // keep the whole result for refreshApex
if (result.data) {
this.cases = result.data;
this.error = undefined;
} else if (result.error) {
this.error = result.error.body?.message ?? 'Unknown error';
this.cases = undefined;
}
}
async handleCloseAll() {
try {
await closeCases({ caseIds: this.cases.map((c) => c.Id) });
await refreshApex(this.wiredResult);
this.dispatchEvent(new ShowToastEvent({
title: 'Cases closed', variant: 'success'
}));
} catch (e) {
this.dispatchEvent(new ShowToastEvent({
title: 'Could not close cases',
message: e.body?.message,
variant: 'error'
}));
}
}
}
public with sharing class CaseController {
@AuraEnabled(cacheable=true)
public static List<Case> getOpenCases(Id accountId) {
return [SELECT Id, CaseNumber, Subject, Status FROM Case
WHERE AccountId = :accountId AND IsClosed = false
WITH USER_MODE ORDER BY CreatedDate DESC LIMIT 200];
}
@AuraEnabled
public static void closeCases(List<Id> caseIds) {
List<Case> updates = new List<Case>();
for (Id caseId : caseIds) {
updates.add(new Case(Id = caseId, Status = 'Closed'));
}
try {
update as user updates;
} catch (DmlException e) {
throw new AuraHandledException(e.getDmlMessage(0));
}
}
}
LDS first, wired Apex for custom reads, imperative Apex for changes.
Displaying errors well. Users should see a clear, specific message near the problem; developers should get the details in logs. Patterns the exam expects:
- Apex to LWC or Aura: catch exceptions and throw
AuraHandledExceptionwith a readable message. Other uncaught exceptions reach the client with a generic message, and the details stay hidden. - LWC: show a toast with
ShowToastEventfor action results, render inline error text in the template for load errors, and uselightning-messagesinsidelightning-record-edit-formto display save errors (including validation rule messages) automatically. Field-level errors fromaddError()on a field show next to that field in record forms. - Validation in the browser:
reportValidity()andsetCustomValidity()onlightning-inputgive immediate feedback before calling the server. - Visualforce: add messages with
ApexPages.addMessage(new ApexPages.Message(ApexPages.Severity.ERROR, 'message'))and render them with<apex:pageMessages/>. DML exceptions caught in a controller can be added the same way. - Aura: read errors from
response.getError()in the callback and show them withlightning:notificationsLibraryor a toast event.
Communicating between components. Match the relationship to the tool:
| Relationship | Lightning web components | Aura components |
|---|---|---|
| Parent to child | Set a public @api property, or call a public @api method on the child | Set attributes; call aura:method |
| Child to parent | Dispatch a CustomEvent (with detail); parent listens with on<eventname> in markup | Component event (aura:registerEvent and aura:handler) |
| Event across shadow boundaries | new CustomEvent('select', { bubbles: true, composed: true }) (use sparingly) | Component events bubble through containment |
| Unrelated components on the same page, including Visualforce and Aura | Lightning Message Service (lightning/messageService) with a message channel | Lightning Message Service (lightning:messageChannel) or application events |
| Server-pushed updates | lightning/empApi to subscribe to platform events or change events | lightning:empApi |
Event names in LWC are lowercase with no hyphens (orderselected), and the parent listens with onorderselected. Pass data in detail, and treat it as read-only in the parent. The old pubsub module was a workaround that Lightning Message Service replaces.
// child: orderTile.js
handleClick() {
this.dispatchEvent(new CustomEvent('orderselected', {
detail: { orderId: this.order.Id }
}));
}
// parent template: orderList.html
// <c-order-tile order={order} onorderselected={handleOrderSelected}></c-order-tile>
// unrelated component: publishes on a Lightning Message Service channel
import { publish, MessageContext } from 'lightning/messageService';
import ORDER_CHANNEL from '@salesforce/messageChannel/Order_Selected__c';
@wire(MessageContext) messageContext;
notifyOthers(orderId) {
publish(this.messageContext, ORDER_CHANNEL, { orderId });
}
Properties down, events up, message channels across.
Responsive design by form factor. Questions ask what makes markup adapt to phones, tablets and desktops:
lightning-layoutandlightning-layout-itemwithsize,small-device-size,medium-device-sizeandlarge-device-size(out of 12 columns), plusmultiple-rows. SLDS grid utility classes such asslds-size_1-of-1 slds-medium-size_1-of-2do the same with CSS.import FORM_FACTOR from '@salesforce/client/formFactor'returnsLarge,MediumorSmall, so JavaScript can choose a different layout or template.- In the component's
js-meta.xml,supportedFormFactorscontrols which page types and devices can use the component in Lightning App Builder. - In Aura,
$Browser.formFactor(and$Browser.isPhone,$Browser.isTablet) andlightning:layoutItemattributes play the same roles.
Visualforce: actions, partial refreshes and async. Visualforce is still on the exam. Know these components:
| Component or feature | What it does |
|---|---|
<apex:actionFunction> | Defines a JavaScript function that calls a controller action, optionally rerendering parts of the page |
<apex:actionSupport> | Adds Ajax behavior to another component on a DOM event (for example onchange) |
<apex:actionPoller> | Calls an action on a timer |
<apex:actionStatus> | Shows a status (such as a spinner) during an Ajax request |
<apex:actionRegion> | Limits which part of the form the server processes during an Ajax request, so unrelated required fields don't block it |
rerender attribute | Refreshes only the named components (partial page refresh) |
JavaScript remoting (@RemoteAction) | Calls a static Apex method from JavaScript without posting the view state; fast and stateless |
Continuation | Makes a long-running callout asynchronously, with a callback method when the response arrives |
transient keyword | Keeps a controller variable out of the view state (maximum view state is 170 KB) |
readOnly="true" on <apex:page> | Raises the query row limit to 1 million and collection iteration to 10,000 items for read-only pages |
Choosing LWC, Aura or Visualforce. Default to Lightning web components: they're standards-based, fast, and work in Lightning Experience, the mobile app, Experience Cloud sites, Flow screens, quick actions and utility bars. Use Aura when you need something LWC can't do in your context, or when an existing Aura app wraps your LWC (for example Lightning Out, which surfaces components in Visualforce or external pages). Use Visualforce for PDF rendering (renderAs="pdf"), Visualforce email templates, or legacy pages you're not ready to rebuild. Lightning web components can be embedded in Visualforce through Lightning Out (<apex:includeLightning/> and $Lightning.use()), and in quick actions through the lightning__RecordAction target (screen actions or headless actions).
Static resources. Upload JavaScript libraries, CSS, images and fonts once (single files or a zip, up to 5 MB each), and they're cached and served efficiently. Reference them with {!$Resource.name} and URLFOR($Resource.zip, 'path/file.js') in Visualforce, ltng:require in Aura, and in LWC by importing @salesforce/resourceUrl/name and loading it with loadScript() and loadStyle() from lightning/platformResourceLoader. Benefits: caching, versioning separate from code, and no dependence on external CDNs that Lightning Locker or Lightning Web Security and Content Security Policy may block.
import { LightningElement } from 'lwc';
import { loadScript, loadStyle } from 'lightning/platformResourceLoader';
import CHART_JS from '@salesforce/resourceUrl/chartjs';
export default class SalesChart extends LightningElement {
chartLoaded = false;
async renderedCallback() {
if (this.chartLoaded) {
return; // renderedCallback runs after every render
}
this.chartLoaded = true;
await Promise.all([
loadScript(this, CHART_JS + '/chart.umd.js'),
loadStyle(this, CHART_JS + '/chart.css')
]);
this.initializeChart();
}
}
Key takeaway: use Lightning Data Service before Apex, wire only cacheable reads, refresh caches after changes, send readable errors with AuraHandledException, and match communication tools to component relationships.
Practice questions: User Interface
Question 12. A Lightning web component wires an Apex method to display related contacts. A button in the same component calls a second Apex method imperatively to update the contacts' mailing addresses. After the update succeeds, the list still shows old addresses. What should the developer do?
- A. Remove
cacheable=truefrom the wired method - B. Store the wired result in a property and call
refreshApex()with it after the update - C. Reload the browser page with
window.location.reload() - D. Call the wired method imperatively every five seconds
Answer: B. Wired results are cached, and refreshApex() with the provisioned value re-fetches them. Why not the others: wired Apex requires cacheable=true (A); a full reload (C) and polling (D) are poor user experiences and waste server calls.
Question 13. A developer writes an Apex method that is wired in a Lightning web component. The method queries opportunities and also updates a Last_Viewed_By__c field on each one. The component throws an error at run time. Why?
- A. Wired methods must be
global - B. Methods marked
@AuraEnabled(cacheable=true)can't perform DML - C. Wired methods can't accept parameters
- D. The method must return a
Mapinstead of aList
Answer: B. Cacheable methods must be read-only, and wiring requires cacheable. Move the update to a separate imperative method. Why not the others: wired methods can be public (A), can take parameters (C) and can return lists (D).
Question 14. A Visualforce page has a form with several required fields. A picklist at the top should refresh a dependent section through Ajax when it changes, but the request fails with "required field" errors from fields lower on the page. Which change fixes it?
- A. Wrap the picklist and its
<apex:actionSupport>in an<apex:actionRegion> - B. Add
immediate="true"to the<apex:page>tag - C. Replace the page with a Lightning web component
- D. Mark the required fields
transient
Answer: A. actionRegion limits which components the server processes during the Ajax request, so other required fields aren't validated. Why not the others: immediate isn't an apex:page attribute (B); rebuilding the page is overkill (C); transient affects controller variables and the view state, not form validation (D).
Question 15. A child component needs to tell its parent which product a user selected. Both are Lightning web components and the parent contains the child directly. What's the recommended approach?
- A. Publish a message on a Lightning Message Service channel
- B. Dispatch a
CustomEventnamedproductselectedwith the product ID indetail, and handle it withonproductselectedin the parent's markup - C. Write the product ID to a static resource
- D. Use an Aura application event
Answer: B. Custom events are the standard way for a child to talk to its direct parent. Why not the others: message channels are for components that aren't in the same hierarchy (A); static resources are files, not state (C); application events are an Aura pattern for broadcast and are overkill here (D).
Question 16. A record page component should show a compact single-column layout on phones and a three-column layout on desktop. Which two techniques achieve this? Choose 2 answers.
- A.
lightning-layout-itemwithsize="12"andlarge-device-size="4" - B. Import
@salesforce/client/formFactorand switch layouts based on its value - C. Create a separate Visualforce page for mobile
- D. Set
isExposedtofalsein thejs-meta.xmlfile
Answer: A and B. Layout item sizes adapt to screen width, and the form factor import lets JavaScript choose layouts by device. Why not the others: a Visualforce page doesn't make the LWC responsive (C); isExposed controls App Builder availability, not layout (D).
Testing, Debugging, and Deployment (20%)
Platform Developer asked whether you can write a test. Platform Developer II asks whether you can test code that calls out, runs asynchronously, depends on other classes, or only breaks with certain users and data. The official objectives:
- Apply advanced techniques and tools for testing Apex classes and triggers, such as mocks and stubs.
- Apply techniques and tools for testing and debugging LWC, Aura components, Visualforce controllers and extensions, and JavaScript.
- Given a scenario, Apex code, Apex trigger, or Apex tests that's not executing as expected, apply techniques and tools to identify the cause.
- Given a scenario, formulate the deployment process, supporting tools, and mechanisms for source-driven development.
Test fundamentals you're assumed to know. Tests don't see org data by default (@IsTest(SeeAllData=false) is the default), and their DML is rolled back. Create data in a @TestSetup method or a test data factory. Wrap the code under test in Test.startTest() and Test.stopTest(): the code between them gets a fresh set of governor limits, and asynchronous work queued inside runs synchronously at stopTest(), so you can assert its results right after. Use System.runAs() to test as a specific user (it enforces that user's record sharing; it's also the standard way around mixed DML in tests). Assert behavior with the Assert class (Assert.areEqual, Assert.isTrue, Assert.fail), not just coverage.
Mocks, stubs and test doubles. Tests can't make real callouts, so Apex provides interfaces you implement to fake the other side.
| Scenario | Tool |
|---|---|
| REST or HTTP callout | Implement HttpCalloutMock (respond(HttpRequest)) and register it with Test.setMock(HttpCalloutMock.class, new MyMock()) |
| HTTP callout with a fixed response body | StaticResourceCalloutMock (one static resource) or MultiStaticResourceCalloutMock (one per endpoint) |
| SOAP callout from WSDL2Apex classes | Implement WebServiceMock (doInvoke(...)) and register it with Test.setMock(WebServiceMock.class, ...) |
| Replace a dependency class with a fake | The Stub API: implement System.StubProvider and create the stub with Test.createStub(MyService.class, provider) |
| SOSL results | Test.setFixedSearchResults(ids) |
| Platform events | Publish in the test; events are delivered at Test.stopTest(), or immediately with Test.getEventBus().deliver() |
| Change Data Capture | Test.enableChangeDataCapture(), then Test.getEventBus().deliver() |
| Callouts after test data DML | Do the DML first, then put Test.startTest(), Test.setMock() and the callout code after it, finishing with Test.stopTest() |
The Stub API needs dependency injection. You can only stub instances that your code receives from outside (through a constructor, a setter or a factory). The Stub API can't stub static methods, private methods, properties (getters and setters), triggers, inner classes, system types, classes that implement Database.Batchable, or classes with only private constructors. That's why testable code depends on interfaces or overridable instances instead of calling static helpers everywhere. Mocking libraries such as ApexMocks are built on the Stub API.
@IsTest
private class OrderSyncTest {
private class ErpSuccessMock implements HttpCalloutMock {
public HttpResponse respond(HttpRequest req) {
Assert.areEqual('POST', req.getMethod());
Assert.isTrue(req.getEndpoint().startsWith('callout:ERP_Orders'));
HttpResponse res = new HttpResponse();
res.setStatusCode(201);
res.setBody('{"status":"accepted"}');
return res;
}
}
@TestSetup
static void makeData() {
insert new Order__c(Name = 'ORD-1001', Total__c = 2500);
}
@IsTest
static void syncSendsOrdersToErp() {
List<Id> ids = new List<Id>(new Map<Id, Order__c>(
[SELECT Id FROM Order__c]).keySet());
Test.startTest();
Test.setMock(HttpCalloutMock.class, new ErpSuccessMock());
System.enqueueJob(new SyncOrderJob(ids));
Test.stopTest(); // the queueable runs here, synchronously
// Assert the outcome your code records, for example a status field.
// Asserting only that no exception occurred proves very little.
}
}
// Stub API: replace a pricing dependency in a unit test.
@IsTest
private class QuoteServiceTest {
private class PricingStub implements System.StubProvider {
public Object handleMethodCall(Object stubbedObject, String stubbedMethodName,
Type returnType, List<Type> listOfParamTypes,
List<String> listOfParamNames, List<Object> listOfArgs) {
if (stubbedMethodName == 'getDiscount') {
return 0.15;
}
return null;
}
}
@IsTest
static void appliesDiscountFromPricingService() {
PricingService pricing = (PricingService) Test.createStub(
PricingService.class, new PricingStub());
QuoteService service = new QuoteService(pricing); // constructor injection
Assert.areEqual(850, service.netPrice(1000));
}
}
If your code can't be tested without a real callout or a real dependency, change the design, not the test.
Testing Lightning web components with Jest. Jest tests run locally with Node.js through @salesforce/sfdx-lwc-jest (npm run test:unit), not in the org, and they don't count toward Apex code coverage. A typical test creates the component with createElement(), appends it to document.body, sets public properties, then awaits a promise to let the DOM re-render before asserting. Mock Apex imports with jest.mock(). Wired Apex methods and LDS wire adapters are emitted with test wire adapters (getOpenCases.emit(mockData) or .error()). Clean up the DOM in afterEach. Aura components can be tested with the Lightning Testing Service; Visualforce controllers and extensions are tested with Apex tests (Test.setCurrentPage(), ApexPages.currentPage().getParameters(), new ApexPages.StandardController(record)).
import { createElement } from 'lwc';
import OpenCases from 'c/openCases';
import getOpenCases from '@salesforce/apex/CaseController.getOpenCases';
jest.mock('@salesforce/apex/CaseController.getOpenCases', () => {
const { createApexTestWireAdapter } = require('@salesforce/sfdx-lwc-jest');
return { default: createApexTestWireAdapter(jest.fn()) };
}, { virtual: true });
describe('c-open-cases', () => {
afterEach(() => {
while (document.body.firstChild) {
document.body.removeChild(document.body.firstChild);
}
});
it('renders one row per case', async () => {
const element = createElement('c-open-cases', { is: OpenCases });
element.recordId = '001000000000001AAA';
document.body.appendChild(element);
getOpenCases.emit([{ Id: '500000000000001AAA', Subject: 'Broken pump' }]);
await Promise.resolve();
const rows = element.shadowRoot.querySelectorAll('.case-row');
expect(rows.length).toBe(1);
});
});
Debugging code that doesn't behave. Pick the tool by the symptom:
| Symptom | Tool or technique |
|---|---|
| "Something" fails, no idea where | Debug logs with a trace flag on the user, Automated Process user or Apex class; raise Apex Code and Database log levels; read the Execution Overview in the Developer Console |
| Need to step through a production failure | Apex Replay Debugger in VS Code: replay a debug log (Apex Code at FINEST) with breakpoints and variable inspection |
| Need to see variable and heap state at a point | Developer Console checkpoints and heap dumps |
| Limit exception or slow transaction | Limits methods (Limits.getQueries(), Limits.getCpuTime()), the log's cumulative limit usage section, and the Query Plan tool for slow queries |
| Test passes alone but fails in a full run | Tests depending on org data (SeeAllData), hard-coded IDs, test order, or shared static state |
| Async job failed silently | Setup: Apex Jobs, AsyncApexJob queries, BatchApexErrorEvent for batch classes that implement Database.RaisesPlatformEvents, and finalizers on queueables |
| Platform event trigger stopped | Its subscription status in Setup (error state after retries), and the debug logs of the trigger's running user |
Debug logs truncate at 20 MB, so turn down noisy categories (Validation, Workflow, Visualforce) when you're chasing Apex. Logs are only generated while a trace flag is active; the Developer Console sets one for you while it's open.
Deployment and source-driven development. The exam expects you to know the tools and when each fits:
| Tool or mechanism | Use it for |
|---|---|
Salesforce CLI (sf) | Source-driven development: sf project retrieve start, sf project deploy start, sf project deploy validate, sf project deploy quick, sf apex run test |
| Scratch orgs | Disposable, source-tracked orgs created from a definition file and a Dev Hub (sf org create scratch); ideal for feature development and CI |
| Sandboxes | Developer, Developer Pro, Partial Copy and Full; testing with production-like metadata and data |
| Unlocked packages | Modular, versioned deployments of your own org's code from source control (sf package create, sf package version create) |
| Second-generation managed packages | Distributing apps to other orgs, such as AppExchange partners |
Metadata API with package.xml and destructiveChanges.xml | Deploying or deleting specific components, and CI tools |
| Change sets | Point-and-click deployments between connected orgs; no source control or deletions |
| DevOps Center | Source-control-backed pipelines with a UI for admins and developers |
Test levels for deployments. NoTestRun (default for non-production), RunSpecifiedTests (each class and trigger in the deployment needs 75% coverage from the specified tests), RunLocalTests (all tests except those from managed packages; the default for production deployments with Apex) and RunAllTestsInOrg. Production needs 75% overall Apex coverage and every trigger must have some coverage. A validation that passes can be quick deployed within 10 days without rerunning tests. I walk through installing the CLI in Set Up Claude Code and the Salesforce CLI, and the Development Lifecycle and Deployment Architect study guide goes much deeper on environments and release strategy.
# Validate against production with local tests, then quick deploy the validated job
sf project deploy validate --source-dir force-app --test-level RunLocalTests --target-org prod
sf project deploy quick --job-id 0Af000000000001AAA --target-org prod
# Create a scratch org for a feature branch and push source to it
sf org create scratch --definition-file config/project-scratch-def.json --alias feature-123 --duration-days 7
sf project deploy start --target-org feature-123
Source control is the source of truth; orgs are where you test it.
Key takeaway: design for testability (inject dependencies, isolate callouts), use the right mock for each boundary, debug with the tool that fits the symptom, and deploy from source with validated, tested releases.
Practice questions: Testing, Debugging, and Deployment
Question 17. A test inserts an account and then calls a method that makes an HTTP callout. The test fails with "You have uncommitted work pending." The mock is set correctly. What should the developer change?
- A. Add
SeeAllData=trueto the test - B. Move
Test.startTest()andTest.setMock()after the account insert, run the callout code betweenTest.startTest()andTest.stopTest() - C. Remove the account insert and hard-code an account ID
- D. Make the callout from a
@TestSetupmethod
Answer: B. Wrapping the callout in startTest() and stopTest() after the test data DML is the documented pattern for testing callouts after DML. Why not the others: org data doesn't fix uncommitted work (A); hard-coded IDs break between orgs (C); setup methods can't make callouts and wouldn't test the method (D).
Question 18. A developer wants to unit test InvoiceService without running the real TaxCalculator, which calls an external tax API and has complex setup. InvoiceService currently creates the calculator with new TaxCalculator() inside each method. What should the developer do first?
- A. Use
Test.createStub()onTaxCalculatorwithout changingInvoiceService - B. Refactor
InvoiceServiceto receive aTaxCalculator(or an interface) through its constructor, then pass a stub created withTest.createStub()in the test - C. Add
if (Test.isRunningTest())branches throughoutTaxCalculator - D. Make
TaxCalculatormethodsstatic
Answer: B. The Stub API only replaces instances your code receives, so the class must accept the dependency through injection. Why not the others: a stub that the class never receives does nothing (A); test-only branches clutter production code and don't test the real logic (C); static methods can't be stubbed at all (D).
Question 19. A queueable job that updates order records fails intermittently in production, and nobody notices until customers complain. Which approach both detects failures and allows a controlled retry?
- A. Wrap the
executemethod in an emptytry/catch - B. Attach a transaction finalizer that checks for an unhandled exception, logs it and re-enqueues the job a limited number of times
- C. Increase the batch size
- D. Run the job synchronously instead
Answer: B. Finalizers run after the queueable finishes, even if it failed, so they can log and retry. Why not the others: swallowing exceptions hides the problem (A); queueables don't have a batch size (C); running synchronously loses the async limits and still doesn't report failures (D).
Question 20. A developer needs to debug why a trigger behaves differently for one sales user in production. The issue can't be reproduced in the sandbox. Which approach lets the developer step through the exact execution with breakpoints without pausing other users?
- A. Set a trace flag on the user, reproduce the issue, download the debug log, and use the Apex Replay Debugger in VS Code
- B. Add
System.debugstatements and deploy them to production repeatedly - C. Give the developer the user's password to log in as them
- D. Use the Query Plan tool
Answer: A. Replay Debugger simulates a live debugging session from a debug log, with breakpoints and variable values, and doesn't affect other users. Why not the others: repeated deployments of debug statements are slow and risky (B); sharing passwords violates security policy (C); Query Plan analyzes query selectivity, not trigger logic (D).
Question 21. A team wants modular, versioned releases of its custom Apex and LWC code from source control into its own production org, with clear dependencies between modules. Which mechanism fits best?
- A. Change sets
- B. Unlocked packages
- C. Copying classes manually in Setup
- D. Unmanaged packages
Answer: B. Unlocked packages give versioned, source-driven modules with dependencies for your own orgs. Why not the others: change sets aren't source-driven or versioned (A); manual copying isn't a deployment process (C); unmanaged packages can't be upgraded (D).
Performance (18%)
Performance questions usually show code that works in a demo org and fails at scale, then ask what to change. The official objectives:
- Identify the common performance issues for user interfaces and demonstrate knowledge of techniques and tools to mitigate them.
- Given a scenario, choose the appropriate logic and query structure to maximize application performance and handle large data volumes.
- Analyze a scenario and determine performance improvements that can be achieved with an asynchronous callout.
- Identify scenarios where code reuse is applicable and how the reuse should be implemented.
- Given sample code, identify inefficiencies and demonstrate how to resolve them.
User interface performance. The fastest server call is the one you don't make.
- Use Lightning Data Service and cacheable Apex so repeated reads come from the client cache.
- Load less. Query only the fields you display, add
LIMIT, page or lazy-load data (lightning-datatablewithenable-infinite-loadingandonloadmore), and load hidden tabs or sections only when the user opens them (lwc:ifinstead of hiding with CSS). - Batch server calls. One Apex call that returns a wrapper with everything a component needs beats five chatty calls.
- Debounce input. Wait until the user stops typing (for example 300 ms) before calling a search method.
- Keep
renderedCallbacklight, and guard one-time work with a flag. Avoid getters that do heavy work, because they run on every render. - Visualforce: shrink the view state with
transientand by not storing large collections, useStandardSetControllerfor pagination, JavaScript remoting for fast stateless calls, andreadOnly="true"for large read-only pages. - Measure with the browser's developer tools and the Lightning Usage App or Salesforce's page performance tools before optimizing.
Selective queries and large data volumes. On large objects, a query is fast only when it's selective: its filter uses an index and returns a small enough share of the records. In a trigger on an object with more than 200,000 records, a non-selective query fails with "Non-selective query against large object type."
| Index type | Selective when the filter returns fewer than |
|---|---|
Standard index (Id, Name, OwnerId, CreatedDate, SystemModstamp, RecordTypeId, lookup and master-detail fields, unique and external ID fields) | 30% of the first million records and 15% of records beyond that, up to 1 million records |
| Custom index (requested from Salesforce Support or created on external ID or unique fields) | 10% of the first million records and 5% beyond that, up to 333,333 records |
Filters that can't use an index include negative operators (!=, NOT IN, EXCLUDES), leading wildcards (LIKE '%term'), comparisons on non-deterministic formula fields, and, for most indexes, comparing to null. Use the Query Plan tool in the Developer Console: a cost below 1 means the query is selective. Other large-data techniques: skinny tables (from Support), avoiding data skew (keep any parent or owner under about 10,000 child or owned records), Batch Apex and the Bulk API for processing, and archiving old data.
// Inefficient: a query and DML inside the loop. Fails at 101 records.
for (Contact c : Trigger.new) {
Account a = [SELECT Id, Region__c FROM Account WHERE Id = :c.AccountId];
c.Region__c = a.Region__c;
update c; // also wrong: DML on Trigger.new records in a trigger
}
// Efficient: one query, a map, no DML (before trigger).
Set<Id> accountIds = new Set<Id>();
for (Contact c : (List<Contact>) Trigger.new) {
if (c.AccountId != null) {
accountIds.add(c.AccountId);
}
}
Map<Id, Account> accounts = new Map<Id, Account>(
[SELECT Id, Region__c FROM Account WHERE Id IN :accountIds]);
for (Contact c : (List<Contact>) Trigger.new) {
Account a = accounts.get(c.AccountId);
if (a != null) {
c.Region__c = a.Region__c;
}
}
Common inefficiencies and their fixes.
| Inefficiency | Fix |
|---|---|
| SOQL or DML inside a loop | Query once with a Set of IDs; collect records and do one DML per list |
| Nested loops to match records | Build a Map<Id, SObject> or Map<String, List<SObject>> and look up in constant time |
| Loading a huge result into memory (heap limit) | SOQL for loop (for (List<Account> chunk : [SELECT ...])) processes 200 records at a time; or Batch Apex |
| Repeated describe calls | Cache describe results in a static variable; avoid Schema.getGlobalDescribe() in loops |
| Same query run many times in one transaction | Cache results in a static map, or use Platform Cache across transactions |
| Non-selective filter on a large object | Filter on indexed fields, request a custom index, avoid negative operators and leading wildcards |
| Synchronous callout makes users wait | Continuation from the UI, or queueable callouts for background work |
| Heavy logic in a synchronous trigger | Move non-urgent work to a queueable or platform event subscriber |
| Recalculating values on every trigger run | Act only when relevant fields change (compare with Trigger.oldMap) |
| Rolling up child counts in code that locks the parent | Consider roll-up summary fields, or batch updates ordered by parent to reduce lock contention |
Index the filter, keep the result small, and prove it with the Query Plan tool.
Asynchronous callouts with Continuation. When a user action needs a slow external service (several seconds), a normal synchronous callout ties up a server thread while it waits. A Continuation hands the request to the platform and calls back when the response arrives. Asynchronous callouts made this way don't count toward the limit on long-running synchronous requests, so many users can wait on a slow service without hurting the rest of the org. In LWC, the Apex method is annotated @AuraEnabled(continuation=true cacheable=true) and returns a Continuation that names a callback method, which is annotated @AuraEnabled(cacheable=true).
public with sharing class ShippingQuoteController {
@AuraEnabled(continuation=true cacheable=true)
public static Object startQuote(String postalCode) {
Continuation con = new Continuation(40); // timeout in seconds, up to 120
con.continuationMethod = 'processQuote';
HttpRequest req = new HttpRequest();
req.setMethod('GET');
req.setEndpoint('callout:Shipping_API/quotes?postal=' +
EncodingUtil.urlEncode(postalCode, 'UTF-8'));
con.state = con.addHttpRequest(req);
return con;
}
@AuraEnabled(cacheable=true)
public static Object processQuote(List<String> labels, Object state) {
HttpResponse res = Continuation.getResponse((String) state);
return res.getBody();
}
}
Code reuse. Reuse reduces bugs and makes performance fixes happen in one place. The exam asks where reuse applies and how:
- Apex layers. Selector classes own queries (consistent fields and security), service classes own business processes callable from triggers, LWC, REST and batch, and domain classes own object-specific rules. This is the Apex Enterprise Patterns approach (service, domain, selector, unit of work).
- Interfaces, abstract and virtual classes. Define a contract (
DiscountStrategy) with several implementations, picked at run time withType.forName()and custom metadata. - Invocable methods expose Apex to Flow so admins reuse it without code.
- LWC. Share JavaScript through service modules (a component folder with only a JS file, imported by other components), compose small components, and reuse labels and static resources.
- Aura. Component inheritance with
extendsand abstract components, plus shared helper code. - Visualforce. Custom components (
<apex:component>), templates (<apex:composition>and<apex:insert>), and controller extensions.
Salesforce's architects publish a long list of recommended patterns and anti-patterns, which I wrote about in The Pattern Anti-Pattern Master List. It's worth browsing before this exam.
Key takeaway: query selectively and once, keep collections in maps, move slow and heavy work out of the user's request, and put shared logic in one well-designed place.
Practice questions: Performance
Question 22. A trigger on a custom object with 3 million records runs this query and fails with a non-selective query error: SELECT Id FROM Shipment__c WHERE Status__c != 'Delivered' AND Region__c = :region. Which change is most likely to make it selective?
- A. Rewrite the negative filter as
Status__c IN ('Pending', 'In Transit')and have Salesforce Support add a custom index onStatus__c - B. Add
ORDER BY CreatedDate - C. Add
ALL ROWS - D. Move the query into a
try/catchblock
Answer: A. Negative operators can't use indexes, so rewriting as a positive filter on an indexed field gives the optimizer a selective path. Why not the others: sorting doesn't make a filter selective (B); ALL ROWS adds deleted records (C); catching the exception doesn't fix the query (D).
Question 23. A Lightning web component calls an external pricing service that takes 15 to 30 seconds to respond. During busy periods, other users report errors and slow pages. What should the developer do?
- A. Increase the callout timeout to 120 seconds in a synchronous Apex method
- B. Use an Apex method with
@AuraEnabled(continuation=true)that returns aContinuation - C. Call the service from JavaScript with
fetch()directly - D. Use a
@future(callout=true)method and return the result to the component
Answer: B. Continuation makes the callout asynchronous, so long waits don't tie up server threads or count toward the long-running request limit. Why not the others: a longer synchronous timeout makes the problem worse (A); direct browser calls expose credentials and are often blocked by Content Security Policy (C); future methods can't return values to the component (D).
Question 24. A developer reviews this code in a batch class's execute method.
for (Opportunity opp : scope) {
for (OpportunityLineItem item : allLineItems) {
if (item.OpportunityId == opp.Id) {
opp.Line_Count__c = (opp.Line_Count__c == null ? 0 : opp.Line_Count__c) + 1;
}
}
}
With large scopes, the job hits the CPU time limit. What's the best fix?
- A. Reduce the batch scope to 1
- B. Build a
Map<Id, Integer>of line counts per opportunity in one pass over the line items (or use an aggregate query), then update each opportunity from the map - C. Add
System.debugstatements to find the slow line - D. Move the code to a synchronous trigger
Answer: B. The nested loop is O(n × m). A map or COUNT() aggregate grouped by OpportunityId makes it a single pass. Why not the others: a scope of 1 makes the job extremely slow (A); debugging doesn't fix the algorithm (C); synchronous limits are lower (D).
Question 25. Three Lightning web components and a REST resource each contain a copy of the same 40-line query for active contracts, with slightly different fields and security checks. What should the developer do?
- A. Leave them; duplication is faster than a method call
- B. Create a selector class with one method that queries active contracts with consistent fields and
WITH USER_MODE, and call it from all four places - C. Move the query into a custom label
- D. Copy the query into a static resource
Answer: B. A selector centralizes queries so fields, filters and security stay consistent and changes happen once. Why not the others: duplication causes drift and bugs (A); labels (C) and static resources (D) aren't for executable logic.
Deep dive: choosing the right asynchronous tool
Async questions appear in at least three sections (logic, integration and performance), and the wrong answers are usually real async tools used in the wrong place. Work through these questions in order:
- Does it need to happen at a specific time or on a schedule? Use Schedulable, usually to start a batch or queueable. (A scheduled flow can also work for simple record updates.)
- Does it process more records than one transaction can handle (tens of thousands to millions)? Use Batch Apex with a
QueryLocator. AddDatabase.Statefulif you need totals across chunks, andDatabase.AllowsCalloutsfor callouts per chunk. - Does it need to chain steps, pass sObjects or complex types, report a job ID, or handle failures? Use Queueable, with a finalizer for failure handling.
- Is it a simple, fire-and-forget call with primitive parameters, such as a callout from a trigger or a mixed DML workaround?
@futureworks, though a queueable is usually the better long-term choice. - Do several independent subscribers need to react, possibly outside Salesforce? Publish a platform event and let each subscriber process it in its own transaction.
- Is a user waiting on a slow callout? Use a Continuation, not a background job, because the user needs the answer.
Start with "when" and "how much," then "how complex."
| Feature | Future | Queueable | Batch | Schedulable |
|---|---|---|---|---|
| Parameters | Primitives and collections of primitives | Any serializable member variables, including sObjects | Defined in the class | Defined in the class |
| Returns a job ID to monitor | No | Yes | Yes | Yes (CronTrigger ID) |
| Chaining | No | One child job per execution | Start another batch from finish | Start other jobs from execute |
| Callouts | @future(callout=true) | Database.AllowsCallouts | Database.AllowsCallouts | Not directly; start an async job that calls out |
| Volume | Small | Small to medium | Up to 50 million records with QueryLocator | Depends on what it starts |
| Failure handling | Check AsyncApexJob | Finalizers | BatchApexErrorEvent with Database.RaisesPlatformEvents | Check CronTrigger and the jobs it starts |
| Testing | Runs at Test.stopTest() | Runs at Test.stopTest() | One execute runs at Test.stopTest() (keep test data within one scope) | Runs at Test.stopTest() |
Limits that often decide the answer. You can enqueue 50 queueable jobs from a synchronous transaction but only one from a queueable or other async job. You can't call a future method from a future method or a batch job. Only five batch jobs process at once, with up to 100 more waiting in the Apex flex queue. An org can have 100 scheduled Apex jobs.
Worked example: an end-to-end order sync feature
Let's put the whole outline together with a fictional company.
The company. Summit Outfitters sells outdoor gear to retailers in the United States, Canada and Germany. Orders are captured in Salesforce, but fulfillment happens in an external ERP. Sales reps need to see shipping status on the order page, and regional managers need to see only their regions' orders.
Configuration first (Advanced Developer Fundamentals). The ERP endpoint and batch sizes live in a custom metadata type (ERP_Setting__mdt) with one record per environment, and authentication lives in a named credential (ERP_Orders) backed by an external credential, with access granted through a permission set. A hierarchy custom setting provides a bypass switch for data migrations. Multi-currency is enabled, so the sync sends each order's CurrencyIsoCode with its amounts, and the order page shows totals with lightning-formatted-number in the user's locale.
Sharing (Advanced Developer Fundamentals). The Order__c org-wide default is Private. A sharing rule covers regions, and Apex managed sharing with an Apex sharing reason (Account_Team__c) shares each order with the account team members stored in a related object, so access survives owner changes.
Trigger and sync (Process Automation, Logic, and Integration). One Order__c trigger calls a handler. When an order moves to Submitted, the handler collects the IDs and enqueues SyncOrderJob, a queueable with Database.AllowsCallouts and a finalizer. The job queries with WITH USER_MODE, posts JSON to callout:ERP_Orders/api/orders, and updates each order's sync status. If the ERP is down, the finalizer logs the error and re-enqueues the job up to three times.
Status coming back (integration and platform events). The ERP calls a custom Apex REST resource (/services/apexrest/orders/*) with shipment updates. The resource updates the order, then publishes an Order_Shipped__e platform event with Publish After Commit behavior. A platform event trigger creates follow-up tasks, and a platform event-triggered flow updates the retailer's loyalty points.
User interface. A Lightning web component on the order record page uses @wire(getRecord) for header fields and a wired cacheable Apex method for shipment lines. It subscribes to the shipment event with lightning/empApi, then calls refreshApex() when a matching event arrives. Errors arrive as AuraHandledException messages in a toast. The layout uses lightning-layout-item sizes so it works on phones in the warehouse.
Testing and deployment. Apex tests use an HttpCalloutMock for the ERP, Test.startTest() and Test.stopTest() to run the queueable, and Test.getEventBus().deliver() to deliver the platform event. The pricing dependency is replaced with the Stub API. Jest tests cover the component with emitted wire data. The team develops in scratch orgs, packages the feature as an unlocked package, validates with RunLocalTests in a full sandbox, and quick deploys to production.
Performance. A nightly batch reconciles up to 2 million orders against the ERP using a selective QueryLocator on an indexed Last_Synced__c date, with Database.Stateful totals emailed from finish. The order history page loads 50 rows at a time with infinite scrolling.
Every exam section shows up in one real feature.
Hands-on checklist
Build these in a free Developer Edition org, Trailhead Playground or scratch org. Each one maps to likely exam questions.
- Write a one-trigger-per-object framework with a handler, a recursion guard and a hierarchy custom setting bypass. Load 200 records with Data Loader or anonymous Apex and confirm no limits are hit.
- Create a custom object with a Private org-wide default and an Apex sharing reason, then write Apex managed sharing for a user lookup. Change the owner and confirm the share survives.
- Create a custom metadata type with two records and read them with
getAll()andgetInstance()in Apex and in a test withoutSeeAllData. - Write a queueable that makes a callout through a named credential, attach a finalizer, and test it with an
HttpCalloutMock. - Write a batch class with
Database.Statefulthat counts records across chunks, schedule it with a cron expression, and test it. - Define a platform event, publish it from Apex, subscribe with a trigger that uses
setResumeCheckpoint(), and test it withTest.getEventBus().deliver(). - Expose an Apex REST resource with
@HttpGetand@HttpPost, then call it with Postman or the Salesforce CLI. - Build an LWC that wires a cacheable Apex method, updates records imperatively, calls
refreshApex(), and shows errors with a toast. - Add a child-to-parent custom event and a Lightning Message Service channel between two unrelated components.
- Build a Visualforce page with
actionSupport,actionRegion,actionStatusand arerendertarget, and a JavaScript remoting call. - Use the Stub API to replace a dependency in a unit test.
- Write a Jest test for your LWC and run it with
npm run test:unit. - Capture a debug log for a failing transaction and step through it with the Apex Replay Debugger.
- Run a query through the Query Plan tool and compare an indexed filter with a negative filter.
- Create a scratch org, deploy with
sf project deploy start, validate a deployment withRunLocalTests, and quick deploy it.
Common exam traps
- Callouts after DML. Uncommitted work pending means callout first or move the callout to async.
- No synchronous callouts from triggers. Use a queueable or
@future(callout=true). - Cacheable means read-only.
@wireneedscacheable=true, and cacheable methods can't do DML. with sharingisn't FLS. UseWITH USER_MODE, user-mode DML orstripInaccessible()for object and field security.- Apex sharing reasons are custom-object only, and only those shares survive owner changes.
- Custom setting records are data. They don't deploy and tests can't see them without creating them. Custom metadata records deploy and are visible in tests.
- Static variables reset between batch chunks. Use
Database.Statefulfor totals. - Future methods take primitives. Pass IDs and query inside the method.
- One child queueable per execution, and only one
enqueueJobfrom an async context. - Limit exceptions can't be caught. Prevent them; don't try to catch them.
Database.rollback()doesn't clear IDs on the sObject variables you inserted.- Publish After Commit versus Publish Immediately. Business events after commit; logging events immediately.
- Negative operators and leading wildcards aren't selective. Rewrite filters positively on indexed fields.
- The Stub API needs dependency injection and can't stub static methods.
- Jest tests don't count toward Apex coverage. They're for component behavior.
- Old names in answer choices. Platform Developer I,
sfdx forcecommands, Process Builder and workflow rules still appear in study material and possibly in questions.
Flashcard terms
- Order of execution: the fixed sequence of flows, triggers, validation, saves, automation and commit for a record save.
- Bulkification: writing code that handles 200 records as efficiently as one, with no queries or DML in loops.
- Trigger handler pattern: a logic-less trigger delegating to a handler class that routes by context.
- Share object: the object (
AccountShare,Project__Share) that stores record access grants with a row cause. - Apex sharing reason: a custom row cause for Apex managed sharing on a custom object.
- Inherited sharing: a class runs in its caller's sharing mode, or with sharing as the entry point.
- User mode:
WITH USER_MODEqueries andas userDML that enforce sharing, CRUD and FLS. stripInaccessible(): removes fields the user can't access from records before you use or return them.- Hierarchy custom setting: configuration with org, profile and user-level values.
- Custom metadata type: deployable configuration records readable without SOQL limits.
convertCurrency(): SOQL function returning currency fields in the user's currency.toLabel(): SOQL function returning translated picklist labels.FOR UPDATE: locks queried rows for the rest of the transaction.GROUP BY ROLLUP: adds subtotal rows to aggregate queries.TYPEOF: selects fields by type on a polymorphic relationship.- Savepoint: a point you can roll back DML to with
Database.rollback(). - Transaction finalizer: a class attached to a queueable that runs after it, even on failure.
Database.Stateful: keeps batch instance variables between chunks.- Apex flex queue: holds up to 100 batch jobs waiting to run.
- Platform event: a publish/subscribe message on the event bus.
- Replay ID: the position of an event in the stream, used to resume.
- Named credential: an endpoint plus authentication used with
callout:URLs. - Continuation: an asynchronous callout from a user-facing request.
HttpCalloutMock: interface that fakes HTTP responses in tests.- Stub API:
System.StubProviderandTest.createStub()for replacing dependencies. - Lightning Data Service: the client-side cache and data layer behind record forms and
ui*Apiadapters. refreshApex(): re-fetches a wired Apex result.- Lightning Message Service: message channels between unrelated components, including Aura and Visualforce.
- Selective query: a query whose indexed filter returns few enough records to use the index.
- Query Plan tool: Developer Console tool showing a query's cost and index use.
- Data skew: too many child or owned records on one parent or user, causing locks.
- Unlocked package: a versioned, source-driven package for your own orgs.
- Quick deploy: deploying a validated deployment within 10 days without rerunning tests.
Quick-reference cheat sheet
| Topic | Remember |
|---|---|
| Format | 60 scored + up to 5 unscored, 120 min, 70% to pass, US$200, retake US$100 |
| Prerequisite | Platform Developer (formerly PD1); no superbadge required since October 1, 2025 |
| Largest section | Process Automation, Logic, and Integration 27% |
| Transaction | One request, one transaction, one set of limits; async starts after commit |
| Triggers | One per object, logic-less, bulkified, before for same record, after for related |
| Security | Sharing keyword for records; WITH USER_MODE or stripInaccessible() for CRUD and FLS |
| Sharing | Apex sharing reasons on custom objects survive owner changes |
| Configuration | Custom metadata deploys; hierarchy custom settings vary by user or profile |
| Errors | Savepoints for all-or-nothing, Database.insert(list, false) for partial success, AuraHandledException for UI |
| Async | Queueable by default, batch for volume, schedulable for time, future only for simple cases |
| Platform events | After commit for business events, immediately for logging; checkpoint and retry in triggers |
| Integration | Named credentials; no callouts after DML; Continuation for slow UI callouts |
| LWC data | LDS first; wire cacheable reads; imperative for DML; refreshApex() after changes |
| Events | Properties down, events up, Lightning Message Service across |
| Testing | startTest/stopTest, HttpCalloutMock, Stub API with injection, Jest for LWC |
| Deployment | sf CLI, scratch orgs, unlocked packages, RunLocalTests, quick deploy within 10 days |
| Performance | Selective filters on indexes, maps not nested loops, SOQL for loops for heap |
Review this the night before the exam.
Frequently asked questions
Do I still need superbadges for Platform Developer II? No. Since October 1, 2025, passing the proctored Platform Developer II exam earns the credential. The Advanced Apex Specialist superbadge requirement was retired. The Apex Specialist, Apex Callouts and Platform API superbadges are still recommended practice in the exam guide.
How hard is the exam? It's one of the harder developer exams because most questions are scenarios or code snippets with several plausible answers. The 70% passing score is lower than some exams, but the questions are longer. Developers with two or more years of real Apex and LWC work, plus focused study on Visualforce, mocks and performance, usually find it fair.
What's the prerequisite? You need the Salesforce Certified Platform Developer credential (formerly Platform Developer I) before you can register. My Platform Developer study guide covers it.
How many questions and how long? 60 scored questions plus up to 5 unscored questions in 120 minutes.
How much does it cost? US$200 to register and US$100 to retake, plus applicable taxes.
Is Visualforce still on the exam? Yes. The User Interface section includes Visualforce actions, partial page refreshes, asynchronous operations and static resources, and the Testing section includes Visualforce controllers and extensions.
Does the exam cover Agentforce? The current exam guide aligns to the Winter '24 release and doesn't include Agentforce topics. Invocable methods and well-designed services are still the foundation of agent actions, so the skills carry over. For agents themselves, see the Agentforce Specialist study guide.
How do I keep the certification? Complete the annual Platform Developer II maintenance module on Trailhead before the due date.
What should I take after Platform Developer II? JavaScript Developer if you build a lot of LWC, or the architect path (Platform Integration Architect, Platform Data Architect, Development Lifecycle and Deployment Architect) if you design systems. The certification study guides hub can help you pick.
Related study guides
- Salesforce Platform Developer (formerly Platform Developer I) study guide for the required prerequisite.
- JavaScript Developer study guide for modern JavaScript and Lightning web components.
- Platform Integration Architect study guide for integration patterns, APIs and event-driven design.
- Platform Data Architect study guide for data modeling, large data volumes and data governance.
- Development Lifecycle and Deployment Architect study guide for environments, packaging and release management.
- Agentforce Specialist study guide for agents, prompts and actions.
- The Science of Studying for Salesforce Certifications for how to study.
- All Salesforce certification study guides.
Want to go deeper on automation? PDII expects you to know where Flow ends and Apex begins, because both run in the same transaction and many of the best solutions combine them with invocable methods. My Salesforce Flows course walks through record-triggered, screen, scheduled and platform event flows with real-world challenges, which makes the declarative half of these questions much easier.
Hope this helps!
Best,
Nick