
Sf Apex
- 34 installs
- 423 repo stars
- Updated April 27, 2026
- jaganpro/claude-code-sfskills
This is a copy of sf-apex by jaganpro - installs and ranking accrue to the original listing.
Helps with ai & agent building tasks.
About
sf-apex is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- sf-apex
- AI & Agent Building
- AI-coding skill
Sf Apex by the numbers
- 34 all-time installs (skills.sh)
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/jaganpro/claude-code-sfskills --skill sf-apexAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 34 |
|---|---|
| repo stars | ★ 423 |
| Last updated | April 27, 2026 |
| Repository | jaganpro/claude-code-sfskills ↗ |
What it does
Helps with ai & agent building tasks.
Files
sf-apex: Salesforce Apex Code Generation and Review
Use this skill when the user needs production Apex: new classes, triggers, selectors, services, async jobs, invocable methods, test classes, or evidence-based review of existing .cls / .trigger code.
When This Skill Owns the Task
Use sf-apex when the work involves:
- Apex class generation or refactoring
- trigger design and trigger-framework decisions
@InvocableMethod, Queueable, Batch, Schedulable, or test-class work- review of bulkification, sharing, security, testing, or maintainability
Delegate elsewhere when the user is:
- editing LWC JavaScript / HTML / CSS → sf-lwc
- building Flow XML or Flow orchestration → sf-flow
- writing SOQL only → sf-soql
- deploying or validating metadata to orgs → sf-deploy
---
Required Context to Gather First
Ask for or infer:
- class type: trigger, service, selector, batch, queueable, schedulable, invocable, test
- target object(s) and business goal
- whether code is net-new, refactor, or fix
- org / API constraints if known
- expected test coverage or deployment target
Before authoring, inspect the project shape:
- existing classes / triggers
- current trigger framework or handler pattern
- related tests, flows, and selectors
- whether TAF is already in use
---
Recommended Workflow
1. Discover local architecture
Check for:
- existing trigger handlers / frameworks
- service-selector-domain conventions
- related tests and data factories
- invocable or async patterns already used in the repo
2. Choose the smallest correct pattern
| Need | Preferred pattern |
|---|---|
| simple reusable logic | service class |
| query-heavy data access | selector |
| single object trigger behavior | one trigger + handler / TAF action |
| Flow needs complex logic | @InvocableMethod |
| background processing | Queueable by default |
| very large datasets | Batch Apex or Database.Cursor patterns |
| repeatable verification | dedicated test class + test data factory |
3. Author with guardrails
Generate code that is:
- bulk-safe
- sharing-aware
- CRUD/FLS-safe where applicable
- testable in isolation
- consistent with project naming and layering
4. Validate and score
Evaluate against the 150-point rubric before handoff.
5. Hand off deploy/test next steps
When org validation is needed, hand off to:
- sf-testing for test execution loops
- sf-deploy for deploy / dry-run / verification
---
Generation Guardrails
Never generate these without explicitly stopping and explaining the problem:
| Anti-pattern | Why it blocks |
|---|---|
| SOQL in loops | governor-limit failure |
| DML in loops | governor-limit failure |
| missing sharing model | security / data exposure risk |
| hardcoded IDs | deployment and portability failure |
empty catch blocks | silent failure / poor observability |
| string-built SOQL with user input | injection risk |
| tests without assertions | false-positive test suite |
Default fix direction:
- query once, operate on collections
- use
with sharingunless justified otherwise - use bind variables and
WITH USER_MODEwhere appropriate - create assertions for positive, negative, and bulk cases
See references/anti-patterns.md and references/security-guide.md.
---
High-Signal Build Rules
Trigger architecture
- Prefer one trigger per object.
- If TAF is already installed and used, extend it instead of inventing a second trigger pattern.
- Triggers should delegate logic; avoid heavy business logic directly in trigger bodies.
Async choice
| Scenario | Default |
|---|---|
| standard async work | Queueable |
| very large record processing | Batch Apex |
| recurring schedule | Scheduled Flow or Schedulable |
| post-job cleanup | Finalizer |
| long-running Lightning callouts | Continuation |
Testing minimums
Use the PNB pattern for every feature:
- Positive path
- Negative / error path
- Bulk path (251+ records where relevant)
Modern Apex expectations
Prefer current idioms when available:
- safe navigation:
obj?.Field__c - null coalescing:
value ?? fallback Assert.*over legacy assertion styleWITH USER_MODEand explicit security handling where relevant
---
Output Format
When finishing, report in this order: 1. What was created or reviewed 2. Files changed 3. Key design decisions 4. Risk / guardrail notes 5. Test guidance 6. Deployment guidance
Suggested shape:
Apex work: <summary>
Files: <paths>
Design: <pattern / framework choices>
Risks: <security, bulkification, async, dependency notes>
Tests: <what to run / add>
Deploy: <dry-run or next step>---
LSP Validation Note
This skill supports an LSP-assisted authoring loop for .cls and .trigger files:
- syntax issues can be detected immediately after write/edit
- the skill can auto-fix common syntax errors in a short loop
- semantic quality still depends on the 150-point review rubric
Full guide: references/troubleshooting.md
---
Cross-Skill Integration
| Need | Delegate to | Reason |
|---|---|---|
| describe objects / fields first | sf-metadata | avoid coding against wrong schema |
| seed bulk or edge-case data | sf-data | create realistic test datasets |
| run Apex tests / fix failing tests | sf-testing | execute and iterate on failures |
| deploy to org | sf-deploy | validation and deployment orchestration |
| build Flow that calls Apex | sf-flow | declarative orchestration |
| build LWC that calls Apex | sf-lwc | UI/controller integration |
---
Reference Map
Start here
- references/patterns-deep-dive.md
- references/security-guide.md
- references/bulkification-guide.md
- references/testing-patterns.md
High-signal checklists
- references/code-review-checklist.md
- references/anti-patterns.md
- references/naming-conventions.md
Specialized patterns
- references/trigger-actions-framework.md
- references/automation-density-guide.md
- references/flow-integration.md
- references/triangle-pattern.md
- references/design-patterns.md
- references/solid-principles.md
Troubleshooting / validation
- references/troubleshooting.md
- references/llm-anti-patterns.md
- references/testing-guide.md
---
Score Guide
| Score | Meaning |
|---|---|
| 120+ | strong production-ready Apex |
| 90–119 | good implementation, review before deploy |
| 67–89 | acceptable but needs improvement |
| < 67 | block deployment |
/**
* @description {{ClassDescription}}
* @author {{Author}}
* @date {{Date}}
*/
public with sharing class {{ClassName}} {
/**
* @description {{MethodDescription}}
* @param {{paramName}} {{ParamDescription}}
* @return {{ReturnDescription}}
*/
public {{ReturnType}} {{methodName}}({{ParamType}} {{paramName}}) {
// TODO: Implement logic
return null;
}
}
/**
* @description Batch job for {{Description}}
* @author {{Author}}
* @date {{Date}}
*/
public with sharing class {{ClassName}}_Batch implements Database.Batchable<SObject>, Database.Stateful {
private Integer recordsProcessed = 0;
private Integer recordsFailed = 0;
private List<String> errors = new List<String>();
/**
* @description Start method - defines the scope of records to process
* @param bc BatchableContext
* @return QueryLocator for the records to process
*/
public Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator([
SELECT Id, Name
FROM {{ObjectName}}
WHERE Status__c = 'Pending'
WITH USER_MODE
]);
}
/**
* @description Execute method - processes each batch of records
* @param bc BatchableContext
* @param scope List of records in current batch
*/
public void execute(Database.BatchableContext bc, List<{{ObjectName}}> scope) {
List<{{ObjectName}}> toUpdate = new List<{{ObjectName}}>();
for ({{ObjectName}} record : scope) {
try {
// TODO: Implement processing logic
record.Status__c = 'Processed';
toUpdate.add(record);
recordsProcessed++;
} catch (Exception e) {
recordsFailed++;
errors.add('Record ' + record.Id + ': ' + e.getMessage());
}
}
if (!toUpdate.isEmpty()) {
Database.SaveResult[] results = Database.update(toUpdate, false);
for (Integer i = 0; i < results.size(); i++) {
if (!results[i].isSuccess()) {
recordsFailed++;
for (Database.Error err : results[i].getErrors()) {
errors.add('Record ' + toUpdate[i].Id + ': ' + err.getMessage());
}
}
}
}
}
/**
* @description Finish method - called after all batches complete
* @param bc BatchableContext
*/
public void finish(Database.BatchableContext bc) {
// Log results
System.debug('{{ClassName}}_Batch completed:');
System.debug('Records processed: ' + recordsProcessed);
System.debug('Records failed: ' + recordsFailed);
if (!errors.isEmpty()) {
System.debug('Errors:');
for (String error : errors) {
System.debug(error);
}
}
// Optional: Send completion email
// sendCompletionEmail(bc.getJobId());
// Optional: Chain next batch
// Database.executeBatch(new NextBatch());
}
/**
* @description Sends completion notification email
* @param jobId The batch job ID
*/
@SuppressWarnings('PMD.ApexSOQLInjection')
private void sendCompletionEmail(Id jobId) {
AsyncApexJob job = [
SELECT Id, Status, NumberOfErrors, JobItemsProcessed, TotalJobItems
FROM AsyncApexJob
WHERE Id = :jobId
];
Messaging.SingleEmailMessage email = new Messaging.SingleEmailMessage();
email.setToAddresses(new List<String>{UserInfo.getUserEmail()});
email.setSubject('{{ClassName}}_Batch Completed: ' + job.Status);
email.setPlainTextBody(
'Job ID: ' + jobId + '\n' +
'Status: ' + job.Status + '\n' +
'Items Processed: ' + job.JobItemsProcessed + '/' + job.TotalJobItems + '\n' +
'Errors: ' + job.NumberOfErrors
);
Messaging.sendEmail(new List<Messaging.Email>{email});
}
}
// Usage:
// Database.executeBatch(new {{ClassName}}_Batch(), 200);
// Or schedule:
// System.schedule('{{ClassName}} Daily', '0 0 1 * * ?', new {{ClassName}}_Scheduler());
/**
* @description Invocable Apex for Flow/Process Builder integration
* Exposes business logic as a Flow Action
* @author {{Author}}
* @date {{Date}}
*
* FLOW USAGE:
* 1. Add Action element in Flow Builder
* 2. Search for "{{ActionLabel}}"
* 3. Map input/output variables
*
* PATTERN: Request/Response wrappers enable complex data exchange
* with Flow while maintaining type safety
*/
public with sharing class {{ClassName}}Invocable {
// ═══════════════════════════════════════════════════════════════════════
// INVOCABLE METHOD
// ═══════════════════════════════════════════════════════════════════════
/**
* @description Main entry point for Flow Actions
* Method must be static, accept List<Request>, return List<Response>
* This signature supports bulkification when Flow runs for multiple records
*
* @param requests List of request wrappers from Flow
* @return List of response wrappers to Flow
*/
@InvocableMethod(
label='{{ActionLabel}}'
description='{{ActionDescription}}'
category='{{Category}}'
)
public static List<Response> execute(List<Request> requests) {
List<Response> responses = new List<Response>();
// ─────────────────────────────────────────────────────────────────
// BULKIFICATION: Collect all IDs first, then query once
// Avoids SOQL-in-loop governor limit issues
// ─────────────────────────────────────────────────────────────────
Set<Id> recordIds = new Set<Id>();
for (Request req : requests) {
if (req.recordId != null) {
recordIds.add(req.recordId);
}
}
// Single bulk query with USER_MODE for FLS/CRUD enforcement
Map<Id, {{ObjectName}}> recordsById = new Map<Id, {{ObjectName}}>();
if (!recordIds.isEmpty()) {
recordsById = new Map<Id, {{ObjectName}}>(
[SELECT Id, Name
FROM {{ObjectName}}
WHERE Id IN :recordIds
WITH USER_MODE]
);
}
// ─────────────────────────────────────────────────────────────────
// PROCESS EACH REQUEST
// ─────────────────────────────────────────────────────────────────
for (Request req : requests) {
Response res = new Response();
try {
{{ObjectName}} record = recordsById.get(req.recordId);
if (record == null) {
res.isSuccess = false;
res.errorMessage = 'Record not found: ' + req.recordId;
} else {
// Delegate to business logic method
res = processRecord(record, req);
}
} catch (Exception e) {
res.isSuccess = false;
res.errorMessage = e.getMessage();
res.errorType = e.getTypeName();
}
responses.add(res);
}
return responses;
}
// ═══════════════════════════════════════════════════════════════════════
// BUSINESS LOGIC
// ═══════════════════════════════════════════════════════════════════════
/**
* @description Core business logic implementation
* Separated for testability and reuse
*
* @param record The record to process
* @param req The original request with parameters
* @return Response with results
*/
private static Response processRecord({{ObjectName}} record, Request req) {
Response res = new Response();
// TODO: Implement your business logic here
// Example: Calculate, validate, transform, etc.
res.isSuccess = true;
res.outputMessage = 'Successfully processed: ' + record.Name;
res.outputRecordId = record.Id;
return res;
}
// ═══════════════════════════════════════════════════════════════════════
// INVOCABLE VARIABLE WRAPPERS
// Each variable becomes a Flow input/output parameter
// ═══════════════════════════════════════════════════════════════════════
/**
* @description Request wrapper for Flow inputs
* Each @InvocableVariable appears in Flow's input mapping UI
*
* SUPPORTED TYPES:
* - Primitives: Boolean, Date, DateTime, Decimal, Double, Integer, Long, String, Time
* - SObject types: Account, Contact, etc.
* - Collections: List<T> of above types
* - Apex-defined types (nested classes with @InvocableVariable)
*/
public class Request {
@InvocableVariable(
label='Record ID'
description='ID of the record to process'
required=true
)
public Id recordId;
@InvocableVariable(
label='Operation Type'
description='Type of operation: validate, process, calculate'
required=false
)
public String operationType;
@InvocableVariable(
label='Include Related Records'
description='Whether to include related records in processing'
required=false
)
public Boolean includeRelated;
@InvocableVariable(
label='Amount'
description='Numeric amount for calculations'
required=false
)
public Decimal amount;
@InvocableVariable(
label='Additional Record IDs'
description='Collection of additional record IDs'
required=false
)
public List<Id> additionalRecordIds;
}
/**
* @description Response wrapper for Flow outputs
* Each @InvocableVariable appears in Flow's output mapping UI
*
* TIP: Always include isSuccess and errorMessage for consistent error handling
*/
public class Response {
@InvocableVariable(
label='Is Success'
description='Whether the operation completed successfully'
)
public Boolean isSuccess;
@InvocableVariable(
label='Error Message'
description='Error message if operation failed (null if successful)'
)
public String errorMessage;
@InvocableVariable(
label='Error Type'
description='Exception type name for debugging'
)
public String errorType;
@InvocableVariable(
label='Output Message'
description='Human-readable result message'
)
public String outputMessage;
@InvocableVariable(
label='Output Record ID'
description='ID of processed or created record'
)
public Id outputRecordId;
@InvocableVariable(
label='Output Value'
description='Primary result value (string format)'
)
public String outputValue;
@InvocableVariable(
label='Output Amount'
description='Numeric result (for calculations)'
)
public Decimal outputAmount;
@InvocableVariable(
label='Output Records'
description='Collection of processed records'
)
public List<SObject> outputRecords;
/**
* @description Convenience constructor for success responses
*/
public Response() {
this.isSuccess = false;
}
/**
* @description Factory method for success response
*/
public static Response success(String message, Id recordId) {
Response res = new Response();
res.isSuccess = true;
res.outputMessage = message;
res.outputRecordId = recordId;
return res;
}
/**
* @description Factory method for error response
*/
public static Response error(String message) {
Response res = new Response();
res.isSuccess = false;
res.errorMessage = message;
return res;
}
}
}
/**
* @description Queueable job for {{Description}}
* @author {{Author}}
* @date {{Date}}
*/
public with sharing class {{ClassName}}_Queueable implements Queueable, Database.AllowsCallouts {
private List<Id> recordIds;
/**
* @description Constructor
* @param recordIds List of record IDs to process
*/
public {{ClassName}}_Queueable(List<Id> recordIds) {
this.recordIds = recordIds;
}
/**
* @description Execute the queueable job
* @param context QueueableContext
*/
public void execute(QueueableContext context) {
if (recordIds == null || recordIds.isEmpty()) {
return;
}
try {
// Query records
List<{{ObjectName}}> records = [
SELECT Id, Name
FROM {{ObjectName}}
WHERE Id IN :recordIds
WITH USER_MODE
];
// Process records
List<{{ObjectName}}> toUpdate = new List<{{ObjectName}}>();
for ({{ObjectName}} record : records) {
// TODO: Implement processing logic
toUpdate.add(record);
}
// Update records
if (!toUpdate.isEmpty()) {
update toUpdate;
}
// Chain next job if needed
// if (hasMoreWork) {
// System.enqueueJob(new {{ClassName}}_Queueable(nextBatch));
// }
} catch (Exception e) {
// Log error
System.debug(LoggingLevel.ERROR, '{{ClassName}}_Queueable Error: ' + e.getMessage());
System.debug(LoggingLevel.ERROR, 'Stack Trace: ' + e.getStackTraceString());
// Consider: create error log record, send notification, etc.
}
}
}
/**
* @description Selector class for {{ObjectName}} queries
* @author {{Author}}
* @date {{Date}}
*/
public inherited sharing class {{ObjectName}}Selector {
/**
* @description Default fields to query
*/
private static final List<String> DEFAULT_FIELDS = new List<String>{
'Id',
'Name',
'CreatedDate',
'LastModifiedDate'
};
/**
* @description Selects records by IDs
* @param ids Set of record IDs
* @return List of {{ObjectName}} records
*/
public List<{{ObjectName}}> selectById(Set<Id> ids) {
if (ids == null || ids.isEmpty()) {
return new List<{{ObjectName}}>();
}
return [
SELECT Id, Name, CreatedDate, LastModifiedDate
FROM {{ObjectName}}
WHERE Id IN :ids
WITH USER_MODE
];
}
/**
* @description Selects records by IDs with related records
* @param ids Set of record IDs
* @return List of {{ObjectName}} records with child records
*/
public List<{{ObjectName}}> selectByIdWithRelated(Set<Id> ids) {
if (ids == null || ids.isEmpty()) {
return new List<{{ObjectName}}>();
}
return [
SELECT Id, Name, CreatedDate, LastModifiedDate,
(SELECT Id, Name FROM {{ChildRelationship}})
FROM {{ObjectName}}
WHERE Id IN :ids
WITH USER_MODE
];
}
/**
* @description Selects records by Name
* @param name Name to search for (supports wildcards)
* @return List of matching {{ObjectName}} records
*/
public List<{{ObjectName}}> selectByName(String name) {
if (String.isBlank(name)) {
return new List<{{ObjectName}}>();
}
String searchName = '%' + String.escapeSingleQuotes(name) + '%';
return [
SELECT Id, Name, CreatedDate, LastModifiedDate
FROM {{ObjectName}}
WHERE Name LIKE :searchName
WITH USER_MODE
LIMIT 100
];
}
/**
* @description Selects active records
* @return List of active {{ObjectName}} records
*/
public List<{{ObjectName}}> selectActive() {
return [
SELECT Id, Name, CreatedDate, LastModifiedDate
FROM {{ObjectName}}
WHERE IsActive__c = true
WITH USER_MODE
ORDER BY Name
];
}
/**
* @description Selects records created in date range
* @param startDate Start date
* @param endDate End date
* @return List of {{ObjectName}} records in date range
*/
public List<{{ObjectName}}> selectByCreatedDateRange(Date startDate, Date endDate) {
return [
SELECT Id, Name, CreatedDate, LastModifiedDate
FROM {{ObjectName}}
WHERE CreatedDate >= :startDate
AND CreatedDate <= :endDate
WITH USER_MODE
ORDER BY CreatedDate DESC
];
}
/**
* @description Counts records matching criteria
* @return Count of matching records
*/
public Integer countAll() {
return [SELECT COUNT() FROM {{ObjectName}} WITH USER_MODE];
}
}
/**
* @description Service class for {{ObjectName}} business logic
* @author {{Author}}
* @date {{Date}}
*/
public with sharing class {{ObjectName}}Service {
private {{ObjectName}}Selector selector;
/**
* @description Default constructor
*/
public {{ObjectName}}Service() {
this.selector = new {{ObjectName}}Selector();
}
/**
* @description Constructor with dependency injection
* @param selector Selector instance for querying
*/
@TestVisible
private {{ObjectName}}Service({{ObjectName}}Selector selector) {
this.selector = selector;
}
/**
* @description Gets records by IDs
* @param ids Set of record IDs
* @return Map of records by ID
*/
public Map<Id, {{ObjectName}}> getRecordsById(Set<Id> ids) {
if (ids == null || ids.isEmpty()) {
return new Map<Id, {{ObjectName}}>();
}
List<{{ObjectName}}> records = selector.selectById(ids);
return new Map<Id, {{ObjectName}}>(records);
}
/**
* @description Creates new records
* @param records List of records to create
* @return List of created records with IDs
*/
public List<{{ObjectName}}> createRecords(List<{{ObjectName}}> records) {
if (records == null || records.isEmpty()) {
return new List<{{ObjectName}}>();
}
// Apply defaults
for ({{ObjectName}} record : records) {
applyDefaults(record);
}
// Validate
validateRecords(records);
// Insert
insert records;
return records;
}
/**
* @description Updates existing records
* @param records List of records to update
* @return List of updated records
*/
public List<{{ObjectName}}> updateRecords(List<{{ObjectName}}> records) {
if (records == null || records.isEmpty()) {
return new List<{{ObjectName}}>();
}
// Validate
validateRecords(records);
// Update
update records;
return records;
}
/**
* @description Deletes records
* @param ids Set of record IDs to delete
*/
public void deleteRecords(Set<Id> ids) {
if (ids == null || ids.isEmpty()) {
return;
}
List<{{ObjectName}}> records = selector.selectById(ids);
delete records;
}
/**
* @description Example: Bulkified pattern for processing related records
* Demonstrates: Query before loop, Map for O(1) lookup, collect then DML
* @param contactIds Set of Contact IDs to process
*/
public void processRelatedContacts(Set<Id> contactIds) {
if (contactIds == null || contactIds.isEmpty()) {
return;
}
Map<Id, {{ObjectName}}> parentMap = new Map<Id, {{ObjectName}}>(
[SELECT Id, Name FROM {{ObjectName}} WHERE Id IN :contactIds WITH USER_MODE]
);
List<Contact> contactsToUpdate = new List<Contact>();
for (Contact c : [SELECT Id, AccountId, Description FROM Contact WHERE AccountId IN :contactIds WITH USER_MODE]) {
{{ObjectName}} parent = parentMap.get(c.AccountId);
if (parent != null) {
c.Description = 'Linked to: ' + parent.Name;
contactsToUpdate.add(c);
}
}
if (!contactsToUpdate.isEmpty()) {
update contactsToUpdate;
}
}
/**
* @description Applies default values to a record
* @param record Record to apply defaults to
*/
private void applyDefaults({{ObjectName}} record) {
// TODO: Implement default logic
// record.Status__c = record.Status__c ?? 'New';
}
/**
* @description Validates records before DML
* @param records Records to validate
* @throws ValidationException if validation fails
*/
private void validateRecords(List<{{ObjectName}}> records) {
List<String> errors = new List<String>();
for ({{ObjectName}} record : records) {
if (String.isBlank(record.Name)) {
errors.add('Name is required');
}
// TODO: Add more validation rules
}
if (!errors.isEmpty()) {
throw new ValidationException(String.join(errors, '; '));
}
}
/**
* @description Custom exception for validation errors
*/
public class ValidationException extends Exception {}
}
/**
* @description Test class for {{TestedClassName}}
* @author {{Author}}
* @date {{Date}}
*/
@isTest
private class {{TestedClassName}}Test {
@TestSetup
static void setup() {
// Create test data using TestDataFactory
// TestDataFactory.createAccounts(5);
}
@isTest
static void testPositiveScenario() {
// Arrange
// Query test data or create specific records
// Act
Test.startTest();
// Call method under test
Test.stopTest();
// Assert
// Assert.areEqual(expected, actual, 'Message');
}
@isTest
static void testNegativeScenario() {
// Arrange - setup invalid data
// Act & Assert
Test.startTest();
try {
// Call method that should throw exception
Assert.fail('Expected exception was not thrown');
} catch ({{ExceptionType}} e) {
Assert.isTrue(e.getMessage().contains('expected text'), 'Error message should contain expected text');
}
Test.stopTest();
}
@isTest
static void testBulkScenario() {
// Arrange - create 251+ records to span trigger batches
List<{{ObjectName}}> records = new List<{{ObjectName}}>();
for (Integer i = 0; i < 251; i++) {
records.add(new {{ObjectName}}(
Name = 'Test ' + i
));
}
// Act
Test.startTest();
insert records;
Test.stopTest();
// Assert
Assert.areEqual(251, [SELECT COUNT() FROM {{ObjectName}}], 'All records should be created');
}
@isTest
static void testNullInput() {
// Test null handling
Test.startTest();
try {
// Call method with null
Assert.fail('Expected exception for null input');
} catch (IllegalArgumentException e) {
Assert.isTrue(e.getMessage().contains('null'), 'Should mention null');
}
Test.stopTest();
}
}
/**
* @description Factory class for creating test data
* @author {{Author}}
* @date {{Date}}
*/
@isTest
public class TestDataFactory {
/**
* @description Creates Account records
* @param count Number of records to create
* @return List of inserted Account records
*/
public static List<Account> createAccounts(Integer count) {
return createAccounts(count, true);
}
/**
* @description Creates Account records with insert option
* @param count Number of records to create
* @param doInsert Whether to insert records
* @return List of Account records
*/
public static List<Account> createAccounts(Integer count, Boolean doInsert) {
List<Account> accounts = new List<Account>();
for (Integer i = 0; i < count; i++) {
accounts.add(new Account(
Name = 'Test Account ' + i,
Industry = 'Technology',
BillingCity = 'San Francisco',
BillingState = 'CA',
BillingCountry = 'USA'
));
}
if (doInsert) {
insert accounts;
}
return accounts;
}
/**
* @description Creates Contact records for an Account
* @param count Number of records to create
* @param accountId Parent Account Id
* @return List of inserted Contact records
*/
public static List<Contact> createContacts(Integer count, Id accountId) {
List<Contact> contacts = new List<Contact>();
for (Integer i = 0; i < count; i++) {
contacts.add(new Contact(
FirstName = 'Test',
LastName = 'Contact ' + i,
Email = 'test' + i + '@example.com',
AccountId = accountId
));
}
insert contacts;
return contacts;
}
/**
* @description Creates Opportunity records for an Account
* @param count Number of records to create
* @param accountId Parent Account Id
* @return List of inserted Opportunity records
*/
public static List<Opportunity> createOpportunities(Integer count, Id accountId) {
List<Opportunity> opps = new List<Opportunity>();
for (Integer i = 0; i < count; i++) {
opps.add(new Opportunity(
Name = 'Test Opportunity ' + i,
AccountId = accountId,
StageName = 'Prospecting',
CloseDate = Date.today().addDays(30),
Amount = 10000 + (i * 1000)
));
}
insert opps;
return opps;
}
/**
* @description Creates a User with specified profile
* @param profileName Name of the profile
* @return Inserted User record
*/
public static User createUser(String profileName) {
Profile p = [SELECT Id FROM Profile WHERE Name = :profileName LIMIT 1];
String uniqueKey = String.valueOf(DateTime.now().getTime());
User u = new User(
Alias = 'test' + uniqueKey.right(4),
Email = 'testuser' + uniqueKey + '@example.com',
EmailEncodingKey = 'UTF-8',
FirstName = 'Test',
LastName = 'User ' + uniqueKey.right(4),
LanguageLocaleKey = 'en_US',
LocaleSidKey = 'en_US',
ProfileId = p.Id,
TimeZoneSidKey = 'America/Los_Angeles',
Username = 'testuser' + uniqueKey + '@example.com.test'
);
insert u;
return u;
}
}
/**
* @description Trigger action for {{ObjectName}}: {{ActionDescription}}
* @author {{Author}}
* @date {{Date}}
*/
public with sharing class TA_{{ObjectName}}_{{ActionName}} implements TriggerAction.{{TriggerContext}} {
/**
* @description {{ContextDescription}}
* @param newList List of {{ObjectName}} records being processed
*/
public void {{contextMethod}}(List<{{ObjectName}}> newList) {
for ({{ObjectName}} record : newList) {
// TODO: Implement logic
}
}
}
// For BeforeUpdate/AfterUpdate contexts, use this signature:
// public void {{contextMethod}}(List<{{ObjectName}}> newList, List<{{ObjectName}}> oldList) {
// Map<Id, {{ObjectName}}> oldMap = new Map<Id, {{ObjectName}}>(oldList);
// for ({{ObjectName}} record : newList) {
// {{ObjectName}} oldRecord = oldMap.get(record.Id);
// // Compare old vs new values
// }
// }
/**
* @description Trigger for {{ObjectName}} using Trigger Actions Framework
* @author {{Author}}
* @date {{Date}}
*/
trigger {{ObjectName}}Trigger on {{ObjectName}} (
before insert,
after insert,
before update,
after update,
before delete,
after delete,
after undelete
) {
new MetadataTriggerHandler().run();
}
Credits & Acknowledgments
This skill was built upon the collective wisdom of the Salesforce developer community. We gratefully acknowledge the following authors and resources whose ideas, patterns, and best practices have shaped this skill.
---
Authors & Contributors
James Simone
[Joys of Apex](https://www.jamessimone.net/blog/)
Key contributions:
- Factory Pattern for dependency injection
- Repository Pattern for data access abstraction
- Singleton Pattern implementation
- Performant Apex testing strategies
- Naming conventions and code organization
Pablo Gonzalez
[Clean Apex Code](https://www.pablogonzalez.io/)
Key contributions:
- SOLID principles applied to Apex
- Clean code refactoring techniques
- Boolean clarity and self-documenting code
- Software design principles for Salesforce
Mitch Spano
[Trigger Actions Framework](https://github.com/mitchspano/trigger-actions-framework)
Key contributions:
- Metadata-driven trigger management
- One trigger per object pattern
- Bypass mechanisms (global, transaction, permission-based)
- Unified Apex and Flow action execution
Beyond the Cloud (Salesforce Blog)
[blog.beyondthecloud.dev](https://blog.beyondthecloud.dev/)
Key contributions:
- Code review red flags and anti-patterns
- Common mistakes checklist
- Best practices for sharing modes
- Test data factory recommendations
Justus van den Berg
[Medium @justusvandenberg](https://medium.com/@justusvandenberg)
Key contributions:
- Heap size optimization techniques
- CPU time optimization
- Maps vs Arrays performance analysis
- Large string handling strategies
Coding With The Force
[codingwiththeforce.com](https://codingwiththeforce.com/) | [YouTube](https://www.youtube.com/@CodingWithTheForce)
Key contributions:
- Separation of Concerns tutorial series
- Apex Common Library guidance
- SOLID design principles tutorials
- Unit testing with Apex Mocks
Saurabh Samir
[Medium @saurabh.samirs](https://medium.com/@saurabh.samirs)
Key contributions:
- Decorator Pattern for adding behavior without modification
- Observer Pattern for event-driven architecture
- Command Pattern for operation queuing and undo
- Facade Pattern for simplifying complex subsystems
César Parra
[ApexDocs](https://github.com/cesarParra/apexdocs)
Key contributions:
- ApexDoc documentation standards
- Documentation generation best practices
- OpenAPI spec generation for REST classes
---
Frameworks & Libraries
Trigger Actions Framework
- Author: Mitch Spano
- Repository: https://github.com/mitchspano/trigger-actions-framework
- License: MIT
Apex Common Library (fflib)
- Original Author: Andy Fawcett (FinancialForce)
- Maintainer: John Daniel & Community
- Repository: https://github.com/apex-enterprise-patterns/fflib-apex-common
Apex Mockery
- Author: Salesforce
- Repository: https://github.com/salesforce/apex-mockery
---
Official Salesforce Resources
- Salesforce Developer Blog: https://developer.salesforce.com/blogs
- Trailhead: https://trailhead.salesforce.com
- Apex Developer Guide: https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/
---
Community Resources
Apex Hours
[apexhours.com](https://www.apexhours.com/)
- Test class best practices
- Trigger framework patterns
- Security best practices
- Governor limits guidance
Salesforce Ben
[salesforceben.com](https://www.salesforceben.com/)
- Apex best practices articles
- Trigger handler framework guides
Salesforce Stack Exchange
[salesforce.stackexchange.com](https://salesforce.stackexchange.com/)
- Community Q&A and solutions
- Design pattern discussions
---
Books
Salesforce Lightning Platform Enterprise Architecture
- Author: Andy Fawcett
- Topics: Enterprise patterns, Service Layer, Domain Layer, Selector Layer, Unit of Work
Clean Apex Code: Software Design for Salesforce Developers
- Author: Pablo Gonzalez
- Publisher: Apress
- Topics: SOLID principles, refactoring, clean code, testing
---
Special Thanks
To the entire Salesforce developer community for sharing knowledge, writing blogs, creating open-source tools, and helping each other build better solutions.
---
If we've missed anyone whose work influenced this skill, please let us know so we can add proper attribution.
#!/usr/bin/env python3
"""
Apex LSP Validation Hook
========================
This PostToolUse hook validates .cls and .trigger files after Write/Edit
operations using the Salesforce Apex Language Server (apex-jorje-lsp).
Behavior (Auto-fix loop):
- Outputs errors to Claude so it can automatically fix them
- Repeats until valid or max attempts reached
- Complements existing 150-point semantic validation
Prerequisites:
- VS Code with Salesforce Extension Pack installed
- Java 11+ (Adoptium recommended)
Usage:
Triggered automatically by hooks.json configuration
Input: JSON from stdin with tool_name and tool_input
Output: Diagnostic messages to stdout (or empty if valid)
"""
import json
import os
import sys
import tempfile
from pathlib import Path
from typing import Dict, List, Any, Optional
# Add shared lsp-engine to path
# The installer places lsp-engine at ~/.claude/lsp-engine/ but in a dev
# repo checkout it lives at <repo>/shared/lsp-engine/. Try both.
SCRIPT_DIR = Path(__file__).parent
PLUGIN_ROOT = SCRIPT_DIR.parent.parent
_LSP_CANDIDATES = [
Path.home() / ".claude" / "lsp-engine", # Installed path
PLUGIN_ROOT.parent.parent / "shared" / "lsp-engine", # Dev repo path
]
LSP_ENGINE_PATH = next((p for p in _LSP_CANDIDATES if p.is_dir()), _LSP_CANDIDATES[0])
sys.path.insert(0, str(LSP_ENGINE_PATH))
# Track validation attempts to prevent infinite loops
ATTEMPT_FILE = Path(tempfile.gettempdir()) / "apex_lsp_attempts.json"
MAX_ATTEMPTS = 3
# Apex file extensions
APEX_EXTENSIONS = {".cls", ".trigger"}
# LSP Severity levels
SEVERITY_ERROR = 1
SEVERITY_WARNING = 2
SEVERITY_INFO = 3
SEVERITY_HINT = 4
SEVERITY_NAMES = {
SEVERITY_ERROR: "ERROR",
SEVERITY_WARNING: "WARNING",
SEVERITY_INFO: "INFO",
SEVERITY_HINT: "HINT",
}
SEVERITY_ICONS = {
SEVERITY_ERROR: "❌",
SEVERITY_WARNING: "⚠️",
SEVERITY_INFO: "ℹ️",
SEVERITY_HINT: "💡",
}
def get_attempt_count(file_path: str) -> int:
"""Get the current attempt count for a file."""
try:
if ATTEMPT_FILE.exists():
with open(ATTEMPT_FILE, "r") as f:
attempts = json.load(f)
return attempts.get(file_path, 0)
except Exception:
pass
return 0
def increment_attempt_count(file_path: str) -> int:
"""Increment and return the attempt count for a file."""
attempts = {}
try:
if ATTEMPT_FILE.exists():
with open(ATTEMPT_FILE, "r") as f:
attempts = json.load(f)
except Exception:
pass
attempts[file_path] = attempts.get(file_path, 0) + 1
count = attempts[file_path]
try:
with open(ATTEMPT_FILE, "w") as f:
json.dump(attempts, f)
except Exception:
pass
return count
def reset_attempt_count(file_path: str):
"""Reset attempt count when validation succeeds."""
try:
if ATTEMPT_FILE.exists():
with open(ATTEMPT_FILE, "r") as f:
attempts = json.load(f)
if file_path in attempts:
del attempts[file_path]
with open(ATTEMPT_FILE, "w") as f:
json.dump(attempts, f)
except Exception:
pass
def format_apex_diagnostics(
result: Dict[str, Any],
file_path: str,
max_attempts: int = 3,
current_attempt: int = 1,
) -> str:
"""
Format LSP validation result for Claude Code hooks.
This output is designed to be understood by Claude so it can
automatically fix any issues found.
"""
# If LSP had an error
if "error" in result and result["error"]:
return f"⚠️ Apex LSP validation skipped: {result['error']}"
diagnostics = result.get("diagnostics", [])
success = result.get("success", False)
# No issues found - show success message
if success and not diagnostics:
file_name = Path(file_path).name
lines = []
lines.append(f"✅ Apex LSP Validation Passed: {file_name}")
lines.append(" • Syntax check: OK")
lines.append(" • Type resolution: OK")
lines.append(" • Symbol references: OK")
return "\n".join(lines)
# Count errors and warnings
error_count = sum(1 for d in diagnostics if d.get("severity", 1) == SEVERITY_ERROR)
warning_count = sum(1 for d in diagnostics if d.get("severity", 2) == SEVERITY_WARNING)
# Build output for Claude
lines = []
# Header
lines.append("=" * 60)
lines.append("🔍 APEX LSP VALIDATION RESULTS")
lines.append(f" File: {file_path}")
lines.append(f" Attempt: {current_attempt}/{max_attempts}")
lines.append("=" * 60)
lines.append("")
# Summary
if error_count > 0 or warning_count > 0:
lines.append(f"Found {error_count} error(s), {warning_count} warning(s)")
lines.append("")
# Diagnostics
if diagnostics:
lines.append("ISSUES TO FIX:")
lines.append("-" * 40)
for diag in diagnostics:
severity = diag.get("severity", SEVERITY_ERROR)
severity_name = SEVERITY_NAMES.get(severity, "UNKNOWN")
icon = SEVERITY_ICONS.get(severity, "❓")
message = diag.get("message", "Unknown error")
# Extract line info
range_info = diag.get("range", {})
start = range_info.get("start", {})
start_line = start.get("line", 0) + 1 # LSP is 0-indexed
source = diag.get("source", "apex")
lines.append(f"{icon} [{severity_name}] line {start_line}: {message} (source: {source})")
lines.append("")
# Instructions for Claude
if error_count > 0:
lines.append("ACTION REQUIRED:")
lines.append("Please fix the Apex syntax errors above and try again.")
if current_attempt < max_attempts:
lines.append(f"(Attempt {current_attempt}/{max_attempts})")
else:
lines.append("⚠️ Maximum attempts reached. Manual review may be needed.")
lines.append("=" * 60)
return "\n".join(lines)
def is_apex_file(file_path: str) -> bool:
"""Check if file is an Apex file."""
return Path(file_path).suffix.lower() in APEX_EXTENSIONS
def main():
"""Main hook entry point."""
# Read hook input from stdin
try:
hook_input = json.load(sys.stdin)
except json.JSONDecodeError:
# No input or invalid JSON - skip validation
sys.exit(0)
# Extract file path
tool_input = hook_input.get("tool_input", {})
file_path = tool_input.get("file_path", "")
# Only validate Apex files
if not is_apex_file(file_path):
sys.exit(0)
# Check if file exists
if not os.path.exists(file_path):
sys.exit(0)
# Track attempts
current_attempt = increment_attempt_count(file_path)
# If max attempts exceeded, skip validation to avoid infinite loop
if current_attempt > MAX_ATTEMPTS:
print(f"⚠️ Apex LSP validation: Maximum attempts ({MAX_ATTEMPTS}) exceeded for {file_path}")
print(" Manual review may be required.")
reset_attempt_count(file_path) # Reset for next edit session
sys.exit(0)
# Try to import LSP engine
try:
from lsp_client import LSPClient
except ImportError as e:
# LSP engine not available - skip validation silently
# This allows the plugin to work even without LSP
sys.exit(0)
# Check if Apex LSP wrapper exists
apex_wrapper = LSP_ENGINE_PATH / "apex_wrapper.sh"
if not apex_wrapper.exists():
# Apex LSP wrapper not available - skip silently
sys.exit(0)
# Create LSP client with Apex wrapper and language ID
try:
client = LSPClient(wrapper_path=str(apex_wrapper), language_id="apex")
except Exception as e:
# LSP initialization error - skip silently
sys.exit(0)
# Check if LSP is available
if not client.is_available():
# LSP not available - skip validation silently
sys.exit(0)
# Validate the file
try:
result = client.validate_file(file_path)
except Exception as e:
# LSP error - report but don't block
print(f"⚠️ Apex LSP validation error: {e}")
sys.exit(0)
# Format output for Claude
output = format_apex_diagnostics(
result,
file_path=file_path,
max_attempts=MAX_ATTEMPTS,
current_attempt=current_attempt,
)
# If valid, reset attempt counter
if result.get("success", False):
reset_attempt_count(file_path)
# Output diagnostics (empty = success)
if output:
print(output)
# Always exit 0 for auto-fix loop (don't block)
sys.exit(0)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
LLM Pattern Validator for Apex Code.
Detects common mistakes that LLMs make when generating Salesforce Apex code:
1. Java types (ArrayList, HashMap, StringBuilder, etc.)
2. Hallucinated methods (addMilliseconds, stream(), etc.)
3. Unsafe Map access (Map.get() without null checks)
4. Missing SOQL fields (accessing fields not in query)
This validator is ADVISORY - it provides warnings but does not block operations.
Source: https://salesforcediaries.com/2026/01/16/llm-mistakes-in-apex-lwc-salesforce-code-generation-rules/
"""
import re
import os
from typing import Dict, List, Tuple, Set
class LLMPatternValidator:
"""Detects LLM-specific anti-patterns in Apex code."""
# Java types that don't exist in Apex
JAVA_TYPES = {
'ArrayList': 'List',
'HashMap': 'Map',
'HashSet': 'Set',
'StringBuffer': 'String or List<String> + String.join()',
'StringBuilder': 'String or List<String> + String.join()',
'LinkedList': 'List',
'TreeMap': 'Map',
'Vector': 'List',
'Hashtable': 'Map',
'LinkedHashMap': 'Map',
'TreeSet': 'Set',
'LinkedHashSet': 'Set',
'ArrayDeque': 'List',
'Stack': 'List',
'Queue': 'List',
'PriorityQueue': 'List',
}
# Methods that don't exist in Apex but LLMs commonly generate
HALLUCINATED_METHODS = [
# DateTime methods
(r'\.addMilliseconds\s*\(', 'Datetime.addMilliseconds() does not exist. Use addSeconds() instead.'),
(r'\.addMicroseconds\s*\(', 'Datetime.addMicroseconds() does not exist. Apex has no sub-second precision.'),
(r'DateTime\.today\s*\(\)', 'DateTime.today() does not exist. Use Date.today() or DateTime.now().'),
(r'Datetime\.today\s*\(\)', 'Datetime.today() does not exist. Use Date.today() or Datetime.now().'),
# Java stream operations
(r'\.stream\s*\(\)', 'stream() does not exist in Apex. Use for loops instead.'),
(r'\.collect\s*\(', 'collect() does not exist in Apex. Use for loops to build collections.'),
(r'\.filter\s*\(\s*\w+\s*->', 'Lambda filter() does not exist in Apex. Use for loops with if statements.'),
(r'\.map\s*\(\s*\w+\s*->', 'Lambda map() does not exist in Apex. Use for loops.'),
(r'\.forEach\s*\(\s*\w+\s*->', 'Lambda forEach() does not exist in Apex. Use for loops.'),
(r'\.reduce\s*\(', 'reduce() does not exist in Apex. Use a loop with an accumulator.'),
(r'\.flatMap\s*\(', 'flatMap() does not exist in Apex. Use nested for loops.'),
# Map methods from Java
(r'\.getOrDefault\s*\(', 'Map.getOrDefault() does not exist. Use: map.get(key) ?? defaultValue'),
(r'\.putIfAbsent\s*\(', 'Map.putIfAbsent() does not exist. Use: if (!map.containsKey(key)) map.put(key, value)'),
(r'\.computeIfAbsent\s*\(', 'Map.computeIfAbsent() does not exist. Use containsKey() check instead.'),
(r'\.computeIfPresent\s*\(', 'Map.computeIfPresent() does not exist. Use containsKey() check instead.'),
(r'\.merge\s*\(', 'Map.merge() does not exist in Apex.'),
(r'\.entrySet\s*\(\)', 'Map.entrySet() does not exist. Use map.keySet() and map.get() instead.'),
# String methods from Java
(r'String\.format\s*\([^)]*%[sdf]', 'String.format() in Apex uses different syntax. Use String.format(template, args).'),
(r'\.charAt\s*\(\d+\)', 'String.charAt() does not exist. Use substring(index, index+1) or split(\'\')[index].'),
(r'\.toCharArray\s*\(\)', 'String.toCharArray() does not exist. Use split(\'\') to get List<String>.'),
(r'\.getBytes\s*\(\)', 'String.getBytes() does not exist. Use Blob.valueOf(str) instead.'),
(r'\.matches\s*\(', 'String.matches() does not exist. Use Pattern.matches(regex, input).'),
# List/Collection methods from Java
(r'\.addAll\s*\(\s*\d+\s*,', 'List.addAll(index, collection) does not exist. Only addAll(collection) is supported.'),
(r'\.subList\s*\(', 'List.subList() does not exist. Use a loop or clone and remove.'),
(r'\.toArray\s*\([^)]*\)', 'List.toArray(T[]) does not exist. Collections are already typed in Apex.'),
# Other common hallucinations
(r'Objects\.equals\s*\(', 'Objects.equals() does not exist. Use == or custom comparison.'),
(r'Objects\.hash\s*\(', 'Objects.hash() does not exist. Use String.valueOf() for simple hashing.'),
(r'Optional\s*<', 'Optional<T> does not exist in Apex. Use null checks instead.'),
(r'\.orElse\s*\(', 'orElse() does not exist in Apex. Use null coalescing: value ?? default.'),
(r'\.ifPresent\s*\(', 'ifPresent() does not exist in Apex. Use null checks.'),
]
# Patterns for unsafe Map access
MAP_ACCESS_PATTERNS = [
# Direct method call on Map.get() result without null check
r'(\w+)\.get\s*\([^)]+\)\s*\.\s*\w+\s*\(', # map.get(key).method()
r'(\w+)\.get\s*\([^)]+\)\s*\.\s*\w+\s*[^?]', # map.get(key).property (not safe nav)
]
def __init__(self, file_path: str):
"""
Initialize the validator with an Apex file.
Args:
file_path: Path to .cls or .trigger file
"""
self.file_path = file_path
self.content = ""
self.lines = []
self.issues = []
try:
with open(file_path, 'r', encoding='utf-8') as f:
self.content = f.read()
self.lines = self.content.split('\n')
except Exception as e:
self.issues.append({
'severity': 'ERROR',
'category': 'file',
'message': f'Cannot read file: {e}',
'line': 0
})
def validate(self) -> Dict:
"""
Run all LLM pattern validations.
Returns:
Dictionary with validation results
"""
if not self.content:
return {
'file': os.path.basename(self.file_path),
'issues': self.issues,
'issue_count': len(self.issues)
}
# Run all checks
self._check_java_types()
self._check_hallucinated_methods()
self._check_unsafe_map_access()
self._check_soql_field_coverage()
return {
'file': os.path.basename(self.file_path),
'issues': self.issues,
'issue_count': len(self.issues)
}
def _check_java_types(self):
"""Check for Java collection types that don't exist in Apex."""
for java_type, apex_alternative in self.JAVA_TYPES.items():
# Pattern: JavaType<...> or new JavaType<...>
pattern = rf'\b{java_type}\s*<'
for i, line in enumerate(self.lines, 1):
# Skip comments
stripped = line.strip()
if stripped.startswith('//') or stripped.startswith('*'):
continue
if re.search(pattern, line):
self.issues.append({
'severity': 'CRITICAL',
'category': 'java_type',
'message': f'Java type "{java_type}" does not exist in Apex',
'line': i,
'fix': f'Use {apex_alternative} instead',
'source': 'llm-pattern-validator'
})
def _check_hallucinated_methods(self):
"""Check for methods that LLMs commonly hallucinate."""
for pattern, message in self.HALLUCINATED_METHODS:
for i, line in enumerate(self.lines, 1):
# Skip comments
stripped = line.strip()
if stripped.startswith('//') or stripped.startswith('*'):
continue
if re.search(pattern, line, re.IGNORECASE):
self.issues.append({
'severity': 'CRITICAL',
'category': 'hallucinated_method',
'message': message,
'line': i,
'source': 'llm-pattern-validator'
})
def _check_unsafe_map_access(self):
"""Check for Map.get() without null safety."""
# More sophisticated check: look for Map.get() followed by . without ?
# Skip if there's a containsKey check nearby or safe navigation
map_get_pattern = r'(\w+)\.get\s*\(([^)]+)\)\s*\.(?!\s*\?)'
for i, line in enumerate(self.lines, 1):
# Skip comments
stripped = line.strip()
if stripped.startswith('//') or stripped.startswith('*'):
continue
# Skip lines with safe navigation operator
if '?.' in line:
continue
matches = re.finditer(map_get_pattern, line)
for match in matches:
map_var = match.group(1)
key_expr = match.group(2)
# Check if there's a containsKey check in the surrounding context
# Look at the previous 5 lines for a containsKey check
context_start = max(0, i - 6)
context = '\n'.join(self.lines[context_start:i])
# Also check if there's an if (map_var != null) check
has_null_check = (
f'containsKey({key_expr})' in context or
f'{map_var}.containsKey' in context or
f'{map_var} != null' in context or
f'{map_var} == null' in context or
'if (' in self.lines[i-1] if i > 0 else False
)
if not has_null_check:
self.issues.append({
'severity': 'WARNING',
'category': 'unsafe_map_access',
'message': f'Potential NPE: {map_var}.get() used without null check',
'line': i,
'fix': f'Use {map_var}.get({key_expr})?.property or check containsKey() first',
'source': 'llm-pattern-validator'
})
def _check_soql_field_coverage(self):
"""
Check for potential SOQL field coverage issues.
This is a simplified check that looks for common patterns where
fields might be accessed but not queried.
"""
# Find SOQL queries and extract field lists
soql_pattern = r'\[\s*SELECT\s+([^F][^\]]+?)\s+FROM\s+(\w+)'
soql_queries = []
for i, line in enumerate(self.lines, 1):
matches = re.finditer(soql_pattern, line, re.IGNORECASE)
for match in matches:
fields_str = match.group(1)
sobject = match.group(2)
# Parse field names (simplified)
fields = set()
for field in fields_str.split(','):
field = field.strip()
# Handle relationship fields like Account.Name
if '(' not in field: # Skip subqueries
fields.add(field.lower())
soql_queries.append({
'line': i,
'sobject': sobject,
'fields': fields
})
# This is a very simplified check - just warn if a query has very few fields
# and later code accesses many properties
for query in soql_queries:
if len(query['fields']) <= 2 and 'id' in query['fields']:
# Very minimal query - might be missing fields
# Check following lines for field access patterns
query_line = query['line']
following_lines = '\n'.join(self.lines[query_line:min(query_line + 20, len(self.lines))])
# Count distinct field accesses that look like sobject.Field
field_access_pattern = rf"\.([A-Z][a-zA-Z0-9_]+)(?:\s*[;,\)\]\}}=]|\s*!=|\s*==)"
accessed_fields = set(re.findall(field_access_pattern, following_lines))
# If accessing many more fields than queried, warn
if len(accessed_fields) > len(query['fields']) + 2:
self.issues.append({
'severity': 'INFO',
'category': 'soql_field_coverage',
'message': f"SOQL on line {query_line} queries {len(query['fields'])} fields but code may access more",
'line': query_line,
'fix': 'Verify all accessed fields are in the SELECT clause',
'source': 'llm-pattern-validator'
})
def validate_apex_llm_patterns(file_path: str) -> Dict:
"""
Validate an Apex file for LLM-specific anti-patterns.
Args:
file_path: Path to .cls or .trigger file
Returns:
Dictionary with validation results
"""
validator = LLMPatternValidator(file_path)
return validator.validate()
def format_output(results: Dict) -> str:
"""Format validation results for display."""
issues = results.get('issues', [])
if not issues:
return ""
output_parts = []
output_parts.append("")
output_parts.append(f"🤖 LLM Pattern Check: {results['file']}")
output_parts.append("─" * 50)
# Group by severity
critical = [i for i in issues if i['severity'] == 'CRITICAL']
warnings = [i for i in issues if i['severity'] == 'WARNING']
info = [i for i in issues if i['severity'] == 'INFO']
if critical:
output_parts.append(f"🔴 Critical ({len(critical)}):")
for issue in critical[:5]:
output_parts.append(f" L{issue['line']}: {issue['message']}")
if issue.get('fix'):
output_parts.append(f" 💡 {issue['fix']}")
if warnings:
output_parts.append(f"🟡 Warnings ({len(warnings)}):")
for issue in warnings[:3]:
output_parts.append(f" L{issue['line']}: {issue['message']}")
if issue.get('fix'):
output_parts.append(f" 💡 {issue['fix']}")
if info and not critical and not warnings:
output_parts.append(f"ℹ️ Info ({len(info)}):")
for issue in info[:2]:
output_parts.append(f" L{issue['line']}: {issue['message']}")
remaining = len(issues) - len(critical[:5]) - len(warnings[:3]) - (len(info[:2]) if not critical and not warnings else 0)
if remaining > 0:
output_parts.append(f" ... and {remaining} more issues")
output_parts.append("─" * 50)
output_parts.append("📚 See: sf-apex/references/llm-anti-patterns.md")
return "\n".join(output_parts)
if __name__ == "__main__":
import sys
import json
if len(sys.argv) < 2:
print("Usage: python llm_pattern_validator.py <file.cls|file.trigger>")
sys.exit(1)
file_path = sys.argv[1]
if not os.path.exists(file_path):
print(f"Error: File not found: {file_path}")
sys.exit(1)
results = validate_apex_llm_patterns(file_path)
# Print formatted output
output = format_output(results)
if output:
print(output)
else:
print(f"✅ No LLM anti-patterns detected in {results['file']}")
# Return non-zero if critical issues
critical_count = sum(1 for i in results['issues'] if i['severity'] == 'CRITICAL')
sys.exit(0) # Advisory only - don't block
#!/usr/bin/env python3
"""
Post-Tool Validation Hook for sf-apex plugin.
This hook runs AFTER Write or Edit tool completes and provides validation
feedback for Salesforce Apex files (*.cls, *.trigger).
Integrates:
1. Custom 150-point scoring (8 categories)
2. Salesforce Code Analyzer V5 (all available engines)
Hook Input (stdin): JSON with tool_input and tool_response
Hook Output (stdout): JSON with optional output message
This hook is ADVISORY - it provides feedback but does not block operations.
"""
import sys
import os
import json
# Add script directory to path for imports
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, SCRIPT_DIR)
# Find shared modules — try installed path first, then dev repo path
_CLAUDE_DIR = os.path.join(os.path.expanduser("~"), ".claude")
_CODE_ANALYZER_CANDIDATES = [
os.path.join(_CLAUDE_DIR, "code_analyzer"), # Installed path
os.path.join(SCRIPT_DIR, "..", "..", "..", "..", "shared", "code_analyzer"), # Dev repo
]
for _ca_path in _CODE_ANALYZER_CANDIDATES:
_ca_path = os.path.normpath(_ca_path)
if os.path.isdir(_ca_path):
sys.path.insert(0, os.path.dirname(_ca_path)) # Parent so "from code_analyzer import" works
break
# Also add shared dir for soql_extractor and other shared modules
PLUGIN_ROOT = os.path.dirname(os.path.dirname(SCRIPT_DIR)) # sf-apex/
SKILLS_ROOT = os.path.dirname(PLUGIN_ROOT) # parent of skills/
SHARED_DIR = os.path.join(SKILLS_ROOT, "shared")
if os.path.isdir(SHARED_DIR):
sys.path.insert(0, SHARED_DIR)
def validate_apex_with_ca(file_path: str) -> dict:
"""
Run comprehensive Apex validation combining custom scoring with Code Analyzer.
Args:
file_path: Path to .cls or .trigger file
Returns:
dict with validation results and output message
"""
output_parts = []
file_name = os.path.basename(file_path)
try:
# ═══════════════════════════════════════════════════════════════════
# PHASE 1: Custom 150-point validation
# ═══════════════════════════════════════════════════════════════════
from validate_apex import ApexValidator
validator = ApexValidator(file_path)
custom_results = validator.validate()
custom_score = custom_results.get('score', 0)
custom_max = custom_results.get('max_score', 150)
custom_issues = custom_results.get('issues', [])
custom_scores = custom_results.get('scores', {})
custom_rating = custom_results.get('rating', '')
# ═══════════════════════════════════════════════════════════════════
# PHASE 1.5: LLM Pattern Validation (Java types, hallucinated methods)
# ═══════════════════════════════════════════════════════════════════
llm_issues = []
try:
from llm_pattern_validator import LLMPatternValidator
llm_validator = LLMPatternValidator(file_path)
llm_results = llm_validator.validate()
llm_issues = llm_results.get('issues', [])
# Add LLM issues to custom_issues with adjusted severity
for issue in llm_issues:
custom_issues.append({
'severity': issue.get('severity', 'WARNING'),
'category': issue.get('category', 'llm_pattern'),
'message': issue.get('message', ''),
'line': issue.get('line', 0),
'fix': issue.get('fix', ''),
'source': 'llm-validator'
})
except ImportError:
pass # LLM validator not available
except Exception:
pass # Don't fail validation on LLM check errors
# ═══════════════════════════════════════════════════════════════════
# PHASE 2: Code Analyzer V5 scanning (if available)
# ═══════════════════════════════════════════════════════════════════
ca_violations = []
ca_engines_used = []
ca_engines_unavailable = []
ca_available = False
scan_time_ms = 0
try:
from code_analyzer.scanner import CodeAnalyzerScanner, SkillType
from code_analyzer.score_merger import ScoreMerger
scanner = CodeAnalyzerScanner()
if scanner.is_available():
ca_available = True
scan_result = scanner.scan(file_path, SkillType.APEX)
if scan_result.success:
ca_violations = scan_result.violations
ca_engines_used = scan_result.engines_used
ca_engines_unavailable = scan_result.engines_unavailable
scan_time_ms = scan_result.scan_time_ms
else:
ca_engines_unavailable = ["Error: " + (scan_result.error_message or "Unknown")]
else:
ca_engines_unavailable = ["sf CLI with Code Analyzer not installed"]
except ImportError as e:
ca_engines_unavailable = [f"Module not available: {e}"]
except Exception as e:
ca_engines_unavailable = [f"Scanner error: {e}"]
# ═══════════════════════════════════════════════════════════════════
# PHASE 2.5: Live Query Plan Analysis (if org connected)
# ═══════════════════════════════════════════════════════════════════
live_plan_results = []
org_name = None
live_plan_available = False
try:
from code_analyzer.live_query_plan import LiveQueryPlanAnalyzer
from soql_extractor import SOQLExtractor
# Read file content for SOQL extraction
with open(file_path, 'r') as f:
file_content = f.read()
analyzer = LiveQueryPlanAnalyzer()
if analyzer.is_org_available():
live_plan_available = True
org_name = analyzer.get_target_org()
# Extract SOQL queries from Apex
extractor = SOQLExtractor(file_content, "apex")
queries = extractor.extract()
# Analyze each query (limit to first 5 to avoid timeout)
for query_info in queries[:5]:
# Skip dynamic variable queries
if query_info.query_type == 'dynamic_variable':
continue
plan_result = analyzer.analyze(query_info.query)
live_plan_results.append({
'line': query_info.line,
'query': query_info.query[:60],
'in_loop': query_info.in_loop,
'plan': plan_result
})
# Add non-selective queries to issues
if plan_result.success and not plan_result.is_selective:
custom_issues.append({
'severity': 'WARNING',
'line': query_info.line,
'message': f'Non-selective SOQL (cost: {plan_result.relative_cost:.1f}, op: {plan_result.leading_operation})',
'fix': 'Add indexed fields to WHERE clause or reduce result set'
})
except ImportError:
pass # Live analysis not available
except Exception as e:
pass # Don't fail validation on live plan errors
# ═══════════════════════════════════════════════════════════════════
# PHASE 3: Merge scores (if CA results available)
# ═══════════════════════════════════════════════════════════════════
final_score = custom_score
final_max = custom_max
rating = custom_rating
rating_stars = 0
ca_deductions = 0
deductions = []
if ca_violations and ca_available:
try:
merger = ScoreMerger(
custom_scores=custom_scores,
custom_max_scores=validator.scores
)
merged = merger.merge(
[v if isinstance(v, dict) else v.__dict__ for v in ca_violations],
engines_used=ca_engines_used,
engines_unavailable=ca_engines_unavailable,
)
final_score = merged.final_score
final_max = merged.final_max
rating = merged.rating
rating_stars = merged.rating_stars
ca_deductions = merged.ca_deductions
deductions = merged.deductions
except Exception as e:
# Fallback to custom score only
pass
# Calculate rating stars from custom score if not set
if rating_stars == 0:
pct = (final_score / final_max * 100) if final_max > 0 else 0
if pct >= 90:
rating_stars = 5
elif pct >= 75:
rating_stars = 4
elif pct >= 60:
rating_stars = 3
elif pct >= 45:
rating_stars = 2
else:
rating_stars = 1
# ═══════════════════════════════════════════════════════════════════
# PHASE 4: Format output
# ═══════════════════════════════════════════════════════════════════
stars = "" * rating_stars + "" * (5 - rating_stars)
output_parts.append("")
output_parts.append(f" Apex Validation: {file_name}")
output_parts.append("" * 60)
# Combined score
output_parts.append(f" Score: {final_score}/{final_max} {stars} {rating}")
# Show CA deductions if any
if ca_deductions > 0:
output_parts.append(f" (Custom: {custom_score}, CA deductions: -{ca_deductions})")
# Category breakdown
if custom_scores:
output_parts.append("")
output_parts.append(" Category Breakdown:")
for cat, score in custom_scores.items():
max_score = validator.scores.get(cat, 0)
if max_score > 0:
icon = "" if score == max_score else ("" if score >= max_score * 0.7 else "")
diff = f" (-{max_score - score})" if score < max_score else ""
display_name = cat.replace("_", " ").title()
output_parts.append(f" {icon} {display_name}: {score}/{max_score}{diff}")
# Code Analyzer status
output_parts.append("")
if ca_engines_used:
output_parts.append(f" Code Analyzer: {', '.join(ca_engines_used)}")
elif ca_available:
output_parts.append(" Code Analyzer: No engines ran")
else:
output_parts.append(" Code Analyzer: Not available")
if ca_engines_unavailable:
for unavail in ca_engines_unavailable[:3]:
output_parts.append(f" {unavail}")
if scan_time_ms > 0:
output_parts.append(f" Scan time: {scan_time_ms}ms")
# Live Query Plan section
if live_plan_results:
output_parts.append("")
output_parts.append(f"🌐 Live Query Plan Analysis (Org: {org_name})")
for lp in live_plan_results[:3]: # Show first 3
plan = lp['plan']
if plan.success:
loop_warn = " ⚠️ IN LOOP" if lp['in_loop'] else ""
output_parts.append(f" L{lp['line']}: {plan.icon} Cost {plan.relative_cost:.1f} ({plan.leading_operation}){loop_warn}")
if plan.notes:
output_parts.append(f" 📝 {str(plan.notes[0])[:55]}")
if len(live_plan_results) > 3:
output_parts.append(f" ... and {len(live_plan_results) - 3} more queries")
elif live_plan_available:
output_parts.append("")
output_parts.append("🌐 Live Query Plan: No SOQL queries found")
elif org_name is None and not live_plan_available:
pass # Don't show if org not connected (too noisy)
# Issues list
all_issues = []
# Add custom issues
for issue in custom_issues:
severity = issue.get('severity', 'INFO')
all_issues.append({
'severity': severity,
'source': 'sf-skills',
'line': issue.get('line', 0),
'message': issue.get('message', ''),
'fix': issue.get('fix', ''),
})
# Add CA violations
for v in ca_violations:
if isinstance(v, dict):
all_issues.append({
'severity': v.get('severity_label', 'INFO'),
'source': f"CA:{v.get('engine', '')}",
'line': v.get('line', 0),
'message': v.get('message', '')[:80],
'rule': v.get('rule', ''),
})
if all_issues:
output_parts.append("")
output_parts.append(f" Issues Found ({len(all_issues)}):")
# Sort by severity
severity_order = {'CRITICAL': 0, 'HIGH': 1, 'MODERATE': 2, 'WARNING': 3, 'LOW': 4, 'INFO': 5}
all_issues.sort(key=lambda x: severity_order.get(x['severity'], 5))
# Display up to 12 issues
for issue in all_issues[:12]:
icon = {'CRITICAL': '', 'HIGH': '', 'MODERATE': '', 'WARNING': '', 'LOW': '', 'INFO': ''}.get(
issue['severity'], ''
)
source = f"[{issue['source']}]" if issue.get('source') else ""
line_info = f"L{issue['line']}" if issue.get('line') else ""
message = issue['message'][:65] + "..." if len(issue['message']) > 65 else issue['message']
output_parts.append(f" {icon} {issue['severity']} {source} {line_info}: {message}")
if issue.get('fix'):
fix = issue['fix'][:55] + "..." if len(issue['fix']) > 55 else issue['fix']
output_parts.append(f" Fix: {fix}")
if len(all_issues) > 12:
output_parts.append(f" ... and {len(all_issues) - 12} more issues")
else:
output_parts.append("")
output_parts.append(" No issues found!")
output_parts.append("" * 60)
return {
"continue": True,
"output": "\n".join(output_parts)
}
except ImportError as e:
return {
"continue": True,
"output": f" Apex validator not available: {e}"
}
except Exception as e:
return {
"continue": True,
"output": f" Apex validation error: {e}"
}
def main():
"""
Main hook entry point.
Reads hook input from stdin, validates Apex files.
"""
try:
# Read hook input from stdin
hook_input = json.load(sys.stdin)
# Extract file path from tool input
tool_input = hook_input.get("tool_input", {})
file_path = tool_input.get("file_path", "")
# Check if operation was successful
tool_response = hook_input.get("tool_response", {})
if not tool_response.get("success", True):
# Operation failed, don't validate
print(json.dumps({"continue": True}))
return 0
# Only validate Apex files
result = {"continue": True}
if file_path.endswith(".cls") or file_path.endswith(".trigger"):
result = validate_apex_with_ca(file_path)
# Output result
print(json.dumps(result))
return 0
except json.JSONDecodeError:
# No valid JSON input, continue silently
print(json.dumps({"continue": True}))
return 0
except Exception as e:
# Unexpected error, log but don't block
print(json.dumps({
"continue": True,
"output": f" Hook error: {e}"
}))
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""
Prettier Auto-Format Hook for Apex files.
PostToolUse hook that runs prettier --write on .cls/.trigger files
after Write/Edit operations. Ensures consistent code formatting
before other validators run.
Requires: npm install -g prettier prettier-plugin-apex
Degrades gracefully if not installed.
"""
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
APEX_EXTENSIONS = {".cls", ".trigger"}
# Prettier runtime: local install at ~/.claude/prettier/ with prettier-plugin-apex
PRETTIER_DIR = Path.home() / ".claude" / "prettier"
def is_prettier_available() -> bool:
"""Check if prettier and prettier-plugin-apex are installed in the runtime dir."""
npx_path = PRETTIER_DIR / "node_modules" / ".bin" / "prettier"
return npx_path.exists()
def format_file(file_path: str) -> dict:
"""Run prettier on an Apex file and return the result."""
if not os.path.exists(file_path):
return {"formatted": False, "reason": "File not found"}
ext = Path(file_path).suffix.lower()
if ext not in APEX_EXTENSIONS:
return {"formatted": False, "reason": "Not an Apex file"}
if not is_prettier_available():
return {"formatted": False, "reason": "prettier not installed (run sf-skills --update)"}
# Read content before formatting
try:
with open(file_path, "r") as f:
before = f.read()
except Exception:
return {"formatted": False, "reason": "Cannot read file"}
# Run prettier from the runtime dir (where node_modules has the plugin)
prettier_bin = str(PRETTIER_DIR / "node_modules" / ".bin" / "prettier")
try:
result = subprocess.run(
[
prettier_bin, "--write",
"--plugin=prettier-plugin-apex",
"--tab-width=4",
"--print-width=120",
os.path.abspath(file_path)
],
capture_output=True, text=True, timeout=15,
cwd=str(PRETTIER_DIR)
)
if result.returncode != 0:
return {
"formatted": False,
"reason": f"prettier error: {result.stderr.strip()[:100]}"
}
# Read content after formatting
with open(file_path, "r") as f:
after = f.read()
if before == after:
return {"formatted": False, "reason": "Already formatted"}
else:
return {"formatted": True, "reason": "Auto-formatted by prettier"}
except subprocess.TimeoutExpired:
return {"formatted": False, "reason": "prettier timed out"}
except Exception as e:
return {"formatted": False, "reason": f"Error: {e}"}
def main():
"""Main entry point — reads hook input from stdin."""
try:
hook_input = json.load(sys.stdin)
except (json.JSONDecodeError, EOFError):
print(json.dumps({"continue": True}))
return
tool_input = hook_input.get("tool_input", {})
file_path = tool_input.get("file_path", "")
if not file_path:
print(json.dumps({"continue": True}))
return
result = format_file(file_path)
if result.get("formatted"):
output = {
"continue": True,
"output": f"✨ {result['reason']}: {Path(file_path).name}"
}
else:
# Silent — don't pollute output when not formatted or unavailable
output = {"continue": True}
print(json.dumps(output))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Apex Validator for sf-skills plugin.
Validates Salesforce Apex code (.cls, .trigger) for patterns that
Code Analyzer (PMD) does not cover.
Scoring Categories (90 points total):
1. Testing (25 pts): test methods, assertions, coverage
2. Architecture (20 pts): separation of concerns, patterns
3. Clean Code (20 pts): naming, complexity, comments
4. Error Handling (15 pts): try-catch, custom exceptions
5. Performance (10 pts): limits, caching, async
NOTE: Bulkification, security, documentation, and hardcoded IDs are
handled by sf code-analyzer (PMD rules) which runs as a separate
validation phase. This avoids duplicate checking with less accuracy.
"""
import re
import sys
import os
from typing import Dict, List, Tuple
class ApexValidator:
"""Validates Apex code for best practices."""
def __init__(self, file_path: str):
"""
Initialize the validator with an Apex file.
Args:
file_path: Path to .cls or .trigger file
"""
self.file_path = file_path
self.content = ""
self.lines = []
self.issues = []
self.scores = {
'testing': 25,
'architecture': 20,
'clean_code': 20,
'error_handling': 15,
'performance': 10,
}
# Read file content
try:
with open(file_path, 'r', encoding='utf-8') as f:
self.content = f.read()
self.lines = self.content.split('\n')
except Exception as e:
self.issues.append({
'severity': 'CRITICAL',
'category': 'file',
'message': f'Cannot read file: {e}',
'line': 0
})
def validate(self) -> Dict:
"""
Run all validations on the Apex file.
Returns:
Dictionary with validation results
"""
if not self.content:
return {
'file': os.path.basename(self.file_path),
'score': 0,
'max_score': 150,
'rating': 'CRITICAL',
'issues': self.issues
}
# Run checks (bulkification, security, documentation handled by Code Analyzer PMD)
self._check_null_checks()
self._check_naming_conventions()
self._check_error_handling()
# Calculate total score
total_score = sum(self.scores.values())
max_score = 90
# Determine rating
if total_score >= 81:
rating = '⭐⭐⭐⭐⭐ Excellent'
elif total_score >= 68:
rating = '⭐⭐⭐⭐ Very Good'
elif total_score >= 54:
rating = '⭐⭐⭐ Good'
elif total_score >= 40:
rating = '⭐⭐ Needs Work'
else:
rating = '⭐ Critical Issues'
return {
'file': os.path.basename(self.file_path),
'score': total_score,
'max_score': max_score,
'rating': rating,
'scores': self.scores.copy(),
'issues': self.issues
}
def _check_null_checks(self):
"""Check for missing null checks before method calls."""
# Look for patterns like variable.method() without prior null check
# This is a simplified check
null_check_pattern = r'(\w+)\s*!=\s*null'
method_call_pattern = r'(\w+)\.\w+\s*\('
checked_vars = set()
for line in self.lines:
matches = re.findall(null_check_pattern, line)
checked_vars.update(matches)
# Check if method calls are on unchecked variables (simplified)
# This is advisory only since full analysis requires AST
pass
def _check_naming_conventions(self):
"""Check for naming convention violations."""
# Class names should be PascalCase
# Match actual class declarations (with optional modifiers), not "class" in comments
class_pattern = r'^\s*(?:public|private|global|virtual|abstract|with\s+sharing|without\s+sharing|\s)*\s*class\s+(\w+)'
for i, line in enumerate(self.lines, 1):
# Skip comment lines
stripped = line.strip()
if stripped.startswith('//') or stripped.startswith('*') or stripped.startswith('/*'):
continue
match = re.search(class_pattern, line, re.IGNORECASE)
if match:
class_name = match.group(1)
if not class_name[0].isupper():
self.issues.append({
'severity': 'INFO',
'category': 'clean_code',
'message': f'Class name "{class_name}" should be PascalCase',
'line': i
})
self.scores['clean_code'] -= 2
# Method names should be camelCase
method_pattern = r'(public|private|protected|global)\s+(static\s+)?(\w+)\s+(\w+)\s*\('
for i, line in enumerate(self.lines, 1):
match = re.search(method_pattern, line)
if match:
method_name = match.group(4)
# Skip constructors and test methods
if method_name[0].isupper() and '@isTest' not in self.content[:i]:
if method_name not in [m.group(1) for m in re.finditer(class_pattern, self.content)]:
self.issues.append({
'severity': 'INFO',
'category': 'clean_code',
'message': f'Method name "{method_name}" should be camelCase',
'line': i
})
self.scores['clean_code'] -= 2
def _check_error_handling(self):
"""Check for error handling patterns."""
has_try = 'try {' in self.content or 'try{' in self.content
has_catch = 'catch (' in self.content or 'catch(' in self.content
# Check for empty catch blocks
empty_catch_pattern = r'catch\s*\([^)]+\)\s*\{\s*\}'
for i, line in enumerate(self.lines, 1):
if re.search(empty_catch_pattern, line):
self.issues.append({
'severity': 'WARNING',
'category': 'error_handling',
'message': 'Empty catch block - exceptions are silently swallowed',
'line': i,
'fix': 'Log the exception or handle it appropriately'
})
self.scores['error_handling'] -= 5
# Check for generic exception catch without specific handling
if 'catch (Exception e)' in self.content:
# This is OK as a fallback, but should have specific catches first
pass
def main():
"""Command-line interface for Apex validation."""
if len(sys.argv) < 2:
print("Usage: python validate_apex.py <file.cls|file.trigger>")
sys.exit(1)
file_path = sys.argv[1]
if not os.path.exists(file_path):
print(f"Error: File not found: {file_path}")
sys.exit(1)
validator = ApexValidator(file_path)
results = validator.validate()
# Print results
print(f"\n🔍 Apex Validation (patterns): {results['file']}")
print(f"Score: {results['score']}/{results['max_score']} {results['rating']} (PMD handles bulkification, security, docs)")
print()
if results['issues']:
print("Issues found:")
for issue in results['issues']:
severity_icon = {'CRITICAL': '🔴', 'WARNING': '🟡', 'INFO': '🔵'}.get(issue['severity'], '⚪')
print(f" {severity_icon} [{issue['severity']}] Line {issue['line']}: {issue['message']}")
if 'fix' in issue:
print(f" Fix: {issue['fix']}")
else:
print("✅ No issues found!")
# Return non-zero if critical issues
critical_count = sum(1 for i in results['issues'] if i['severity'] == 'CRITICAL')
sys.exit(1 if critical_count > 0 else 0)
if __name__ == "__main__":
main()
sf-apex
Generates and reviews Salesforce Apex code with 2025 best practices and 150-point scoring. Build production-ready, secure, and maintainable Apex.
Features
- Code Generation: Create Apex classes, triggers (TAF), tests, batch jobs, queueables from requirements
- Code Review: Analyze existing Apex for best practices violations with actionable fixes
- 150-Point Scoring: Automated validation across 8 categories
- Template Library: Pre-built patterns for common class types
- LSP Integration: Real-time syntax validation via Apex Language Server
Installation
# Install as part of sf-skills
npx skills add Jaganpro/sf-skills
# Or install just this skill
npx skills add Jaganpro/sf-skills --skill sf-apexQuick Start
1. Invoke the skill
Skill: sf-apex
Request: "Create an AccountService class with CRUD methods"2. Answer requirements questions
The skill will ask about:
- Class type (Service, Selector, Trigger, Batch, etc.)
- Primary purpose
- Target object(s)
- Test requirements
3. Review generated code
The skill generates:
- Main class with ApexDoc comments
- Corresponding test class with 90%+ coverage patterns
- Proper naming following conventions
Scoring System (150 Points)
| Category | Points | Focus |
|---|---|---|
| Bulkification | 25 | No SOQL/DML in loops, collection patterns |
| Security | 25 | CRUD/FLS checks, no injection, SOQL injection prevention |
| Testing | 25 | Test coverage, assertions, negative tests |
| Architecture | 20 | SOLID principles, separation of concerns |
| Error Handling | 15 | Try-catch, custom exceptions, logging |
| Naming | 15 | Consistent naming, ApexDoc comments |
| Performance | 15 | Async patterns, efficient queries |
| Code Quality | 10 | Clean code, no hardcoding |
Thresholds: 90+ | 80-89 | 70-79 | Block: <60
Templates
| Template | Use Case |
|---|---|
trigger.trigger | Trigger with TAF pattern |
trigger-action.cls | Trigger Actions Framework handler |
service.cls | Business logic service class |
selector.cls | SOQL selector pattern |
batch.cls | Batch Apex job |
queueable.cls | Queueable async job |
test-class.cls | Test class with data factory |
Cross-Skill Integration
| Related Skill | When to Use |
|---|---|
| sf-flow | Create Flow to call @InvocableMethod |
| sf-lwc | Create LWC to call @AuraEnabled controllers |
| sf-testing | Run tests and analyze coverage |
| sf-deploy | Deploy Apex to org |
Documentation
- Naming Conventions
- Best Practices
- Testing Guide
- Flow Integration
- Design Patterns
Requirements
- sf CLI v2
- Target Salesforce org
- Java 11+ (for Apex LSP validation)
License
MIT License. See LICENSE file. Copyright (c) 2024-2025 Jag Valaiyapathy
<!-- Parent: sf-apex/SKILL.md -->
Apex Anti-Patterns
Comprehensive catalog of common Apex anti-patterns, code smells, and how to fix them.
---
Table of Contents
1. Critical Anti-Patterns 2. Code Review Red Flags 3. Performance Anti-Patterns 4. Security Anti-Patterns 5. Testing Anti-Patterns 6. Code Smell Catalog
---
<a id="critical-anti-patterns"></a>
Critical Anti-Patterns
These patterns will cause immediate failures or security vulnerabilities. NEVER allow these in production code.
1. SOQL in Loop
Problem: Hits 100 SOQL query limit.
❌ BAD:
for (Account acc : accounts) {
List<Contact> contacts = [SELECT Id FROM Contact WHERE AccountId = :acc.Id];
// Process contacts
}
// Fails after 100 accounts✅ GOOD:
Set<Id> accountIds = new Set<Id>();
for (Account acc : accounts) {
accountIds.add(acc.Id);
}
Map<Id, List<Contact>> contactsByAccountId = new Map<Id, List<Contact>>();
for (Contact con : [SELECT Id, AccountId FROM Contact WHERE AccountId IN :accountIds]) {
if (!contactsByAccountId.containsKey(con.AccountId)) {
contactsByAccountId.put(con.AccountId, new List<Contact>());
}
contactsByAccountId.get(con.AccountId).add(con);
}
for (Account acc : accounts) {
List<Contact> contacts = contactsByAccountId.get(acc.Id) ?? new List<Contact>();
// Process contacts
}Detection: Search for [SELECT inside for loops.
---
2. DML in Loop
Problem: Hits 150 DML statement limit.
❌ BAD:
for (Account acc : accounts) {
acc.Industry = 'Technology';
update acc; // DML in loop!
}
// Fails after 150 accounts✅ GOOD:
for (Account acc : accounts) {
acc.Industry = 'Technology';
}
update accounts; // Single DML after loopDetection: Search for insert, update, delete, upsert inside for loops.
---
3. Missing Sharing Keyword
Problem: Bypasses record-level security by default.
❌ BAD:
public class AccountService {
// Implicitly "without sharing" - security risk!
}✅ GOOD:
public with sharing class AccountService {
// Respects sharing rules
}Detection: Classes without with sharing, without sharing, or inherited sharing.
---
4. Hardcoded Record IDs
Problem: IDs differ between orgs, causing deployment failures.
❌ BAD:
Id recordTypeId = '012000000000000AAA'; // Hardcoded ID!✅ GOOD:
// Option 1: Query at runtime
Id recordTypeId = Schema.SObjectType.Account.getRecordTypeInfosByDeveloperName()
.get('Enterprise').getRecordTypeId();
// Option 2: Custom Metadata
Account_Config__mdt config = Account_Config__mdt.getInstance('Default');
Id recordTypeId = config.Record_Type_Id__c;Detection: 15 or 18-character ID literals in code.
---
5. Empty Catch Blocks
Problem: Silently swallows errors, making debugging impossible.
❌ BAD:
try {
insert accounts;
} catch (DmlException e) {
// Silent failure - no logging!
}✅ GOOD:
try {
insert accounts;
} catch (DmlException e) {
System.debug(LoggingLevel.ERROR, 'Failed to insert accounts: ' + e.getMessage());
throw e; // Or handle gracefully with user feedback
}Detection: catch blocks with no statements or only comments.
---
6. SOQL Injection
Problem: User input concatenated into SOQL allows malicious queries.
❌ BAD:
String query = 'SELECT Id FROM Account WHERE Name = \'' + userInput + '\'';
List<Account> accounts = Database.query(query);
// userInput = "test' OR '1'='1" returns ALL accounts!✅ GOOD:
// Use bind variables
List<Account> accounts = [SELECT Id FROM Account WHERE Name = :userInput];Detection: String concatenation in SOQL with variables.
---
7. Test Without Assertions
Problem: False positive tests that pass even when code fails.
❌ BAD:
@IsTest
static void testAccountCreation() {
Account acc = new Account(Name = 'Test');
insert acc;
// No assertions - test passes even if logic is broken!
}✅ GOOD:
@IsTest
static void testAccountCreation() {
Account acc = new Account(Name = 'Test', Industry = 'Tech');
insert acc;
Account inserted = [SELECT Id, Industry FROM Account WHERE Id = :acc.Id];
Assert.areEqual('Tech', inserted.Industry, 'Industry should be set');
}Detection: @IsTest methods with no Assert.* calls.
---
Code Review Red Flags
These patterns indicate poor code quality and should be refactored.
| Anti-Pattern | Problem | Fix |
|---|---|---|
| SOQL without WHERE or LIMIT | Returns all records, slow | Always add WHERE clause or LIMIT |
| Multiple triggers on object | Unpredictable execution order | Single trigger + Trigger Actions Framework |
| Generic `Exception` only | Masks specific errors | Catch specific exceptions first |
| No trigger bypass flag | Can't disable for data loads | Add Custom Setting bypass |
| `System.debug()` everywhere | Performance impact, clutters logs | Use logging framework with levels |
| Unnecessary `isEmpty()` before DML | Wastes CPU | Remove - DML handles empty lists |
| `!= false` comparisons | Confusing double negative | Use == true or just the boolean |
| No Test Data Factory | Duplicated test data setup | Centralize in factory class |
| God Class | Single class does everything | Split into Service/Selector/Domain |
| Magic Numbers | Hardcoded values like if (score > 75) | Use named constants |
---
SOQL Without WHERE or LIMIT
❌ BAD:
List<Account> accounts = [SELECT Id FROM Account];
// Returns ALL accounts - could be millions!✅ GOOD:
// Option 1: Filter
List<Account> accounts = [SELECT Id FROM Account WHERE Industry = 'Technology'];
// Option 2: Limit
List<Account> accounts = [SELECT Id FROM Account ORDER BY CreatedDate DESC LIMIT 200];
// Option 3: Both
List<Account> accounts = [SELECT Id FROM Account WHERE CreatedDate = THIS_YEAR LIMIT 1000];---
Multiple Triggers on Same Object
❌ BAD:
// AccountTrigger1.trigger
trigger AccountTrigger1 on Account (before insert) {
// Some logic
}
// AccountTrigger2.trigger
trigger AccountTrigger2 on Account (before insert) {
// More logic - which runs first?
}✅ GOOD:
// Single trigger + TAF
trigger AccountTrigger on Account (before insert, after insert, before update, after update) {
new MetadataTriggerHandler().run();
}
// Separate action classes
public class TA_Account_SetDefaults implements TriggerAction.BeforeInsert { }
public class TA_Account_Validate implements TriggerAction.BeforeInsert { }---
Generic Exception Only
❌ BAD:
try {
insert accounts;
} catch (Exception e) {
// Catches EVERYTHING - too broad
}✅ GOOD:
try {
insert accounts;
} catch (DmlException e) {
// Handle DML errors specifically
System.debug('DML failed: ' + e.getDmlMessage(0));
} catch (Exception e) {
// Catch unexpected errors
System.debug('Unexpected error: ' + e.getMessage());
throw e;
}---
Unnecessary isEmpty() Before DML
❌ BAD:
if (!accounts.isEmpty()) {
update accounts;
}
// Wastes CPU checking - DML already handles empty lists✅ GOOD:
update accounts; // No-op if empty, no error thrown---
Double Negative Comparisons
❌ BAD:
if (acc.IsActive__c != false) {
// Confusing logic
}✅ GOOD:
if (acc.IsActive__c == true) {
// Clear intent
}
// Or even better
if (acc.IsActive__c) {
// Most concise
}---
<a id="performance-anti-patterns"></a>
Performance Anti-Patterns
1. Nested Loops with SOQL
❌ BAD:
for (Account acc : accounts) {
for (Contact con : [SELECT Id FROM Contact WHERE AccountId = :acc.Id]) {
// Nested SOQL - quadratic complexity!
}
}✅ GOOD:
Map<Id, Account> accountsWithContacts = new Map<Id, Account>([
SELECT Id, (SELECT Id FROM Contacts)
FROM Account
WHERE Id IN :accountIds
]);
for (Account acc : accountsWithContacts.values()) {
for (Contact con : acc.Contacts) {
// No SOQL in loop
}
}---
2. Querying in Constructor
❌ BAD:
public class AccountService {
private List<Account> accounts;
public AccountService() {
accounts = [SELECT Id FROM Account]; // Runs on EVERY instantiation
}
}✅ GOOD:
public class AccountService {
private List<Account> accounts;
public AccountService(List<Account> accounts) {
this.accounts = accounts; // Inject dependencies
}
// Or lazy load only when needed
private List<Account> getAccounts() {
if (accounts == null) {
accounts = [SELECT Id FROM Account LIMIT 200];
}
return accounts;
}
}---
3. Excessive CPU Time
❌ BAD:
for (Account acc : accounts) {
for (Integer i = 0; i < 10000; i++) {
String hash = EncodingUtil.convertToHex(Crypto.generateDigest('SHA256', Blob.valueOf(acc.Name + i)));
// Expensive crypto in nested loop
}
}✅ GOOD:
// Move expensive operations outside loops
String baseHash = EncodingUtil.convertToHex(Crypto.generateDigest('SHA256', Blob.valueOf('base')));
for (Account acc : accounts) {
acc.Hash__c = baseHash; // Reuse computed value
}---
4. Inefficient Collections
❌ BAD:
List<Id> uniqueIds = new List<Id>();
for (Id accountId : allIds) {
if (!uniqueIds.contains(accountId)) { // O(n) lookup in List
uniqueIds.add(accountId);
}
}✅ GOOD:
Set<Id> uniqueIds = new Set<Id>(allIds); // O(1) deduplication---
<a id="security-anti-patterns"></a>
Security Anti-Patterns
1. without sharing Everywhere
❌ BAD:
public without sharing class AccountController {
@AuraEnabled
public static List<Account> getAccounts() {
// Bypasses sharing - user sees ALL accounts!
return [SELECT Id FROM Account];
}
}✅ GOOD:
public with sharing class AccountController {
@AuraEnabled
public static List<Account> getAccounts() {
// Respects sharing rules
return [SELECT Id FROM Account WITH USER_MODE];
}
}---
2. No CRUD/FLS Checks
❌ BAD:
public static void updateAccounts(List<Account> accounts) {
update accounts; // No permission check!
}✅ GOOD:
public static void updateAccounts(List<Account> accounts) {
if (!Schema.sObjectType.Account.isUpdateable()) {
throw new SecurityException('User cannot update Accounts');
}
// Or use WITH USER_MODE in queries
update accounts;
}---
3. Hardcoded Credentials
❌ BAD:
String apiKey = 'sk_live_abc123xyz'; // NEVER hardcode secrets!✅ GOOD:
// Use Named Credentials
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:MyNamedCredential/api'); // Auth handled by platform---
<a id="testing-anti-patterns"></a>
Testing Anti-Patterns
1. @SeeAllData=true
❌ BAD:
@IsTest(SeeAllData=true)
private class AccountServiceTest {
// Depends on org data - brittle, slow
}✅ GOOD:
@IsTest
private class AccountServiceTest {
@TestSetup
static void setup() {
TestDataFactory.createAccounts(10); // Isolated test data
}
}---
2. No Bulk Testing
❌ BAD:
@IsTest
static void testAccountCreation() {
Account acc = new Account(Name = 'Test');
insert acc;
// Only tests 1 record - misses bulkification bugs
}✅ GOOD:
@IsTest
static void testBulkAccountCreation() {
List<Account> accounts = new List<Account>();
for (Integer i = 0; i < 251; i++) {
accounts.add(new Account(Name = 'Bulk Test ' + i));
}
insert accounts;
Assert.areEqual(251, [SELECT COUNT() FROM Account]);
}---
3. Testing Implementation, Not Behavior
❌ BAD:
@IsTest
static void testGetAccountsCallsQuery() {
// Tests internal implementation
Assert.areEqual(1, Limits.getQueries(), 'Should call SOQL once');
}✅ GOOD:
@IsTest
static void testGetAccountsReturnsCorrectRecords() {
TestDataFactory.createAccounts(5);
List<Account> results = AccountService.getAccounts();
Assert.areEqual(5, results.size(), 'Should return all accounts');
}---
Code Smell Catalog
Based on "Clean Apex Code" by Pablo Gonzalez and clean code principles.
Long Method
Smell: Method exceeds 30 lines.
❌ BAD:
public static void processAccount(Account acc) {
// 100 lines of mixed logic
if (acc.Industry == 'Tech') {
// Validation logic
if (acc.AnnualRevenue == null) { ... }
// Calculation logic
Decimal score = ...;
// DML logic
update acc;
// Notification logic
EmailService.send(...);
}
}✅ GOOD:
public static void processAccount(Account acc) {
validateAccount(acc);
calculateScore(acc);
saveAccount(acc);
notifyOwner(acc);
}
private static void validateAccount(Account acc) { ... }
private static void calculateScore(Account acc) { ... }
private static void saveAccount(Account acc) { ... }
private static void notifyOwner(Account acc) { ... }Refactoring: Extract Method - split into smaller methods with single responsibilities.
---
Large Class (God Class)
Smell: Class exceeds 500 lines or has 20+ methods.
❌ BAD:
public class AccountService {
// 50 methods mixing concerns:
public static void createAccount() { }
public static void updateAccount() { }
public static void validateAccount() { }
public static void calculateScore() { }
public static void sendEmail() { }
public static void generateReport() { }
// ... 44 more methods
}✅ GOOD:
// Split by responsibility
public class AccountService { } // Business logic
public class AccountValidator { } // Validation
public class AccountScoreCalculator { } // Scoring
public class AccountEmailService { } // Notifications
public class AccountReportGenerator { } // ReportingRefactoring: Extract Class - split into multiple classes by concern.
---
Magic Numbers
Smell: Unexplained numeric literals.
❌ BAD:
if (acc.Score__c > 75) {
acc.Rating = 'Hot';
}✅ GOOD:
private static final Integer HOT_LEAD_THRESHOLD = 75;
if (acc.Score__c > HOT_LEAD_THRESHOLD) {
acc.Rating = 'Hot';
}---
Long Parameter List
Smell: Method has 5+ parameters.
❌ BAD:
public static void createAccount(
String name,
String industry,
Decimal revenue,
String phone,
String email,
String website,
Id ownerId
) { }✅ GOOD:
public class AccountRequest {
public String name;
public String industry;
public Decimal revenue;
public String phone;
public String email;
public String website;
public Id ownerId;
}
public static void createAccount(AccountRequest request) { }---
Feature Envy
Smell: Method uses more methods/fields from another class than its own.
❌ BAD:
public class OrderService {
public static Decimal calculateDiscount(Order__c order) {
Account acc = [SELECT Id, Tier__c FROM Account WHERE Id = :order.Account__c];
if (acc.Tier__c == 'Gold') {
return order.Amount__c * 0.2;
} else if (acc.Tier__c == 'Silver') {
return order.Amount__c * 0.1;
}
return 0;
}
}✅ GOOD:
public class Account extends SObject {
public Decimal getDiscountRate() {
if (this.Tier__c == 'Gold') return 0.2;
if (this.Tier__c == 'Silver') return 0.1;
return 0;
}
}
public class OrderService {
public static Decimal calculateDiscount(Order__c order) {
Account acc = [SELECT Id, Tier__c FROM Account WHERE Id = :order.Account__c];
return order.Amount__c * acc.getDiscountRate();
}
}Refactoring: Move Method - move logic to the class it uses most.
---
Primitive Obsession
Smell: Using primitives instead of small objects to represent concepts.
❌ BAD:
public static void sendEmail(String address, String subject, String body) {
// Validates email format inline
if (!address.contains('@')) throw new InvalidEmailException();
}✅ GOOD:
public class EmailAddress {
private String value;
public EmailAddress(String address) {
if (!address.contains('@')) {
throw new InvalidEmailException('Invalid email format');
}
this.value = address;
}
public String getValue() {
return value;
}
}
public static void sendEmail(EmailAddress address, String subject, String body) {
// Email is already validated
}---
Detection Tools
How to find anti-patterns:
| Tool | What It Finds |
|---|---|
| Salesforce Code Analyzer | SOQL/DML in loops, security issues |
| PMD (via VS Code) | Code quality, complexity, unused code |
| Developer Console | Test coverage, debug logs |
| Grep/Search | Hardcoded IDs, empty catches, magic numbers |
VS Code Command:
sf code-analyzer run --workspace force-app/main/default/classes --view tableExample output:
Severity File Line Rule Message
────────────────────────────────────────────────────────────────────────────
3 AccountService.cls 45 ApexSOQLInjection SOQL injection risk
2 AccountTrigger.trigger 12 ApexCRUDViolation Missing FLS check
1 ContactController.cls 28 ApexUnitTestClassShouldHaveAsserts No assertions---
Refactoring Checklist
When reviewing code, check for:
Bulkification:
- [ ] No SOQL in loops
- [ ] No DML in loops
- [ ] Collections used efficiently (Maps for lookups)
- [ ] Tested with 251+ records
Security:
- [ ] All classes have sharing keyword
- [ ] SOQL uses
WITH USER_MODEorSecurity.stripInaccessible() - [ ] No hardcoded credentials
- [ ] No SOQL injection vulnerabilities
Clean Code:
- [ ] Methods under 30 lines
- [ ] Classes under 500 lines
- [ ] No magic numbers (use constants)
- [ ] Meaningful variable/method names
Testing:
- [ ] All methods covered by tests
- [ ] Tests have assertions
- [ ] Bulk tests exist (251+ records)
- [ ] No
@SeeAllData=true
Error Handling:
- [ ] No empty catch blocks
- [ ] Specific exceptions before generic
- [ ] Errors logged with context
---
Reference
Full Documentation: See references/ folder for comprehensive guides:
code-smells-guide.md- Complete code smell catalogbest-practices.md- Correct patternscode-review-checklist.md- 150-point scoring
Back to Main: SKILL.md
<!-- Parent: sf-apex/SKILL.md | Cross-ref: sf-flow/SKILL.md -->
Automation Density Guide
Source: Salesforce Architect Decision Guides — Record-Triggered Automation
Related: patterns-deep-dive.md | trigger-actions-framework.md
---
Automation Density Framework
Automation density = the number of automations (triggers, flows, processes) firing on a single object. Higher density increases governor limit risk and debugging complexity.
| Density | Triggers + Flows on Object | Recommended Tool | Rationale |
|---|---|---|---|
| Low (0-2) | Few automations, simple logic | Flow (Record-Triggered) | Declarative, admin-maintainable, faster to build |
| Medium (3-5) | Multiple automations, some complexity | Hybrid (Flow + Invocable Apex) | Flow orchestrates, Apex handles complex logic |
| High (6+) | Many automations, complex interdependencies | Apex (TAF or single trigger) | Full control over execution order and governor limits |
Key Decision Factors
- Team skill mix: More admins → favor Flow. More developers → favor Apex.
- Change frequency: Frequently changing business rules → Flow (no deploy cycle for admins)
- Testing requirements: Apex has mature test framework. Flow testing is improving but less granular.
- Debug complexity: Multiple flows on one object are hard to debug. Apex has better stack traces.
---
One Entry Point Per Object
Rule: Each object should have ONE primary entry point for record-triggered automation.
Multiple triggers and record-triggered flows on the same object create:
- Unpredictable execution order between triggers and flows
- Governor limit stacking across independent automations
- Debugging nightmares when logic conflicts
Implementation Patterns
Pure Flow (Low Density):
- Single Record-Triggered Flow per object per timing (Before Save / After Save)
- Use Subflows for modularity within that single flow
Pure Apex (High Density):
- Single trigger per object → TAF MetadataTriggerHandler
- All logic in ordered Trigger Action classes
Hybrid (Medium Density):
- Record-Triggered Flow as entry point
- Complex logic delegated to
@InvocableMethodApex
Coexistence Management
When an org has BOTH Flow and Apex triggers on the same object (common in brownfield orgs):
1. Audit first: Use Flow Trigger Explorer (Setup → Process Automation → Flow Trigger Explorer) to see all automations per object 2. Document execution order: Flows before triggers? After? Both? 3. Consolidate incrementally: Don't refactor everything at once 4. Test together: A change in one automation can break another
⚠️ Salesforce Execution Order (simplified):
Before Flows → Before Triggers → Validation Rules →
After Triggers → After Flows → Assignment Rules → Workflow Rules →
Escalation Rules → Entitlement Rules → Record-Triggered Flows (After Save)---
Hybrid Pattern: Flow + Invocable Apex
The hybrid pattern uses Flow as the orchestrator and Apex for complex operations.
When to Use
- Business rules change frequently (Flow) but implementation is complex (Apex)
- Admins need to control WHEN logic runs; developers control WHAT it does
- You need Apex capabilities (complex queries, callouts, governor limit management) inside a Flow
Architecture
Record-Triggered Flow (After Save)
├── Decision: Check entry conditions
├── Action: Call @InvocableMethod (Apex)
│ └── Complex business logic, callouts, multi-object DML
└── Fault Path: Error handlerCritical Limitation
`@InvocableMethod` calls from Flow execute in after-save context only. You cannot call Invocable Apex from a Before-Save Flow. Before-Save flows are limited to field updates on the triggering record.
public with sharing class ProcessOrderInvocable {
@InvocableMethod(label='Process Order' category='Orders')
public static List<Response> execute(List<Request> requests) {
List<Response> responses = new List<Response>();
for (Request req : requests) {
Response res = new Response();
try {
// Complex logic here
res.isSuccess = true;
} catch (Exception e) {
res.isSuccess = false;
res.errorMessage = e.getMessage();
}
responses.add(res);
}
return responses;
}
public class Request {
@InvocableVariable(label='Record ID' required=true)
public Id recordId;
}
public class Response {
@InvocableVariable(label='Success')
public Boolean isSuccess;
@InvocableVariable(label='Error Message')
public String errorMessage;
}
}---
CDC as Async Pattern from Triggers
Change Data Capture provides a built-in async mechanism for trigger-like behavior with failure isolation.
When to Use CDC Instead of After-Save Triggers
| Factor | After-Save Trigger/Flow | CDC Subscriber |
|---|---|---|
| Timing | Same transaction | Async (separate transaction) |
| Failure impact | Rolls back triggering DML | Isolated — triggering DML succeeds |
| Replay | None | 72-hour replay window |
| Governor limits | Shared with triggering transaction | Separate transaction limits |
| Use case | Critical same-transaction logic | External sync, audit, non-critical updates |
Pattern
1. Enable CDC on the object (Setup → Integrations → Change Data Capture) 2. Create CDC subscriber trigger on {Object}ChangeEvent 3. Process changes asynchronously with full replay capability
trigger AccountCDCSubscriber on AccountChangeEvent (after insert) {
for (AccountChangeEvent event : Trigger.new) {
String changeType = event.ChangeEventHeader.getChangeType();
if (changeType == 'UPDATE') {
List<String> changedFields = event.ChangeEventHeader.getChangedFields();
if (changedFields.contains('Status__c')) {
// Queue external sync — isolated from original transaction
System.enqueueJob(new ExternalSyncQueueable(
event.ChangeEventHeader.getRecordIds()[0]
));
}
}
}
}---
Scheduled Alternative Pattern
For non-time-critical automation, use a scheduled approach instead of trigger-based automation.
Pattern: Set Status → Scheduled Job Processes
1. Trigger/Flow: Set a status field (e.g., Processing_Status__c = 'Pending') 2. Scheduled Flow or Batch: Periodically queries pending records and processes them
Benefits
- Decouples trigger execution from heavy processing
- Batches multiple records for efficient governor limit usage
- Retryable — failed records stay in pending status
- Observable — query
Processing_Status__cfor pipeline visibility
Preference: Scheduled Flow over Apex Schedulable
| Factor | Scheduled Flow | Apex Schedulable |
|---|---|---|
| Deployment | Deployable metadata, packageable | Requires code deployment |
| Admin maintenance | Admins can modify schedule and logic | Developer-only |
| Job limit | No hard limit | 100 scheduled jobs max |
| Best for | Simple-to-medium scheduled tasks | Complex processing needing Batch Apex |
Recommendation: Use Scheduled Flow for most scheduled automation. Use Apex Schedulable only when you need Batch Apex chaining or complex Apex-only logic.---
Flow Trigger Explorer
Location: Setup → Process Automation → Flow Trigger Explorer
This tool shows all automations (Flows, Process Builder, Workflow Rules) that fire on a given object, in execution order.
Use Cases
- Pre-development: Check what already exists before adding automation
- Debugging: Understand why a record update produces unexpected results
- Consolidation planning: Identify redundant or conflicting automations
- Impact analysis: Before deactivating an automation, see what else runs alongside it
Recommended Practice
Before creating any new Record-Triggered Flow or Trigger Action, check Flow Trigger Explorer to understand the existing automation landscape for that object.
---
Summary: Decision Checklist
1. Check density: How many automations already exist on this object? 2. Choose tool: Low → Flow, Medium → Hybrid, High → Apex 3. One entry point: Don't add a second trigger or record-triggered flow if one exists 4. Check coexistence: Use Flow Trigger Explorer to see the full picture 5. Consider CDC: For external sync or non-critical async processing 6. Consider scheduled: For non-time-critical batch processing