Salesforce Platform Developer II Certification Study Guide (2026): Exam Outline, Apex Patterns and Practice Questions

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.

SectionWeightApprox. questions
Advanced Developer Fundamentals15%~9
Process Automation, Logic, and Integration27%~16
User Interface20%~12
Testing, Debugging, and Deployment20%~12
Performance18%~11
Exam factDetail
Official nameSalesforce Certified Platform Developer II (often shortened to PDII or PD2)
Format60 multiple-choice/multiple-select questions, plus up to 5 unscored questions
Time120 minutes
Passing score70% (42 of 60)
FeeUS$200 to register, US$100 to retake, plus applicable taxes
PrerequisiteSalesforce Certified Platform Developer (formerly Platform Developer I)
SuperbadgesNot required since October 1, 2025; the exam alone earns the credential
Release alignmentWinter '24, per the current exam guide
DeliveryOnsite at a testing center or online proctored; no reference materials allowed
MaintenanceOne Platform Developer II maintenance module on Trailhead per year
Official resourcesExam guide, credential page and the official prep Trailmix

Platform Developer II exam weights60 scored questions, 120 minutes, 70% to passAdvanced Developer Fundamentals15%Process Automation, Logic, and Integration27%User Interface20%Testing, Debugging, and Deployment20%Performance18%Approximate scored questions: Fundamentals ~9, Automation and Integration ~16, UI ~12, Testing ~12, Performance ~11.nickfrates.com

Process Automation, Logic, and Integration is the biggest single section, but the other four are close together.

Contents

  1. Who this certification is for
  2. What changed for 2026
  3. How to use this guide
  4. The Apex transaction in one picture
  5. Advanced Developer Fundamentals (15%)
  6. Process Automation, Logic, and Integration (27%)
  7. User Interface (20%)
  8. Testing, Debugging, and Deployment (20%)
  9. Performance (18%)
  10. Deep dive: choosing the right asynchronous tool
  11. Worked example: an end-to-end order sync feature
  12. Hands-on checklist
  13. Common exam traps
  14. Flashcard terms
  15. Quick-reference cheat sheet
  16. Frequently asked questions
  17. 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 materialCurrent name or replacement
Platform Developer I (PD1)Salesforce Certified Platform Developer
sfdx force:... commandsThe unified sf CLI (sf project deploy start, sf org create scratch)
Advanced Apex Specialist superbadge (required)No longer required; exam only
Superbadge unitsSuperbadges (shorter, optional skill assessments)
getRecordNotifyChange()notifyRecordUpdateAvailable() in lightning/uiRecordApi
Named credentials with embedded authNamed 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.

  1. 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.
  2. Read one section at a time. Each ends with a key takeaway and practice questions.
  3. 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.
  4. 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 Platform Developer II planFor developers who already hold Platform DeveloperWEEK 1Transaction and triggersOrder of execution, governor limits,trigger framework, bulkification,recursion, error handling andsavepoints.WEEK 2FundamentalsApex managed sharing, sharingkeywords, user mode, localization,multi-currency, custom metadata andcustom settings.WEEK 3Async and integrationQueueable, batch, schedulable,future, platform events, REST andSOAP callouts, Apex REST, namedcredentials.WEEK 4User interfaceLWC with LDS and Apex, events,Lightning Message Service, responsivelayouts, Aura basics, Visualforce Ajax.WEEK 5Testing and deploymentCallout mocks, Stub API, async tests,Jest, debug logs, Replay Debugger, sfCLI, scratch orgs, packages.WEEK 6Performance and reviewSelective queries, Query Plan, codeinefficiencies, Continuation, practicequestions, cheat sheet.nickfrates.com

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:

  1. 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).
  2. Run before-save record-triggered flows.
  3. Run before triggers.
  4. Run system validation again plus custom validation rules.
  5. Run duplicate rules.
  6. Save the record to the database, but don't commit.
  7. Run after triggers.
  8. 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.
  9. Run escalation rules, then after-save record-triggered flows, then entitlement rules.
  10. Update roll-up summary fields and cross-object formulas on parents, which runs the parent through its own save.
  11. Evaluate criteria-based sharing.
  12. Commit everything.
  13. Run post-commit logic: send emails, start queued asynchronous Apex, and publish platform events configured to publish after commit.

