--- name: salesforce-development description: Expert patterns for Salesforce platform development including Lightning Web Components (LWC), Apex triggers and classes, REST/Bulk APIs, Connected Apps, and Salesforce DX with scratch orgs and 2nd generation packages (2GP). risk: safe source: vibeship-spawner-skills (Apache 2.0) date_added: 2026-02-27 --- # Salesforce Development Expert patterns for Salesforce platform development including Lightning Web Components (LWC), Apex triggers and classes, REST/Bulk APIs, Connected Apps, and Salesforce DX with scratch orgs and 2nd generation packages (2GP). ## Patterns ### Lightning Web Component with Wire Service Use @wire decorator for reactive data binding with Lightning Data Service or Apex methods. @wire fits LWC's reactive architecture and enables Salesforce performance optimizations. // myComponent.js import { LightningElement, wire, api } from 'lwc'; import { getRecord, getFieldValue } from 'lightning/uiRecordApi'; import getRelatedRecords from '@salesforce/apex/MyController.getRelatedRecords'; import ACCOUNT_NAME from '@salesforce/schema/Account.Name'; import ACCOUNT_INDUSTRY from '@salesforce/schema/Account.Industry'; const FIELDS = [ACCOUNT_NAME, ACCOUNT_INDUSTRY]; export default class MyComponent extends LightningElement { @api recordId; // Passed from parent or record page // Wire to Lightning Data Service (preferred for single records) @wire(getRecord, { recordId: '$recordId', fields: FIELDS }) account; // Wire to Apex method (for complex queries) @wire(getRelatedRecords, { accountId: '$recordId' }) wiredRecords({ error, data }) { if (data) { this.relatedRecords = data; this.error = undefined; } else if (error) { this.error = error; this.relatedRecords = undefined; } } get accountName() { return getFieldValue(this.account.data, ACCOUNT_NAME); } get isLoading() { return !this.account.data && !this.account.error; } // Reactive: changing recordId automatically re-fetches } // myComponent.html // MyController.cls public with sharing class MyController { @AuraEnabled(cacheable=true) public static List getRelatedRecords(Id accountId) { return [ SELECT Id, Name, Email, Phone FROM Contact WHERE AccountId = :accountId WITH SECURITY_ENFORCED LIMIT 100 ]; } } ### Context - building LWC components - fetching Salesforce data - reactive UI ### Bulkified Apex Trigger with Handler Pattern Apex triggers must be bulkified to handle 200+ records per transaction. Use handler pattern for separation of concerns, testability, and recursion prevention. // AccountTrigger.trigger trigger AccountTrigger on Account ( before insert, before update, before delete, after insert, after update, after delete, after undelete ) { new AccountTriggerHandler().run(); } // TriggerHandler.cls (base class) public virtual class TriggerHandler { // Recursion prevention private static Set executedHandlers = new Set(); public void run() { String handlerName = String.valueOf(this).split(':')[0]; // Prevent recursion String contextKey = handlerName + '_' + Trigger.operationType; if (executedHandlers.contains(contextKey)) { return; } executedHandlers.add(contextKey); switch on Trigger.operationType { when BEFORE_INSERT { this.beforeInsert(); } when BEFORE_UPDATE { this.beforeUpdate(); } when BEFORE_DELETE { this.beforeDelete(); } when AFTER_INSERT { this.afterInsert(); } when AFTER_UPDATE { this.afterUpdate(); } when AFTER_DELETE { this.afterDelete(); } when AFTER_UNDELETE { this.afterUndelete(); } } } // Override in child classes protected virtual void beforeInsert() {} protected virtual void beforeUpdate() {} protected virtual void beforeDelete() {} protected virtual void afterInsert() {} protected virtual void afterUpdate() {} protected virtual void afterDelete() {} protected virtual void afterUndelete() {} } // AccountTriggerHandler.cls public class AccountTriggerHandler extends TriggerHandler { private List newAccounts; private List oldAccounts; private Map newMap; private Map oldMap; public AccountTriggerHandler() { this.newAccounts = (List) Trigger.new; this.oldAccounts = (List) Trigger.old; this.newMap = (Map) Trigger.newMap; this.oldMap = (Map) Trigger.oldMap; } protected override void afterInsert() { createDefaultContacts(); notifySlack(); } protected override void afterUpdate() { handleIndustryChange(); } // BULKIFIED: Query once, update once private void createDefaultContacts() { List contactsToInsert = new List(); for (Account acc : newAccounts) { if (acc.Type == 'Prospect') { contactsToInsert.add(new Contact( AccountId = acc.Id, LastName = 'Primary Contact', Email = 'contact@' + acc.Website )); } } if (!contactsToInsert.isEmpty()) { insert contactsToInsert; // Single DML for all } } private void handleIndustryChange() { Set changedAccountIds = new Set(); for (Account acc : newAccounts) { Account oldAcc = oldMap.get(acc.Id); if (acc.Industry != oldAcc.Industry) { changedAccountIds.add(acc.Id); } } if (!changedAccountIds.isEmpty()) { // Queue async processing for heavy work System.enqueueJob(new IndustryChangeQueueable(changedAccountIds)); } } private void notifySlack() { // Offload callouts to async List accountIds = new List(newMap.keySet()); System.enqueueJob(new SlackNotificationQueueable(accountIds)); } } ### Context - apex triggers - data operations - automation ### Queueable Apex for Async Processing Use Queueable Apex for async processing with support for non-primitive types, monitoring via AsyncApexJob, and job chaining. Limit: 50 jobs per transaction, 1 child job when chaining. // IndustryChangeQueueable.cls public class IndustryChangeQueueable implements Queueable, Database.AllowsCallouts { private Set accountIds; private Integer retryCount; public IndustryChangeQueueable(Set accountIds) { this(accountIds, 0); } public IndustryChangeQueueable(Set accountIds, Integer retryCount) { this.accountIds = accountIds; this.retryCount = retryCount; } public void execute(QueueableContext context) { try { // Query with fresh data List accounts = [ SELECT Id, Name, Industry, OwnerId FROM Account WHERE Id IN :accountIds WITH SECURITY_ENFORCED ]; // Process and make callout for (Account acc : accounts) { syncToExternalSystem(acc); } // Update records updateRelatedOpportunities(accountIds); } catch (Exception e) { handleError(e); } } private void syncToExternalSystem(Account acc) { HttpRequest req = new HttpRequest(); req.setEndpoint('callout:ExternalCRM/accounts'); req.setMethod('POST'); req.setHeader('Content-Type', 'application/json'); req.setBody(JSON.serialize(new Map{ 'salesforceId' => acc.Id, 'name' => acc.Name, 'industry' => acc.Industry })); Http http = new Http(); HttpResponse res = http.send(req); if (res.getStatusCode() != 200 && res.getStatusCode() != 201) { throw new CalloutException('Sync failed: ' + res.getBody()); } } private void updateRelatedOpportunities(Set accIds) { List oppsToUpdate = [ SELECT Id, Industry__c, AccountId FROM Opportunity WHERE AccountId IN :accIds WITH SECURITY_ENFORCED ]; Map accountMap = new Map([ SELECT Id, Industry FROM Account WHERE Id IN :accIds ]); for (Opportunity opp : oppsToUpdate) { opp.Industry__c = accountMap.get(opp.AccountId).Industry; } if (!oppsToUpdate.isEmpty()) { update oppsToUpdate; } } private void handleError(Exception e) { // Log error System.debug(LoggingLevel.ERROR, 'Queueable failed: ' + e.getMessage()); // Retry with exponential backoff (max 3 retries) if (retryCount < 3) { // Chain new job for retry System.enqueueJob(new IndustryChangeQueueable(accountIds, retryCount + 1)); } else { // Create error record for monitoring insert new Integration_Error__c( Type__c = 'Industry Sync', Message__c = e.getMessage(), Stack_Trace__c = e.getStackTraceString(), Record_Ids__c = String.join(new List(accountIds), ',') ); } } } ### Context - async processing - long-running operations - callouts from triggers ### REST API Integration with Connected App External integrations use Connected Apps with OAuth 2.0. JWT Bearer flow for server-to-server, Web Server flow for user-facing apps. Always use Named Credentials for secure callout configuration. // Node.js - JWT Bearer Flow (server-to-server) import jwt from 'jsonwebtoken'; import fs from 'fs'; class SalesforceClient { private accessToken: string | null = null; private instanceUrl: string | null = null; private tokenExpiry: number = 0; constructor( private clientId: string, private username: string, private privateKeyPath: string, private loginUrl: string = 'https://login.salesforce.com' ) {} async authenticate(): Promise { // Check if token is still valid (5 min buffer) if (this.accessToken && Date.now() < this.tokenExpiry - 300000) { return; } const privateKey = fs.readFileSync(this.privateKeyPath, 'utf8'); // Create JWT assertion const claim = { iss: this.clientId, sub: this.username, aud: this.loginUrl, exp: Math.floor(Date.now() / 1000) + 300 // 5 minutes }; const assertion = jwt.sign(claim, privateKey, { algorithm: 'RS256' }); // Exchange JWT for access token const response = await fetch(`${this.loginUrl}/services/oauth2/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion }) }); if (!response.ok) { const error = await response.json(); throw new Error(`Auth failed: ${error.error_description}`); } const data = await response.json(); this.accessToken = data.access_token; this.instanceUrl = data.instance_url; this.tokenExpiry = Date.now() + 7200000; // 2 hours } async query(soql: string): Promise { await this.authenticate(); const response = await fetch( `${this.instanceUrl}/services/data/v59.0/query?q=${encodeURIComponent(soql)}`, { headers: { 'Authorization': `Bearer ${this.accessToken}`, 'Content-Type': 'application/json' } } ); if (!response.ok) { await this.handleError(response); } return response.json(); } async createRecord(sobject: string, data: object): Promise { await this.authenticate(); const response = await fetch( `${this.instanceUrl}/services/data/v59.0/sobjects/${sobject}`, { method: 'POST', headers: { 'Authorization': `Bearer ${this.accessToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify(data) } ); if (!response.ok) { await this.handleError(response); } return response.json(); } private async handleError(response: Response): Promise { const error = await response.json(); if (response.status === 401) { // Token expired, clear and retry this.accessToken = null; throw new Error('Session expired, retry required'); } throw new Error(`API Error: ${JSON.stringify(error)}`); } } // Usage const sf = new SalesforceClient( process.env.SF_CLIENT_ID!, process.env.SF_USERNAME!, './certificates/server.key' ); const accounts = await sf.query( "SELECT Id, Name FROM Account WHERE CreatedDate = TODAY" ); ### Context - external integration - REST API access - connected apps ### Bulk API 2.0 for Large Data Operations Use Bulk API 2.0 for operations on 10K+ records. Asynchronous processing with job-based workflow. Part of REST API with streamlined interface compared to original Bulk API. // Node.js - Bulk API 2.0 insert class SalesforceBulkClient extends SalesforceClient { async bulkInsert(sobject: string, records: object[]): Promise { await this.authenticate(); // Step 1: Create job const job = await this.createBulkJob(sobject, 'insert'); try { // Step 2: Upload data (CSV format) await this.uploadJobData(job.id, records); // Step 3: Close job to start processing await this.closeJob(job.id); // Step 4: Poll for completion return await this.waitForJobCompletion(job.id); } catch (error) { // Abort job on error await this.abortJob(job.id); throw error; } } private async createBulkJob(sobject: string, operation: string): Promise { const response = await fetch( `${this.instanceUrl}/services/data/v59.0/jobs/ingest`, { method: 'POST', headers: { 'Authorization': `Bearer ${this.accessToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ object: sobject, operation, contentType: 'CSV', lineEnding: 'LF' }) } ); return response.json(); } private async uploadJobData(jobId: string, records: object[]): Promise { // Convert to CSV const csv = this.recordsToCSV(records); await fetch( `${this.instanceUrl}/services/data/v59.0/jobs/ingest/${jobId}/batches`, { method: 'PUT', headers: { 'Authorization': `Bearer ${this.accessToken}`, 'Content-Type': 'text/csv' }, body: csv } ); } private async closeJob(jobId: string): Promise { await fetch( `${this.instanceUrl}/services/data/v59.0/jobs/ingest/${jobId}`, { method: 'PATCH', headers: { 'Authorization': `Bearer ${this.accessToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ state: 'UploadComplete' }) } ); } private async waitForJobCompletion(jobId: string): Promise { const maxWaitTime = 10 * 60 * 1000; // 10 minutes const pollInterval = 5000; // 5 seconds const startTime = Date.now(); while (Date.now() - startTime < maxWaitTime) { const response = await fetch( `${this.instanceUrl}/services/data/v59.0/jobs/ingest/${jobId}`, { headers: { 'Authorization': `Bearer ${this.accessToken}` } } ); const job = await response.json(); if (job.state === 'JobComplete') { // Get results return { success: job.numberRecordsProcessed - job.numberRecordsFailed, failed: job.numberRecordsFailed, failedResults: job.numberRecordsFailed > 0 ? await this.getFailedResults(jobId) : [] }; } if (job.state === 'Failed' || job.state === 'Aborted') { throw new Error(`Bulk job failed: ${job.state}`); } await new Promise(r => setTimeout(r, pollInterval)); } throw new Error('Bulk job timeout'); } private async getFailedResults(jobId: string): Promise { const response = await fetch( `${this.instanceUrl}/services/data/v59.0/jobs/ingest/${jobId}/failedResults`, { headers: { 'Authorization': `Bearer ${this.accessToken}` } } ); const csv = await response.text(); return this.parseCSV(csv); } private recordsToCSV(records: object[]): string { if (records.length === 0) return ''; const headers = Object.keys(records[0]); const rows = records.map(r => headers.map(h => this.escapeCSV(r[h])).join(',') ); return [headers.join(','), ...rows].join('\n'); } private escapeCSV(value: any): string { if (value === null || value === undefined) return ''; const str = String(value); if (str.includes(',') || str.includes('"') || str.includes('\n')) { return `"${str.replace(/"/g, '""')}"`; } return str; } } ### Context - large data volumes - data migration - bulk operations ### Salesforce DX with Scratch Orgs Source-driven development with disposable scratch orgs for isolated testing. Scratch orgs exist 7-30 days and can be created throughout the day, unlike sandbox refresh limits. // project-scratch-def.json - Scratch org definition { "orgName": "MyApp Dev Org", "edition": "Developer", "features": ["EnableSetPasswordInApi", "Communities"], "settings": { "lightningExperienceSettings": { "enableS1DesktopEnabled": true }, "mobileSettings": { "enableS1EncryptedStoragePref2": false }, "securitySettings": { "passwordPolicies": { "enableSetPasswordInApi": true } } } } // sfdx-project.json - Project configuration { "packageDirectories": [ { "path": "force-app", "default": true, "package": "MyPackage", "versionName": "ver 1.0", "versionNumber": "1.0.0.NEXT", "dependencies": [ { "package": "SomePackage@2.0.0" } ] } ], "namespace": "myns", "sfdcLoginUrl": "https://login.salesforce.com", "sourceApiVersion": "59.0" } # Development workflow commands # 1. Create scratch org sf org create scratch \ --definition-file config/project-scratch-def.json \ --alias myapp-dev \ --duration-days 7 \ --set-default # 2. Push source to scratch org sf project deploy start --target-org myapp-dev # 3. Assign permission set sf org assign permset --name MyApp_Admin --target-org myapp-dev # 4. Import sample data sf data import tree --plan data/sample-data-plan.json --target-org myapp-dev # 5. Open org sf org open --target-org myapp-dev # 6. Run tests sf apex run test \ --code-coverage \ --result-format human \ --wait 10 \ --target-org myapp-dev # 7. Pull changes back sf project retrieve start --target-org myapp-dev ### Context - development workflow - CI/CD - testing ### 2nd Generation Package (2GP) Development 2GP replaces 1GP with source-driven, modular packaging. Requires Dev Hub with 2GP enabled, namespace linked, and 75% code coverage for promoted packages. # Enable Dev Hub and 2GP in Setup: # Setup > Dev Hub > Enable Dev Hub # Setup > Dev Hub > Enable Unlocked Packages and 2GP # Link namespace (required for managed packages) sf package create \ --name "MyManagedPackage" \ --package-type Managed \ --path force-app \ --target-dev-hub DevHub # Create package version (beta) sf package version create \ --package "MyManagedPackage" \ --installation-key-bypass \ --wait 30 \ --code-coverage \ --target-dev-hub DevHub # Check version status sf package version list --packages "MyManagedPackage" --target-dev-hub DevHub # Promote to released (requires 75% coverage) sf package version promote \ --package "MyManagedPackage@1.0.0-1" \ --target-dev-hub DevHub # Install in sandbox for testing sf package install \ --package "MyManagedPackage@1.0.0-1" \ --target-org MySandbox \ --wait 20 # CI/CD Pipeline (GitHub Actions) # .github/workflows/salesforce-ci.yml name: Salesforce CI on: push: branches: [main, develop] pull_request: branches: [main] jobs: validate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install Salesforce CLI run: npm install -g @salesforce/cli - name: Authenticate Dev Hub run: | echo "${{ secrets.SFDX_AUTH_URL }}" > auth.txt sf org login sfdx-url --sfdx-url-file auth.txt --alias DevHub --set-default-dev-hub - name: Create Scratch Org run: | sf org create scratch \ --definition-file config/project-scratch-def.json \ --alias ci-scratch \ --duration-days 1 \ --set-default - name: Deploy Source run: sf project deploy start --target-org ci-scratch - name: Run Tests run: | sf apex run test \ --code-coverage \ --result-format human \ --wait 20 \ --target-org ci-scratch - name: Delete Scratch Org if: always() run: sf org delete scratch --target-org ci-scratch --no-prompt ### Context - packaging - ISV development - AppExchange ## Sharp Edges ### Governor Limits Apply Per Transaction, Not Per Record Severity: CRITICAL ### @wire Results Are Cached and May Be Stale Severity: HIGH ### LWC Properties Are Case-Sensitive Severity: MEDIUM ### Null Pointer Exceptions in Apex Collections Severity: HIGH ### Trigger Recursion Causes Infinite Loops Severity: CRITICAL ### Cannot Make Callouts from Synchronous Triggers Severity: HIGH ### Cannot Mix Setup and Non-Setup DML Severity: HIGH ### Dynamic SOQL Is Vulnerable to Injection Severity: CRITICAL ### Scratch Orgs Expire and Lose All Data Severity: MEDIUM ### API Version Mismatches Cause Silent Failures Severity: MEDIUM ## Validation Checks ### SOQL Query Inside Loop Severity: ERROR SOQL in loops causes governor limit exceptions with bulk data Message: SOQL query inside loop. Query once outside the loop and use a Map. ### DML Operation Inside Loop Severity: ERROR DML in loops hits 150 statement limit Message: DML operation inside loop. Collect records and perform single DML outside loop. ### HTTP Callout in Trigger Severity: ERROR Synchronous triggers cannot make callouts Message: Callout in trigger. Use @future(callout=true) or Queueable with Database.AllowsCallouts. ### Potential SOQL Injection Severity: ERROR Dynamic SOQL with string concatenation is vulnerable Message: Dynamic SOQL with concatenation. Use bind variables or String.escapeSingleQuotes(). ### Missing WITH SECURITY_ENFORCED Severity: WARNING SOQL should enforce FLS/CRUD permissions Message: SOQL without security enforcement. Add WITH SECURITY_ENFORCED. ### Hardcoded Salesforce ID Severity: WARNING Record IDs differ between orgs Message: Hardcoded Salesforce ID. Query by DeveloperName or ExternalId instead. ### Hardcoded Credentials Severity: ERROR Credentials must use Named Credentials or Custom Metadata Message: Hardcoded credentials. Use Named Credentials or Custom Metadata. ### Direct DOM Manipulation in LWC Severity: WARNING LWC uses shadow DOM, direct manipulation breaks encapsulation Message: Direct DOM access in LWC. Use this.template.querySelector() or data binding. ### Reactive Property Without @track Severity: INFO Complex object properties need @track for reactivity Message: Object assignment may need @track for reactivity (post-Spring '20 objects are auto-tracked). ### Wire Without Refresh After DML Severity: WARNING Cached wire data becomes stale after updates Message: DML after @wire without refreshApex. Data may be stale. ## Collaboration ### Delegation Triggers - user needs external API integration -> backend (REST API design, external system sync) - user needs complex UI beyond LWC -> frontend (Custom portal with React/Next.js) - user needs HubSpot integration -> hubspot-integration (Salesforce-HubSpot sync patterns) - user needs data warehouse sync -> data-engineer (ETL from Salesforce to warehouse) - user needs payment processing -> stripe-integration (Beyond Salesforce Billing) - user needs advanced auth -> auth-specialist (SSO, SAML, custom portals) ## When to Use - User mentions or implies: salesforce - User mentions or implies: sfdc - User mentions or implies: apex - User mentions or implies: lwc - User mentions or implies: lightning web components - User mentions or implies: sfdx - User mentions or implies: scratch org - User mentions or implies: visualforce - User mentions or implies: soql - User mentions or implies: governor limits - User mentions or implies: connected app ## Limitations - Use this skill only when the task clearly matches the scope described above. - Do not treat the output as a substitute for environment-specific validation, testing, or expert review. - Stop and ask for clarification if required inputs, permissions, safety boundaries, or success criteria are missing.