sf-agent-framework
Version:
AI Agent Orchestration Framework for Salesforce Development - Two-phase architecture with 70% context reduction
958 lines (793 loc) • 28.3 kB
Markdown
# Salesforce Validation Best Practices
## Overview
Validation is crucial for maintaining data quality and enforcing business rules
in Salesforce. This document provides comprehensive best practices for
implementing effective validation strategies across different layers of the
platform.
## Validation Strategy Framework
### Validation Layers
```yaml
validation_layers:
database_level:
- Required fields
- Unique constraints
- Data types
- Field lengths
application_level:
- Validation rules
- Apex triggers
- Process validation
- Flow validation
ui_level:
- JavaScript validation
- Lightning component validation
- Input masks
- Real-time feedback
integration_level:
- API validation
- External system checks
- Data transformation rules
- Error handling
```
### Validation Principles
1. **Fail Fast**: Validate as early as possible
2. **Clear Messages**: Provide actionable error messages
3. **Consistent Rules**: Apply same rules across all entry points
4. **Performance**: Balance thoroughness with speed
5. **User Experience**: Guide users to correct input
## Validation Rule Best Practices
### Rule Design Guidelines
```javascript
// Good validation rule structure
AND(
/* Condition when rule should fire */
ISCHANGED(Stage__c),
ISPICKVAL(Stage__c, "Closed Won"),
/* What makes the data invalid */
OR(
ISBLANK(Close_Reason__c),
ISBLANK(Competitor__c),
Amount < 10000
)
)
// Clear error message
"When closing an opportunity as Won, please provide:
- Close Reason (why we won)
- Competitor information
- Amount must be at least $10,000"
```
### Validation Rule Organization
```apex
// Naming convention for validation rules
// ObjectName_FieldName_RuleType_Description
// Examples:
Account_Phone_Format_USPhoneValidation
Opportunity_Stage_Progression_ForwardOnly
Contact_Email_Required_WhenTypeIsEmployee
Case_Priority_Conditional_HighForEnterprise
```
### Performance Optimization
```javascript
// Avoid expensive operations in validation rules
// Bad: Multiple VLOOKUP calls
AND(
VLOOKUP($ObjectType.User.Fields.Profile.Name, $User.Id) != 'Admin',
VLOOKUP($ObjectType.User.Fields.Department, $User.Id) != 'Sales',
VLOOKUP($ObjectType.User.Fields.Role.Name, $User.Id) != 'Manager'
);
// Good: Use global variables
AND($Profile.Name != 'Admin', $User.Department != 'Sales', $UserRole.Name != 'Manager');
// Bad: Complex nested logic
IF(AND(OR(A, B), OR(C, D)), IF(E, F, G), IF(OR(H, I), J, K));
// Good: Simplified logic
OR(AND(Condition1__c, Condition2__c), AND(Condition3__c, Condition4__c));
```
## Apex Trigger Validation
### Trigger Framework for Validation
```apex
public class ValidationTriggerHandler extends TriggerHandler {
// Centralized validation logic
private List<ValidationRule> validationRules;
public ValidationTriggerHandler() {
this.validationRules = new List<ValidationRule>();
initializeValidationRules();
}
private void initializeValidationRules() {
// Add validation rules
validationRules.add(new RequiredFieldValidation());
validationRules.add(new BusinessLogicValidation());
validationRules.add(new CrossObjectValidation());
validationRules.add(new DuplicatePreventionValidation());
}
public override void beforeInsert() {
validateRecords(Trigger.new, null);
}
public override void beforeUpdate() {
validateRecords(Trigger.new, Trigger.oldMap);
}
private void validateRecords(List<SObject> newRecords, Map<Id, SObject> oldMap) {
for (ValidationRule rule : validationRules) {
if (rule.isApplicable(newRecords, oldMap)) {
rule.validate(newRecords, oldMap);
}
}
}
}
// Abstract validation rule
public abstract class ValidationRule {
public abstract Boolean isApplicable(List<SObject> newRecords, Map<Id, SObject> oldMap);
public abstract void validate(List<SObject> newRecords, Map<Id, SObject> oldMap);
protected void addError(SObject record, String field, String message) {
if (String.isNotBlank(field)) {
record.addError(field, message);
} else {
record.addError(message);
}
}
}
```
### Complex Business Logic Validation
```apex
public class OpportunityValidation extends ValidationRule {
public override Boolean isApplicable(List<SObject> newRecords, Map<Id, SObject> oldMap) {
return newRecords[0].getSObjectType() == Opportunity.SObjectType;
}
public override void validate(List<SObject> newRecords, Map<Id, SObject> oldMap) {
List<Opportunity> opps = (List<Opportunity>) newRecords;
// Collect related data for validation
Set<Id> accountIds = new Set<Id>();
for (Opportunity opp : opps) {
if (opp.AccountId != null) {
accountIds.add(opp.AccountId);
}
}
Map<Id, Account> accounts = new Map<Id, Account>([
SELECT Id, Type, AnnualRevenue, Credit_Limit__c
FROM Account
WHERE Id IN :accountIds
]);
// Validate each opportunity
for (Opportunity opp : opps) {
validateOpportunity(opp, accounts.get(opp.AccountId), oldMap);
}
}
private void validateOpportunity(Opportunity opp, Account acc, Map<Id, SObject> oldMap) {
// Stage progression validation
if (oldMap != null && opp.StageName != oldMap.get(opp.Id).get('StageName')) {
validateStageProgression(opp, (String)oldMap.get(opp.Id).get('StageName'));
}
// Amount validation based on account
if (acc != null && acc.Type == 'Enterprise') {
if (opp.Amount < 50000) {
addError(opp, 'Amount', 'Enterprise accounts require minimum $50,000 deal size');
}
}
// Credit limit validation
if (acc != null && opp.Amount > acc.Credit_Limit__c) {
addError(opp, 'Amount',
'Opportunity amount exceeds account credit limit of ' + acc.Credit_Limit__c);
}
}
private void validateStageProgression(Opportunity opp, String oldStage) {
Map<String, Integer> stageOrder = new Map<String, Integer>{
'Prospecting' => 1,
'Qualification' => 2,
'Needs Analysis' => 3,
'Value Proposition' => 4,
'Id. Decision Makers' => 5,
'Perception Analysis' => 6,
'Proposal/Price Quote' => 7,
'Negotiation/Review' => 8,
'Closed Won' => 9,
'Closed Lost' => 9
};
Integer oldOrder = stageOrder.get(oldStage);
Integer newOrder = stageOrder.get(opp.StageName);
if (newOrder < oldOrder && !isAllowedBackwardMove(oldStage, opp.StageName)) {
addError(opp, 'StageName',
'Cannot move opportunity backward from ' + oldStage + ' to ' + opp.StageName);
}
}
}
```
## UI-Level Validation
### Lightning Component Validation
```javascript
// Client-side validation in Lightning components
({
validateForm: function (component, event, helper) {
let isValid = true;
const validationRules = [
{
field: 'accountName',
rules: [
{ type: 'required', message: 'Account Name is required' },
{
type: 'minLength',
value: 3,
message: 'Account Name must be at least 3 characters',
},
{
type: 'pattern',
value: /^[a-zA-Z0-9\s]+$/,
message: 'Account Name can only contain letters, numbers, and spaces',
},
],
},
{
field: 'email',
rules: [
{ type: 'required', message: 'Email is required' },
{ type: 'email', message: 'Please enter a valid email address' },
],
},
{
field: 'phone',
rules: [
{ type: 'required', message: 'Phone is required' },
{ type: 'phone', message: 'Please enter a valid phone number' },
],
},
];
validationRules.forEach((fieldRule) => {
const fieldCmp = component.find(fieldRule.field);
const fieldValue = fieldCmp.get('v.value');
fieldRule.rules.forEach((rule) => {
if (!helper.validateField(fieldValue, rule)) {
fieldCmp.setCustomValidity(rule.message);
fieldCmp.reportValidity();
isValid = false;
} else {
fieldCmp.setCustomValidity('');
fieldCmp.reportValidity();
}
});
});
return isValid;
},
handleSubmit: function (component, event, helper) {
event.preventDefault();
if (helper.validateForm(component, event, helper)) {
// Proceed with submission
helper.submitForm(component);
} else {
helper.showToast('Error', 'Please fix validation errors', 'error');
}
},
})(
// Helper methods
{
validateField: function (value, rule) {
switch (rule.type) {
case 'required':
return value && value.trim().length > 0;
case 'minLength':
return value && value.length >= rule.value;
case 'maxLength':
return !value || value.length <= rule.value;
case 'pattern':
return !value || rule.value.test(value);
case 'email':
return !value || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
case 'phone':
return !value || /^\+?[1-9]\d{1,14}$/.test(value.replace(/\D/g, ''));
default:
return true;
}
},
}
);
```
### Real-time Validation Feedback
```javascript
// LWC with real-time validation
import { LightningElement, track } from 'lwc';
export default class RealTimeValidation extends LightningElement {
@track formData = {
email: '',
phone: '',
website: '',
};
@track errors = {
email: '',
phone: '',
website: '',
};
validationRules = {
email: [
{
test: (value) => value.length > 0,
message: 'Email is required',
},
{
test: (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
message: 'Please enter a valid email',
},
],
phone: [
{
test: (value) => value.length > 0,
message: 'Phone is required',
},
{
test: (value) => /^\(?([0-9]{3})\)?[-. ]?([0-9]{3})[-. ]?([0-9]{4})$/.test(value),
message: 'Please enter a valid US phone number',
},
],
website: [
{
test: (value) => !value || /^https?:\/\/.+\..+/.test(value),
message: 'Please enter a valid URL starting with http:// or https://',
},
],
};
handleInputChange(event) {
const field = event.target.name;
const value = event.target.value;
this.formData[field] = value;
this.validateField(field, value);
}
validateField(field, value) {
const rules = this.validationRules[field];
if (!rules) return true;
for (let rule of rules) {
if (!rule.test(value)) {
this.errors[field] = rule.message;
return false;
}
}
this.errors[field] = '';
return true;
}
get isFormValid() {
return Object.keys(this.formData).every((field) => this.validateField(field, this.formData[field]));
}
}
```
## Integration Validation
### API Validation Layer
```apex
@RestResource(urlMapping='/api/v1/account/*')
global class AccountAPIValidator {
@HttpPost
global static APIResponse createAccount() {
APIResponse response = new APIResponse();
try {
// Parse request
Map<String, Object> requestData = (Map<String, Object>)
JSON.deserializeUntyped(RestContext.request.requestBodyAsString);
// Validate request
ValidationResult validation = validateAccountData(requestData);
if (!validation.isValid) {
response.success = false;
response.errors = validation.errors;
response.statusCode = 400;
return response;
}
// Create account
Account acc = mapToAccount(requestData);
insert acc;
response.success = true;
response.data = acc;
response.statusCode = 201;
} catch (Exception e) {
response.success = false;
response.errors = new List<String>{e.getMessage()};
response.statusCode = 500;
}
return response;
}
private static ValidationResult validateAccountData(Map<String, Object> data) {
ValidationResult result = new ValidationResult();
// Required field validation
if (!data.containsKey('name') || String.isBlank((String)data.get('name'))) {
result.addError('name', 'Account name is required');
}
// Email format validation
if (data.containsKey('email')) {
String email = (String)data.get('email');
if (!Pattern.matches('^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$', email)) {
result.addError('email', 'Invalid email format');
}
}
// Phone format validation
if (data.containsKey('phone')) {
String phone = (String)data.get('phone');
if (!isValidPhoneNumber(phone)) {
result.addError('phone', 'Invalid phone number format');
}
}
// Business rule validation
if (data.containsKey('type') && data.get('type') == 'Enterprise') {
if (!data.containsKey('annualRevenue') ||
(Decimal)data.get('annualRevenue') < 1000000) {
result.addError('annualRevenue',
'Enterprise accounts must have annual revenue >= $1M');
}
}
return result;
}
global class APIResponse {
global Boolean success;
global Object data;
global List<String> errors;
global Integer statusCode;
}
public class ValidationResult {
public Boolean isValid { get; private set; }
public Map<String, List<String>> fieldErrors { get; private set; }
public List<String> errors { get; private set; }
public ValidationResult() {
this.isValid = true;
this.fieldErrors = new Map<String, List<String>>();
this.errors = new List<String>();
}
public void addError(String field, String message) {
this.isValid = false;
if (!fieldErrors.containsKey(field)) {
fieldErrors.put(field, new List<String>());
}
fieldErrors.get(field).add(message);
errors.add(field + ': ' + message);
}
}
}
```
## Cross-Object Validation
### Multi-Object Validation Framework
```apex
public class CrossObjectValidator {
public static void validateAccountHierarchy(List<Account> accounts) {
// Collect parent IDs
Set<Id> parentIds = new Set<Id>();
for (Account acc : accounts) {
if (acc.ParentId != null) {
parentIds.add(acc.ParentId);
}
}
// Query parent accounts
Map<Id, Account> parentAccounts = new Map<Id, Account>([
SELECT Id, Type, RecordTypeId, OwnerId
FROM Account
WHERE Id IN :parentIds
]);
// Validate relationships
for (Account acc : accounts) {
if (acc.ParentId != null) {
Account parent = parentAccounts.get(acc.ParentId);
// Validate hierarchy rules
if (parent.Type == 'Competitor' && acc.Type != 'Competitor') {
acc.addError('Cannot set competitor account as parent for non-competitor');
}
// Validate record type consistency
if (parent.RecordTypeId != acc.RecordTypeId) {
acc.addError('Child account must have same record type as parent');
}
}
}
}
public static void validateOpportunityProducts(List<OpportunityLineItem> items) {
// Collect opportunity IDs
Set<Id> oppIds = new Set<Id>();
Set<Id> productIds = new Set<Id>();
for (OpportunityLineItem item : items) {
oppIds.add(item.OpportunityId);
productIds.add(item.Product2Id);
}
// Query related data
Map<Id, Opportunity> opportunities = new Map<Id, Opportunity>([
SELECT Id, StageName, Type, CurrencyIsoCode
FROM Opportunity
WHERE Id IN :oppIds
]);
Map<Id, Product2> products = new Map<Id, Product2>([
SELECT Id, IsActive, Family, Minimum_Deal_Size__c
FROM Product2
WHERE Id IN :productIds
]);
// Validate
for (OpportunityLineItem item : items) {
Opportunity opp = opportunities.get(item.OpportunityId);
Product2 product = products.get(item.Product2Id);
// Product must be active
if (!product.IsActive) {
item.addError('Cannot add inactive product');
}
// Check minimum deal size
if (product.Minimum_Deal_Size__c != null &&
item.TotalPrice < product.Minimum_Deal_Size__c) {
item.addError('Product requires minimum deal size of ' +
product.Minimum_Deal_Size__c);
}
// Stage-specific validation
if (opp.StageName == 'Closed Won' || opp.StageName == 'Closed Lost') {
item.addError('Cannot modify products on closed opportunities');
}
}
}
}
```
## Validation Testing
### Unit Testing Validation Rules
```apex
@isTest
private class ValidationRuleTest {
@isTest
static void testRequiredFieldValidation() {
// Test required field validation
Account acc = new Account();
// Intentionally leave Name blank
Test.startTest();
Database.SaveResult result = Database.insert(acc, false);
Test.stopTest();
System.assert(!result.isSuccess(), 'Insert should fail');
System.assert(result.getErrors()[0].getMessage().contains('required'),
'Error message should mention required field');
}
@isTest
static void testBusinessRuleValidation() {
// Setup test data
Account acc = new Account(
Name = 'Test Enterprise Account',
Type = 'Enterprise'
);
insert acc;
Opportunity opp = new Opportunity(
Name = 'Small Deal',
AccountId = acc.Id,
StageName = 'Prospecting',
CloseDate = Date.today().addDays(30),
Amount = 5000 // Below enterprise minimum
);
Test.startTest();
Database.SaveResult result = Database.insert(opp, false);
Test.stopTest();
System.assert(!result.isSuccess(),
'Should fail due to enterprise minimum amount rule');
}
@isTest
static void testValidationBypass() {
// Test validation bypass for integration user
User integrationUser = [SELECT Id FROM User WHERE Profile.Name = 'Integration User' LIMIT 1];
System.runAs(integrationUser) {
// This should bypass certain validations
Account acc = new Account(
Name = 'Integration Test',
Bypass_Validation__c = true
);
Test.startTest();
insert acc;
Test.stopTest();
System.assertNotEquals(null, acc.Id, 'Integration user should bypass validation');
}
}
}
```
### Validation Test Data Builder
```apex
@isTest
public class ValidationTestDataBuilder {
public static Account createInvalidAccount(String validationToTest) {
Account acc = new Account();
switch on validationToTest {
when 'MISSING_NAME' {
// Name is required
acc.BillingCity = 'San Francisco';
}
when 'INVALID_EMAIL' {
acc.Name = 'Test Account';
acc.Email__c = 'invalid-email';
}
when 'INVALID_PHONE' {
acc.Name = 'Test Account';
acc.Phone = '123'; // Too short
}
when 'BUSINESS_RULE' {
acc.Name = 'Enterprise Account';
acc.Type = 'Enterprise';
acc.AnnualRevenue = 50000; // Below minimum
}
}
return acc;
}
public static Map<String, String> getExpectedErrors() {
return new Map<String, String>{
'MISSING_NAME' => 'Account Name is required',
'INVALID_EMAIL' => 'Please enter a valid email',
'INVALID_PHONE' => 'Phone number must be 10 digits',
'BUSINESS_RULE' => 'Enterprise accounts require minimum revenue'
};
}
}
```
## Performance Best Practices
### Efficient Validation Patterns
```apex
// Bulk-safe validation
public class BulkSafeValidator {
public static void validateAccounts(List<Account> accounts) {
// Collect all needed data in one query
Set<String> accountNames = new Set<String>();
Set<Id> ownerIds = new Set<Id>();
for (Account acc : accounts) {
accountNames.add(acc.Name);
ownerIds.add(acc.OwnerId);
}
// Single query for duplicate check
Map<String, Account> existingAccounts = new Map<String, Account>();
for (Account acc : [
SELECT Id, Name
FROM Account
WHERE Name IN :accountNames
]) {
existingAccounts.put(acc.Name.toLowerCase(), acc);
}
// Single query for owner validation
Map<Id, User> owners = new Map<Id, User>([
SELECT Id, IsActive, Profile.Name
FROM User
WHERE Id IN :ownerIds
]);
// Validate using cached data
for (Account acc : accounts) {
// Duplicate check
Account existing = existingAccounts.get(acc.Name.toLowerCase());
if (existing != null && existing.Id != acc.Id) {
acc.addError('An account with this name already exists');
}
// Owner validation
User owner = owners.get(acc.OwnerId);
if (owner != null && !owner.IsActive) {
acc.addError('Cannot assign to inactive user');
}
}
}
}
```
### Validation Rule Optimization
```javascript
// Optimize validation rule formulas
// Bad: Multiple related object lookups
AND((Account.Type = 'Enterprise'), (Account.Owner.Profile.Name = 'Sales Rep'), (Account.Parent.Type = 'Global'));
// Good: Minimize lookups, use formula fields
AND(
(Account_Type_Formula__c = 'Enterprise'),
(Owner_Profile_Formula__c = 'Sales Rep'),
(Parent_Type_Formula__c = 'Global')
);
// Bad: Complex nested IF statements
IF((Type = 'A'), IF((Status = '1'), true, false), IF((Type = 'B'), IF((Status = '2'), true, false), false));
// Good: Use CASE for clarity
CASE(Type, 'A', (Status = '1'), 'B', (Status = '2'), false);
```
## Validation Documentation
### Validation Rule Documentation Template
```yaml
validation_rule:
name: Opportunity_Amount_Enterprise_Minimum
object: Opportunity
active: true
description: |
Ensures enterprise accounts have minimum deal size of $50,000
business_justification: |
Enterprise accounts require larger deals to justify the
resources allocated to them
formula: |
AND(
Account.Type = "Enterprise",
Amount < 50000
)
error_message: |
Enterprise accounts require a minimum deal size of $50,000
error_location: Amount
bypass_criteria:
- Integration User profile
- System Administrator profile
- When Bypass_Validation__c = true
test_scenarios:
- Enterprise account with $45,000 amount (should fail)
- Enterprise account with $50,000 amount (should pass)
- Non-enterprise account with any amount (should pass)
related_automation:
- Process: Update Account Tier
- Flow: Calculate Commission
change_history:
- date: 2024-01-15
changed_by: Admin User
change: Initial creation
- date: 2024-02-01
changed_by: Business User
change: Updated minimum from $25k to $50k
```
## Validation Governance
### Validation Change Management
```apex
// Track validation rule changes
public class ValidationRuleGovernance {
public static void documentValidationChange(
String ruleName,
String changeType,
String description,
String justification
) {
Validation_Rule_History__c history = new Validation_Rule_History__c(
Rule_Name__c = ruleName,
Change_Type__c = changeType,
Description__c = description,
Business_Justification__c = justification,
Changed_By__c = UserInfo.getUserId(),
Change_Date__c = DateTime.now()
);
insert history;
// Notify stakeholders
notifyValidationChangeStakeholders(history);
}
public static void validateRuleComplexity(String formula) {
// Check formula complexity
Integer complexity = calculateComplexity(formula);
if (complexity > 10) {
throw new ValidationException(
'Formula is too complex. Consider breaking into multiple rules or using Apex.'
);
}
}
}
```
### Validation Monitoring
```apex
// Monitor validation rule effectiveness
@RestResource(urlMapping='/validation/metrics/*')
global class ValidationMetricsService {
@HttpGet
global static ValidationMetrics getMetrics() {
ValidationMetrics metrics = new ValidationMetrics();
// Get validation rule fire counts
List<AggregateResult> results = [
SELECT Validation_Rule__c rule, COUNT(Id) fireCount
FROM Validation_Error_Log__c
WHERE CreatedDate = THIS_MONTH
GROUP BY Validation_Rule__c
ORDER BY COUNT(Id) DESC
];
for (AggregateResult result : results) {
metrics.ruleFires.put(
(String)result.get('rule'),
(Integer)result.get('fireCount')
);
}
// Calculate metrics
metrics.totalFires = getTotalFires();
metrics.uniqueUsers = getUniqueUsersAffected();
metrics.topErrors = getTopErrors();
metrics.bypassRate = calculateBypassRate();
return metrics;
}
global class ValidationMetrics {
global Map<String, Integer> ruleFires;
global Integer totalFires;
global Integer uniqueUsers;
global List<String> topErrors;
global Decimal bypassRate;
global ValidationMetrics() {
this.ruleFires = new Map<String, Integer>();
this.topErrors = new List<String>();
}
}
}
```
## Best Practices Summary
1. **Layer Appropriately**: Use the right validation layer for each rule
2. **Clear Messages**: Provide actionable, user-friendly error messages
3. **Performance First**: Optimize for bulk operations
4. **Test Thoroughly**: Cover positive and negative scenarios
5. **Document Rules**: Maintain clear documentation
6. **Monitor Effectiveness**: Track validation metrics
7. **Governance**: Implement change management
8. **User Experience**: Guide users to correct input
9. **Consistency**: Apply same rules across all channels
10. **Maintainability**: Keep rules simple and organized
## Additional Resources
- [Validation Rules Best Practices](https://help.salesforce.com/s/articleView?id=sf.validation_rules_best_practices.htm)
- [Formula Field Reference](https://help.salesforce.com/s/articleView?id=sf.customize_functions.htm)
- [Apex Trigger Best Practices](https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_triggers_bestpract.htm)
- [Testing Best Practices](https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_testing_best_practices.htm)