The Apex transaction and order of executionOne request, one transaction, one set of governor limits1Load and validateOriginal record, new values,system validation2Before-save flowsFast same-record fieldupdates3Before triggersChange Trigger.new withoutDML4Validation rulesSystem and customvalidation, then duplicaterules5Save, no commitRecord written; IDs available6After triggersRelated records; Trigger.newis read-only7Workflow and rulesAssignment, auto-response,workflow field updatesre-fire triggers once8After-save flowsThen entitlement rules9Roll-ups to parentsParent runs its own save10SharingCriteria-based sharingevaluated11CommitAll or nothing12Post-commitEmails, queued async Apex,publish-after-commit eventsAsync jobs start after the commit, in new transactions with their own limitsnickfrates.com

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)SynchronousAsynchronous
SOQL queries100200
Records retrieved by SOQL50,00050,000
DML statements150150
Records processed by DML10,00010,000
CPU time10,000 ms60,000 ms
Heap size6 MB12 MB
Callouts (HTTP or web service)100100
Cumulative callout timeout120 seconds120 seconds
System.enqueueJob calls501
Future method calls5050 from queueable; 0 from batch or future
SOSL queries2020

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() and Decimal.format() use the context user's locale. UserInfo.getLocale(), UserInfo.getLanguage(), UserInfo.getTimeZone() and UserInfo.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_Submitted in Apex, $Label in 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/currency and friends, and use base components such as lightning-formatted-number and lightning-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 Opportunity returns the amount in the record's currency. Don't add amounts from records in different currencies without converting them.
  • SELECT convertCurrency(Amount) FROM Opportunity returns the amount converted to the user's currency, using the org's conversion rates.
  • FORMAT(Amount) and FORMAT(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 CurrencyIsoCode explicitly if the default (the running user's currency) isn't right. Integration users are a classic source of wrong currencies.
RequirementTool
Show a date or number in the user's formatformat() methods in Apex, FORMAT() in SOQL, lightning-formatted-* base components
Show translated picklist valuestoLabel(field) in SOQL
Show translated messages and button textCustom labels plus the Translation Workbench
Show an amount in the user's currencyconvertCurrency(field) in SOQL
Find the running user's locale or currencyUserInfo methods; @salesforce/i18n/* in LWC
Historical exchange rates for opportunitiesAdvanced 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 fieldMeaning
ParentIdThe record being shared (for standard objects the field name is object-specific, such as AccountId on AccountShare)
UserOrGroupIdThe user, public group, role group or queue that gets access
AccessLevelRead or Edit (All is reserved for owners and admins; standard objects use fields such as AccountAccessLevel)
RowCauseWhy 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 as Schema.Project__Share.RowCause.Assigned_Team__c.
  • Shares with an Apex sharing reason survive an owner change. Shares with the Manual row cause are deleted when the record owner changes. On standard objects, Apex can only create shares with Manual row cause, so code must recalculate them after ownership changes.
  • Recalculation. You can attach an Apex class that implements Database.Batchable to 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 sharing enforces record sharing for the running user, without sharing ignores it, and inherited sharing uses the caller's mode (and runs as with sharing when 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 sharing controls which records a user sees, not which objects and fields. For object and field security, use WITH USER_MODE queries, AccessLevel.USER_MODE DML or Security.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());
            }
        }
    }
}

Apex managed sharing on a custom objectA share record is who, what access, and whyRECORDProject__cOrg-wide default: Private. Owner,plus a Delivery_Lead__c lookup to auser.SHARE OBJECTProject__ShareParentId = project.IdUserOrGroupId = project.Delivery_Lead__cAccessLevel = ‘Edit’RowCause = Delivery_Lead__cOWNERFrom ownershipCreated by the platformfor the record owner.MANUALRemoved on ownerchangeUsers or Apex on standardobjects. Recalculate aftertransfers.RULEFrom sharing rulesOwner- or criteria-basedrules managed by theplatform.APEX SHARING REASONSurvives ownerchangeCustom objects only.Recalculate with a batchclass.nickfrates.com

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 areMetadataData
Deploy records with change sets, packages, the CLIYesNo (definitions only; load records separately)
Read in Apex without using SOQL limitsgetAll(), getInstance(); SOQL on them also doesn't count unless it selects long text area fieldsgetInstance(), getValues(), getOrgDefaults(), getAll() (cached)
Change records from ApexNot with DML; use the Metadata namespace to deploy changes asynchronouslyYes, with normal DML
Per-user or per-profile valuesNo built-in hierarchyYes, with hierarchy custom settings
RelationshipsMetadata relationship fields to other metadata, objects and fieldsNone
Visible in tests without SeeAllDataYesNo; create the records in the test
Best forApp configuration that moves between orgs: mappings, routing tables, feature switches, integration endpoints by environmentValues 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;

