Platform Cache Patterns in Salesforce — Safe Caching & Invalidation

Share

Salesforce Platform Cache (Org & Session) is an easy win when you want faster reads, fewer API calls, and smoother traffic spikes—without spinning up your own cache layer. The key is to use safe, predictable patterns (cache-aside/read-through, versioned keys, event-driven invalidation) so you don’t accidentally serve stale or inconsistent data.


Core Concepts (with quick examples + code snaps)

Cache-Aside (a.k.a. lazy loading)

What it is: Check the cache first. If there’s a miss, load from the source (SOQL or a callout), then store the result back in cache. It’s simple, explicit, and works almost everywhere.

Real-world example: Product tiles on a community page. You fetch each Product2 by Id and cache it so repeat views are instant.

public with sharing class ProductCache {
    private static final String PART = 'Products'; // Org cache partition name

    public static Product2 getById(Id productId) {
        String key = 'p:' + (String)productId;
        Product2 p = (Product2) Cache.Org.get(PART, key);
        if (p != null) return p;

        p = [SELECT Id, Name, Family, Description FROM Product2 WHERE Id = :productId LIMIT 1];
        Cache.Org.put(PART, key, p); // cache the hydrated SObject
        return p;
    }
}

Safe Invalidation — versioned keys

What it is: Instead of hunting down and deleting many keys, you increment a version that becomes part of every cache key. Old entries naturally fall behind and age out.

Real-world example: Merch updates pricing rules. You bump the “catalog” version and all consumers start reading fresh data.

public with sharing class CacheVersioning {
    private static final String PART = 'Products';

    // Read the current version number (or 1 if missing)
    public static Integer current(String scope) {
        Integer v = (Integer) Cache.Org.get(PART, 'v:' + scope);
        return v == null ? 1 : v;
    }

    // Atomically bump version (best-effort)
    public static Integer bump(String scope) {
        Integer v = current(scope) + 1;
        Cache.Org.put(PART, 'v:' + scope, v);
        return v;
    }

    public static String key(String scope, String rawKey) {
        return 'v' + current(scope) + ':' + rawKey;
    }
}

// Usage in cache-aside:
public with sharing class CatalogCache {
    private static final String PART = 'Products';

    public static List<Product2> listByFamily(String family) {
        String key = CacheVersioning.key('catalog', 'fam:' + family);
        List<Product2> cached = (List<Product2>) Cache.Org.get(PART, key);
        if (cached != null) return cached;

        List<Product2> rows = [SELECT Id, Name, Family FROM Product2 WHERE Family = :family ORDER BY Name LIMIT 200];
        Cache.Org.put(PART, key, rows);
        return rows;
    }
}

Invalidate everything: Call CacheVersioning.bump('catalog') (from an admin action, a flow, or a post-deploy step) instead of chasing individual keys.


Event-Driven Invalidation (multi-node friendly)

What it is: Use a Platform Event to broadcast either a version bump or a specific key removal. Every app server hears the message and converges on the same state.

Real-world example: An ERP updates the Pricebook; a Platform Event fires and bumps the catalog version across all nodes.

// Platform Event: Cache_Invalidate__e (Text: Scope__c, Key__c, VersionBump__c)
public with sharing class CacheInvalidationListener {
    @AuraEnabled
    public static void publishBump(String scope) {
        EventBus.publish(new Cache_Invalidate__e(Scope__c = scope, VersionBump__c = 'true'));
    }
}

public with sharing class CacheInvalidationTrigger {
    // Use a PE-trigger or a Flow to handle events; pseudo handler below:
    public static void onEvent(Cache_Invalidate__e[] events) {
        for (Cache_Invalidate__e e : events) {
            if (e.VersionBump__c == 'true') CacheVersioning.bump(e.Scope__c);
            if (!String.isBlank(e.Key__c)) Cache.Org.remove('Products', e.Key__c);
        }
    }
}

Read-Through with CacheBuilder

