slashdev 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/claude-skills/salesforce-development/SKILL.md +341 -0
- package/claude-skills/salesforce-development/references/apex-patterns.md +388 -0
- package/claude-skills/salesforce-development/references/lwc-patterns.md +374 -0
- package/claude-skills/salesforce-development/references/testing-patterns.md +438 -0
- package/package.json +1 -1
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
# Apex Patterns — Reference
|
|
2
|
+
|
|
3
|
+
Load this file when implementing triggers, async Apex, integrations, or custom exceptions.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## TriggerHandler Base Class
|
|
8
|
+
|
|
9
|
+
A virtual base class that routes trigger context to named methods. Provides bypass control
|
|
10
|
+
(disable a handler during data migrations), recursion guards, and a clean extension point.
|
|
11
|
+
|
|
12
|
+
```apex
|
|
13
|
+
public virtual class TriggerHandler {
|
|
14
|
+
|
|
15
|
+
// Bypass management
|
|
16
|
+
private static Set<String> bypassedHandlers = new Set<String>();
|
|
17
|
+
|
|
18
|
+
// Recursion control
|
|
19
|
+
private static Map<String, Integer> loopCountMap = new Map<String, Integer>();
|
|
20
|
+
private static Map<String, Integer> maxLoopMap = new Map<String, Integer>();
|
|
21
|
+
|
|
22
|
+
@TestVisible
|
|
23
|
+
private TriggerOperation context;
|
|
24
|
+
@TestVisible
|
|
25
|
+
private Boolean isTriggerExecuting;
|
|
26
|
+
|
|
27
|
+
public TriggerHandler() {
|
|
28
|
+
this.setTriggerContext();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// --- Public API ---
|
|
32
|
+
|
|
33
|
+
public void run() {
|
|
34
|
+
if (!validateRun()) return;
|
|
35
|
+
addToLoopCount();
|
|
36
|
+
|
|
37
|
+
switch on this.context {
|
|
38
|
+
when BEFORE_INSERT { this.beforeInsert(); }
|
|
39
|
+
when BEFORE_UPDATE { this.beforeUpdate(); }
|
|
40
|
+
when BEFORE_DELETE { this.beforeDelete(); }
|
|
41
|
+
when AFTER_INSERT { this.afterInsert(); }
|
|
42
|
+
when AFTER_UPDATE { this.afterUpdate(); }
|
|
43
|
+
when AFTER_DELETE { this.afterDelete(); }
|
|
44
|
+
when AFTER_UNDELETE { this.afterUndelete(); }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// --- Bypass control ---
|
|
49
|
+
|
|
50
|
+
public static void bypass(String handlerName) {
|
|
51
|
+
bypassedHandlers.add(handlerName);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
public static void clearBypass(String handlerName) {
|
|
55
|
+
bypassedHandlers.remove(handlerName);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
public static Boolean isBypassed(String handlerName) {
|
|
59
|
+
return bypassedHandlers.contains(handlerName);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
public static void clearAllBypasses() {
|
|
63
|
+
bypassedHandlers.clear();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// --- Recursion control ---
|
|
67
|
+
|
|
68
|
+
public void setMaxLoopCount(Integer max) {
|
|
69
|
+
String handlerName = getHandlerName();
|
|
70
|
+
maxLoopMap.put(handlerName, max);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
public void clearMaxLoopCount() {
|
|
74
|
+
maxLoopMap.remove(getHandlerName());
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// --- Virtual methods — override in subclasses ---
|
|
78
|
+
|
|
79
|
+
protected virtual void beforeInsert() {}
|
|
80
|
+
protected virtual void beforeUpdate() {}
|
|
81
|
+
protected virtual void beforeDelete() {}
|
|
82
|
+
protected virtual void afterInsert() {}
|
|
83
|
+
protected virtual void afterUpdate() {}
|
|
84
|
+
protected virtual void afterDelete() {}
|
|
85
|
+
protected virtual void afterUndelete() {}
|
|
86
|
+
|
|
87
|
+
// --- Private helpers ---
|
|
88
|
+
|
|
89
|
+
private void setTriggerContext() {
|
|
90
|
+
this.isTriggerExecuting = Trigger.isExecuting;
|
|
91
|
+
this.context = Trigger.operationType;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private Boolean validateRun() {
|
|
95
|
+
if (!this.isTriggerExecuting) {
|
|
96
|
+
throw new TriggerHandlerException('TriggerHandler called outside of trigger context');
|
|
97
|
+
}
|
|
98
|
+
return !bypassedHandlers.contains(getHandlerName());
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private void addToLoopCount() {
|
|
102
|
+
String handlerName = getHandlerName();
|
|
103
|
+
if (!loopCountMap.containsKey(handlerName)) {
|
|
104
|
+
loopCountMap.put(handlerName, 0);
|
|
105
|
+
}
|
|
106
|
+
Integer count = loopCountMap.get(handlerName) + 1;
|
|
107
|
+
loopCountMap.put(handlerName, count);
|
|
108
|
+
|
|
109
|
+
if (maxLoopMap.containsKey(handlerName) && count > maxLoopMap.get(handlerName)) {
|
|
110
|
+
throw new TriggerHandlerException(
|
|
111
|
+
'Max loop count of ' + maxLoopMap.get(handlerName) +
|
|
112
|
+
' reached for ' + handlerName
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private String getHandlerName() {
|
|
118
|
+
return String.valueOf(this).split(':')[0];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
public class TriggerHandlerException extends Exception {}
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Example Subclass — Detecting Field Changes
|
|
126
|
+
|
|
127
|
+
```apex
|
|
128
|
+
public class OpportunityTriggerHandler extends TriggerHandler {
|
|
129
|
+
|
|
130
|
+
public OpportunityTriggerHandler() {
|
|
131
|
+
super();
|
|
132
|
+
this.setMaxLoopCount(2); // Guard against recursion from cross-object updates
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
protected override void afterUpdate() {
|
|
136
|
+
List<Opportunity> stageChanged = new List<Opportunity>();
|
|
137
|
+
Map<Id, Opportunity> oldMap = (Map<Id, Opportunity>) Trigger.oldMap;
|
|
138
|
+
|
|
139
|
+
for (Opportunity opp : (List<Opportunity>) Trigger.new) {
|
|
140
|
+
if (opp.StageName != oldMap.get(opp.Id).StageName
|
|
141
|
+
&& opp.StageName == 'Closed Won') {
|
|
142
|
+
stageChanged.add(opp);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!stageChanged.isEmpty()) {
|
|
147
|
+
OpportunityService.handleClosedWon(stageChanged);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Bypassing Handlers in Data Migrations
|
|
154
|
+
|
|
155
|
+
```apex
|
|
156
|
+
// In a Batch Apex execute() or anonymous script:
|
|
157
|
+
TriggerHandler.bypass('OpportunityTriggerHandler');
|
|
158
|
+
update records;
|
|
159
|
+
TriggerHandler.clearBypass('OpportunityTriggerHandler');
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## Queueable with Callouts and Chaining
|
|
165
|
+
|
|
166
|
+
`Queueable` is the default async pattern. It supports complex types, callouts (with the
|
|
167
|
+
`Database.AllowsCallouts` interface), and job chaining.
|
|
168
|
+
|
|
169
|
+
```apex
|
|
170
|
+
public class SyncAccountToERP implements Queueable, Database.AllowsCallouts {
|
|
171
|
+
|
|
172
|
+
private List<Id> accountIds;
|
|
173
|
+
|
|
174
|
+
public SyncAccountToERP(List<Id> accountIds) {
|
|
175
|
+
this.accountIds = accountIds;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
public void execute(QueueableContext ctx) {
|
|
179
|
+
List<Account> accounts = [
|
|
180
|
+
SELECT Id, Name, BillingStreet, BillingCity, BillingState
|
|
181
|
+
FROM Account
|
|
182
|
+
WHERE Id IN :this.accountIds
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
HttpRequest req = new HttpRequest();
|
|
186
|
+
req.setEndpoint('callout:ERP_API/accounts/sync');
|
|
187
|
+
req.setMethod('POST');
|
|
188
|
+
req.setHeader('Content-Type', 'application/json');
|
|
189
|
+
req.setBody(JSON.serialize(accounts));
|
|
190
|
+
|
|
191
|
+
HttpResponse res = new Http().send(req);
|
|
192
|
+
|
|
193
|
+
if (res.getStatusCode() != 200) {
|
|
194
|
+
// Log failure — don't silently swallow
|
|
195
|
+
ErrorLogger.log('SyncAccountToERP', 'HTTP ' + res.getStatusCode(), res.getBody());
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Chain next job if there are more records to process
|
|
199
|
+
// Guard with !Test.isRunningTest() because chaining in tests causes
|
|
200
|
+
// "Maximum stack depth reached" errors
|
|
201
|
+
if (!this.accountIds.isEmpty() && !Test.isRunningTest()) {
|
|
202
|
+
System.enqueueJob(new SyncAccountFollowUp(this.accountIds));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### Enqueuing from a trigger
|
|
209
|
+
|
|
210
|
+
```apex
|
|
211
|
+
// In a trigger handler's afterInsert():
|
|
212
|
+
if (!System.isBatch() && !System.isFuture()) {
|
|
213
|
+
System.enqueueJob(new SyncAccountToERP(accountIds));
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
---
|
|
218
|
+
|
|
219
|
+
## Batch Apex
|
|
220
|
+
|
|
221
|
+
Use Batch Apex when processing more than 50K records. Implements `Database.Batchable<SObject>`
|
|
222
|
+
and optionally `Database.Stateful` to maintain state across `execute()` calls.
|
|
223
|
+
|
|
224
|
+
```apex
|
|
225
|
+
public class DeactivateStaleLeadsBatch implements Database.Batchable<SObject>, Database.Stateful {
|
|
226
|
+
|
|
227
|
+
private Integer totalProcessed = 0; // Stateful — survives between execute() calls
|
|
228
|
+
private Integer totalErrors = 0;
|
|
229
|
+
|
|
230
|
+
public Database.QueryLocator start(Database.BatchableContext bc) {
|
|
231
|
+
return Database.getQueryLocator([
|
|
232
|
+
SELECT Id, Status
|
|
233
|
+
FROM Lead
|
|
234
|
+
WHERE LastActivityDate < LAST_N_DAYS:90
|
|
235
|
+
AND IsConverted = false
|
|
236
|
+
AND Status != 'Closed - Not Converted'
|
|
237
|
+
]);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
public void execute(Database.BatchableContext bc, List<Lead> scope) {
|
|
241
|
+
for (Lead l : scope) {
|
|
242
|
+
l.Status = 'Closed - Not Converted';
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
List<Database.SaveResult> results = Database.update(scope, false); // partial success
|
|
246
|
+
for (Database.SaveResult sr : results) {
|
|
247
|
+
if (sr.isSuccess()) {
|
|
248
|
+
totalProcessed++;
|
|
249
|
+
} else {
|
|
250
|
+
totalErrors++;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
public void finish(Database.BatchableContext bc) {
|
|
256
|
+
// Post-processing: send summary email, enqueue follow-up, log results
|
|
257
|
+
System.debug('DeactivateStaleLeads — processed: ' + totalProcessed + ', errors: ' + totalErrors);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
### Launching a Batch
|
|
263
|
+
|
|
264
|
+
```apex
|
|
265
|
+
// Default scope size is 200 — reduce for callout-heavy batches
|
|
266
|
+
Database.executeBatch(new DeactivateStaleLeadsBatch(), 200);
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
---
|
|
270
|
+
|
|
271
|
+
## Scheduled Job
|
|
272
|
+
|
|
273
|
+
Schedulable classes run on a cron schedule. Keep the `execute()` body minimal — enqueue a
|
|
274
|
+
Queueable or kick off a Batch.
|
|
275
|
+
|
|
276
|
+
```apex
|
|
277
|
+
public class NightlyLeadCleanupSchedule implements Schedulable {
|
|
278
|
+
|
|
279
|
+
public void execute(SchedulableContext ctx) {
|
|
280
|
+
Database.executeBatch(new DeactivateStaleLeadsBatch(), 200);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
### Scheduling via Anonymous Apex
|
|
286
|
+
|
|
287
|
+
```apex
|
|
288
|
+
// Run nightly at 2 AM
|
|
289
|
+
String cronExpr = '0 0 2 * * ?';
|
|
290
|
+
System.schedule('Nightly Lead Cleanup', cronExpr, new NightlyLeadCleanupSchedule());
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
295
|
+
## Integration Callout Pattern
|
|
296
|
+
|
|
297
|
+
Standard pattern for HTTP callouts via Named Credentials with proper error handling.
|
|
298
|
+
|
|
299
|
+
```apex
|
|
300
|
+
public with sharing class ExternalAccountService {
|
|
301
|
+
|
|
302
|
+
public static ExternalAccount getAccount(String externalId) {
|
|
303
|
+
HttpRequest req = new HttpRequest();
|
|
304
|
+
req.setEndpoint('callout:ExternalSystem/api/v2/accounts/' + EncodingUtil.urlEncode(externalId, 'UTF-8'));
|
|
305
|
+
req.setMethod('GET');
|
|
306
|
+
req.setTimeout(30000); // 30 second timeout
|
|
307
|
+
|
|
308
|
+
HttpResponse res = new Http().send(req);
|
|
309
|
+
|
|
310
|
+
if (res.getStatusCode() == 200) {
|
|
311
|
+
return (ExternalAccount) JSON.deserialize(res.getBody(), ExternalAccount.class);
|
|
312
|
+
} else if (res.getStatusCode() == 404) {
|
|
313
|
+
return null;
|
|
314
|
+
} else {
|
|
315
|
+
throw new CalloutException(
|
|
316
|
+
'External API error: HTTP ' + res.getStatusCode() + ' — ' + res.getBody()
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// DTO for deserialization
|
|
322
|
+
public class ExternalAccount {
|
|
323
|
+
public String id;
|
|
324
|
+
public String name;
|
|
325
|
+
public String status;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
### Calling from a trigger (async required)
|
|
331
|
+
|
|
332
|
+
```apex
|
|
333
|
+
// WRONG — triggers cannot make sync callouts
|
|
334
|
+
// HttpResponse res = new Http().send(req); // throws CalloutException
|
|
335
|
+
|
|
336
|
+
// RIGHT — enqueue a Queueable
|
|
337
|
+
System.enqueueJob(new SyncAccountToERP(accountIds));
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
### @future vs Queueable from trigger context
|
|
341
|
+
|
|
342
|
+
- `@future(callout=true)` — simpler, but only accepts primitive parameters
|
|
343
|
+
(`Id`, `String`, `List<Id>`). No chaining, no complex objects.
|
|
344
|
+
- `Queueable` with `Database.AllowsCallouts` — accepts complex types, supports chaining,
|
|
345
|
+
allows state tracking. **Prefer this.**
|
|
346
|
+
|
|
347
|
+
---
|
|
348
|
+
|
|
349
|
+
## Custom Exception Patterns
|
|
350
|
+
|
|
351
|
+
Create domain-specific exceptions to distinguish business logic failures from platform errors.
|
|
352
|
+
|
|
353
|
+
```apex
|
|
354
|
+
public class AccountServiceException extends Exception {}
|
|
355
|
+
public class IntegrationException extends Exception {}
|
|
356
|
+
public class ValidationException extends Exception {
|
|
357
|
+
public List<String> fieldErrors { get; private set; }
|
|
358
|
+
|
|
359
|
+
public ValidationException(List<String> fieldErrors) {
|
|
360
|
+
this('Validation failed: ' + String.join(fieldErrors, '; '));
|
|
361
|
+
this.fieldErrors = fieldErrors;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
### When to throw vs return
|
|
367
|
+
|
|
368
|
+
- **Throw** when the caller cannot reasonably continue — missing required data, integration
|
|
369
|
+
failure, security violation.
|
|
370
|
+
- **Return a result object** when partial success is expected — batch processing, multi-record
|
|
371
|
+
validation where you want to collect all errors before reporting.
|
|
372
|
+
|
|
373
|
+
```apex
|
|
374
|
+
public class OperationResult {
|
|
375
|
+
public Boolean isSuccess;
|
|
376
|
+
public List<String> errors = new List<String>();
|
|
377
|
+
public List<SObject> successRecords = new List<SObject>();
|
|
378
|
+
|
|
379
|
+
public OperationResult() {
|
|
380
|
+
this.isSuccess = true;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
public void addError(String message) {
|
|
384
|
+
this.isSuccess = false;
|
|
385
|
+
this.errors.add(message);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
```
|