Where configuration should liveTwo questions: does it deploy with code, and does it differ by user?DEPLOYS WITH METADATACustom metadata typesMappings, routing tables, feature switches, endpoint pathsby environment. Read with getAll() and getInstance() withoutSOQL limits. Visible in tests.VARIES BY USER OR PROFILEHierarchy custom settingsTrigger bypass flags, per-user defaults, values Apex updatesat run time with DML. Records are data and must be loadedper org.TRANSLATED TEXTCustom labelsMessages and UI text, translated with the TranslationWorkbench. System.Label in Apex, @salesforce/label in LWC.ENDPOINTS AND SECRETSNamed credentialsCallout URLs and authentication through externalcredentials and permission sets. Use callout: URLs; neverhard-code secrets.nickfrates.com

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 Opportunity so amounts return in the user's currency
  • B. Divide each amount by the corporate currency conversion rate in Apex
  • C. Change each opportunity's CurrencyIsoCode to 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__Share records with a custom Apex sharing reason
  • D. Apex that inserts Engagement__Share records with RowCause set to Manual

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 sharing to the class
  • B. Add WITH USER_MODE to the query
  • C. Pass the query results through Security.stripInaccessible(AccessType.READABLE, contacts) and return getRecords()
  • 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 as Account) in the same transaction. Move one of them to a @future or queueable method, or use System.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 Set of IDs, build a Map, 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 (a System.TriggerOperation enum) or the context booleans to route logic, and Trigger.oldMap to 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);
                }
            }
        }
    }
}

A thin trigger and a handler frameworkThe trigger routes, the handler decides, the service does the work1One triggerOne per object, allevents, no logic. Callsthe handler.new Handler().run();2HandlerChecks the bypasssetting and recursionguard, routes byTrigger.operationType,filters changed records.3ServicesBulkified business logic,reusable from LWC, REST,batch and Flow.4Selectors and DMLOne query per need withWITH USER_MODE; oneDML statement per list.Before triggers: same-record fields, no DML. After triggers: related records and anything that needs the ID.nickfrates.com

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:

ToolWhat it doesUse it when
try / catch / finallyCatches exceptions so you can log, recover or rethrowYou can handle the failure meaningfully
Custom exceptionspublic 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.SaveResultBulk loads where one bad row shouldn't block the rest
Database.setSavepoint() / Database.rollback(sp)Rolls back DML done after the savepointSeveral dependent DML steps must succeed or fail together
addError()Fails a specific record (or field) in a trigger with a messageValidation that needs code
AuraHandledExceptionSends a readable message to a Lightning componentReturning errors from @AuraEnabled methods
Transaction finalizersSystem.Finalizer attached to a queueable runs after the job, even if it failsLogging, retries or cleanup for async work
Publish-immediately platform eventsEvent is published even if the transaction rolls backError 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));
        }
    }
}

Transaction control in ApexAll or nothing, best effort, or a checkpoint you can return toALL OR NONEinsert records;One failure throws a DmlExceptionand fails the whole list. Default forDML statements.PARTIAL SUCCESSDatabase.insert(records, false)Good records save. Inspect eachDatabase.SaveResult and log thefailures.CHECKPOINTSavepoint and rollbackDatabase.setSavepoint() thenDatabase.rollback(sp) for dependentsteps. Counts as DML; IDs are notcleared.ONE RECORDrecord.addError()Fails a specific record or field in atrigger with a message the user sees.TO THE UIAuraHandledExceptionSends a readable message from@AuraEnabled methods to LWC andAura.CAN’T BE CAUGHTLimitExceptionGovernor limit breaches end thetransaction. Prevent them; you cannotcatch them.nickfrates.com

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:

