Salesforce Apex Anti-Pattern Scanner
A Visual Studio Code extension that detects common Apex anti-patterns in real-time, helping you write better Salesforce code and avoid governor limit issues.
Features
Anti-Pattern Detection
| Anti-Pattern |
Severity |
Description |
| SOQL in Loops |
Error |
Detects SOQL queries executed inside for, while, or do-while loops |
| DML in Loops |
Error |
Detects DML operations (insert, update, delete, upsert, merge, undelete) inside loops |
| SOQL via Method Calls |
Error |
Detects methods containing SOQL that are called from within loops |
| DML via Method Calls |
Error |
Detects methods containing DML that are called from within loops |
| Hardcoded IDs |
Warning |
Detects hardcoded Salesforce record IDs that break between environments |
| Missing LIMIT |
Warning |
Detects SOQL queries without a LIMIT clause (disabled by default) |
| Untested Fields |
Warning |
Detects fields referenced in source classes that are not used in corresponding test classes |
| RecordType Query |
Warning |
Detects SOQL queries on RecordType object and suggests using Schema methods instead |
| Single SObject Parameter |
Warning |
Detects methods that accept a single SObject and perform DML on it, suggesting bulkification with a List parameter |
| Non-Bulkified Invocable |
Error |
Detects @InvocableMethod methods that don't accept a List parameter |
| Trigger Without Recursion Guard |
Warning |
Detects triggers with DML that lack recursion protection |
| Deeply Nested Code |
Warning |
Detects code nested more than 3 levels deep (configurable) |
Real-Time Analysis
- Scans Apex files as you type (debounced for performance)
- Scans on file save
- Scans when files are opened
- Provides inline diagnostics in the editor
Workspace Scanning
Scan your entire workspace for anti-patterns with a single command.
Installation
- Open VS Code
- Press
Ctrl+Shift+X (Windows/Linux) or Cmd+Shift+X (Mac) to open Extensions
- Search for "Salesforce Apex Anti-Pattern Scanner"
- Click Install
Usage
Commands
Open the Command Palette (Ctrl+Shift+P / Cmd+Shift+P) and run:
- Salesforce: Scan File for Anti-Patterns - Scan the currently open Apex file
- Salesforce: Scan Workspace for Anti-Patterns - Scan all
.cls files in the workspace
Viewing Issues
Issues appear in:
- The editor as squiggly underlines (red for errors, yellow for warnings, blue for info)
- The Problems panel (
Ctrl+Shift+M / Cmd+Shift+M)
Configuration
Configure the extension in VS Code settings (Ctrl+, / Cmd+,):
| Setting |
Type |
Default |
Description |
sfAntipattern.enableOnSave |
boolean |
true |
Scan for anti-patterns when saving Apex files |
sfAntipattern.enableRealTime |
boolean |
true |
Scan for anti-patterns in real-time as you type |
sfAntipattern.detectSOQLInLoops |
boolean |
true |
Detect SOQL queries inside loops |
sfAntipattern.detectDMLInLoops |
boolean |
true |
Detect DML operations inside loops |
sfAntipattern.detectHardcodedIds |
boolean |
true |
Detect hardcoded Salesforce IDs |
sfAntipattern.detectMissingLimits |
boolean |
false |
Detect SOQL queries without LIMIT clause |
sfAntipattern.followMethodCalls |
boolean |
true |
Follow method calls to detect SOQL/DML in called methods |
sfAntipattern.detectUntestedFields |
boolean |
true |
Detect fields in source classes not referenced in test classes |
sfAntipattern.detectRecordTypeQueries |
boolean |
true |
Detect SOQL queries on RecordType and suggest Schema methods |
sfAntipattern.detectNonBulkifiedMethods |
boolean |
true |
Detect single SObject parameters with DML (suggesting bulkification) and non-bulkified @InvocableMethod |
sfAntipattern.detectTriggerRecursion |
boolean |
true |
Detect triggers with DML that lack recursion protection |
sfAntipattern.detectDeeplyNestedCode |
boolean |
true |
Detect code nested too deeply |
sfAntipattern.maxNestingDepth |
number |
3 |
Maximum nesting depth before warning (1-10) |
Example settings.json
{
"sfAntipattern.enableRealTime": true,
"sfAntipattern.detectMissingLimits": true,
"sfAntipattern.followMethodCalls": true
}
Examples
SOQL in Loop (Error)
// BAD - Will trigger an error
for (Account acc : accounts) {
List<Contact> contacts = [SELECT Id FROM Contact WHERE AccountId = :acc.Id];
}
// GOOD - Query outside the loop
Map<Id, List<Contact>> contactsByAccount = new Map<Id, List<Contact>>();
for (Contact c : [SELECT Id, AccountId FROM Contact WHERE AccountId IN :accountIds]) {
if (!contactsByAccount.containsKey(c.AccountId)) {
contactsByAccount.put(c.AccountId, new List<Contact>());
}
contactsByAccount.get(c.AccountId).add(c);
}
DML in Loop (Error)
// BAD - Will trigger an error
for (Account acc : accounts) {
acc.Name = 'Updated';
update acc;
}
// GOOD - Collect and update outside the loop
for (Account acc : accounts) {
acc.Name = 'Updated';
}
update accounts;
Hardcoded ID (Warning)
// BAD - Will trigger a warning
Account acc = [SELECT Id FROM Account WHERE Id = '001000000000001'];
// GOOD - Use Custom Settings or Custom Metadata
Account acc = [SELECT Id FROM Account WHERE Id = :MyCustomSetting__c.getInstance().AccountId__c];
Untested Field (Warning)
If your source class queries fields that aren't referenced in the test class, you may have gaps in test coverage:
// MyClass.cls - Source class
public class MyClass {
public void cleanup() {
List<Opportunity> opps = [
SELECT Id FROM Opportunity
WHERE Account_Billing_Country__c != 'US' // <-- Warning: not in test
AND Account.Sales_Region_Override__c != 'NA' // <-- Warning: not in test
];
}
}
// MyClassTest.cls - Test class (missing field references)
@isTest
private class MyClassTest {
@isTest
static void testCleanup() {
// Test data doesn't set Account_Billing_Country__c or Sales_Region_Override__c
// This means those filter conditions aren't being tested!
Account acc = new Account(Name = 'Test');
insert acc;
// ...
}
}
The scanner will flag Account_Billing_Country__c and Sales_Region_Override__c as untested fields, reminding you to add test cases that verify these conditions work correctly.
RecordType Query (Warning)
SOQL queries on the RecordType object should be replaced with Schema methods:
// BAD - Uses SOQL (counts against limits)
Map<String, Id> recordTypeMap = new Map<String, Id>();
for (RecordType rt : [SELECT Id, Name FROM RecordType WHERE SObjectType = 'Case']) {
recordTypeMap.put(rt.Name, rt.Id);
}
// GOOD - Uses Schema (cached, no SOQL limits)
Map<String, Id> recordTypeMap = new Map<String, Id>();
for (Schema.RecordTypeInfo rtInfo : Schema.SObjectType.Case.getRecordTypeInfosByDeveloperName().values()) {
if (rtInfo.isActive()) {
recordTypeMap.put(rtInfo.getDeveloperName(), rtInfo.getRecordTypeId());
}
}
// GOOD - Single RecordType lookup
Id supportRecordTypeId = Schema.SObjectType.Case.getRecordTypeInfosByDeveloperName()
.get('Support').getRecordTypeId();
Single SObject Parameter (Warning)
Methods that accept a single SObject and perform DML should be bulkified:
// BAD - Will trigger a warning
public void saveAccount(Account acc) {
insert acc; // DML on single record
}
// GOOD - Accept a List for bulk processing
public void saveAccounts(List<Account> accounts) {
insert accounts;
}
Non-Bulkified Invocable (Error)
@InvocableMethod methods must accept a List parameter because they receive bulk input from Flow:
// BAD - Will trigger an error
@InvocableMethod(label='Create Account')
public static void createAccount(Account acc) {
insert acc;
}
// BAD - Will trigger an error (non-List collection)
@InvocableMethod(label='Create Account')
public static void createAccount(Set<String> names) {
// ...
}
// GOOD - Accepts List parameter
@InvocableMethod(label='Create Accounts')
public static void createAccounts(List<Account> accounts) {
insert accounts;
}
// GOOD - Using request wrapper class
@InvocableMethod(label='Create Accounts')
public static List<Result> createAccounts(List<Request> requests) {
// Process requests in bulk
}
Trigger Without Recursion Guard (Warning)
Triggers that perform DML can cause infinite recursion if they update records that re-fire the same trigger:
// BAD - Will trigger a warning (no recursion protection)
trigger AccountTrigger on Account (after insert, after update) {
List<Account> toUpdate = new List<Account>();
for (Account acc : Trigger.new) {
toUpdate.add(new Account(Id = acc.Id, Description = 'Updated'));
}
update toUpdate; // This update will re-fire the trigger!
}
// GOOD - Using a recursion guard
public class TriggerRecursionHandler {
public static Boolean isFirstRun = true;
}
trigger AccountTrigger on Account (after insert, after update) {
if (!TriggerRecursionHandler.isFirstRun) {
return;
}
TriggerRecursionHandler.isFirstRun = false;
List<Account> toUpdate = new List<Account>();
for (Account acc : Trigger.new) {
toUpdate.add(new Account(Id = acc.Id, Description = 'Updated'));
}
update toUpdate;
}
// GOOD - Using Set to track processed IDs
public class TriggerRecursionHandler {
private static Set<Id> processedIds = new Set<Id>();
public static Boolean hasProcessed(Id recordId) {
return processedIds.contains(recordId);
}
public static void markProcessed(Set<Id> recordIds) {
processedIds.addAll(recordIds);
}
}
trigger AccountTrigger on Account (after update) {
List<Account> toUpdate = new List<Account>();
for (Account acc : Trigger.new) {
if (TriggerRecursionHandler.hasProcessed(acc.Id)) {
continue;
}
toUpdate.add(new Account(Id = acc.Id, Description = 'Updated'));
}
if (!toUpdate.isEmpty()) {
TriggerRecursionHandler.markProcessed(new Map<Id, Account>(toUpdate).keySet());
update toUpdate;
}
}
Deeply Nested Code (Warning)
Code that is nested too deeply is hard to read and maintain. Extract inner logic to separate methods:
// BAD - Will trigger a warning (4 levels deep)
public void processAccounts(List<Account> accounts) {
for (Account acc : accounts) { // Level 1
if (acc.IsActive__c) { // Level 2
for (Contact con : acc.Contacts) { // Level 3
if (con.Email != null) { // Level 4 - WARNING!
// Complex logic here
}
}
}
}
}
// GOOD - Extract to separate methods
public void processAccounts(List<Account> accounts) {
for (Account acc : accounts) {
if (acc.IsActive__c) {
processAccountContacts(acc.Contacts);
}
}
}
private void processAccountContacts(List<Contact> contacts) {
for (Contact con : contacts) {
if (con.Email != null) {
processContactEmail(con);
}
}
}
private void processContactEmail(Contact con) {
// Logic here - now at a reasonable nesting level
}
You can configure the maximum nesting depth with sfAntipattern.maxNestingDepth (default: 3).
Why These Patterns Matter
Salesforce enforces governor limits to ensure efficient use of shared resources:
- 100 SOQL queries per synchronous transaction
- 150 DML statements per transaction
- 10,000 records retrieved by SOQL queries
Code that executes SOQL or DML inside loops can easily exceed these limits when processing bulk data, causing runtime failures.
Requirements
- Visual Studio Code 1.85.0 or higher
- Apex language support (Salesforce Extension Pack recommended)
Contributing
Contributions are welcome! Please feel free to submit issues and pull requests.
License
MIT