├── .forceignore
├── .gitignore
├── LICENSE
├── README.md
├── config
└── project-scratch-def.json
├── force-app
└── main
│ ├── default
│ ├── classes
│ │ ├── Chainable.cls
│ │ ├── Chainable.cls-meta.xml
│ │ ├── ChainableBatch.cls
│ │ ├── ChainableBatch.cls-meta.xml
│ │ ├── ChainableQueueable.cls
│ │ ├── ChainableQueueable.cls-meta.xml
│ │ ├── ChainableSchedulable.cls
│ │ └── ChainableSchedulable.cls-meta.xml
│ ├── flows
│ │ └── Chainable.flow-meta.xml
│ └── objects
│ │ └── Chainable__e
│ │ ├── Chainable__e.object-meta.xml
│ │ └── fields
│ │ ├── Arguments__c.field-meta.xml
│ │ ├── InstanceName__c.field-meta.xml
│ │ └── Shared__c.field-meta.xml
│ └── test
│ └── classes
│ ├── Chainable_Test.cls
│ ├── Chainable_Test.cls-meta.xml
│ ├── SampleBatch.cls
│ ├── SampleBatch.cls-meta.xml
│ ├── SampleDeferArgQueueable.cls
│ ├── SampleDeferArgQueueable.cls-meta.xml
│ ├── SampleFailRebuild.cls
│ ├── SampleFailRebuild.cls-meta.xml
│ ├── SampleQueueable.cls
│ ├── SampleQueueable.cls-meta.xml
│ ├── SampleSchedulable.cls
│ └── SampleSchedulable.cls-meta.xml
├── ruleset.xml
├── scripts
├── config.sh
└── createScratchOrg.sh
└── sfdx-project.json
/.forceignore:
--------------------------------------------------------------------------------
1 | # List files or directories below to ignore them when running force:source:push, force:source:pull, and force:source:status
2 | # More information: https://developer.salesforce.com/docs/atlas.en-us.sfdx_dev.meta/sfdx_dev/sfdx_dev_exclude_source.htm
3 |
4 | package.xml
5 | **appMenu
6 | **appSwitcher
7 | **profiles
8 | **settings
9 | **authproviders
10 | **namedCredential
11 |
12 | # LWC configuration files
13 | **/jsconfig.json
14 | **/.eslintrc.json
15 |
16 | # LWC Jest
17 | **/__tests__/**
18 |
19 | AppSwitcher.appMenu
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # This file is used for Git repositories to specify intentionally untracked files that Git should ignore.
2 | # If you are not using git, you can delete this file. For more information see: https://git-scm.com/docs/gitignore
3 | # For useful gitignore templates see: https://github.com/github/gitignore
4 |
5 | # Salesforce cache
6 | .sfdx/
7 | .localdevserver/
8 | .sf/
9 |
10 | # IDEs
11 | .idea/
12 | .vscode/
13 | IlluminatedCloud/
14 | projectFilesBackup/
15 | # LWC VSCode autocomplete
16 | **/lwc/jsconfig.json
17 |
18 | # Logs
19 | logs
20 | *.log
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
25 | # Dependency directories
26 | node_modules/
27 |
28 | # Eslint cache
29 | .eslintcache
30 |
31 | # MacOS system files
32 | .DS_Store
33 |
34 | # Windows system files
35 | Thumbs.db
36 | ehthumbs.db
37 | [Dd]esktop.ini
38 | $RECYCLE.BIN/
39 | .prettierignore
40 | .prettierrc
41 | package-lock.json
42 | /sfdxproject.iml
43 |
44 | # Temporary folder created by scripts
45 | metadata/
46 | /sfcleverreach.iml
47 | /out
48 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Robert Sösemann
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Apex Chainable [](https://www.codacy.com/gh/rsoesemann/apex-chainable/dashboard?utm_source=github.com&utm_medium=referral&utm_content=rsoesemann/apex-chainable&utm_campaign=Badge_Grade)
2 |
3 |
4 |
6 |
7 |
8 | Apex Batches can be chained by calling the successor batch from the `finish()` method of the previous batch.
9 | But such hardcoding makes this model inflexible. It's hard to build the chain from outside, neighter from a central class
10 | nor on runtime dependant on business logic.
11 |
12 | The same applies when the `execute()` method of `Schedulable` or `Queueable` classes call other classes.
13 |
14 | ## With `Chainable`
15 |
16 | The `Chainable` wrapper class of this repository overcomes those drawbacks.
17 |
18 | - No need to hardcode successor batch in `finish()` method
19 | - Created batch chains of arbitrary length without changing existing Batch classes
20 | - Support `Batchable`, `Queueable` and `Schedulable` classes as chain members
21 | - Allows sharing and passing of variables between chain members
22 |
23 | ```java
24 | new FirstBatch().setShared('result', new Money(0))
25 | .then(AnotherBatch())
26 | .then(QueueableJob())
27 | .then(ScheduledJob())
28 | ...
29 | .execute();
30 | ```
31 |
32 | ## Without `Chainable`
33 |
34 | ```java
35 | class FirstBatch implements Batchable {
36 | Iterator start(BatchableContext ctx) { ... }
37 |
38 | void execute(BatchableContext ctx, List scope) { ... }
39 |
40 | void finish(BatchableContext ctx) {
41 | Database.enqueueBatch(new SecondBatch());
42 | }
43 | }
44 | ```
45 |
46 | ```java
47 | class AnotherBatch implements Batchable {
48 | Iterator start(BatchableContext ctx) { ... }
49 |
50 | void execute(BatchableContext ctx, List scope) { ... }
51 |
52 | void finish(BatchableContext ctx) {
53 | System.schedule('name', cron, new ScheduledJob());
54 | }
55 | }
56 | ```
57 |
58 | ## Deferring
59 |
60 | When all the "links" in the chain cannot be completely identified beforehand in order to assemble them in a single chain and trigger its execution, the chain execution can be *deferred* until the end of the transaction. All chainable processes that have been *deferred* will be automatically chained together in a **single** chain and executed sequentually.
61 |
62 | ```java
63 |
64 | // automation 1
65 | new FirstBatch()
66 | .then(AnotherBatch())
67 | .setShared('result', new Money(0)) // shared variables will be available across other following deferred chainables
68 | .executeDeferred();
69 |
70 | // automation 2
71 | new QueueableJob()
72 | .then(ScheduledJob())
73 | ...
74 | .executeDeferred();
75 |
76 | // the framework would internally build the chain in a separate transaction with a definition like this
77 | new FirstBatch()
78 | .then(AnotherBatch())
79 | .setShared('result', new Money(0))
80 | .then(QueueableJob())
81 | .then(ScheduledJob())
82 | .execute();
83 |
84 | ```
85 |
86 | ### Considerations
87 |
88 | In order to leverage the deferring of the Chainable instances there are some nuances compared to its direct execution.
89 |
90 | * Override the `getDeferArgs` and `setDeferredArgs` if the class receives external input before its execution via constructor parameters or setters to serialize and deserialize them (See the `SampleDeferArgQueueable`).
91 | * Must have a no-arg constructor (if no explicit constructor exists, the default implicit one will be used)
92 | * Must not be an inner class (due to difficulty on dynamic name inferring)
--------------------------------------------------------------------------------
/config/project-scratch-def.json:
--------------------------------------------------------------------------------
1 | {
2 | "orgName": "apex-chainable_DEV",
3 | "country": "DE",
4 | "edition": "Developer",
5 | "language": "de",
6 | "hasSampleData": true,
7 | "features": [],
8 | "settings": {
9 | }
10 | }
--------------------------------------------------------------------------------
/force-app/main/default/classes/Chainable.cls:
--------------------------------------------------------------------------------
1 | public abstract class Chainable {
2 |
3 | @TestVisible
4 | private static final String INVALID_CLASS_MSG = 'Invalid class ';
5 |
6 | private Chainable previous;
7 | private Chainable next;
8 |
9 | private Map sharedVariables = new Map();
10 |
11 |
12 | // ABSTRACT
13 |
14 | protected abstract void executeAsynchronously();
15 | protected abstract void executeSynchronously(Context ctx);
16 |
17 |
18 | // VIRTUAL
19 |
20 | protected virtual void setDeferredArgs(String serializedArgs) {}
21 |
22 |
23 | protected virtual void setDeferredShared(String serializedShared) {
24 | Map hydratedShared = (Map)JSON.deserializeUntyped(serializedShared);
25 | for(String key : hydratedShared.keySet()) {
26 | setShared(key, hydratedShared.get(key));
27 | }
28 | }
29 |
30 |
31 | protected virtual String getDeferArgs() {
32 | return null;
33 | }
34 |
35 |
36 | protected virtual String getDeferShared() {
37 | return JSON.serialize(sharedVariables);
38 | }
39 |
40 |
41 | private virtual void handleDeferException(Exception e) {
42 | throw e;
43 | }
44 |
45 | // PUBLIC
46 |
47 |
48 | @InvocableMethod(label='Rebuild chain from deferred chainables and execute')
49 | public static List rebuildAndExecuteChain(List deferredLinks) {
50 | Chainable chain;
51 | Chainable iteratingInstance;
52 |
53 | List results = new List();
54 |
55 | for(DeferredChainLink deferredLink : deferredLinks) {
56 |
57 | try {
58 | iteratingInstance = deferredLink.getLinkableInstance();
59 |
60 | if(chain == null) {
61 | chain = iteratingInstance;
62 | }
63 | else {
64 | chain.then(iteratingInstance);
65 | }
66 | results.add(new DeferRebuildResult());
67 | }
68 | catch(Exception e) {
69 | results.add(new DeferRebuildResult(e.getMessage()));
70 | }
71 | }
72 |
73 | chain?.execute();
74 |
75 | return results;
76 | }
77 |
78 |
79 | public Chainable then(Chainable successor) {
80 | if(next != null) {
81 | next.then(successor);
82 | }
83 | else {
84 | next = successor;
85 | next.previous = this;
86 |
87 | next.sharedVariables = sharedVariables;
88 | }
89 |
90 | return this;
91 | }
92 |
93 |
94 | public Chainable execute() {
95 | if(Test.isRunningTest()) {
96 | executeSynchronously(new Context());
97 | executeNext();
98 | }
99 | else {
100 | executeAsynchronously();
101 | }
102 |
103 | return this;
104 | }
105 |
106 |
107 | public Chainable setShared(String key, Object value) {
108 | sharedVariables.put(key, value);
109 |
110 | return this;
111 | }
112 |
113 |
114 | public Object getShared(String key) {
115 | return sharedVariables.get(key);
116 | }
117 |
118 |
119 | public Chainable executeDeferred() {
120 |
121 | List deferEvents = unlink();
122 |
123 | if(Limits.getLimitDMLStatements() < deferEvents.size()) {
124 | handleDeferException(new DeferLimitException());
125 | }
126 |
127 | for(Database.SaveResult publishResult : EventBus.publish(deferEvents)) {
128 | if(!publishResult.isSuccess()) {
129 | handleDeferException(new DeferPublishException(publishResult.getErrors()));
130 | }
131 | }
132 |
133 | return this;
134 | }
135 |
136 | @TestVisible
137 | private List unlink() {
138 |
139 | List deferEvents = new List();
140 |
141 | try {
142 | deferEvents.add(new Chainable__e(
143 | InstanceName__c = String.valueOf(this).substringBefore(':'),
144 | Arguments__c = getDeferArgs(),
145 | Shared__c = getDeferShared()
146 | ));
147 | }
148 | catch(Exception e) {
149 | handleDeferException(new DeferUnlinkException(e));
150 | }
151 | finally {
152 | if(next != null) {
153 | deferEvents.addAll(next.unlink());
154 | }
155 | }
156 |
157 | return deferEvents;
158 | }
159 |
160 |
161 | // PROTECTED
162 |
163 | protected void executeNext() {
164 | if(next != null) {
165 | next.execute();
166 | }
167 | }
168 |
169 |
170 | // INNER
171 |
172 | public class Context {
173 |
174 | private Object originalContext;
175 |
176 | public Context() {}
177 |
178 | public Context(Database.BatchableContext ctx) {
179 | originalContext = ctx;
180 | }
181 |
182 | public Context(QueueableContext ctx) {
183 | originalContext = ctx;
184 | }
185 |
186 | public Context(SchedulableContext ctx) {
187 | originalContext = ctx;
188 | }
189 |
190 | public Object get() {
191 | return originalContext;
192 | }
193 | }
194 |
195 | public class DeferredChainLink {
196 |
197 | @InvocableVariable(required=true)
198 | public Chainable__e deferEvent;
199 |
200 | public Chainable getLinkableInstance() {
201 | Chainable linkableInstance;
202 |
203 | Type jobType = Type.forName(deferEvent.InstanceName__c);
204 |
205 | if(jobType != null && Chainable.class.isAssignableFrom(jobType)) {
206 |
207 | linkableInstance = (Chainable)jobType.newInstance();
208 | linkableInstance.setDeferredShared(deferEvent.Shared__c);
209 | if(String.isNotBlank(deferEvent.Arguments__c)) {
210 | linkableInstance.setDeferredArgs(deferEvent.Arguments__c);
211 | }
212 | }
213 | else {
214 | throw new DeferredIsNotChainableException(INVALID_CLASS_MSG + deferEvent.InstanceName__c);
215 | }
216 |
217 | return linkableInstance;
218 | }
219 | }
220 |
221 |
222 | public class DeferRebuildResult {
223 |
224 | @InvocableVariable
225 | public Boolean success;
226 |
227 | @InvocableVariable
228 | public String error;
229 |
230 | public DeferRebuildResult() {
231 | success = true;
232 | }
233 |
234 | public DeferRebuildResult(String errorDetail) {
235 | success = false;
236 | error = errorDetail;
237 | }
238 | }
239 |
240 |
241 | public class DeferLimitException extends Exception {}
242 | public class DeferUnlinkException extends Exception {}
243 | public class DeferredIsNotChainableException extends Exception {}
244 | public class DeferPublishException extends Exception {
245 | public DeferPublishException(List publishErrors) {
246 | List messages = new List();
247 | for(Database.Error error : publishErrors) {
248 | messages.add(error.getMessage());
249 | }
250 |
251 | setMessage(String.join(messages, '\n'));
252 | }
253 | }
254 | }
255 |
--------------------------------------------------------------------------------
/force-app/main/default/classes/Chainable.cls-meta.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 44.0
4 | Active
5 |
6 |
--------------------------------------------------------------------------------
/force-app/main/default/classes/ChainableBatch.cls:
--------------------------------------------------------------------------------
1 | public abstract class ChainableBatch extends Chainable
2 | implements Database.Batchable