KeywordWhat it does
FOR UPDATELocks 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_MODERuns the query with the user's sharing, object and field permissions (or in system mode); recommended over WITH SECURITY_ENFORCED
WITH SECURITY_ENFORCEDThrows an exception if the query references fields or objects the user can't access (older; doesn't enforce sharing)
GROUP BY ROLLUP / GROUP BY CUBEAdds subtotal rows (and cross-tab subtotals for CUBE); GROUPING() tells you which rows are subtotals
HAVINGFilters on aggregate results, like WHERE for groups
TYPEOFSelects different fields depending on the type of a polymorphic field such as What or Owner
Semi-join / anti-joinWHERE Id IN (SELECT AccountId FROM Opportunity) and NOT IN to filter by related records
ALL ROWSIncludes deleted and archived records (Apex only)
FOR VIEW / FOR REFERENCEUpdates the LastViewedDate or LastReferencedDate of returned records
OFFSETSkips rows for paging; maximum offset 2,000
USING SCOPEFilters 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:

ToolBest forKey facts
@futureSimple fire-and-forget work, such as a callout from a trigger or avoiding mixed DMLStatic 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
QueueableThe default choice for async work that needs sObject or complex parameters, chaining or monitoringSystem.enqueueJob() returns a job ID; one child job per queueable; implement Database.AllowsCallouts for callouts; attach a Finalizer for failures
Batch ApexProcessing large data volumes in chunksstart (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
SchedulableRunning something at a specific time or on a recurring scheduleCron 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());
        }
    }
}

Four asynchronous Apex toolsHigher limits, new transaction, starts after the commitSIMPLE@futureStatic void, primitiveparameters only. Nochaining, no job ID.Callouts with callout=true.Not from batch or future.DEFAULT CHOICEQueueablesObject and complexparameters, a job ID, onechained child per run,Database.AllowsCallouts,and finalizers for failures.LARGE VOLUMESBatch Apexstart, execute, finish.QueryLocator up to 50million records, scope 200by default (max 2,000).Database.Stateful fortotals.TIME-BASEDSchedulableCron expression withSystem.schedule(). Usuallystarts a batch orqueueable. Up to 100scheduled jobs per org.nickfrates.com

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.

NeedDynamic Apex feature
List an object's fields, labels or picklist valuesSchema.SObjectType.Account.fields.getMap(), DescribeFieldResult, getPicklistValues()
Describe objects by nameSchema.describeSObjects(new List<String>{ 'Account' }) (cheaper than Schema.getGlobalDescribe() when you know the names)
Find record type IDs without SOQLSchema.SObjectType.Case.getRecordTypeInfosByDeveloperName().get('Support').getRecordTypeId()
Build a query at run timeDatabase.query(soql) with bind variables, or Database.queryWithBinds(soql, bindMap, AccessLevel.USER_MODE)
Read or set a field by namerecord.get('Field__c'), record.put('Field__c', value), record.getSObject('Account')
Create an instance of a class by nameType.forName('DiscountStrategyEU').newInstance() (strategy or factory pattern driven by custom metadata)
Check access dynamicallyDescribeSObjectResult.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 returns Database.SaveResult objects and counts against the DML statement limit. Flows, processes and external apps through APIs can publish too.
  • Subscribe with an Apex trigger (after insert only), 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. Throw EventBus.RetryableException to 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;
}

Platform events: publish and subscribePublishers do not know who is listeningPUBLISHERSSUBSCRIBERSApexEventBus.publish() returnsSaveResults; counts as DMLFlowCreate Records on the event objectExternal appsREST, SOAP or Pub/Sub APIEvent busOrder_Shipped__ePublish After Commit:business eventsPublish Immediately: loggingApex triggerafter insert; batches up to 2,000;checkpoints and retriesEvent-triggered flowDeclarative subscriberLWC with empApiLive updates in the UIExternal systemsPub/Sub API or CometDnickfrates.com

Publishers don't know who's listening. That's the point.

Integration: inbound and outbound. Know the Apex side of both directions.

DirectionTechniqueKey facts
Outbound RESTHttpRequest, Http.send(), HttpResponseUse 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 SOAPWSDL2Apex generates a proxy class from a WSDLTest with WebServiceMock; large or complex WSDLs may need editing
Outbound, declarativeExternal Services (OpenAPI spec to invocable actions), outbound messages, platform eventsLess code for admins; flows can call the generated actions
Long-running callouts from UIContinuation (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, @HttpDeleteRestContext.request and RestContext.response; runs as the authenticated user
Inbound SOAPwebservice methods in a global classSalesforce generates a WSDL for the class
Inbound, standard APIsREST, SOAP, Bulk API 2.0, Composite, Pub/Sub APIPrefer 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];
    }
}

