Idiomatic Salesforce Apex
./img/joys-of-apex-thumbnail.png
We often hear the word "idiomatic" applied to programming languages to express the language-specific way of accomplishing routinely encountered problems. In this post, we dive into how to write idiomatic Salesforce Apex to make the most of each line of code as I refactoring some existing vendor code into an easier to understand format.
Following up on the Apex Logging Service post, I was looking to push exceptions generated in Apex to Rollbar. Organizations, if they're using plenty of services beyond Salesforce, will undoubtedly be using a centralized logging solution. I've seen many ELK-stack approaches out there, but Rollbar (and competitors like Sumo Logic) is commonly seen on the web development side as an easy vendor to opt into, without having to worry about hosting or dashboard building. Once a company's using a vendor for one side of their logging approach, you can best believe that getting any other service's exceptions into said solution is going to be desirable — centralize where you view exceptions being generated, and you increase the visibility of all code-related issues.
At the time, I naively assumed that because Rollbar had an Apex installed package, my conscience could rest easy going with the provided solution. I was wrong. Upon installing the Rollbar package into a sandbox, I realized quickly that there were going to be a few issues (I want to use this as an opportunity to say that I love Rollbar. I think they're a great company that provides a great service. I use their code as an opportunity to make a point in this post; I'm not trying to trash it):
That being said ... no need to reinvent the wheel, I thought. You can't see namespaced code in your org unless it's a global class (and even then you can't see the full object) ... but that wasn't the case for this code, so I dove right in.
I had already seen via the documentation on Rollbar's Apex documentation page that they were employing the Singleton pattern for accessing the logger, so that was the first thing I chose to look at:
1linkpublic with sharing class Rollbar {
2link
3link public static Rollbar instance() {
4link if (Rollbar.instance == null) {
5link Rollbar.instance = new Rollbar();
6link }
7link
8link return Rollbar.instance;
9link }
10link
11link public static Rollbar init()
12link {
13link return Rollbar.init(
14link RollbarSettings__c.getInstance().AccessToken__c,
15link UserInfo.getOrganizationName()
16link );
17link }
18link
19link public static Rollbar init(String accessToken, String environment) {
20link return Rollbar.init(new Config(accessToken, environment));
21link }
22link
23link public static Rollbar init(Config config) {
24link Rollbar instance = instance();
25link instance.config = config;
26link instance.notifier = new Notifier(instance.config);
27link instance.initialized = true;
28link return instance;
29link }
30link
31link public static HttpResponse log(String level, String message) {
32link Rollbar instance = initializedInstance();
33link return instance.notifier.log(level, message);
34link }
35link
36link public static HttpResponse log(Exception exc) {
37link Rollbar instance = initializedInstance();
38link return instance.notifier.log(exc);
39link }
40link
41link public static HttpResponse log(Exception exc, Map<String, Object> custom) {
42link Rollbar instance = initializedInstance();
43link return instance.notifier.log(exc, custom);
44link }
45link
46link public static HttpResponse log(ExceptionData exData) {
47link Rollbar instance = initializedInstance();
48link return instance.notifier.log(exData);
49link }
50link
51link private static Rollbar initializedInstance()
52link {
53link Rollbar instance = Rollbar.instance();
54link if (!instance.initialized) {
55link Rollbar.init();
56link }
57link
58link return instance;
59link }
60link
61link private Rollbar() {
62link }
63link
64link private static Rollbar instance = null;
65link private Config config = null;
66link private Notifier notifier = null;
67link private Boolean initialized = false;
68link}
Let's just leave aside all those public init methods and hone in here ...
So we've got ~ 70 lines of code mostly dealing with the problem of initializing a singleton instance / logging with strings that are actually constants.
First let's review the idomatic way to initialize Singletons in Apex (if you're interested in more information on the singleton shown below, please read Building A Better Singleton to learn more!):
1linkpublic class MyClass {
2link private MyClass() {
3link //prevent public initialization
4link //you can still use dependency injection within your constructor though
5link }
6link
7link //Singleton method
8link private static final MyClass Instance = new MyClass();
9link
10link //expose public static methods for using MyClass
11link public static void sayHi() {
12link Instance.say('hi');
13link }
14link
15link private void say(String sayString) {
16link System.debug(sayString);
17link }
18link}
With that in mind, let's streamline this implementation:
1linkprivate final Http http;
2linkprivate final RollbarDataBuilder dataBuilder;
3link
4linkprivate static final String API_URI = 'https://api.rollbar.com/api/1/item/';
5link
6linkpublic enum Level { Critical, Debug, Error, Info, Warning }
7link
8linkprivate Rollbar() {
9link this.dataBuilder = new RollbarDataBuilder();
10link this.http = new Http();
11link}
12link
13linkpublic static final Rollbar Instance = new Rollbar();
14link
15linkpublic static HttpResponse log(Level level, String message) {
16link Message payload = Instance.dataBuilder.build(level, message);
17link return send(payload);
18link}
19link
20linkpublic static HttpResponse log(Exception ex) {
21link Message payload = Instance.dataBuilder.build(ex);
22link return send(payload);
23link}
24link
25linkprivate static HttpResponse send(Message payload) {
26link HttpRequest request = new HttpRequest();
27link request.setEndpoint(API_URI);
28link request.setMethod(RestMethod.POST.name());
29link //Rollbar only wants non-null properties sent over
30link //The second argument suppresses null values
31link request.setBody(Json.serialize(payload, true));
32link
33link HttpResponse res = Instance.http.send(request);
34link if(res.getStatusCode() != 200) {
35link throw new CalloutException(
36link 'Rollbar callout failed with response: ' + res.getBody()
37link );
38link }
39link return res;
40link}
So what we have gained? We killed off the Notifier class (which wasn't shown, but also was just wrapping calls to the DataBuilder) in favor of the Rollbar object encapsulating the full callout. We got rid of a bunch of boilerplate related to initialization. We traded string constants for a Rollbar.Level
enum which can easily be understood as the severity of the log being sent.
The astute reader might note that I'm not following the pattern I prescribed in Future Methods, Callouts & Callbacks. You'd be exactly right in saying that, but I was recently reminded of this age-old adage while reading something that came up on the React subreddit:
Duplication is far cheaper than the wrong abstraction
The Callout & Callback pattern that I documented previously is great when you have many consumers whose post-callout behavior is tightly coupled to interactions with the database or further processing is necessary. In this case, however, it would be a mistake to try to shoehorn this specific logging implementation into an abstraction meant for post-processing ... especially because having the Http object as a member of the Rollbar class will prove helpful for fully testing everything out.
But I'm getting ahead of myself. What does this DataBuilder
object look like?
1linkpublic with sharing class DataBuilder {
2link public DataBuilder(Config config) {
3link this.config = config;
4link }
5link
6link public Map<String, Object> buildPayload(String level, String message)
7link {
8link return buildPayloadStructure(level, buildMessageBody(message), null);
9link }
10link
11link public Map<String, Object> buildPayload(Exception exc)
12link {
13link return buildPayloadStructure('error', buildExceptionBody(exc), null);
14link }
15link
16link public Map<String, Object> buildPayload(Exception exc, Map<String, Object> custom)
17link {
18link return buildPayloadStructure('error', buildExceptionBody(exc), custom);
19link }
20link
21link public Map<String, Object> buildPayload(ExceptionData exData)
22link {
23link Map<String, Object> custom = new Map<String, Object>();
24link custom.put('context', exData.context());
25link
26link return buildPayloadStructure('error', buildTraceBody(exData), custom);
27link }
28link
29link private Map<String, Object> buildExceptionBody(Exception exc)
30link {
31link if (exc.getCause() == null) {
32link return buildTraceBody(exc);
33link } else {
34link return buildTraceChainBody(exc);
35link }
36link }
37link
38link private Map<String, Object> buildTraceChainBody(Exception exc)
39link {
40link Map<String, Object> outterExTrace = (Map<String, Object>)
41link this.buildTraceBody(exc).get('trace');
42link Map<String, Object> innerExTrace = (Map<String, Object>)
43link this.buildTraceBody(exc.getCause()).get('trace');
44link
45link List<Map<String, Object>> traceChainList = new List<Map<String, Object>>();
46link traceChainList.add(outterExTrace);
47link traceChainList.add(innerExTrace);
48link
49link
50link Map<String, Object> body = new Map<String, Object>();
51link body.put('trace_chain', traceChainList);
52link
53link return body;
54link }
55link
56link private Map<String, Object> buildPayloadStructure(
57link String level,
58link Map<String, Object> body,
59link Map<String, Object> custom
60link ) {
61link Map<String, Object> data = this.buildDataStructure(
62link level,
63link this.config.environment(),
64link body,
65link custom
66link );
67link
68link Map<String, Object> structure = new Map<String, Object>();
69link structure.put('access_token', this.config.accessToken());
70link structure.put('data', data);
71link return structure;
72link }
73link
74link private Map<String, Object> buildDataStructure(
75link String level,
76link String environment,
77link Map<String, Object> body,
78link Map<String, Object> custom
79link ) {
80link
81link Map<String, Object> notifierMap = new Map<String, Object>();
82link notifierMap.put('name', Notifier.NAME);
83link notifierMap.put('version', Notifier.VERSION);
84link
85link Map<String, Object> structure = new Map<String, Object>();
86link structure.put('notifier', notifierMap);
87link structure.put('level', level);
88link structure.put('environment', environment);
89link structure.put('framework', 'apex');
90link structure.put('body', body);
91link structure.put('custom', custom);
92link
93link return structure;
94link }
95link
96link private Map<String, Object> buildMessageBody(String message)
97link {
98link Map<String, Object> messageMap = new Map<String, Object>();
99link messageMap.put('body', message);
100link
101link Map<String, Object> body = new Map<String, Object>();
102link body.put('message', messageMap);
103link
104link return body;
105link }
106link
107link private Map<String, Object> buildTraceBody(ExceptionData exData)
108link {
109link List<Map<String, Object>> framesList = new List<Map<String, Object>>();
110link
111link Map<String, Object> frameMap = new Map<String, Object>();
112link frameMap.put('filename', exData.fileName());
113link frameMap.put('class_name', exData.className());
114link frameMap.put('method', exData.fileName());
115link frameMap.put('lineno', exData.line());
116link frameMap.put('colno', exData.column());
117link
118link framesList.add(frameMap);
119link
120link Map<String, Object> excMap = new Map<String, Object>();
121link excMap.put('class', exData.className());
122link excMap.put('message', exData.message());
123link
124link return buildTraceStructure(excMap, framesList);
125link }
126link
127link private Map<String, Object> buildTraceBody(Exception exc)
128link {
129link List<Map<String, Object>> framesList = new List<Map<String, Object>>();
130link
131link String[] frames = exc.getStackTraceString().split('\n');
132link for (String frameStr : frames) {
133link if (frameStr == '()') {
134link continue;
135link } else if (frameStr.toLowerCase() == 'caused by') {
136link break;
137link }
138link
139link Map<String, Object> frameMap = new Map<String, Object>();
140link frameMap.put('filename', frameStr);
141link
142link String className = frameStr.split(':')[0];
143link String methodName = '';
144link if (className != 'AnonymousBlock') {
145link className = className.split('\\.')[1];
146link methodName = frameStr.split(':')[0].split('\\.')[2];
147link }
148link
149link frameMap.put('class_name', className);
150link frameMap.put('method', methodName);
151link
152link Pattern linePattern = Pattern.compile('line (\\d+)');
153link Matcher lineMatcher = linePattern.matcher(frameStr);
154link lineMatcher.find();
155link frameMap.put('lineno', Integer.valueOf(lineMatcher.group(1)));
156link
157link Pattern colPattern = Pattern.compile('column (\\d+)');
158link Matcher colMatcher = colPattern.matcher(frameStr);
159link colMatcher.find();
160link frameMap.put('colno', Integer.valueOf(colMatcher.group(1)));
161link
162link framesList.add(frameMap);
163link }
164link
165link Map<String, Object> excMap = new Map<String, Object>();
166link excMap.put('class', exc.getTypeName());
167link excMap.put('message', exc.getMessage());
168link
169link return buildTraceStructure(excMap, framesList);
170link }
171link
172link private Map<String, Object> buildTraceStructure(
173link Map<String, Object> exceptionMap,
174link List<Map<String, Object>> framesList
175link ) {
176link Map<String, Object> body = new Map<String, Object>();
177link
178link Map<String, Object> traceMap = new Map<String, Object>();
179link
180link traceMap.put('exception', exceptionMap);
181link traceMap.put('frames', framesList);
182link
183link body.put('trace', traceMap);
184link
185link return body;
186link }
187link
188link private Config config;
189link}
Eep. Hopefully you just scrolled through that at a rapid staccato. We're using a strongly-typed language — let's try to take advantage of that to cut down on some of the string maps here. We can also make use of the same Rollbar.Level
enum introduced above, making it easier to differentiate between method arguments. Unfortunately, not everything will be able to be strongly typed — if you'll notice, the JSON object that's being built here makes use of two properties, exception
and class
, both of which are reserved keywords in Apex.
Again, since the JSON object that's being built is singularly constrained to use with Rollbar, I'm less concerned with the tight coupling between this DataBuilder object and the Rollbar class, and more concerned with how I'd like to refer to things within a "Rollbar namespace" — in other words, let's put those strongly typed classes into the Rollbar class, and rename the "DataBuilder" so that it's the "RollbarDataBuilder". That's idomatic Apex — typings to help us and our tests verify that things are being shaped correctly, with descriptive names to assist:
1linkpublic class Message {
2link public String access_token { get; set; }
3link public Data data { get; set; }
4link}
5link
6linkpublic class Data {
7link public Data() {
8link notifier = new Notifier();
9link }
10link public Notifier notifier { get; set; }
11link public String level { get; set; }
12link public String environment { get; set; }
13link public String framework { get { set; }
14link public MessageBody body { get; set; }
15link}
16link
17linkpublic class MessageBody {
18link //trace can't be strongly typed because
19link //it has a property named "exception"
20link //which is a reserved word in Apex
21link //the "exception" property also has "class" and "message"
22link //strings, and "class" is another reserved word
23link public Map<String, Object> trace { get; set; }
24link public List<Map<String, Object>> trace_chain { get; set; }
25link public InnerMessage message { get; set; }
26link}
27link
28linkpublic class InnerMessage {
29link public String body { get; set; }
30link}
31link
32linkpublic class Notifier {
33link public String name { get; set;}
34link public String version { get; set; }
35link}
36link
37linkpublic class ExceptionFrame {
38link public String filename { get; set; }
39link public String class_name { get; set; }
40link public String method { get; set; }
41link public Integer lineno { get; set; }
42link public Integer colno { get; set; }
43link}
So we get a bunch of POJOs, which thanks to IDE intellisense are going to prove enormously helpful in making clear the shape of the log objects being constructed.
And the much-reduced RollbarDataBuilder
:
1linkpublic class RollbarDataBuilder {
2link public static final String FRAMEWORK = 'apex';
3link public static final String NAME = 'rollbar-sf-apex';
4link public static final String VERSION = '1.0.0';
5link
6link public Rollbar.Message build(Rollbar.Level level, String message) {
7link return buildPayloadStructure(level, buildMessageBody(message));
8link }
9link
10link public Rollbar.Message build(Exception ex) {
11link return buildPayloadStructure(Rollbar.Level.Error, buildExceptionBody(ex));
12link }
13link
14link private Rollbar.Message buildPayloadStructure(
15link Rollbar.Level level,
16link Rollbar.MessageBody body) {
17link Rollbar.Message message = new Rollbar.Message();
18link //wherever you store secrets, be it a custom setting, object, or metadata
19link message.access_token = someValue;
20link message.data = this.buildDataStructure(level, body);
21link return message;
22link }
23link
24link private Rollbar.Data buildDataStructure(Rollbar.Level level,
25link Rollbar.MessageBody body) {
26link Rollbar.Data data = new Rollbar.Data();
27link data.body = body;
28link //I didn't like using the Org name, preferring the granularity of the Org URL.
29link // You could realistically put any kind of domain-recognizable identifier here.
30link data.environment = Url.getSalesforceBaseUrl().toExternalForm();
31link data.framework = FRAMEWORK;
32link data.level = level.name().toLowerCase();
33link data.notifier.name = NAME;
34link data.notifier.version = VERSION;
35link return data;
36link }
37link
38link private Rollbar.MessageBody buildMessageBody(String messageBody) {
39link Rollbar.InnerMessage innerMessage = new Rollbar.InnerMessage();
40link innerMessage.body = messageBody;
41link
42link Rollbar.MessageBody body = new Rollbar.MessageBody();
43link body.message = innerMessage;
44link return body;
45link }
46link
47link private Rollbar.MessageBody buildExceptionBody(Exception ex) {
48link if (ex.getCause() == null) {
49link return buildTraceMessage(ex);
50link } else {
51link return buildTraceChainBody(ex);
52link }
53link }
54link
55link private Rollbar.MessageBody buildTraceMessage(Exception ex) {
56link //note that while the typings have changed in this method
57link //the underlying logic I left alone.
58link List<Rollbar.ExceptionFrame> framesList = new List<Rollbar.ExceptionFrame>();
59link
60link String[] frames = ex.getStackTraceString().split('\n');
61link for (String frameStr : frames) {
62link if (frameStr == '()') {
63link continue;
64link } else if (frameStr.toLowerCase() == 'caused by') {
65link break;
66link }
67link
68link Rollbar.ExceptionFrame frame = new Rollbar.ExceptionFrame();
69link frame.filename = frameStr;
70link
71link String className = frameStr.split(':')[0];
72link String methodName = '';
73link if (className != 'AnonymousBlock') {
74link className = className.split('\\.')[1];
75link methodName = frameStr.split(':')[0].split('\\.')[2];
76link }
77link
78link frame.class_name = className;
79link frame.method = methodName;
80link
81link Pattern linePattern = Pattern.compile('line (\\d+)');
82link Matcher lineMatcher = linePattern.matcher(frameStr);
83link lineMatcher.find();
84link frame.lineno = Integer.valueOf(lineMatcher.group(1));
85link
86link Pattern colPattern = Pattern.compile('column (\\d+)');
87link Matcher colMatcher = colPattern.matcher(frameStr);
88link colMatcher.find();
89link frame.colno = Integer.valueOf(colMatcher.group(1));
90link
91link framesList.add(frame);
92link }
93link
94link Map<String, Object> excMap = new Map<String, Object>();
95link excMap.put('class', exc.getTypeName());
96link excMap.put('message', exc.getMessage());
97link
98link return buildTraceBody(excMap, framesList);
99link }
100link
101link private Rollbar.MessageBody buildTraceBody(Map<String, Object> exceptionMap,
102link List<Rollbar.ExceptionFrame> framesList) {
103link Rollbar.MessageBody body = new Rollbar.MessageBody();
104link body.trace = new Map<String, Object>();
105link body.trace.put('exception', exceptionMap);
106link body.trace.put('frames', framesList);
107link return body;
108link }
109link
110link private Rollbar.MessageBody buildTraceChainBody(Exception ex) {
111link Map<String, Object> outterExTrace = this.buildTraceMessage(ex).trace;
112link Map<String, Object> innerExTrace = this.buildTraceMessage(ex.getCause()).trace;
113link
114link List<Map<String, Object>> traceChainList = new List<Map<String, Object>>();
115link traceChainList.add(outterExTrace);
116link traceChainList.add(innerExTrace);
117link
118link Rollbar.MessageBody body = new Rollbar.MessageBody();
119link body.trace_chain = traceChainList;
120link return body;
121link }
122link}
Because the DataBuilder class came with tests, it's easy to verify that this refactor, which cut the lines of code in half, is still doing exactly what we'd like it to. The tests themselves cleaned up nicely, because there was a heck of a lot less casting to Map<String, Object>
happening. I also changed some method names; particularly for public methods, it's often a waste of characters to describe the return type in the name of the method — intellisense will tell you the return type!
Lastly, I'll just touch on what I was talking about before — using the Http member object on the Rollbar class to confirm end-to-end testing. Yes, we use Test.setMock(System.HttpCalloutMock)
to properly mock API responses in tests, but in order to verify that our data is built correctly prior to sending out, having access to the body of the HttpRequest
is paramount. Again, I typically don't like to utilize this kind of pattern, but because logging as an activity is something that's going to happen many layers deep into your application, sometimes it's necessary (as opposed to passing an object back up the entire stack):
1linkprivate final HttpWrapper http;
2linkprivate final RollbarDataBuilder dataBuilder;
3link
4linkprivate Rollbar() {
5link this.dataBuilder = new RollbarDataBuilder();
6link this.http = new HttpWrapper();
7link}
8link
9link//...
10link
11link@testVisible private static HttpRequest testReq;
12linkprivate class HttpWrapper {
13link private final Http http;
14link public HttpWrapper() {
15link this.http = new Http();
16link }
17link
18link public HttpResponse send(HttpRequest req) {
19link testReq = req;
20link return this.http.send(req);
21link }
22link}
This exhibits two other important idiomatic Apex patterns:
final
whenever possible. Let the compiler enforce for you that no changes to these members can occur. This is true of many other strongly typed languages as well, regardless of what the nomenclature is; C# uses readonly
to achieve the same effect, for example.@TestVisible
(or @testVisible
, Apex isn't case sensitive) when necessary to access deeply nested and otherwise certifiably private state variables can help to reduce test complexity and enforce that object consumers won't have access to things other than what they need in production. I always advise using this pattern sparingly, but there's no denying it's quite helpful.In the end, it's a shame that some of the JSON object being sent to Rollbar made use of reserved words in Apex. This isn't something that they, as a vendor, could have anticipated; they have many SDKS across a variety of commonly used languages, and planning out their object structure with deference to one specific language is a little much to ask of anybody. Still, it made the object structure less clean than I would have liked. That the real world demands us to do things we'd prefer to avoid doing is another important lesson.
I realize I'm just brushing the surface of an extremely broad topic — "Idiomatic Apex" would be an enormous book, if printed, and Singleton initialization and strong typing is barely a start. Have some thoughts on other excellent idiomatic Apex examples? Feel free to reach out — perhaps your suggestions will inform more posts on this subject!
The original version of Idiomatic Salesforce Apex can be read on my blog.
Home Apex Logging Service Apex Object-Oriented Basics Batchable And Queueable Apex Building A Better Singleton Continuous Integration With SFDX Dependency Injection & Factory Pattern Enum Apex Class Gotchas Extendable Apis Formula Date Issues Future Methods, Callouts & Callbacks Idiomatic Salesforce Apex Introduction & Testing Philosophy Lazy Iterators Lightweight Trigger Handler LWC Composable Modal LWC Composable Pagination LWC Custom Lead Path Mocking DML React Versus Lightning Web Components Refactoring Tips & Tricks Replacing DLRS With Custom Rollup Repository Pattern setTimeout & Implementing Delays Sorting And Performance In Apex Test Driven Development Example Testing Custom Permissions Writing Performant Apex Tests