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.
@@ -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
+ ```