Inbound and outbound integration from ApexStandard APIs first; custom code when you need custom logicOutbound: Salesforce calls outInbound: systems call SalesforceREST calloutsHttpRequest and Http.send() tocallout:Named_Credential/path. 100 callouts, 120 s pertransaction.SOAP calloutsWSDL2Apex proxy classes. Test with WebServiceMock.DeclarativeExternal Services, outbound messages, platform events.From the UIContinuation for slow services. Async jobs for backgroundwork.Standard APIsREST, SOAP, Composite, Bulk API 2.0 and Pub/Sub API. Trythese first.Apex REST@RestResource(urlMapping) on a global class with @HttpGet,@HttpPost and friends; RestContext.Apex SOAPwebservice methods in a global class; Salesforce generates theWSDL.SecurityRuns as the authenticated user. Use with sharing and WITHUSER_MODE.No callouts after uncommitted DML, and no synchronous callouts from triggersnickfrates.com

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 CalloutException is 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.Stateful and keep the failure count in an instance variable
  • C. Query the count of failures in the finish method using ALL 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 set Priority on Trigger.new without 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() with AccessLevel.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 = 404 before 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:

NeedUseWhy
Read or edit one record's fields with standard layout and securitylightning-record-form, lightning-record-view-form, lightning-record-edit-formLightning 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*ApiLDS cache shared across components; updates automatically
Create, update or delete one record in JavaScriptcreateRecord, updateRecord, deleteRecordLDS 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 triggersCall 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));
        }
    }
}

Getting data into a Lightning web componentUse the lightest tool that does the job1Base recordcomponentslightning-record-form,view-form and edit-form.No JavaScript data code,LDS handles security.2LDS wire adaptersgetRecord, getObjectInfo,getPicklistValues,updateRecord. Sharedcache acrosscomponents.3Wired ApexApex markedcacheable=true,read-only. Reactive $parameters.refreshApex() to re-fetch.4Imperative ApexDML, callouts,user-triggered actions.Returns a promise. Thenrefresh caches.After changes outside LDS: refreshApex(wiredResult) and notifyRecordUpdateAvailable(recordIds)nickfrates.com

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 AuraHandledException with a readable message. Other uncaught exceptions reach the client with a generic message, and the details stay hidden.
  • LWC: show a toast with ShowToastEvent for action results, render inline error text in the template for load errors, and use lightning-messages inside lightning-record-edit-form to display save errors (including validation rule messages) automatically. Field-level errors from addError() on a field show next to that field in record forms.
  • Validation in the browser: reportValidity() and setCustomValidity() on lightning-input give 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 with lightning:notificationsLibrary or a toast event.

Communicating between components. Match the relationship to the tool:

RelationshipLightning web componentsAura components
Parent to childSet a public @api property, or call a public @api method on the childSet attributes; call aura:method
Child to parentDispatch a CustomEvent (with detail); parent listens with on<eventname> in markupComponent event (aura:registerEvent and aura:handler)
Event across shadow boundariesnew CustomEvent('select', { bubbles: true, composed: true }) (use sparingly)Component events bubble through containment
Unrelated components on the same page, including Visualforce and AuraLightning Message Service (lightning/messageService) with a message channelLightning Message Service (lightning:messageChannel) or application events
Server-pushed updateslightning/empApi to subscribe to platform events or change eventslightning: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 });
}

How Lightning components communicateProperties down, events up, message channels acrossPARENT TO CHILD@apiSet a public property orcall a public method onthe child component.CHILD TO PARENTCustomEventdispatchEvent(newCustomEvent(‘orderselected’,{ detail })) andonorderselected in theparent.ACROSS THE PAGEMessage Servicelightning/messageServicewith a message channel.Works across LWC, Auraand Visualforce.FROM THE SERVERempApiSubscribe to platformevents and change eventsfor live updates.nickfrates.com

Properties down, events up, message channels across.

Responsive design by form factor. Questions ask what makes markup adapt to phones, tablets and desktops:

  • lightning-layout and lightning-layout-item with size, small-device-size, medium-device-size and large-device-size (out of 12 columns), plus multiple-rows. SLDS grid utility classes such as slds-size_1-of-1 slds-medium-size_1-of-2 do the same with CSS.
  • import FORM_FACTOR from '@salesforce/client/formFactor' returns Large, Medium or Small, so JavaScript can choose a different layout or template.
  • In the component's js-meta.xml, supportedFormFactors controls which page types and devices can use the component in Lightning App Builder.
  • In Aura, $Browser.formFactor (and $Browser.isPhone, $Browser.isTablet) and lightning:layoutItem attributes play the same roles.