What it is: A Cache Builder fetches the value automatically when the cache misses. Your load logic lives in one place, and callers stay clean.

Real-world example: A frequently accessed “store config” JSON blob is cached, sourced from Custom Metadata.

// Setup: In the partition, map key prefix "cfg:" to this builder class.
public class StoreConfigBuilder implements Cache.CacheBuilder {
    public Object load(String key) {
        // Key example: "cfg:store"
        Store_Settings__mdt s = Store_Settings__mdt.getInstance('Default');
        Map<String,Object> cfg = new Map<String,Object>{
            'currency' => s.DefaultCurrency__c,
            'locale'   => s.Locale__c
        };
        return cfg;
    }
}

// Caller code (miss triggers StoreConfigBuilder.load automatically)
public with sharing class StoreConfig {
    public static Map<String,Object> get() {
        return (Map<String,Object>) Cache.Org.get('Products', 'cfg:store');
    }
}

Session Cache — per-user, short-lived state

What it is: Cache.Session is scoped to a single user session. It’s perfect for chatty UI bits, wizards, and small ephemeral states.

Real-world example: A multi-step wizard saves a selection so you don’t re-query between screens.

public with sharing class WizardState {
    public static void saveDraft(Set<Id> selectedProductIds) {
        Cache.Session.put('draft:sel', selectedProductIds);
    }
    public static Set<Id> loadDraft() {
        Set<Id> s = (Set<Id>) Cache.Session.get('draft:sel');
        return s == null ? new Set<Id>() : s;
    }
}

Soft TTL & Negative Caching (avoid stampedes)

What it is: Platform Cache doesn’t give you a per-item TTL in Apex, so you store a timestamp alongside the value and treat it as a soft TTL. Also cache misses briefly so you don’t hammer the database for nonexistent records.

Real-world example: A product was deleted. Caching the “not found” result prevents repeat queries during the next few minutes.

public class Cached<T> {
    public Long ts; public T val; public Boolean negative;
    public Cached(T v, Boolean neg) { ts = DateTime.now().getTime(); val = v; negative = neg; }
}

public with sharing class SafeCache {
    private static final String PART = 'Products';
    private static final Integer SOFT_TTL_MS = 5 * 60 * 1000; // 5 min

    public static Product2 getProduct(Id id) {
        String key = CacheVersioning.key('catalog', 'p:' + id);
        Cached<Product2> wrap = (Cached<Product2>) Cache.Org.get(PART, key);

        if (wrap != null && (DateTime.now().getTime() - wrap.ts) < SOFT_TTL_MS) {
            if (wrap.negative) return null;
            return wrap.val;
        }

        List<Product2> rows = [SELECT Id, Name FROM Product2 WHERE Id = :id LIMIT 1];
        if (rows.isEmpty()) {
            Cache.Org.put(PART, key, new Cached<Product2>(null, true)); // negative cache
            return null;
        }
        Product2 p = rows[0];
        Cache.Org.put(PART, key, new Cached<Product2>(p, false));
        return p;
    }
}

Write-Through (update DB → refresh cache right away)

What it is: After a successful write, update the relevant cache key (or bump the version) so readers immediately see fresh data.

Real-world example: An admin edits a product name; you refresh the single-item key and bump the family list to keep everything aligned.

trigger Product2After on Product2 (after update) {
    for (Product2 p : Trigger.new) {
        String key = CacheVersioning.key('catalog', 'p:' + p.Id);
        Cache.Org.put('Products', key, p);
    }
    CacheVersioning.bump('catalog'); // force list queries to refetch
}

Platform Cache Decision Diagram


Short Summary

Use cache-aside for quick wins, read-through when you want centralized load logic, and session cache for per-user UX. Keep data correct with versioned keys and event-driven invalidation, and prevent thundering herds with soft TTL and negative caching. These Platform Cache patterns deliver faster pages and sturdier integrations—safely.

  • October 18, 2025