Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Writing, reviewing, and deploying Apex, SOQL/SOSL, triggers, and Lightning Web Components on the Salesforce platform — trigger handlers (before/after, one-trigger-per-object), bulkification against governor limits (100 SOQL / 150 DML / 10s CPU), synchronous vs async Apex (@future, Queueable, Batch, Schedulable), LWC decorators and lifecycle, FLS/sharing enforcement in code, test classes to the 75% gate, and SFDX deployment. Use when building or reviewing Apex/trigger/SOQL/LWC code. Not declarati
| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-12 | ✗→✓ | ▲ Improved | 412% | 0% |
| case-10 | ✗→✓ | ▲ Improved | 284% | 0% |
| case-07 | ✓→✓ | = Same ✓ | 505% | 0% |
| case-01 | ✓→✓ | = Same ✓ | 281% | 0% |
| case-02 | ✓→✓ | = Same ✓ | 327% | 0% |
The Salesforce Certified Platform Developer I (PD1) credential validates that a developer understands how to build, deploy, and maintain custom business logic and user interfaces on the Lightning Platform using programmatic tools. It covers the full Apex development lifecycle — data modeling, SOQL/SOSL queries, trigger design, asynchronous processing, Lightning Web Components, Visualforce, unit testing, and deployment tooling — alongside an understanding of when declarative tools (Flow, validation rules, formula fields) are preferable to code.
This file is an operational playbook, not an exam outline. Each section states the rule as an actionable instruction with concrete limits, decision criteria, anti-patterns to catch in review, and live-org verification steps. Always verify structural assumptions against the live org — SFDX metadata can lag, and FLS/picklist state is not captured in XML.
> Load this skill when… writing or reviewing Apex triggers, SOQL/SOSL queries, or LWC components; debugging governor-limit errors or FLS/sharing issues in code; designing trigger handlers or async Apex patterns; deploying SFDX metadata and hitting field-permission or deployment errors. > Not this skill: declarative-only config (profiles, flows, sharing rules) → see salesforce-administrator; advanced async/integration/LDV patterns or design patterns → see salesforce-platform-developer-2; LWC front-end JavaScript deep dives → see salesforce-javascript-developer-1.
> Verify steps assume nothing about your tooling — use your project's Salesforce MCP connection, the Salesforce CLI (sf), or the Salesforce setup UI, in that order of preference.
Credential logistics and study path: see references/study-resources.md.
[volatile — verify live]; sandbox storage sizes [volatile — verify live]; LWC lifecycle API changes and decorator behavior across API versions [volatile — verify live]; any coverage threshold or deployment requirement.without sharing to a class that processes PII or financial data (v67+ note: without sharing is now an explicit opt-in, not the default); disabling or bypassing a package trigger framework in production.[volatile — verify live] or [opinion — house style]. When in doubt, describe the object or run a SOQL query rather than trusting repo XML.These are the hard ceilings the runtime enforces per transaction. Internalize the numbers; most "this will fail in bulk" decisions reduce to one of these.
| Limit | Synchronous | Asynchronous (batch/future/queueable) | |---|---|---| | SOQL queries | 100 [volatile — verify live] | 200 | | Rows retrieved by SOQL | 50,000 | 50,000 | | DML statements | 150 [volatile — verify live] | 150 | | Rows per DML / processed | 10,000 | 10,000 | | Heap size | 6 MB [volatile — verify live] | 12 MB | | CPU time | 10,000 ms [volatile — verify live] | 60,000 ms | | SOSL queries | 20 | 20 | | Callouts (HTTP/Web service) | 100 | 100 | | @future calls per transaction | 50 | n/a | | Queueable jobs enqueued (sync) | 50 | 1 (chaining) |
Apply it: managed-package triggers consume part of the SOQL/DML budget before your code runs. Treat the budget as shared.
Verify: query the actual record count first (SELECT COUNT() FROM Object__c WHERE ...) rather than assuming.
PD1 heavily tests whether a requirement can be met with clicks before code. Default to declarative; reach for Apex only when the requirement crosses what config can do.
| Requirement | Use | Not | |---|---|---| | Field must be non-blank / format-checked / cross-field compared on same record | Validation rule | Apex trigger | | Compute a read-only value from other fields on the same record (or parent via cross-object formula) | Formula field | Apex | | Roll up child values to a Master-Detail parent (SUM/COUNT/MIN/MAX) | Roll-up summary field | Apex trigger | | Roll up across a Lookup, or with complex criteria | Declarative Lookup Rollup Summary (DLRS) or Apex | hand-rolled trigger first | | Multi-step record updates, record creation, screen interaction, scheduled jobs | Flow | Apex (unless logic is too complex/bulk-heavy for Flow) | | Callouts, complex bulk transforms, recursion control, >50k-row processing, anything Flow can't bulkify safely | Apex | Flow |
Anti-pattern: writing a trigger to do what a validation rule or roll-up summary already does. Red flag in review: an Apex trigger whose entire job is "if field A is blank, throw an error" — that's a validation rule.
Watch for legacy automation. Workflow Rules and Process Builder reached end of support Dec 31 2025 (not retired — existing rules still fire). Use Flow or Apex for all new automation. An org may still contain live legacy automation — see Order of Execution.
| Type | Child required? | Cascade delete | Roll-up summary | Reparentable | Use when | |---|---|---|---|---|---| | Lookup | No (nullable) | Optional | No | Yes | Loose association; child can exist alone | | Master-Detail | Yes | Yes (always) | Yes | Optional (off by default) | Child is owned by parent; you need roll-ups; child inherits sharing | | Many-to-many | via junction | — | — | — | Two-sided association → junction object with two M-D | | Hierarchical | — | — | — | — | Self-reference, User object only |
Lookup relationshipName must be unique per parent object. Two Lookups on the same child pointing at the same parent cannot share a relationshipName — deploy fails with "Duplicate relationship name." Use role-specific suffixes (..._Primary, ..._Secondary).
Verify: describe the object to see actual relationship names before adding a new lookup.
An External ID field is unique + indexed and is the key for upsert and integration dedup — re-running the same payload updates rather than duplicates.
Deploying a custom field via SFDX field-meta.xml creates the field but grants FLS to no one — not even System Administrator. Without explicit <fieldPermissions> in a profile or permission set, SOQL on that field returns "Invalid field" even with full object access.
Required fields must be omitted from <fieldPermissions>. Salesforce rejects field permissions on a <required>true</required> field with "You cannot deploy to a required field". A field is either required (no FLS entry needed) or you must add an explicit FLS entry.
Verify before trusting a field is queryable: a SOQL query selecting the field is the definitive check. If it errors "Invalid field," the field exists but FLS is missing.
| Tool | Volume | Objects | Use when | |---|---|---|---| | Import Wizard | < 50k | standard + custom | one-off, UI-driven, dedup needed | | Data Loader (Bulk API 1.0) | up to 5M | all | bulk, CLI/scriptable | | Data Loader (Bulk API 2.0) | up to 150M [volatile — verify live] | all | very-large-volume, scheduled, preferred for new bulk work |
Collections: List for DML (ordered); Set<Id> to dedup before SOQL (WHERE Id IN :idSet); Map<Id, SObject> keyed by parent Id for O(1) in-loop lookups. Interfaces/inheritance: implements for contract, extends for inheritance; virtual/abstract/override modifiers; access: public, protected, global (default is private). Exceptions: try/catch(DmlException)/finally; custom types via extends Exception; Database.insert(records, false) for partial-success with SaveResult[] inspection.
Deep dive with worked examples: references/apex-oop-essentials.md — load when implementing collection algorithms, extending package frameworks, or handling DML partial-success.
Never put SOQL or DML inside a for loop. Query once into a Map keyed by Id, iterate in memory, collect results into a List, then issue one DML at the end. A trigger that does SOQL-in-loop hits the 100-query limit at ~100 records; DML-in-loop hits 150 statements.
Red flag in review: any [SELECT ...], Database.query, insert, update, upsert, or delete whose lexical position is inside a for/while body. Catch it on sight.
apex// WRONG — SOQL + DML in loop for (Contact c : Trigger.new) { Account a = [SELECT Id FROM Account WHERE Id = :c.AccountId]; // dies at scale update a; } // RIGHT — query once, DML once Set<Id> acctIds = new Set<Id>(); for (Contact c : Trigger.new) acctIds.add(c.AccountId); Map<Id, Account> accts = new Map<Id, Account>( [SELECT Id, Number_Of_Contacts__c FROM Account WHERE Id IN :acctIds]); for (Contact c : Trigger.new) accts.get(c.AccountId).Number_Of_Contacts__c++; update accts.values();
insert lst; → all-or-nothing; one bad row rolls back the whole list and throws DmlException.Database.insert(lst, false); → partial success; returns Database.SaveResult[]; inspect .isSuccess() / .getErrors() per row.Decision: use Database.insert(records, false) when partial success is acceptable; use bare insert when atomicity is required.
before insert/update → modify field values on the records being saved (no DML on Trigger.new needed). Lowest cost.after insert/update → access system-set fields (Id, CreatedDate, formula recalcs) and create/update related records.Trigger.new/old/newMap/oldMap, Trigger.isInsert/isUpdate/isDelete, Trigger.isBefore/isAfter. Trigger.old/oldMap are null on insert.Package coexistence: some managed packages own a trigger framework — register custom logic into it, never add a second raw trigger (e.g. NPSP/TDTM). See salesforce-nonprofit-cloud-consultant.
The complete sequence for a record save (Spring '22+):
@future, Queueable, Batch enqueued), Platform Event publishKey before-save flow insight: Before-save record-triggered Flows run at step 2 — before before-triggers and before validation rules. They can update fields on the record being saved without a DML statement. A before-trigger at step 3 can therefore be overwritten by subsequent automation; conversely, a before-save flow's changes are visible to the before-trigger.
Why it matters: a later step can silently overwrite a field your trigger just set. When a field mysteriously changes after your trigger, suspect a later step (workflow/flow/rollup) — confirm with an Apex debug-log probe.
Enforce target-field length and picklist constraints at your input-validation boundary, not via Apex truncation. Keep generated schema constants in sync with the org: regenerate whenever field metadata changes.
Verify: describe an object to get each field's length and picklist values — that is the source of truth.
SELECT Contact.Account.Owner.Name FROM Contact.SELECT Id, (SELECT Id FROM Children__r) FROM Parent__c.FIND 'term' IN ALL FIELDS RETURNING Contact(Id,Name)) for full-text search across multiple objects (20/transaction limit).Dynamic SOQL injection: use bind variables (:var), never concatenate user input. If you must concatenate, String.escapeSingleQuotes().
Verify a query against the live org — simultaneously confirms FLS, relationship names, and record match.
| Need | Use | Key constraints | |---|---|---| | Fire-and-forget after txn; HTTP callout from trigger context | @future (callout=true) | static void, primitive params only — no sObjects, no chaining, 50/txn | | Chainable jobs, hold sObject state, monitor via job Id | Queueable (implements Queueable) | 1 child chain depth in async; Database.AllowsCallouts for callouts | | >10k records, process in chunks | Batch (Database.Batchable<SObject>) | QueryLocator, batch size default 200; each chunk its own governor limits | | Time-based / recurring | Schedulable (implements Schedulable) | cron via System.schedule; often just enqueues a Batch |
Decision shortcut: callout from trigger → @future or Queueable. Chain/object state → Queueable. Huge volume → Batch. Recurring clock → Schedulable.
> API v67+ (Summer '26) breaking change — version-gated: [volatile — verify live] > - API v67.0+: A class with no sharing keyword declared now defaults to with sharing. Bypassing sharing requires an explicit without sharing declaration. > - API v66.0 and lower: Classes with no keyword still default to without sharing (old behavior unchanged for code compiled at ≤v66). > - The change is version-gated per class — bumping an existing class to v67 changes its runtime sharing mode. Test carefully before promoting. > - WITH SECURITY_ENFORCED is removed in v67; replace with WITH USER_MODE.
with sharing → enforces running user's record-level sharing.without sharing → ignores sharing. In API v66 and below, Apex defaults to without sharing if unspecified; in v67+ you must declare it explicitly to bypass sharing.inherited sharing → adopts the caller's sharing; safest default for reusable helper/service classes regardless of API version.WITH USER_MODE (v67+) or Security.stripInaccessible().| Framework | Use when | |---|---| | LWC | All new development on Lightning Experience / Salesforce Mobile / Experience Cloud | | Aura | Only when target doesn't support LWC, or you must contain an LWC (LWC-in-Aura is allowed; Aura-in-LWC is NOT) | | Visualforce | PDF generation (renderAs="pdf"), Classic orgs, legacy embeds | | Declarative (record forms, Quick Actions) | No custom logic needed — prefer this over building a component |
.html, .js, .js-meta.xml; optional scoped .css.@api (public, reactive, parent→child), @wire (reactive data binding to Apex/wire adapter), @track (deep mutation only; fields are reactive by default).constructor() → connectedCallback() (DOM not ready) → renderedCallback() (after render; guard against loops) → disconnectedCallback() → errorCallback().CustomEvent('name', {detail}); parent→child via @api; cross-tree via LMS (Lightning Message Service).@AuraEnabled(cacheable=true) for wired reads (no DML); @AuraEnabled (no cacheable) for mutations, called imperatively.Anti-pattern: DML in a cacheable=true method; unguarded state change in renderedCallback() causes infinite re-render.
Quick Action cache gotcha: adding fields to an existing Quick Action's quickActionLayoutItems via SFDX updates the metadata but the runtime QA cache does NOT invalidate — even after full logout/login. New fields silently don't render, no error. Fix: edit any non-field-list metadata on the QA (<description>, <label>) and redeploy; Salesforce treats it as a structural change and flushes the cache.
Use for PDF generation (renderAs="pdf"), Classic UI, and legacy embeds only — prefer LWC for all new UI. Key constraints: View State ≤ 170 KB (mark non-persistent fields transient); escape="false" requires explicit justification; {!JSENCODE()} for inline JS. Deep dive with controller types, XSS safety, and testing: references/visualforce.md.
[volatile — verify live] (measured across all Apex, not just new code). Every trigger must have some coverage.@isTest on class/methods; @testSetup runs once per class and rolls back after each test method.SeeAllData=false). Create all test data in the test.Test.startTest() / Test.stopTest(): reset governor limits; stopTest() forces enqueued async to run synchronously so you can assert results.HttpCalloutMock registered via Test.setMock(...).Assert.areEqual). Test single-record, bulk (200+ records), and negative paths. Never hardcode Ids.Managed-package caveat: package-specific automation only fires in tests if you explicitly enable it in test setup.
System.debug(LoggingLevel.ERROR, msg) — structured output; never log PII (names, addresses, DOB, medical, file contents). Log record Ids and user/subject Ids only.sf apex run, sf apex get log, sf apex tail log).| Tool | Use when | |---|---| | Salesforce CLI + DX source format (sf project deploy start / retrieve start) | Team dev, CI/CD, Git diff at field level | | Change Sets | Quick ad-hoc org↔org, no VCS, small changes | | Metadata API / package.xml | Scripted/enterprise, underlies all the above |
SFDX rules:
sfdx-project.json. All sf project ... commands must run from that directory or fail with InvalidProjectWorkspaceError.RunLocalTests (excludes managed-package tests) is the right CI default.Connected App / External Client App (ECA) gotchas:
connectedApp-meta.xml returns "You can't create a connected app." Pivot to an ECA.PermissionSetAssignment SOQL.field-meta.xml with the correct type, length, and API name.→ gate: sf sobject describe --sobject <object> — confirm the field appears in the schema output.
<fieldPermissions> (readable + editable) to the target permission set XML. Skip if the field is <required>true</required>.→ gate: confirm the permset XML contains the entry; confirm the field is not required (required fields cause deploy failure).
<description> or <label> to bust the runtime cache.→ gate: confirm field reference in layout XML.
sf project deploy start from the SFDX project root.→ gate: Deploy Succeeded; no missing-component errors.
SELECT <Field__c> FROM <Object__c> LIMIT 1.→ gate: no "Invalid field" error; query runs without exception.
→ gate: field value appears in the browser UI for a non-admin user who has the permset assigned.
before insert, after insert, etc.).→ gate: trigger compiles; no second raw trigger exists on the same object.
Map<Id, SObject> once. Compute results in memory. DML once after the loop.→ gate: code review confirms zero SOQL/DML inside loop bodies.
→ gate: unit test with an update that re-fires the trigger confirms the guard fires only once.
@testSetup creates data; one method tests single-record, one tests bulk (200+ records), one tests negative/error path. Wrap async paths in Test.startTest()/stopTest(). Assert concrete field values.→ gate: sf apex run test shows all test methods passing; no hardcoded Ids.
sf project deploy start --test-level RunLocalTests to the target org.→ gate: deploy output shows coverage ≥ 75% org-wide; every trigger has > 0% coverage.
<fieldPermissions>sfdx-project.json) is the working directory.→ gate: sf project retrieve start --manifest package.xml succeeds; no InvalidProjectWorkspaceError.
<fieldPermissions> entry to the permset XML (readable + editable). Confirm no required fields are listed.→ gate: grep -r 'fieldPermissions' force-app/ shows entries only for non-required fields.
sf project deploy start --dry-run.→ gate: validation succeeds; no missing-component or coverage failures.
sf project deploy start.→ gate: Deploy Succeeded; component count matches validation.
SELECT Field, PermissionsRead, PermissionsEdit FROM FieldPermissions WHERE ParentId IN (SELECT Id FROM PermissionSet WHERE Name='<permset>') AND SobjectType='<object>' (MCP / sf data query / Developer Console).→ gate: each deployed field appears with PermissionsRead = true.
The two scenarios below cover the highest-value PD1 operational gotchas. Additional scenarios: references/scenarios.md — load for package trigger coexistence, bulkification failure patterns, and Quick Action cache issues.
Scenario 1 — FLS after SFDX deploy: field exists but SOQL fails
> Situation: A developer deploys a new custom field Revenue_Tier__c (Text, 50) on Account via sf project deploy start. The field appears in Setup → Object Manager. A SOQL query SELECT Revenue_Tier__c FROM Account LIMIT 1 run as System Administrator in Execute Anonymous immediately throws "System.QueryException: Invalid field: Revenue_Tier__c". > > Competent move: The field was created but FLS was granted to nobody — not even System Administrator. SFDX field-meta.xml creates the field schema; it does not grant field permissions. Add an explicit <fieldPermissions> entry to a permission set or profile covering the System Administrator, redeploy, then re-run the SOQL. > > Tempting-but-wrong: Assuming "View All Data" / "Modify All Data" bypasses FLS on custom fields. It does not. Object-level access and FLS are independent. > > Verify: Run SELECT Revenue_Tier__c FROM Account LIMIT 1 as the target user. If it still errors after adding the <fieldPermissions> entry, confirm the permission set is assigned to the user.
Scenario 2 — Async tool choice: outbound callout needed from a trigger
> Situation: A business rule requires that when an Opportunity is closed-won, the org must POST the opportunity data to an external REST endpoint. A developer proposes doing this in an after update trigger directly, using Http.send(). > > Competent move: Callouts are prohibited in trigger context — Apex throws "System.CalloutException: You have uncommitted work pending" whenever DML has already run in the transaction (which a trigger guarantees). Offload to @future(callout=true) passing the Opportunity Id (primitive), or a Queueable implementing Database.AllowsCallouts. Choose Queueable if you need to chain further jobs or track the job Id. > > Tempting-but-wrong: Wrapping Http.send() in a try/catch inside the trigger. The CalloutException is a runtime enforcement — it fires regardless of catch blocks. > > Verify: In a sandbox, call Http.send() directly in the trigger — you'll see the exception immediately. Then replace with System.enqueueJob(new MyCalloutQueueable(oppId)) and confirm the callout fires via debug log after the transaction commits.
Read this first. Each rule is imperative and concrete.
before triggers to set fields on the saving record (free persistence); after triggers to touch related records / system fields.field-meta.xml grants FLS to nobody. Add explicit <fieldPermissions>, or confirm with a live SOQL query.<fieldPermissions> entry on a required field — deploy fails. Required fields need no FLS entry.relationshipNames (role-suffixed) or deploy fails on duplicate relationship name.System.debug or downstream logs. Log Ids only.Test.startTest()/stopTest(); mock all callouts with HttpCalloutMock. Create all test data (SeeAllData=false).:bind variables.with sharing; in v66 and below it defaults to without sharing. Relying on the implicit default is fragile across version bumps — prefer inherited sharing for service/helper classes, with sharing for entry points, without sharing only when explicitly needed.WITH SECURITY_ENFORCED in new code targeting API v67+ — it was removed; use WITH USER_MODE instead.@AuraEnabled(cacheable=true) for read-only wired Apex (no DML); plain @AuraEnabled + imperative call for mutations.<description>) and redeploying when new QA fields don't render.sf project commands from the SFDX project root (the directory with sfdx-project.json).PermissionSetAssignment SOQL query.Database.insert(records, false) for partial-success bulk loads; bare insert when you need all-or-nothing atomicity.> For org-specific applications of these rules, see a per-org appendix you maintain in your own project, referenced from a CLAUDE.md.
Using this skill and hit a wall? If you find a claim contradicted by the live system or official docs, a missing rule that cost you a wrong attempt, or a decision this skill gave no criteria for — append an entry in the moment to .skill-feedback/salesforce-platform-developer-1.md at the project root (create it if absent):
date | skill last-reviewed | claim or gap | what you observed instead | evidence (error text / doc URL / query output) | suggested fix
These are harvested back into the skill via the learning loop. When the live system and this file disagree, trust the live system.
with sharing when no keyword declared; v66 and below default to without sharing; without sharing is now an explicit opt-in in v67+; WITH SECURITY_ENFORCED removed in v67, use WITH USER_MODE. (2) Order of Execution rewritten: before-save record-triggered Flows added at step 2 (before before-triggers); after-save record-triggered Flows correctly placed at step 11 (separate from legacy Process Builder); 15-step list replaces 13-step list; "legacy" label corrected from "Flows" to workflow/PB end-of-support. (3) Flow/WFR/PB "retired" language corrected to "end of support Dec 31 2025 — existing rules still fire." (4) Data Loader table split: Bulk API 1.0 = 5M, Bulk API 2.0 = 150M. (5) Answer key scenario 3 updated to be version-aware (v66 vs v67 default). Eval probes 13–16 added.[volatile — verify live] marks, executable workflows, tool-agnostic verify steps, and the feedback protocol above. Exam logistics relocated to references/study-resources.md; last-reviewed set to 2026-06-09.Independent educational content to upskill AI agents. Not affiliated with or endorsed by Salesforce; all trademarks belong to their owners. "Salesforce," "Apex," "Lightning," "NPSP," and related names are trademarks of Salesforce, Inc., used here solely to identify the subject matter. Guidance only — verify against official documentation and live orgs before acting. No certification outcome is implied or guaranteed.
Other measured skills in the registry, with their headline benchmark lift.