Visualforce: actions, partial refreshes and async. Visualforce is still on the exam. Know these components:

Component or featureWhat 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 attributeRefreshes 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
ContinuationMakes a long-running callout asynchronously, with a callback method when the response arrives
transient keywordKeeps 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=true from 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 Map instead of a List

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 CustomEvent named productselected with the product ID in detail, and handle it with onproductselected in 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-item with size="12" and large-device-size="4"
  • B. Import @salesforce/client/formFactor and switch layouts based on its value
  • C. Create a separate Visualforce page for mobile
  • D. Set isExposed to false in the js-meta.xml file

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.

ScenarioTool
REST or HTTP calloutImplement HttpCalloutMock (respond(HttpRequest)) and register it with Test.setMock(HttpCalloutMock.class, new MyMock())
HTTP callout with a fixed response bodyStaticResourceCalloutMock (one static resource) or MultiStaticResourceCalloutMock (one per endpoint)
SOAP callout from WSDL2Apex classesImplement WebServiceMock (doInvoke(...)) and register it with Test.setMock(WebServiceMock.class, ...)
Replace a dependency class with a fakeThe Stub API: implement System.StubProvider and create the stub with Test.createStub(MyService.class, provider)
SOSL resultsTest.setFixedSearchResults(ids)
Platform eventsPublish in the test; events are delivered at Test.stopTest(), or immediately with Test.getEventBus().deliver()
Change Data CaptureTest.enableChangeDataCapture(), then Test.getEventBus().deliver()
Callouts after test data DMLDo 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));
    }
}

Test doubles for ApexFake the boundary, test your logicHTTP CALLOUTSHttpCalloutMockTest.setMock(HttpCalloutMock.class,mock). Static resource mocks for fixedbodies.SOAP CALLOUTSWebServiceMockdoInvoke() fills the response forWSDL2Apex classes.DEPENDENCIESStub APISystem.StubProvider andTest.createStub(). Needs dependencyinjection; no static methods.ASYNC CODEstartTest / stopTestFresh limits; queued queueable,future and batch work runs atstopTest().EVENTSTest.getEventBus()deliver() sends platform and changeevents to subscribers immediately.USERS AND DATArunAs and @TestSetupTest as specific users; create dataonce per class; no SeeAllData.nickfrates.com

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:

