A high-performance, production-ready caching layer for Salesforce SOQL queries with intelligent query normalization, flexible storage strategies, and support for parameterized stored procedures.
In Salesforce, identical queries executed multiple times waste resources:
- Governor limits consumed - Each
Database.query()
call counts toward your 100 SOQL limit - Slower performance - Repeated queries in Lightning components, service layers, and API endpoints
- Poor UX - Dashboard/component reloads re-fetch the same data
- Unnecessary database load - Same queries hit the database every time
Common scenarios:
- Triggers - Multiple helper methods querying the same RecordTypes or Custom Metadata
- Lightning components - Refreshing the same data multiple times per page load
- Service layers - Methods called repeatedly with same parameters in one transaction
- API endpoints - Serving the same data to multiple concurrent users
- Dashboards - Aggregate queries that don't need real-time updates
SOQL Query Cache provides intelligent caching for repeated queries:
- Reduces SOQL governor limit usage - Cache hits don't count toward your 100 query limit
- Saves time on cache hits - Typically 5-10ms per cached query vs 15-100ms database queries
- Drop-in replacement for
Database.query()
- minimal code changes - Intelligent normalization - query variations automatically hit the same cache
- Flexible storage - transaction-scoped, Platform Cache, or hybrid two-tier
- Respects security - supports both with/without sharing modes
Note: Performance benefits vary significantly based on query complexity, data volume, and caching strategy. Run the included demos in your org to measure actual impact.
Deploy to your org:
sf project deploy start --source-dir force-app
For persistent caching across requests:
- Navigate to Setup → Platform Cache
- Create a new partition named
SOQLCache
- Allocate 5-10 MB of cache space
Replace Database.query()
with SOQLQueryCache.query()
:
// Before
List<Account> accounts = Database.query('SELECT Id, Name FROM Account WHERE Industry = \'Technology\'');
// After
List<Account> accounts = (List<Account>)SOQLQueryCache.query('SELECT Id, Name FROM Account WHERE Industry = \'Technology\'');
That's it! Your queries are now cached automatically.
Measure actual performance in your org:
SOQLCachePOC.quickDemo(); // Quick comparison demo
SOQLCachePOC.runDemo(); // Full demonstration suite
Important: Performance varies by query complexity, data volume, and org configuration. The demos show actual timings in your environment.
Cache Hit Performance:
- Cache lookup: <1ms (transaction cache) or 1-2ms (Platform Cache)
- Normalization overhead: 1-2ms (simple queries) to 10-15ms (complex with subqueries)
- Net benefit: Saves 5-10ms per cache hit for typical queries
Cache Miss (First Call):
- Normalization: 1-15ms (depending on query complexity)
- Database query: Variable (5ms to 100ms+ depending on data)
- Cache storage: <1ms
- Total: Slightly slower than
Database.query()
due to normalization overhead
Governor Limit Savings:
- Cache hits don't count toward SOQL query limit (100 per transaction)
- Actual reduction depends on your hit rate (typically 40-80%)
Good scenarios (60-80%+ hit rate):
- Trigger helper methods querying same RecordTypes/Custom Metadata
- Service layer methods called repeatedly in one transaction
- Lightning components with multiple refresh cycles
- API endpoints serving same data to multiple concurrent users
Poor scenarios (<30% hit rate):
- Highly dynamic queries with many parameter variations
- One-time queries
- Queries with user-specific WHERE clauses (without Platform Cache)
No query rewriting needed - queries are automatically cached and retrieved:
// First call - executes query and caches result
List<Account> accounts = (List<Account>)SOQLQueryCache.query(
'SELECT Id, Name FROM Account WHERE Industry = \'Technology\''
);
// Second call - returns cached result instantly
List<Account> cached = (List<Account>)SOQLQueryCache.query(
'SELECT Id, Name FROM Account WHERE Industry = \'Technology\''
);
Choose the right caching strategy for your use case:
Fast, automatically cleared at end of request:
SOQLQueryCache.CacheOptions opts = new SOQLQueryCache.CacheOptions()
.setStorage(SOQLQueryCache.CacheStorage.TRANSACTION_ONLY);
List<Account> accounts = (List<Account>)SOQLQueryCache.query(query, opts);
Persists across requests and users with configurable TTL:
SOQLQueryCache.CacheOptions opts = new SOQLQueryCache.CacheOptions()
.setStorage(SOQLQueryCache.CacheStorage.PLATFORM_CACHE)
.setTTL(300); // 5 minutes
List<Account> accounts = (List<Account>)SOQLQueryCache.query(query, opts);
Best of both worlds - fast transaction cache with Platform Cache fallback:
SOQLQueryCache.CacheOptions opts = new SOQLQueryCache.CacheOptions()
.setStorage(SOQLQueryCache.CacheStorage.BOTH)
.setTTL(300); // 5 minutes
List<Account> accounts = (List<Account>)SOQLQueryCache.query(query, opts);
Different query variations automatically hit the same cache entry:
// All three queries use the SAME cache entry:
SOQLQueryCache.query('SELECT Id, Name FROM Account WHERE Status = \'Active\'');
SOQLQueryCache.query('SELECT Name, Id FROM Account WHERE Status = \'Active\''); // Different field order
SOQLQueryCache.query('SELECT Id, Name FROM Account WHERE Status=\'Active\''); // Different spacing
How it works:
- Fields are sorted alphabetically
- Whitespace is normalized
- AND conditions are reordered (OR conditions preserved)
- Subqueries are recursively normalized
- Fast path optimization for simple queries (10x faster normalization)
Define reusable, parameterized queries using Custom Metadata:
- Navigate to Setup → Custom Metadata Types → SOQL Stored Procedure → Manage Records
- Create a new record:
Field | Value |
---|---|
Developer Name | Get_High_Value_Accounts |
Query | SELECT Id, Name, Industry, AnnualRevenue FROM Account WHERE AnnualRevenue > :minRevenue AND Industry = :industry ORDER BY AnnualRevenue DESC |
Parameters | ["minRevenue", "industry"] |
Cache TTL | 300 (5 minutes) |
Cache Storage | Both |
Active | ☑ |
Max Results | 1000 |
Enforce Sharing | ☑ |
// Execute with parameters
Map<String, Object> params = new Map<String, Object>{
'industry' => 'Technology',
'minRevenue' => 1000000
};
List<Account> accounts = (List<Account>)SOQLStoredProcedure.execute('Get_High_Value_Accounts', params);
Benefits:
- Centralized query management
- Parameter validation and sanitization
- Consistent caching configuration
- Easy to update without code deployment
Track cache effectiveness in real-time:
SOQLCacheStatistics stats = SOQLQueryCache.getStatistics();
System.debug('Total queries: ' + stats.getTotalQueries());
System.debug('Cache hits: ' + stats.getCacheHits());
System.debug('Cache misses: ' + stats.getCacheMisses());
System.debug('Hit rate: ' + stats.getHitRate() + '%');
System.debug('Transaction cache hits: ' + stats.getTransactionHits());
System.debug('Platform cache hits: ' + stats.getPlatformHits());
// One-line summary
System.debug(stats.getSummary());
// Output: Total: 100, Hits: 75, Misses: 25, Hit Rate: 75.0%, Transaction: 60, Platform: 15
Problem: Every component render queries the database
@AuraEnabled(cacheable=true)
public static List<Account> getAccounts() {
return Database.query('SELECT Id, Name, Industry FROM Account LIMIT 100');
}
// Every component load: 20-30ms
Solution: Cache with Platform Cache
@AuraEnabled(cacheable=true)
public static List<Account> getAccounts() {
SOQLQueryCache.CacheOptions opts = new SOQLQueryCache.CacheOptions()
.setStorage(SOQLQueryCache.CacheStorage.PLATFORM_CACHE)
.setTTL(300); // 5 minutes
return (List<Account>)SOQLQueryCache.query(
'SELECT Id, Name, Industry FROM Account LIMIT 100',
opts
);
}
// First load: ~25ms (normalization + query) | Subsequent loads: ~2ms (cached)
Problem: Every API call hits the database
@RestResource(urlMapping='/api/accounts/*')
global class AccountAPI {
@HttpGet
global static List<Account> getAccounts() {
return Database.query('SELECT Id, Name FROM Account ORDER BY Name');
}
}
// Every request: 25-50ms
Solution: Cache across API calls
@RestResource(urlMapping='/api/accounts/*')
global class AccountAPI {
@HttpGet
global static List<Account> getAccounts() {
SOQLQueryCache.CacheOptions opts = new SOQLQueryCache.CacheOptions()
.setStorage(SOQLQueryCache.CacheStorage.PLATFORM_CACHE)
.setTTL(600); // 10 minutes
return (List<Account>)SOQLQueryCache.query(
'SELECT Id, Name FROM Account ORDER BY Name',
opts
);
}
}
// First request: ~30ms | Cached requests: ~2ms (saves SOQL limit + time)
Problem: Multiple trigger helper methods query the same reference data
trigger AccountTrigger on Account (before insert, before update) {
AccountTriggerHelper.validateIndustry(Trigger.new); // Queries RecordTypes
AccountTriggerHelper.setDefaults(Trigger.new); // Queries RecordTypes AGAIN
AccountTriggerHelper.enrichData(Trigger.new); // Queries Custom Metadata
AccountTriggerHelper.calculateRiskScore(Trigger.new); // Queries Custom Metadata AGAIN
}
// 4 helper methods = 4+ redundant queries per trigger execution
Solution: Cache reference data across helper methods
public class AccountTriggerHelper {
private static final SOQLQueryCache.CacheOptions CACHE_OPTS = new SOQLQueryCache.CacheOptions()
.setStorage(SOQLQueryCache.CacheStorage.TRANSACTION_ONLY);
private static Map<String, RecordType> getRecordTypeMap() {
List<RecordType> rts = (List<RecordType>)SOQLQueryCache.query(
'SELECT Id, DeveloperName FROM RecordType WHERE SObjectType = \'Account\'',
CACHE_OPTS
);
// First helper method: ~15ms | Subsequent: <1ms (cached)
return new Map<String, RecordType>(/* convert to map */);
}
private static Map<String, Industry_Settings__mdt> getIndustrySettings() {
List<Industry_Settings__mdt> settings = (List<Industry_Settings__mdt>)SOQLQueryCache.query(
'SELECT DeveloperName, Risk_Level__c FROM Industry_Settings__mdt',
CACHE_OPTS
);
// Cached across all helper methods
return new Map<String, Industry_Settings__mdt>(/* convert to map */);
}
public static void validateIndustry(List<Account> accounts) {
Map<String, RecordType> rtMap = getRecordTypeMap(); // First call: DB query
// ... validation logic
}
public static void setDefaults(List<Account> accounts) {
Map<String, RecordType> rtMap = getRecordTypeMap(); // Cached!
// ... default logic
}
}
// 4 helper methods = 2 queries total (instead of 4+)
// Transaction cache automatically cleared when trigger completes
Problem: Same service method called multiple times in one transaction
public class AccountService {
public static List<Account> getActiveAccounts() {
return Database.query('SELECT Id, Name, Industry FROM Account WHERE IsActive__c = true');
}
}
// Called multiple times during transaction
List<Account> accounts1 = AccountService.getActiveAccounts(); // 20ms
List<Account> accounts2 = AccountService.getActiveAccounts(); // 20ms again!
List<Account> accounts3 = AccountService.getActiveAccounts(); // 20ms again!
// Total: 60ms + uses 3 SOQL query limits
Solution: Cache within transaction
public class AccountService {
private static final SOQLQueryCache.CacheOptions CACHE_OPTS = new SOQLQueryCache.CacheOptions()
.setStorage(SOQLQueryCache.CacheStorage.TRANSACTION_ONLY);
public static List<Account> getActiveAccounts() {
return (List<Account>)SOQLQueryCache.query(
'SELECT Id, Name, Industry FROM Account WHERE IsActive__c = true',
CACHE_OPTS
);
}
}
// Called multiple times - only first call hits database
List<Account> accounts1 = AccountService.getActiveAccounts(); // ~20ms (miss)
List<Account> accounts2 = AccountService.getActiveAccounts(); // <1ms (cached hit)
List<Account> accounts3 = AccountService.getActiveAccounts(); // <1ms (cached hit)
// Total: ~22ms vs 60ms uncached | Uses 1 SOQL query vs 3
Problem: Multiple expensive aggregate queries
public class DashboardController {
public List<AggregateResult> getMetrics() {
// 5 different aggregate queries, 30-50ms each = 150-250ms total
List<AggregateResult> revenue = Database.query('SELECT SUM(Amount) FROM Opportunity...');
List<AggregateResult> counts = Database.query('SELECT COUNT(Id) FROM Account...');
// ... more queries
}
}
Solution: Cache all dashboard queries
public class DashboardController {
private static final SOQLQueryCache.CacheOptions CACHE_OPTS = new SOQLQueryCache.CacheOptions()
.setStorage(SOQLQueryCache.CacheStorage.BOTH)
.setTTL(300); // Refresh every 5 minutes
public List<AggregateResult> getMetrics() {
// First load: ~200ms | Subsequent loads: ~10ms (saves SOQL limits)
List<AggregateResult> revenue = (List<AggregateResult>)SOQLQueryCache.query(
'SELECT SUM(Amount) FROM Opportunity...',
CACHE_OPTS
);
// ... more cached queries
}
}
Field | Type | Description |
---|---|---|
Query__c | Long Text Area (131,072) | The SOQL query with :parameter placeholders |
Parameters__c | Long Text Area | JSON array of parameter names: ["param1", "param2"] |
Description__c | Text Area (255) | Human-readable description |
Cache_TTL__c | Number | Time-to-live in seconds (0 = transaction only) |
Cache_Storage__c | Picklist | Transaction Only , Platform Cache , Both |
Active__c | Checkbox | Enable/disable the stored procedure |
Max_Results__c | Number | Safety limit for result size |
Enforce_Sharing__c | Checkbox | Respect sharing rules |
SOQLQueryCache.CacheOptions opts = new SOQLQueryCache.CacheOptions()
.setStorage(SOQLQueryCache.CacheStorage.BOTH) // Storage strategy
.setTTL(300) // 5 minutes TTL
.setMaxResults(1000) // Max 1000 records
.setEnforceSharing(true) // Respect sharing
.setBypassCache(false); // Force cache bypass
List<SObject> results = SOQLQueryCache.query(soql, opts);
// Clear specific query
SOQLQueryCache.clearQuery('SELECT Id FROM Account');
// Clear by normalized cache key
String cacheKey = SOQLNormalizer.normalize('SELECT Id FROM Account');
SOQLQueryCache.clearCacheKey(cacheKey);
// Clear stored procedure cache
SOQLStoredProcedure.clearProcedureCache('Get_Active_Accounts');
// Clear with specific parameters
Map<String, Object> params = new Map<String, Object>{'industry' => 'Technology'};
SOQLStoredProcedure.clearProcedureCache('Get_Active_Accounts', params);
// Clear entire transaction cache
SOQLQueryCache.clearTransactionCache();
// Reset statistics
SOQLQueryCache.resetStatistics();
- ✅ Trigger helper methods - RecordTypes, Custom Metadata queried by multiple helpers
- ✅ Lightning component queries - Same data fetched multiple times per page load
- ✅ Service layer methods - Called repeatedly within a transaction
- ✅ API endpoints - Multiple users/requests querying same data
- ✅ Reference data - Picklists, metadata, configuration tables
- ✅ Dashboard aggregates - Expensive queries that don't need real-time updates
- ✅ Lookup/reference tables - RecordTypes, custom metadata, static hierarchies
- ✅ Read-heavy queries - Data that changes infrequently (hourly/daily)
- ❌ Real-time data - Stock prices, live feeds requiring instant updates
- ❌ User-specific data without sharing - Risks data leakage across users
- ❌ Truly one-time queries - Only executed once per transaction/session
- ❌ High-volatility data - Records that change every few seconds
- ❌ Queries in loops - Anti-pattern; refactor to use sets/maps instead
- ❌ Standard Salesforce Reports/Dashboards - Already optimized by platform
- ❌ Very fast queries (<5ms) - Normalization overhead may exceed benefit
-
Start with Transaction Cache
- No setup required
- Automatic cleanup
- Perfect for queries repeated in same request
-
Use Platform Cache for Expensive Queries
- Queries taking >50ms
- Queries executed across multiple requests
- Data that changes infrequently
-
Set Appropriate TTLs
- Reference data: 600-3600 seconds (10 min - 1 hour)
- Dashboard data: 300-600 seconds (5-10 minutes)
- API responses: 60-300 seconds (1-5 minutes)
-
Monitor Cache Effectiveness
- Aim for >60% hit rate for good ROI
- If hit rate <30%, reconsider caching strategy
- Use
SOQLCacheStatistics
to track performance
-
Invalidate on Data Changes
- Clear cache after DML operations on cached objects
- Use triggers or service layer to maintain consistency
-
Use Stored Procedures for Common Queries
- Centralize query management
- Easier to update and maintain
- Consistent caching configuration
# Run all Apex tests
sf apex run test --test-level RunLocalTests --result-format human
# Run specific test classes
sf apex run test --tests SOQLQueryCacheTest,SOQLNormalizerTest,SOQLStoredProcedureTest
All classes include comprehensive test coverage:
SOQLQueryCacheTest
- Core caching functionalitySOQLNormalizerTest
- Query normalization logicSOQLStoredProcedureTest
- Stored procedure execution
Deploy the demo package and run:
SOQLCachePOC.quickDemo(); // Quick performance comparison
SOQLCachePOC.runDemo(); // Full demonstration suite
Operation | Time |
---|---|
Simple query normalization | 1-2ms |
Complex query normalization (with subqueries) | 10-15ms |
Transaction cache lookup | <0.5ms |
Platform cache lookup | 1-2ms |
Cache storage | <1ms |
Cache Type | Memory per Query Result |
---|---|
Transaction Cache | ~1-10 KB per 100 records |
Platform Cache | ~1-10 KB per 100 records |
Limit | Impact |
---|---|
SOQL queries | ✅ Reduced (cached queries don't count) |
CPU time | |
Heap size | |
Platform Cache |
-
SOQLQueryCache - Main caching layer
- Manages transaction and Platform Cache storage
- Tracks statistics
- Coordinates with normalizer and executor
-
SOQLNormalizer - Query normalization engine
- Fast path for simple queries (indexOf-based)
- Slow path for complex queries with subqueries
- Recursive normalization for nested queries
-
SOQLQueryExecutor - Sharing enforcement
with sharing
inner class for enforced sharingwithout sharing
inner class for system mode- Ensures proper security context
-
SOQLStoredProcedure - Stored procedure executor
- Loads procedures from Custom Metadata
- Handles parameter binding and validation
- Generates procedure-specific cache keys
-
SOQLCacheStatistics - Metrics tracking
- Monitors hit/miss rates
- Tracks storage tier usage
- Provides summary reporting
User Query
↓
SOQLQueryCache.query()
↓
SOQLNormalizer.normalize() → Cache Key
↓
Check Transaction Cache → HIT? Return Result
↓ (MISS)
Check Platform Cache → HIT? Return Result (promote to Transaction)
↓ (MISS)
Database.query() → Execute Query
↓
Store in Cache(s)
↓
Return Result
Stored procedures use String.escapeSingleQuotes()
to prevent SQL injection:
// Safe parameter binding
String paramValue = String.escapeSingleQuotes(userInput);
Control sharing rules via enforceSharing
option:
// WITH SHARING (default) - Respects sharing rules
SOQLQueryCache.CacheOptions withSharingOpts = new SOQLQueryCache.CacheOptions()
.setEnforceSharing(true); // Recommended for user-visible data
List<Account> accounts = (List<Account>)SOQLQueryCache.query(
'SELECT Id, Name FROM Account',
withSharingOpts
);
// WITHOUT SHARING - Bypasses sharing rules (use with caution)
SOQLQueryCache.CacheOptions withoutSharingOpts = new SOQLQueryCache.CacheOptions()
.setEnforceSharing(false); // For system-level operations
List<Account> allAccounts = (List<Account>)SOQLQueryCache.query(
'SELECT Id, Name FROM Account',
withoutSharingOpts
);
How it works:
enforceSharing=true
→ Useswith sharing
inner classenforceSharing=false
→ Useswithout sharing
inner class- Both still respect user permissions (FLS/CRUD)
- Only sharing rules differ
Important: When using Platform Cache with enforceSharing=false
, ensure cache keys include user context to prevent data leakage:
// BAD - All users share the same cache key
String query = 'SELECT Id FROM Account';
SOQLQueryCache.query(query, withoutSharingOpts); // Risk: User A sees User B's cached results
// GOOD - Include user context in query for user-specific data
String query = 'SELECT Id FROM Account WHERE OwnerId = \'' + UserInfo.getUserId() + '\'';
SOQLQueryCache.query(query, withoutSharingOpts); // Safe: Cache key includes user ID
Platform Cache is org-wide and shared across all users. Ensure:
- Sensitive data uses transaction-only cache
- User-specific data includes user ID in cache key
- Sharing rules are enforced for multi-user data
Symptom: Same query always hits database
Solutions:
- Verify Platform Cache partition exists and has space allocated
- Check cache options are properly configured
- Ensure query is identical (use
SOQLNormalizer.normalize()
to debug) - Review debug logs for cache errors
Symptom: Hit rate <30%
Solutions:
- Queries may be too dynamic (too many unique variations)
- TTL may be too short for Platform Cache
- Transaction cache only works within same request
- Consider using Stored Procedures for parameterized queries
Symptom: Platform Cache put failed
warnings
Solutions:
- Verify partition name is exactly
SOQLCache
- Check partition has available space
- Ensure Platform Cache is enabled in org
- Review org limits: Setup → Platform Cache
Symptom: Cached queries not faster
Solutions:
- First query will always be slow (cache miss)
- Normalization adds 1-15ms overhead
- Very fast queries (<5ms) may not benefit from caching
- Review statistics to verify cache hits are occurring
Future enhancements under consideration:
- Cache warming utilities
- Automatic cache invalidation on DML
- Query performance analyzer
- Multi-level cache hierarchies
- Cache size optimization algorithms
- Redis/External cache support
- GraphQL-style query batching
force-app/
├── main/default/
│ ├── classes/
│ │ ├── SOQLQueryCache.cls # Main caching layer
│ │ ├── SOQLNormalizer.cls # Query normalization
│ │ ├── SOQLQueryExecutor.cls # Sharing enforcement (with/without)
│ │ ├── SOQLStoredProcedure.cls # Stored procedure executor
│ │ ├── SOQLCacheStatistics.cls # Statistics tracking
│ │ ├── SOQLQueryCacheTest.cls # Cache tests
│ │ ├── SOQLNormalizerTest.cls # Normalizer tests
│ │ ├── SOQLQueryExecutorTest.cls # Sharing enforcement tests
│ │ └── SOQLStoredProcedureTest.cls # Stored procedure tests
│ ├── cachePartitions/
│ │ └── SOQLCache.cachePartition-meta.xml
│ └── objects/
│ └── SOQL_Stored_Procedure__mdt/ # Custom metadata definition
force-app-demo/
└── main/default/classes/
└── SOQLCachePOC.cls # Demo and POC code
Contributions are welcome! Areas for improvement:
- Additional cache eviction strategies
- Cache warming utilities
- Query performance analyzer
- Multi-tier cache hierarchies
- Documentation improvements
MIT License - Use freely in your projects!
- Issues: Open an issue in this repository
- Questions: Review the demo code in
SOQLCachePOC.cls
- Examples: Check test classes for additional usage patterns
Built with ❤️ for the Salesforce developer community.
Special thanks to all contributors and early adopters who provided feedback and testing.
Ready to reduce SOQL governor limit usage? Deploy today and measure the impact in your org!