├── .eslintignore
├── .forceignore
├── .gitignore
├── .prettierignore
├── .prettierrc
├── README.md
├── config
└── project-scratch-def.json
├── force-app
└── main
│ └── default
│ ├── classes
│ ├── RecordSync.cls
│ ├── RecordSync.cls-meta.xml
│ ├── RecordSyncHandlerTest.cls
│ ├── RecordSyncHandlerTest.cls-meta.xml
│ ├── RecordSyncTestCallable.cls
│ ├── RecordSyncTestCallable.cls-meta.xml
│ ├── RecordSync_Test.cls
│ ├── RecordSync_Test.cls-meta.xml
│ ├── TriggerHandler.cls
│ ├── TriggerHandler.cls-meta.xml
│ ├── TriggerHandler_Test.cls
│ └── TriggerHandler_Test.cls-meta.xml
│ ├── customMetadata
│ └── Trigger_Controller.RecordSyncHandlerTest.md-meta.xml
│ ├── layouts
│ └── Trigger_Controller__mdt-Trigger Controller Layout.layout-meta.xml
│ ├── objects
│ └── Trigger_Controller__mdt
│ │ ├── Trigger_Controller__mdt.object-meta.xml
│ │ ├── fields
│ │ ├── After_Delete__c.field-meta.xml
│ │ ├── After_Insert__c.field-meta.xml
│ │ ├── After_Undelete__c.field-meta.xml
│ │ ├── After_Update__c.field-meta.xml
│ │ ├── Applies_To_Type__c.field-meta.xml
│ │ ├── Applies_To_Value__c.field-meta.xml
│ │ ├── Before_Delete__c.field-meta.xml
│ │ ├── Before_Insert__c.field-meta.xml
│ │ ├── Before_Undelete__c.field-meta.xml
│ │ └── Before_Update__c.field-meta.xml
│ │ ├── listViews
│ │ └── All_Trigger_Controllers.listView-meta.xml
│ │ └── validationRules
│ │ └── Applies_To_Value.validationRule-meta.xml
│ └── triggers
│ ├── RecordSyncTestTrigger.trigger
│ └── RecordSyncTestTrigger.trigger-meta.xml
├── jest.config.js
├── manifest
└── package.xml
├── package.json
└── sfdx-project.json
/.eslintignore:
--------------------------------------------------------------------------------
1 | **/lwc/**/*.css
2 | **/lwc/**/*.html
3 | **/lwc/**/*.json
4 | **/lwc/**/*.svg
5 | **/lwc/**/*.xml
6 | **/aura/**/*.auradoc
7 | **/aura/**/*.cmp
8 | **/aura/**/*.css
9 | **/aura/**/*.design
10 | **/aura/**/*.evt
11 | **/aura/**/*.json
12 | **/aura/**/*.svg
13 | **/aura/**/*.tokens
14 | **/aura/**/*.xml
15 | **/aura/**/*.app
16 | .sfdx
17 |
--------------------------------------------------------------------------------
/.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 |
5 | package.xml
6 |
7 | # LWC configuration files
8 | **/jsconfig.json
9 | **/.eslintrc.json
10 |
11 | # LWC Jest
12 | **/__tests__/**
--------------------------------------------------------------------------------
/.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 | .sf/
7 | .sfdx/
8 | .localdevserver/
9 | deploy-options.json
10 |
11 | # LWC VSCode autocomplete
12 | **/lwc/jsconfig.json
13 |
14 | # LWC Jest coverage reports
15 | coverage/
16 |
17 | # Logs
18 | logs
19 | *.log
20 | npm-debug.log*
21 | yarn-debug.log*
22 | yarn-error.log*
23 |
24 | # Dependency directories
25 | node_modules/
26 |
27 | # Eslint cache
28 | .eslintcache
29 |
30 | # MacOS system files
31 | .DS_Store
32 |
33 | # Windows system files
34 | Thumbs.db
35 | ehthumbs.db
36 | [Dd]esktop.ini
37 | $RECYCLE.BIN/
38 |
39 | # Local environment variables
40 | .env
41 |
42 | .vscode/
43 | .husky/
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | # List files or directories below to ignore them when running prettier
2 | # More information: https://prettier.io/docs/en/ignore.html
3 | #
4 |
5 | **/staticresources/**
6 | .localdevserver
7 | .sfdx
8 | .vscode
9 |
10 | coverage/
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "none",
3 | "overrides": [
4 | {
5 | "files": "**/lwc/**/*.html",
6 | "options": { "parser": "lwc" }
7 | },
8 | {
9 | "files": "*.{cmp,page,component}",
10 | "options": { "parser": "html" }
11 | }
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Salesforce Simple Trigger Framework and Record Sync
2 |
3 | A simplified trigger framework to extract logic from triggers, with an added feature of synchronising between two records uni or bi-directionally.
4 |
5 | ## Features
6 | + Apex triggers are kept to one line, and logic is extracted into conveniently-formatted classes.
7 | + Control trigger activation by user, profile, role, or permission set, and for each individual trigger event.
8 | + Automatically create and synchronise a child record, either one-way or two-ways. Records are kept in-sync after insert, update, delete, and undelete.
9 |
10 | ## Deployment
11 |
12 |
13 |
14 |
15 |
16 | ## How do I make a trigger?
17 |
18 | Start by creating a trigger handler class. This must extend the TriggerHandler class:
19 | ```Apex
20 | public without sharing class MyTriggerHandler extends TriggerHandler {
21 | }
22 | ```
23 |
24 | The following methods are available to override:
25 | + beforeInsert
26 | + beforeUpdate
27 | + beforeDelete
28 | + beforeUndelete
29 | + afterInsert
30 | + afterUpdate
31 | + afterDelete
32 | + afterUndelete
33 |
34 | For example:
35 | ```Apex
36 | public override void beforeUpdate() {
37 | //Get new and old records
38 | List newCases = (List)Trigger.new,
39 | oldCases = (List)Trigger.old;
40 |
41 | //Do some stuff here
42 | }
43 | ```
44 |
45 | Next, create a trigger for the object and instantiate your trigger handler:
46 | ```Apex
47 | trigger ScheduleObjectTrigger on Case(before update) {
48 | new MyTriggerHandler();
49 | }
50 | ```
51 |
52 | Done. Simple, right?
53 |
54 | ## Exception Handling
55 |
56 | By default, exceptions in the trigger are thrown back and crash the current transaction. The error handler can be overridden to implement custom functionality or to ignore the exception altogether:
57 |
58 | ```Apex
59 | public override void onError(Exception e) {
60 | //Handle exception here
61 | }
62 | ```
63 |
64 | My personal approach is that most trigger errors should not block the entire transaction, so creating an onError handler that does not throw the exception (and perhaps logs or reports it via email) is recommended. But I'm not your mother, I can't tell you what to do.
65 |
66 | ## Enabling and Disabling Trigger Functionality via Metadata
67 |
68 | The provided custom metadata type can be used to enable or disable triggers.
69 | Enter Setup -> Custom Metadata Types and select the Trigger Controller object.
70 | Create a new record. In the Master Label field, enter the name of your trigger handler class (for example: MyTriggerHandler).
71 | Use the checkboxes to activate or deactivate specific trigger events.
72 |
73 | Triggers can be deactivated globally, for a single username, profile, role, or permission set. Use the Applies To (Type) and Applies To (Value) to control this. For example, to deactivate a trigger for the System Administrator profile, select:
74 | Applies To (Type): Profile
75 | Applies To (Value): System Administrator
76 |
77 | In the event of a clash (for example, one controller affects a user's profile and another affects their role), then the most restrictive options win (disabled triggers win over enabled ones).
78 |
79 | ## About Uni-Directional Record Synchronisation
80 |
81 | This feature allows you to create a trigger on any object that creates a parallel, always-synchronised record in another object.
82 | This is great for synchronising data between two applications on the platform.
83 |
84 | Child record values can come from the parent record, from hard-coded values, or from a Callable Apex class. Callable Apex classes are cached for increased performance.
85 |
86 | Synchronisation handles the following events:
87 |
88 | ### Record Creation
89 | When a parent record is created, a synchronised (child) record is created.
90 |
91 | ### Record Update
92 | When a parent record is updated, if a child record already exists, it will also be updated. Otherwise, it will be created.
93 |
94 | ### Record Deletion
95 | When a parent record is deleted, the child record is deleted.
96 |
97 | ### Record Undeletion
98 | When a record is undeleted, a child record is re-created (not restored from the recycle bin, at least at this stage).
99 |
100 | ## Synchronisation Example
101 |
102 | Here is a test class that shows how easy it is to synchronise two records:
103 | ```Apex
104 | public without sharing class SyncCaseAndTask extends RecordSync {
105 | public override Schema.DescribeSObjectResult getObjectType() {
106 | //This returns the type of object that needs to be created
107 | return Schema.SObjectType.Task;
108 | }
109 |
110 | public override Schema.DescribeFieldResult getChildRelationalField() {
111 | //This returns the field where the parent record ID will be written.
112 | //This should be a lookup field pointing to the parent object.
113 | return Schema.SObjectType.Task.fields.WhatId;
114 | }
115 |
116 | public override Schema.DescribeFieldResult getParentRelationalField() {
117 | //Implement this method to write the ID of the generated child record
118 | //into a field on the original, parent record. Also required for two-
119 | //way synchronisation.
120 | return Schema.SObjectType.Case.fields.Primary_Task_ID__c;
121 | }
122 |
123 | public override Boolean shouldSync(SObject record) {
124 | //Implement this method to only synchronise records conditionally.
125 | //If true, the parent record will create a child record.
126 | return (String)record.get('Subject') != 'Delete me';
127 | }
128 |
129 | public override Boolean isAsynchronous(List records) {
130 | //Implement this method to optionally execute synchronisation
131 | //as an asynchronous, queueable class. This will run in a new transaction.
132 | //Note that there are Apex governor limits on asynchronous executions.
133 | return false;
134 | }
135 |
136 | public override Map getFieldMapping() {
137 | //Returns a map containing field mappings from the child object perspective:
138 | //Field API Name on the Child Object => Value
139 | return new Map {
140 | 'Subject' => 'Complete the case', //The task subject will be a constant string value (this can be any kind of object, like a decimal or date)
141 | 'WhatId' => Case.fields.Id, //The WhatId field will be mapped to the Case ID field (fields must belong to the parent object)
142 | 'Description' => Case.fields.Subject, //The Description field will be mapped to the case Subject field
143 | 'Status' => MyCallableClass.class //The Status field will be calculated by the class MyCallableClass, which implements the Callable interface
144 | };
145 | }
146 | }
147 | ```
148 |
149 | This should then be followed by a trigger:
150 | ```Apex
151 | trigger RecordSyncTestTrigger on Case (after insert, after update, before delete, after undelete) {
152 | new SyncCaseAndTask();
153 | }
154 | ```
155 |
156 | ## Bi-Directional Synchronisation
157 |
158 | Sometimes, you may want records to synchronise bi-directionally. Meaning, if the parent record changes, so will the child. And if the child record changes, so should the parent. This can be implemented by having two triggers, one on each object, that both synchronise one object into the other. In our example, we may choose to create a trigger on the Task object that syncronises back into a case.
159 |
160 | For this to work, both triggers must implement the getParentRelationalField function. This ensures that the newly-created child record does not create another child of its own, but instead synchronises with the parent record. By definition, this means that both synchronised objects must have lookup fields pointing to one another (in a fictional world where SObjects can have lookups to activities).
161 |
162 | ## Who wasted their time writing this garbage?
163 |
164 | This garbage was written by Amnon Kruvi, whom you can reach for assistance at amnon@kruvi.co.uk
165 |
166 | And if you're involved in a project for managing shift workers or field service, why not give [Isimio](https://www.isimio.com) a try?
167 | Or if you're looking for Salesforce architectural support, implementation, or managed services, reach out to [The Architech Club](https://architechclub.com).
168 |
--------------------------------------------------------------------------------
/config/project-scratch-def.json:
--------------------------------------------------------------------------------
1 | {
2 | "orgName": "Demo company",
3 | "edition": "Developer",
4 | "features": ["EnableSetPasswordInApi"],
5 | "settings": {
6 | "lightningExperienceSettings": {
7 | "enableS1DesktopEnabled": true
8 | },
9 | "mobileSettings": {
10 | "enableS1EncryptedStoragePref2": false
11 | }
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/force-app/main/default/classes/RecordSync.cls:
--------------------------------------------------------------------------------
1 | /* Copyright: Amnon Kruvi, Kruvi Solutions, 30/08/2022 */
2 | global abstract class RecordSync extends TriggerHandler implements Queueable {
3 | private Schema.DescribeSObjectResult objectType;
4 | private Map fieldMapping;
5 | private Map callableCache;
6 | private List records;
7 |
8 | private static Boolean isTest = Test.isRunningTest();
9 |
10 | global abstract Schema.DescribeSObjectResult getObjectType();
11 | global abstract Schema.DescribeFieldResult getChildRelationalField();
12 | global virtual Schema.DescribeFieldResult getParentRelationalField() {return null;}
13 | global virtual void postProcess(SObject record, SObject newRecord) {}
14 | global abstract Map getFieldMapping();
15 |
16 | private Object getParentValue(SObject record, Object value, String childField, List allRecords) {
17 | //Process the value based on the type of object it is
18 | if (value instanceof Schema.SObjectField) {
19 | //Field: get the field value from the record
20 | return record.get((Schema.SObjectField)value);
21 | } else if (value instanceof Type) {
22 | //Class: instantiate and execute the class
23 | return runCallable(record, value, childField, allRecords);
24 | } else if (value instanceof Callable) {
25 | //Callable instance: execute the instance as-is
26 | return runCallableInstance(record, (Callable)value, childField, allRecords);
27 | }
28 | return value;
29 | }
30 |
31 | private Object runCallableInstance(SObject record, Callable callableInstance, String childField, List allRecords) {
32 | return callableInstance.call('Calculate', new Map {
33 | 'record' => record,
34 | 'allRecords' => allRecords,
35 | 'field' => childField
36 | });
37 | }
38 |
39 | private Object runCallable(SObject record, Object value, String childField, List allRecords) {
40 | //Cache callables. This is so callables can query and cache their own data over multiple runs if they need to.
41 | if (callableCache == null) {
42 | callableCache = new Map();
43 | }
44 |
45 | //Find callable instance in the cache
46 | Callable inst = callableCache.get(childField);
47 |
48 | if (inst == null) {
49 | //Not found in cache, instantiate a new one
50 | inst = (Callable)((Type)value).newInstance();
51 | }
52 |
53 | //Cache the callable instance
54 | callableCache.put(childField, inst);
55 |
56 | //Execute callable
57 | return runCallableInstance(record, inst, childField, allRecords);
58 | }
59 |
60 | global virtual Boolean shouldSync(SObject record) {
61 | //Override this method to determine which parent records should synchronise (assume all)
62 | return true;
63 | }
64 |
65 | global virtual Boolean isAsynchronous(List records) {
66 | //Override this method to create asynchronous synchronisation
67 | return false;
68 | }
69 |
70 | public override void afterInsert() {
71 | synchronise(Trigger.new);
72 | }
73 |
74 | public override void afterUndelete() {
75 | afterInsert();
76 | }
77 |
78 | private Map