SymptomTool or technique
"Something" fails, no idea whereDebug 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 failureApex 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 pointDeveloper Console checkpoints and heap dumps
Limit exception or slow transactionLimits 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 runTests depending on org data (SeeAllData), hard-coded IDs, test order, or shared static state
Async job failed silentlySetup: Apex Jobs, AsyncApexJob queries, BatchApexErrorEvent for batch classes that implement Database.RaisesPlatformEvents, and finalizers on queueables
Platform event trigger stoppedIts 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 mechanismUse 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 orgsDisposable, source-tracked orgs created from a definition file and a Dev Hub (sf org create scratch); ideal for feature development and CI
SandboxesDeveloper, Developer Pro, Partial Copy and Full; testing with production-like metadata and data
Unlocked packagesModular, versioned deployments of your own org's code from source control (sf package create, sf package version create)
Second-generation managed packagesDistributing apps to other orgs, such as AppExchange partners
Metadata API with package.xml and destructiveChanges.xmlDeploying or deleting specific components, and CI tools
Change setsPoint-and-click deployments between connected orgs; no source control or deletions
DevOps CenterSource-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-driven deploymentSource control is the source of truth1DevelopScratch orgs ordevelopersandboxes, sf CLI,VS Code2CommitGit branch and pullrequest review3ValidateCI runs sf projectdeploy validate withRunLocalTests4TestPartial or fullsandbox for UATand performance5ReleaseQuick deploy within10 days, or install apackage versionProduction: 75% overall Apex coverage, and every trigger needs some coveragenickfrates.com

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=true to the test
  • B. Move Test.startTest() and Test.setMock() after the account insert, run the callout code between Test.startTest() and Test.stopTest()
  • C. Remove the account insert and hard-code an account ID
  • D. Make the callout from a @TestSetup method

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() on TaxCalculator without changing InvoiceService
  • B. Refactor InvoiceService to receive a TaxCalculator (or an interface) through its constructor, then pass a stub created with Test.createStub() in the test
  • C. Add if (Test.isRunningTest()) branches throughout TaxCalculator
  • D. Make TaxCalculator methods static

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 execute method in an empty try/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.debug statements 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-datatable with enable-infinite-loading and onloadmore), and load hidden tabs or sections only when the user opens them (lwc:if instead 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 renderedCallback light, 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 transient and by not storing large collections, use StandardSetController for pagination, JavaScript remoting for fast stateless calls, and readOnly="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 typeSelective 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.

InefficiencyFix
SOQL or DML inside a loopQuery once with a Set of IDs; collect records and do one DML per list
Nested loops to match recordsBuild 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 callsCache describe results in a static variable; avoid Schema.getGlobalDescribe() in loops
Same query run many times in one transactionCache results in a static map, or use Platform Cache across transactions
Non-selective filter on a large objectFilter on indexed fields, request a custom index, avoid negative operators and leading wildcards
Synchronous callout makes users waitContinuation from the UI, or queueable callouts for background work
Heavy logic in a synchronous triggerMove non-urgent work to a queueable or platform event subscriber
Recalculating values on every trigger runAct only when relevant fields change (compare with Trigger.oldMap)
Rolling up child counts in code that locks the parentConsider roll-up summary fields, or batch updates ordered by parent to reduce lock contention

Selective queries on large objectsIndex the filter, keep the result small, prove it with the Query PlanSTANDARD INDEX30% / 15%Of the first million records, then ofrecords beyond, up to 1 million. Id,Name, OwnerId, CreatedDate,SystemModstamp, RecordTypeId,lookups, unique and external IDs.CUSTOM INDEX10% / 5%Of the first million records, then ofrecords beyond, up to 333,333.Requested from Support or onexternal ID and unique fields.NOT SELECTIVEAvoid theseNegative operators (!=, NOT IN),leading wildcards (LIKE ‘%term’),non-deterministic formulas, most nullcomparisons.Query Plan cost below 1 = selective. Triggers on objects over 200,000 records need selective queries.nickfrates.com

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 with Type.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 extends and 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 on Status__c
  • B. Add ORDER BY CreatedDate
  • C. Add ALL ROWS
  • D. Move the query into a try/catch block

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 a Continuation
  • 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.debug statements 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:

  1. 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.)
  2. Does it process more records than one transaction can handle (tens of thousands to millions)? Use Batch Apex with a QueryLocator. Add Database.Stateful if you need totals across chunks, and Database.AllowsCallouts for callouts per chunk.
  3. 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.
  4. Is it a simple, fire-and-forget call with primitive parameters, such as a callout from a trigger or a mixed DML workaround? @future works, though a queueable is usually the better long-term choice.
  5. Do several independent subscribers need to react, possibly outside Salesforce? Publish a platform event and let each subscriber process it in its own transaction.
  6. Is a user waiting on a slow callout? Use a Continuation, not a background job, because the user needs the answer.

Choosing the right asynchronous toolStart with when and how much, then how complex1Must it run at a set time or on a schedule?yesSchedulable2Is it more data than one transaction can handle?yesBatch Apex3Chaining, sObject parameters, a job ID or failure handling?yesQueueable4Simple fire-and-forget work with primitive parameters?yes@future5Several independent subscribers, maybe outside Salesforce?yesPlatform event6Is a user waiting on a slow callout?yesContinuationnickfrates.com

Start with "when" and "how much," then "how complex."

FeatureFutureQueueableBatchSchedulable
ParametersPrimitives and collections of primitivesAny serializable member variables, including sObjectsDefined in the classDefined in the class
Returns a job ID to monitorNoYesYesYes (CronTrigger ID)
ChainingNoOne child job per executionStart another batch from finishStart other jobs from execute
Callouts@future(callout=true)Database.AllowsCalloutsDatabase.AllowsCalloutsNot directly; start an async job that calls out
VolumeSmallSmall to mediumUp to 50 million records with QueryLocatorDepends on what it starts
Failure handlingCheck AsyncApexJobFinalizersBatchApexErrorEvent with Database.RaisesPlatformEventsCheck CronTrigger and the jobs it starts
TestingRuns 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.

