Salesforce Apex quality
Apply Apex guardrails to code generation and review so Salesforce code remains bulk-safe, secure, testable, and deployable under governor limits.
When to invoke
- "Review this Apex trigger for governor limit issues."
- "Generate a bulk-safe Apex service and tests."
- "Check this Salesforce class for CRUD and FLS enforcement."
- "Make this Apex test cover positive, negative, and bulk paths."
- "Refactor SOQL in loops and unsafe dynamic queries."
Governor limit safety
Scan every Apex class, trigger, and test before declaring it acceptable. SOQL/DML bulk-safety is an automatic fail area.
| Pattern |
Required action |
[SELECT or [SELECT ...] inside a for loop |
Refactor: collect IDs, query once outside the loop, then use maps or grouped lists. |
Database.query inside a loop |
Refactor to a single parameterized or whitelisted query outside the loop. |
insert, update, delete, upsert, or merge inside a loop |
Collect records and perform one DML statement outside the loop. |
| Per-record trigger logic or query/update loops |
Move business logic to a handler that accepts collections. |
// NEVER — causes LimitException at scale
for (Account a : accounts) {
List<Contact> contacts = [SELECT Id FROM Contact WHERE AccountId = :a.Id];
update a;
}
// ALWAYS — collect, query once, update once
Set<Id> accountIds = new Map<Id, Account>(accounts).keySet();
Map<Id, List<Contact>> contactsByAccount = new Map<Id, List<Contact>>();
for (Contact c : [SELECT Id, AccountId FROM Contact WHERE AccountId IN :accountIds]) {
if (!contactsByAccount.containsKey(c.AccountId)) {
contactsByAccount.put(c.AccountId, new List<Contact>());
}
contactsByAccount.get(c.AccountId).add(c);
}
update accounts;
Sharing and security
Every class must declare sharing intent explicitly. Undeclared sharing inherits from the caller and creates unpredictable behaviour.
| Declaration |
Use when |
public with sharing class Foo |
Default for service, handler, selector, and controller classes. |
public without sharing class Foo |
Only for required elevated behavior such as system-level logging or trigger bypass; require a code comment explaining why. |
public inherited sharing class Foo |
Framework entry points that should respect the caller's sharing context. |
Apex code that reads or writes records on behalf of a user must enforce object and field access. Treat missing CRUD/FLS. as a deployment blocker. UI-facing, REST-facing, and @InvocableMethod code must use CRUD/FLS checks; trusted internal services may rely on with sharing only when the call path is controlled.
if (!Schema.sObjectType.Contact.fields.Email.isAccessible()) {
throw new System.NoAccessException();
}
List<Contact> contacts = [SELECT Id, Email FROM Contact WHERE AccountId = :accId WITH USER_MODE];
List<Contact> contacts2 = Database.query('SELECT Id, Email FROM Contact', AccessLevel.USER_MODE);
SOQL injection prevention
| Query shape |
Rule |
| Static SOQL |
Use bind variables such as :userInput. |
| Dynamic SOQL values |
Bind values where possible; never concatenate raw user input. |
| Dynamic field or sort names |
Validate user-controlled values against a whitelist before adding to the query string. |
// NEVER — concatenates user input into SOQL
String soql = 'SELECT Id FROM Account WHERE Name = '' + userInput + ''';
// ALWAYS — bind variable
List<Account> rows = [SELECT Id FROM Account WHERE Name = :userInput];
Set<String> allowedFields = new Set<String>{'Name', 'Industry', 'AnnualRevenue'};
if (!allowedFields.contains(userInput)) {
throw new IllegalArgumentException('Field not permitted: ' + userInput);
}
Tests and trigger architecture
PNB coverage is mandatory: Positive, Negative, and Bulk.
| Path |
Required evidence |
| Positive |
Expected input produces exact expected field values, counts, or return values. |
| Negative |
Nulls, invalid inputs, empty collections, and errors throw the right type/message and do not mutate records. |
| Bulk |
Insert, update, or delete 200–251 records in one test transaction and assert all records process without governor failures. |
@isTest(SeeAllData=false)
private class AccountServiceTest {
@TestSetup
static void makeData() {
// Create all test data here; use a factory if one exists.
}
@isTest
static void givenValidInput_whenProcessAccounts_thenFieldsUpdated() {
List<Account> accounts = [SELECT Id FROM Account LIMIT 10];
Test.startTest();
AccountService.processAccounts(accounts);
Test.stopTest();
List<Account> updated = [SELECT Status__c FROM Account WHERE Id IN :accounts];
Assert.areEqual('Processed', updated[0].Status__c, 'Status should be Processed');
}
}
Trigger checklist:
Modern Apex idioms
| Old pattern |
Modern replacement |
if (obj != null) { x = obj.Field__c; } |
x = obj?.Field__c; |
x = (y != null) ? y : defaultVal; |
x = y ?? defaultVal; |
System.assertEquals(expected, actual) |
Assert.areEqual(expected, actual) |
System.assert(condition) |
Assert.isTrue(condition); migrate System.assert and System.assertEquals to Assert.isTrue and Assert.areEqual. |
[SELECT ... WHERE ...] with no sharing context |
[SELECT ... WHERE ... WITH USER_MODE] |
Hardcoded anti-patterns
| Pattern |
Action |
escape="false" on user data in Visualforce |
Remove it; auto-escaping enforces XSS prevention. |
Empty catch block |
Add logging and appropriate re-throw or error handling. |
| Test with no assertion |
Add meaningful Assert.* calls. |
Hardcoded record ID such as '001...' |
Replace with queried or inserted test data. |
Inline API names to preserve: Test.startTest() and Test.stopTest() isolate governor counters for async and bulk assertions.
Output template
## Apex quality result
**Status:** pass | fix required | blocked
**Scope:** `<files/classes/triggers reviewed>`
| Area | Severity | Evidence | Required fix |
| --- | --- | --- | --- |
| Bulk safety | `<High|Medium|Low>` | `<line or snippet>` | `<fix>` |
### Tests
- Positive path: <covered|missing>
- Negative path: <covered|missing>
- Bulk path: <covered|missing, record count>
### Validation
- Sharing declaration: <pass|fail>
- CRUD/FLS: <pass|fail|not applicable>
- SOQL injection: <pass|fail>
Quality gate
1---2name: salesforce-apex-quality3description: Review or generate Salesforce Apex classes, triggers, handlers, batch jobs, and test classes with quality guardrails for bulk safety, explicit sharing, CRUD/FLS enforcement, SOQL injection prevention, PNB tests, trigger architecture, and modern Apex idioms. Use when asked to catch governor limit risks, security gaps, and Apex deployment quality issues.4---56<!-- Generated from harness/github-copilot/skills/salesforce-apex-quality/SKILL.md by harness/claude-code/scripts/convert_from_copilot.py. Edit the source, not this file. -->78# Salesforce Apex quality910Apply Apex guardrails to code generation and review so Salesforce code remains bulk-safe, secure, testable, and deployable under governor limits.1112## When to invoke1314- "Review this Apex trigger for governor limit issues."15- "Generate a bulk-safe Apex service and tests."16- "Check this Salesforce class for CRUD and FLS enforcement."17- "Make this Apex test cover positive, negative, and bulk paths."18- "Refactor SOQL in loops and unsafe dynamic queries."1920## Governor limit safety2122Scan every Apex class, trigger, and test before declaring it acceptable. SOQL/DML bulk-safety is an automatic fail area.2324| Pattern | Required action |25| --- | --- |26| `[SELECT` or `[SELECT ...]` inside a `for` loop | Refactor: collect IDs, query once outside the loop, then use maps or grouped lists. |27| `Database.query` inside a loop | Refactor to a single parameterized or whitelisted query outside the loop. |28| `insert`, `update`, `delete`, `upsert`, or `merge` inside a loop | Collect records and perform one DML statement outside the loop. |29| Per-record trigger logic or query/update loops | Move business logic to a handler that accepts collections. |3031```apex32// NEVER — causes LimitException at scale33for (Account a : accounts) {34 List<Contact> contacts = [SELECT Id FROM Contact WHERE AccountId = :a.Id];35 update a;36}3738// ALWAYS — collect, query once, update once39Set<Id> accountIds = new Map<Id, Account>(accounts).keySet();40Map<Id, List<Contact>> contactsByAccount = new Map<Id, List<Contact>>();41for (Contact c : [SELECT Id, AccountId FROM Contact WHERE AccountId IN :accountIds]) {42 if (!contactsByAccount.containsKey(c.AccountId)) {43 contactsByAccount.put(c.AccountId, new List<Contact>());44 }45 contactsByAccount.get(c.AccountId).add(c);46}47update accounts;48```4950## Sharing and security5152Every class must declare sharing intent explicitly. Undeclared sharing inherits from the caller and creates unpredictable behaviour.5354| Declaration | Use when |55| --- | --- |56| `public with sharing class Foo` | Default for service, handler, selector, and controller classes. |57| `public without sharing class Foo` | Only for required elevated behavior such as system-level logging or trigger bypass; require a code comment explaining why. |58| `public inherited sharing class Foo` | Framework entry points that should respect the caller's sharing context. |5960Apex code that reads or writes records on behalf of a user must enforce object and field access. Treat missing CRUD/FLS. as a deployment blocker. UI-facing, REST-facing, and `@InvocableMethod` code must use CRUD/FLS checks; trusted internal services may rely on `with sharing` only when the call path is controlled.6162```apex63if (!Schema.sObjectType.Contact.fields.Email.isAccessible()) {64 throw new System.NoAccessException();65}6667List<Contact> contacts = [SELECT Id, Email FROM Contact WHERE AccountId = :accId WITH USER_MODE];68List<Contact> contacts2 = Database.query('SELECT Id, Email FROM Contact', AccessLevel.USER_MODE);69```7071## SOQL injection prevention7273| Query shape | Rule |74| --- | --- |75| Static SOQL | Use bind variables such as `:userInput`. |76| Dynamic SOQL values | Bind values where possible; never concatenate raw user input. |77| Dynamic field or sort names | Validate user-controlled values against a whitelist before adding to the query string. |7879```apex80// NEVER — concatenates user input into SOQL81String soql = 'SELECT Id FROM Account WHERE Name = '' + userInput + ''';8283// ALWAYS — bind variable84List<Account> rows = [SELECT Id FROM Account WHERE Name = :userInput];8586Set<String> allowedFields = new Set<String>{'Name', 'Industry', 'AnnualRevenue'};87if (!allowedFields.contains(userInput)) {88 throw new IllegalArgumentException('Field not permitted: ' + userInput);89}90```9192## Tests and trigger architecture9394PNB coverage is mandatory: Positive, Negative, and Bulk.9596| Path | Required evidence |97| --- | --- |98| Positive | Expected input produces exact expected field values, counts, or return values. |99| Negative | Nulls, invalid inputs, empty collections, and errors throw the right type/message and do not mutate records. |100| Bulk | Insert, update, or delete **200–251 records** in one test transaction and assert all records process without governor failures. |101102```apex103@isTest(SeeAllData=false)104private class AccountServiceTest {105 @TestSetup106 static void makeData() {107 // Create all test data here; use a factory if one exists.108 }109110 @isTest111 static void givenValidInput_whenProcessAccounts_thenFieldsUpdated() {112 List<Account> accounts = [SELECT Id FROM Account LIMIT 10];113 Test.startTest();114 AccountService.processAccounts(accounts);115 Test.stopTest();116 List<Account> updated = [SELECT Status__c FROM Account WHERE Id IN :accounts];117 Assert.areEqual('Processed', updated[0].Status__c, 'Status should be Processed');118 }119}120```121122Trigger checklist:123124- [ ] One trigger per object; consolidate any second trigger into the handler.125- [ ] Trigger body contains only context checks, handler invocation, and routing logic.126- [ ] No business logic, SOQL, or DML lives directly in the trigger body.127- [ ] Existing trigger frameworks such as Trigger Actions Framework, ff-apex-common, or a custom base class are extended instead of bypassed.128- [ ] Handler class is `with sharing` unless elevated access is documented.129130## Modern Apex idioms131132| Old pattern | Modern replacement |133| --- | --- |134| `if (obj != null) { x = obj.Field__c; }` | `x = obj?.Field__c;` |135| `x = (y != null) ? y : defaultVal;` | `x = y ?? defaultVal;` |136| `System.assertEquals(expected, actual)` | `Assert.areEqual(expected, actual)` |137| `System.assert(condition)` | `Assert.isTrue(condition)`; migrate `System.assert` and `System.assertEquals` to `Assert.isTrue` and `Assert.areEqual`. |138| `[SELECT ... WHERE ...]` with no sharing context | `[SELECT ... WHERE ... WITH USER_MODE]` |139140## Hardcoded anti-patterns141142| Pattern | Action |143| --- | --- |144| `escape="false"` on user data in Visualforce | Remove it; auto-escaping enforces XSS prevention. |145| Empty `catch` block | Add logging and appropriate re-throw or error handling. |146| Test with no assertion | Add meaningful `Assert.*` calls. |147| Hardcoded record ID such as `'001...'` | Replace with queried or inserted test data. |148149Inline API names to preserve: `Test.startTest()` and `Test.stopTest()` isolate governor counters for async and bulk assertions.150151## Output template152153```markdown154## Apex quality result155156**Status:** pass | fix required | blocked157**Scope:** `<files/classes/triggers reviewed>`158159| Area | Severity | Evidence | Required fix |160| --- | --- | --- | --- |161| Bulk safety | `<High|Medium|Low>` | `<line or snippet>` | `<fix>` |162163### Tests164- Positive path: <covered|missing>165- Negative path: <covered|missing>166- Bulk path: <covered|missing, record count>167168### Validation169- Sharing declaration: <pass|fail>170- CRUD/FLS: <pass|fail|not applicable>171- SOQL injection: <pass|fail>172```173174## Quality gate175176- [ ] No SOQL, `Database.query`, Insert/update/delete operations, or DML appears inside a loop.177- [ ] Every class declares `with sharing`, `without sharing`, or `inherited sharing` with justification where needed.178- [ ] User-facing record access enforces CRUD/FLS with schema checks, `WITH USER_MODE`, or `AccessLevel.USER_MODE`.179- [ ] Dynamic SOQL uses bind variables or whitelisted identifiers.180- [ ] Tests cover Positive, Negative, and Bulk paths with meaningful `Assert.*` calls.181- [ ] Triggers contain routing only and delegate to handlers.