Worked example: Summit Outfitters order syncEvery exam section in one feature1ConfigurationCustom metadata perenvironment, namedcredential, bypass setting2SharingPrivate OWD, Apex sharingreason for account teams3TriggerThin trigger and handlerenqueue SyncOrderJob onSubmitted4Queueable calloutAllowsCallouts, WITHUSER_MODE, finalizer retries5Apex REST inERP posts shipments;publish Order_Shipped__eafter commit6LWCLDS and wired Apex, empApi,refreshApex, toasts,responsive layout7TestsCallout mock,startTest/stopTest, eventdelivery, Stub API, Jest8Scale and releaseSelective nightly batch,unlocked package, quickdeploynickfrates.com

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.

  1. 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.
  2. 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.
  3. Create a custom metadata type with two records and read them with getAll() and getInstance() in Apex and in a test without SeeAllData.
  4. Write a queueable that makes a callout through a named credential, attach a finalizer, and test it with an HttpCalloutMock.
  5. Write a batch class with Database.Stateful that counts records across chunks, schedule it with a cron expression, and test it.
  6. Define a platform event, publish it from Apex, subscribe with a trigger that uses setResumeCheckpoint(), and test it with Test.getEventBus().deliver().
  7. Expose an Apex REST resource with @HttpGet and @HttpPost, then call it with Postman or the Salesforce CLI.
  8. Build an LWC that wires a cacheable Apex method, updates records imperatively, calls refreshApex(), and shows errors with a toast.
  9. Add a child-to-parent custom event and a Lightning Message Service channel between two unrelated components.
  10. Build a Visualforce page with actionSupport, actionRegion, actionStatus and a rerender target, and a JavaScript remoting call.
  11. Use the Stub API to replace a dependency in a unit test.
  12. Write a Jest test for your LWC and run it with npm run test:unit.
  13. Capture a debug log for a failing transaction and step through it with the Apex Replay Debugger.
  14. Run a query through the Query Plan tool and compare an indexed filter with a negative filter.
  15. Create a scratch org, deploy with sf project deploy start, validate a deployment with RunLocalTests, 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. @wire needs cacheable=true, and cacheable methods can't do DML.
  • with sharing isn't FLS. Use WITH USER_MODE, user-mode DML or stripInaccessible() 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.Stateful for totals.
  • Future methods take primitives. Pass IDs and query inside the method.
  • One child queueable per execution, and only one enqueueJob from 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 force commands, 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_MODE queries and as user DML 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.StubProvider and Test.createStub() for replacing dependencies.
  • Lightning Data Service: the client-side cache and data layer behind record forms and ui*Api adapters.
  • 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

TopicRemember
Format60 scored + up to 5 unscored, 120 min, 70% to pass, US$200, retake US$100
PrerequisitePlatform Developer (formerly PD1); no superbadge required since October 1, 2025
Largest sectionProcess Automation, Logic, and Integration 27%
TransactionOne request, one transaction, one set of limits; async starts after commit
TriggersOne per object, logic-less, bulkified, before for same record, after for related
SecuritySharing keyword for records; WITH USER_MODE or stripInaccessible() for CRUD and FLS
SharingApex sharing reasons on custom objects survive owner changes
ConfigurationCustom metadata deploys; hierarchy custom settings vary by user or profile
ErrorsSavepoints for all-or-nothing, Database.insert(list, false) for partial success, AuraHandledException for UI
AsyncQueueable by default, batch for volume, schedulable for time, future only for simple cases
Platform eventsAfter commit for business events, immediately for logging; checkpoint and retry in triggers
IntegrationNamed credentials; no callouts after DML; Continuation for slow UI callouts
LWC dataLDS first; wire cacheable reads; imperative for DML; refreshApex() after changes
EventsProperties down, events up, Lightning Message Service across
TestingstartTest/stopTest, HttpCalloutMock, Stub API with injection, Jest for LWC
Deploymentsf CLI, scratch orgs, unlocked packages, RunLocalTests, quick deploy within 10 days
PerformanceSelective filters on indexes, maps not nested loops, SOQL for loops for heap

Platform Developer II quick referenceReview the night before your examFORMAT60 + up to 5120 min, 70% to pass, US$200BIGGEST SECTIONLogic 27%Automation, logic and integrationTRIGGERSOne per objectLogic-less, bulkified, before for samerecordSECURITYWITH USER_MODESharing keyword is records only, notFLSASYNCQueueable firstBatch for volume, schedule for timePLATFORM EVENTSAfter commitImmediately only for loggingCALLOUTSNo DML firstNamed credentials; Continuation forslow UILWC DATALDS, then ApexWire cacheable reads; refreshApexafterQUERIESCost below 1Indexed, positive filters; maps notloopsnickfrates.com

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.

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