Utilities - Guide
Framework: KernDX Package Type: Managed Package
Target Audience:
- Developers - Quick reference for common utility operations and advanced patterns including retry, circuit breaker, cache, and type resolution
- Architects - Understanding available utility capabilities, fault tolerance patterns, and extensibility through type resolvers and feature flags
- Business Analysts - Overview of utility functions for data formatting, validation, feature flag management, and Flow invocable methods
What problem does this solve?
Every Salesforce project re-writes the same small helpers: trimming a string to fit a field, working out the next business day, splitting a big list into save-sized chunks, reading a value out of a JSON blob. Written by hand, each one is a place a null-pointer or off-by-one bug can hide.
KernDX ships ready-made, tested versions of those everyday helpers, so you call a proven method instead of hand-rolling one.
Read this when you want a quick way to do a common task in Apex, or to expose a framework capability to a Flow. Developers use it as a day-to-day reference; architects use it to see what already exists before building something new; business analysts can skim what the framework offers for data formatting, validation, and Flow actions. You do not need prior KernDX knowledge to follow along.
Mental model
Think of these utilities as a pre-stocked Swiss Army knife. Instead of forging a new blade every time you need to parse a date, check a cache, or split a string, you pull out a tool that has already been built and stress-tested. Each tool does one job: you pass data in, it hands a result back.
Use this when
- you have a common Apex task (string, date, collection, JSON, describe) and a tested helper already exists for it
- you want to expose a framework capability (logging, email) to a Flow without writing your own Apex action
Don't use this when
- a helper already exists for the job: look here first rather than writing your own
- it is a one-liner you will never reuse, where a plain inline call is simpler and clearer
- the native Salesforce method already covers your case cleanly: these helpers add null-safety and convenience, not replacements for what the platform already does well
Quick Start
The most commonly used utilities are string validation, date arithmetic, and collection operations. Here are the patterns you will use most often:
// String validation
Boolean hasValue = String.isNotBlank(inputValue);
Id validId = UTIL_SObject.validateId(idString);
// Date arithmetic
Date nextBusinessDay = UTIL_Date.addBusinessDays(Date.today(), 5);
Boolean isWeekend = UTIL_Date.isWeekend(Date.today());
// Collection operations
List<List<SObject>> chunks = UTIL_List.partition(largeList, 200);
accounts.sort(new RevenueComparator()); // see "Sorting" below for the Comparator<SObject> patternFor deeper coverage, continue reading the sections below.
Table of Contents
Expand
- What problem does this solve?
- Mental model
- Use this when
- Don't use this when
- Quick Start
- Quick Navigation
- Overview
- How does it work?
- String Utilities
- Date & Time Utilities
- Character Utilities
- Collection Utilities
- System & Reflection
- Data Processing
- Specialised Utilities
- Quick Reference Tables
- Retry Strategy Framework (UTIL_Retry)
- Circuit Breaker Framework (UTIL_CircuitBreaker)
- Platform Cache Framework (UTIL_Cache)
- Type Resolution (UTIL_TypeResolver)
- Random Data Generation (UTIL_Random)
- Advanced Data Indexing (MAP_SObject)
- Metadata Introspection (UTIL_SObjectDescribe)
- Feature Flag Management (UTIL_FeatureFlag)
- Logging Framework (LOG_Builder)
- Omnistudio Integration (SVC_Omnistudio)
- Invocable Methods for Flows
- Health Check
- Anti-Patterns
- Best Practices
- Testing
- Related Documentation
Quick Navigation
| I am a... | I need to... | Go to... |
|---|---|---|
| Admin | Verify my org is configured | Health Check |
| Architect | Understand how the utilities fit together | How does it work? |
| Architect | Design fault tolerance patterns | Circuit Breaker Framework |
| Developer | Use common utilities | Quick Start |
| Developer | Implement retry strategies | Retry Strategy Framework |
| Developer | Work with platform cache | Platform Cache Framework |
| Analyst | Manage feature flags | Feature Flag Management |
| Analyst | Understand type resolution | Type Resolution |
Overview
The utility classes here are stateless helpers: you call them by name (for example UTIL_String.abbreviate(...)), they transform the data you pass in, and they hand back a result.
Responsibilities: Utility classes are stateless helpers. They perform transformations, validations, and calculations on data passed to them. They do not perform DML, query data, or manage state. If a utility grows beyond pure transformation, it likely belongs in a service class or framework component.
What's Covered:
- String operations and validation
- Date and time utilities
- Number formatting and validation
- Collection utilities (List, Map, Set)
- System utilities and type resolution
- JSON path navigation
- Performance timing
- And more...
What's NOT Covered (See Other Guides):
LOG_Builder- See Logging - Guide- Async utilities - See Async Processing - Guide
UTIL_Retry/UTIL_CircuitBreaker(resilience) - See Resilience - GuideUTIL_FeatureFlag(feature flags) - See Feature Flags - Guide
Utilities Scope:
UTIL_*classes plusFLOW_*invocable classes, covering string, date, collection, system, type resolution, cache, random data, and SObject indexing. The Flow invocables expose framework capabilities to declarative automation. Resilience (UTIL_Retry/UTIL_CircuitBreaker) and feature flags (UTIL_FeatureFlag) have their own dedicated guides.
How does it work?
To make the right ones easy to find, the utilities split into two groups by how much they do for you.
The first group is the everyday helpers: simple, do-one-thing methods you call and forget. These are the Basic Utilities for strings, dates, collections, and the like: UTIL_String, UTIL_Date, UTIL_List, UTIL_Map, UTIL_Set (internal), UTIL_Character (internal), UTIL_System, UTIL_Exceptions, UTIL_JsonPath, UTIL_Email, UTIL_StopWatch, UTIL_FormulaFilter, UTIL_BulkUpdates, and UTIL_PurgeRecords.
The second group does more behind the scenes, and some of these remember state between calls or are driven by configuration records. These are the Advanced Utilities: UTIL_Retry (retry strategies), UTIL_CircuitBreaker (fault tolerance), UTIL_Cache (platform cache), UTIL_TypeResolver (dynamic type resolution), UTIL_Random (random data generation), MAP_SObject (in-memory indexing), UTIL_SObjectDescribe (metadata introspection), UTIL_FeatureFlag (feature flags), LOG_Builder (logging), SVC_Omnistudio (Omnistudio integration), and Flow invocable methods.
You call most of these directly by class name, without creating an instance first (for example UTIL_String.abbreviate(value, 20)). The exceptions are the configure-then-run helpers like UTIL_Cache, where you chain a few short calls to set things up before the final call does the work.
String Utilities
UTIL_String
To manipulate, validate, or transform text without writing your own null checks every time, use UTIL_String. It adds null-safe string methods on top of the standard Apex String class, so a null input returns a sensible result instead of throwing.
ID Validation
/**
* @description Safely validates and converts a string to a Salesforce ID.
* Returns null if the string is not a valid ID format.
*/
Id accountId = UTIL_SObject.validateId('001xx000003DGb2AAG');
// Returns null if the string is not a valid 15 or 18-character ID
Id invalidId = UTIL_SObject.validateId('not-an-id'); // nullString Manipulation
// Abbreviate long strings
String abbreviated = UTIL_String.abbreviate('Now is the time for all good men', 20);
// Result: 'Now is the time f...'
// Abbreviate to fit an SObject field (uses field's maximum length)
String truncated = UTIL_String.abbreviate(longErrorMessage, LogEntry__c.Message__c);
// Result: String truncated to fit the Message__c field (32768 chars)String Replacement
// Replace all occurrences
String replaced = UTIL_String.replace('aba', 'a', 'z');
// Result: 'zbz'
// Remove substring
String removed = UTIL_String.remove('Hello World', ' World');
// Result: 'Hello'String Splitting & Joining
// Split by delimiter
List<String> parts = UTIL_String.split('a,b,c', ',');
// Result: ['a', 'b', 'c']
// Split with max
List<String> partMax = UTIL_String.split('a,b,c', ',', 2);
// Result: ['a', 'b,c']
// Join with separator
String joined = UTIL_String.join(new String[]{'a', 'b', 'c'}, ',');
// Result: 'a,b,c'Date & Time Utilities
UTIL_Date
Date logic is a common source of subtle bugs: skipping weekends, parsing an ISO 8601 timestamp, or formatting a date for display. UTIL_Date gives you tested methods for all of these (weekend detection, business-day arithmetic, ISO 8601 conversion, and formatting), building on the standard Apex Date and Datetime classes.
Weekend & Weekday Detection
// Check if a date falls on Saturday or Sunday
Boolean isWeekend = UTIL_Date.isWeekend(Date.newInstance(2025, 9, 6)); // true (Saturday)
Boolean isWeekDay = UTIL_Date.isWeekDay(Date.newInstance(2025, 9, 5)); // true (Friday)
// Also works with Datetime
Boolean isWeekendDt = UTIL_Date.isWeekend(Datetime.now());
Boolean isWeekDayDt = UTIL_Date.isWeekDay(Datetime.now());
// Get the last weekday (Friday or earlier) relative to a date
Date lastWeekDay = UTIL_Date.lastWeekDay(Date.newInstance(2025, 9, 7)); // 2025-09-05 (Friday)
Date lastWeekDayToday = UTIL_Date.lastWeekDay(); // last weekday before today
// Get the first weekday (Monday or later) relative to a date
Date firstWeekDay = UTIL_Date.firstWeekDay(Date.newInstance(2025, 9, 7)); // 2025-09-08 (Monday)Business Day Arithmetic
// Add business days (skips weekends)
Date result = UTIL_Date.addBusinessDays(Date.newInstance(2025, 9, 5), 3);
// Result: 2025-09-10 (Wednesday — skips Saturday and Sunday)
// Subtract business days (negative value)
Date earlier = UTIL_Date.addBusinessDays(Date.newInstance(2025, 9, 10), -3);
// Result: 2025-09-05 (Friday — skips weekend going backwards)ISO 8601 Conversion
// Convert Datetime to ISO 8601 string
String isoDatetime = UTIL_Date.toIso8601(Datetime.newInstance(2025, 9, 5, 15, 30, 0));
// Result: '2025-09-05T15:30:00.000Z'
// Convert Date to ISO 8601 string
String isoDate = UTIL_Date.toIso8601(Date.newInstance(2025, 9, 5));
// Result: '2025-09-05'
// Parse ISO 8601 string to Datetime
Datetime parsedDatetime = UTIL_Date.dateTimeFromIso8601('2025-09-05T15:30:00.000Z');
// Parse ISO 8601 string to Date
Date parsedDate = UTIL_Date.dateFromIso8601('2025-09-05');
// Null inputs return Epoch (1970-01-01)
String epochIso = UTIL_Date.toIso8601((Date)null);
// Result: '1970-01-01'Date Formatting
// Format a Date using Java SimpleDateFormat patterns
String formatted = UTIL_Date.formatDate(Date.newInstance(2025, 9, 5), 'MMMM d, yyyy');
// Result: 'September 5, 2025'
String shortDate = UTIL_Date.formatDate(Date.newInstance(2025, 9, 5), 'yyyy-MM-dd');
// Result: '2025-09-05'Cron Expressions
// Generate a cron expression from a Datetime (useful for scheduling)
String cron = UTIL_Date.getCronExpression(Datetime.newInstance(2025, 9, 5, 15, 30, 0));
// Result: '0 30 15 5 9 ? 2025'Character Utilities
UTIL_Character
UTIL_Character handles single-character checks and conversions. The framework uses it internally to build UTIL_String and UTIL_Random.
Internal class:
UTIL_Characteris a framework-internal helper (declaredpublic, notglobal). The examples below show what it does; for your own code, the character checks you need are available throughUTIL_String.
// Check if whitespace
Boolean isWhitespace = UTIL_Character.isWhitespace(' '); // true
// Convert ASCII value to character
String character = UTIL_Character.toChar(65); // 'A'
// Validate single-character string
UTIL_Character.validateChar('A'); // no exceptionCollection Utilities
UTIL_List
When you need to know whether a list is empty without first null-checking it, or to break a large list into save-sized chunks, use UTIL_List. It adds null-safe helpers on top of the standard Apex List class.
// Check if list is empty (null-safe)
Boolean empty = UTIL_List.isEmpty(myList);
// Check if list is not empty (null-safe)
Boolean notEmpty = UTIL_List.isNotEmpty(myList);
// Partition list into chunks
List<List<SObject>> batches = UTIL_List.partition(records, 200);Sorting: To sort SObjects by a field, use Salesforce's built-in
Comparator<SObject>interface and callList.sort(comparator)(the Sorting section below shows the pattern). The framework's ownUTIL_List.sort(list, comparator)andUTIL_Comparatorsare internal plumbing; the platform-native approach is the one to use in your code.
UTIL_Map
When you need to flatten a map of lists into one list, or turn a map into a delimited string, UTIL_Map has the helper. It builds on the standard Apex Map class.
// Flatten map of lists to single list
List<SObject> flat = UTIL_Map.flattenValues(recordsByType);
// Convert map to delimited string
String delimited = UTIL_Map.toDelimitedString(myMap, '|');UTIL_Set
UTIL_Set holds set helpers the framework uses internally. It is framework plumbing rather than something you call from your own code.
Sorting
To sort a list of records by a field, write a small class that implements Salesforce's built-in Comparator<SObject> interface, then call List.sort(comparator). (The framework's own UTIL_List.sort(...) and UTIL_Comparators.SObjectFieldComparator are internal plumbing; the platform-native interface shown here is what your code should use.)
public with sharing class RevenueComparator implements Comparator<SObject>
{
public Integer compare(SObject first, SObject second)
{
Decimal firstRevenue = (Decimal)first.get('AnnualRevenue');
Decimal secondRevenue = (Decimal)second.get('AnnualRevenue');
return firstRevenue < secondRevenue ? -1 : firstRevenue > secondRevenue ? 1 : 0;
}
}
accountList.sort(new RevenueComparator());System & Reflection
UTIL_System
Managed-package code often needs to answer questions about its own environment: what namespace am I running in, what API version is the org on, what is the runtime type of this object? UTIL_System answers those, and it can also look up an Apex class by name (handy when configuration records point to a class you resolve at runtime).
Type Resolution
// Get Type from class name (subscriber-first resolution via chain of responsibility)
Type myType = UTIL_System.getTypeForClassName('TRG_SetFoobarDefaults');
// Get Type with expected interface validation
Type handlerType = UTIL_System.getTypeForClassName('TRG_SetFoobarDefaults', IF_Trigger.BeforeInsert.class);
// Get runtime type name for an instance
String typeName = UTIL_System.getRuntimeTypeName(new Account());
// Result: 'Account'Namespace & Environment
// Get managed package namespace (e.g., 'kern')
String namespace = UTIL_System.getManagedPackageNamespace();
// Get managed package namespace with delimiter (e.g., 'kern__')
String prefix = UTIL_System.getManagedPackageNamespacePrefix('__');
// Get namespace for a specific class
String classNamespace = UTIL_System.getClassNamespace('TRG_SetFoobarDefaults');
// Get namespace prefix with delimiter (e.g., 'kern.')
String nsPrefix = UTIL_System.getNamespacePrefix('kern', '.');
// Get API-enabled session ID (for callouts requiring session authentication)
String sessionId = UTIL_System.getApiEnabledSessionId();
// Get the org's current API version
String apiVersion = UTIL_System.getOrgApiVersion();UTIL_Exceptions
When your code needs to fail clearly, UTIL_Exceptions gives you ready-made exception types with meaningful names, such as "configuration is missing", "record not found", or "this operation isn't valid right now". They are grouped under one outer class so they're easy to find and throw.
// Configuration missing or malformed
if(credential == null)
{
throw new UTIL_Exceptions.ConfigurationException('Named credential not found for service: ' + serviceName);
}
// Record or resource not found
Foobar__c record = (Foobar__c)new SEL_Foobar().findById(recordId);
if(record == null)
{
throw new UTIL_Exceptions.NotFoundException('Record does not exist: ' + recordId);
}
// Operation attempted on invalid state
if(isFinalized)
{
throw new UTIL_Exceptions.IllegalStateException('Cannot modify a finalized transaction.');
}Data Processing
UTIL_JsonPath
When you have a JSON string (say, a response from an external system) and want one value out of it without deserializing the whole thing into a class, use UTIL_JsonPath. You point at a value with a dotted path like person.name, and it hands back the typed value, or tells you the path doesn't exist.
// Create JsonPath from JSON string
String jsonStr = '{"person":{"name":"John","age":30,"active":true}}';
UTIL_JsonPath jp = new UTIL_JsonPath(jsonStr);
// Get typed values by path
String name = jp.get('person.name').getStringValue(); // 'John'
Integer age = jp.get('person.age').getIntegerValue(); // 30
Boolean active = jp.get('person.active').getBooleanValue(); // true
// Check if path exists
Boolean exists = jp.exists('person.email'); // false
// Navigate to nested node
UTIL_JsonPath person = jp.get('person');
String personName = person.get('name').getStringValue(); // 'John'
// Get array elements
UTIL_JsonPath jpArray = new UTIL_JsonPath('[{"id":1},{"id":2}]');
Integer firstId = jpArray.get('[0].id').getIntegerValue(); // 1Specialised Utilities
UTIL_Email
When you need to validate an email address, check whether your org is even allowed to send email, or send a message with attachments, UTIL_Email covers all three.
Email Validation
// Validate email address format
Boolean isValid = UTIL_Email.isValidEmailAddress('user@example.com'); // true
// Supports international characters
Boolean validIntl = UTIL_Email.isValidEmailAddress('user@example.jp'); // trueCheck Email Deliverability
// Get org's email deliverability level
UTIL_Email.DeliverabilityAccessLevel level =
UTIL_Email.getEmailDeliverabilityAccessLevel();
if(level == UTIL_Email.DeliverabilityAccessLevel.ALL_EMAIL)
{
// Can send all emails
}
else if(level == UTIL_Email.DeliverabilityAccessLevel.SYSTEM_EMAIL_ONLY)
{
// Only system emails allowed
}
else if(level == UTIL_Email.DeliverabilityAccessLevel.NO_ACCESS)
{
// Email disabled
}Send Email
// Send simple HTML email (pass null for no attachments)
UTIL_Email.sendEmail(
new List<String>{'recipient@example.com'},
'Subject Line',
'<h1>Email Body</h1>',
true, // HTML format
null // No attachments
);
// Send plain text email with file attachments
Messaging.EmailFileAttachment fileAttachment = new Messaging.EmailFileAttachment();
fileAttachment.setFileName('report.csv');
fileAttachment.setBody(Blob.valueOf('data'));
UTIL_Email.sendEmail(
new List<String>{'recipient@example.com'},
'Subject',
'Body text',
false, // Plain text
new List<Messaging.EmailFileAttachment>{fileAttachment}
);Working with Attachments
// Build an EmailFileAttachment from a Blob
Messaging.EmailFileAttachment fileAttachment = new Messaging.EmailFileAttachment();
fileAttachment.setFileName('report.csv');
fileAttachment.setContentType('text/csv');
fileAttachment.setBody(Blob.valueOf('Id,Name\n001xx,Acme'));
// Send email with the attachment
UTIL_Email.sendEmail(
new List<String>{'recipient@example.com'},
'Monthly Report',
'<p>Please find the report attached.</p>',
true,
new List<Messaging.EmailFileAttachment>{fileAttachment}
);UTIL_StopWatch
To know how long a piece of code took, so you can spot what's slow, use the built-in timers. UTIL_StopWatch is the framework-internal base class behind the three built-in performance timers (UTIL_PerformanceTimer, UTIL_QueryPerformanceTimer, UTIL_TriggerPerformanceTimer). It is internal plumbing (declared public, not global), so you don't instantiate it yourself.
You get most timing for free: setting TriggerSetting__mdt.EnablePerformanceLogging__c times trigger actions, ApiSetting__mdt times API calls, and UTIL_AsyncChain.ChainContext carries timing through chain steps.
To time a piece of your own code (a custom batch or callout, say), wrap it in a LOG_Builder.scope() block. The scope records the start and end times and writes a LogEntryEvent__e log record, tagged with the same tracking ID that follows the whole user action through triggers, queries, callouts, and jobs (the correlation ID), so you can see the timing alongside everything else that ran.
kern.LOG_Builder.LogScope scope = kern.LOG_Builder.scope();
try
{
// ... code to time ...
}
finally
{
scope.close();
}UTIL_FormulaFilter
To keep "which records should this run for?" as a formula (the kind an admin can read and change) rather than hard-coding the condition in Apex, use UTIL_FormulaFilter. It takes a boolean formula like Name = "Acme" && Industry = "Technology" and returns just the records that match it. It is built on Salesforce's FormulaEval engine and is handy for filtering the records in a trigger.
// Create a formula filter with a process name, context class, and formula expression
UTIL_FormulaFilter formulaFilter = new UTIL_FormulaFilter(
'AccountFilter',
'UTIL_FormulaContext.AccountContext',
'Name = "Acme" && Industry = "Technology"'
);
// Filter records — returns DTO_FilterResults with matched old/new record lists
UTIL_FormulaFilter.DTO_FilterResults results = formulaFilter.filter(
Trigger.old,
Trigger.new
);
// Access filtered records
List<SObject> matchedNewRecords = results.newRecords;
List<SObject> matchedOldRecords = results.oldRecords;UTIL_BulkUpdates
For one-off admin and clean-up jobs (scrambling email addresses in a sandbox, reassigning records from a departing user, deactivating long-inactive users), UTIL_BulkUpdates gives you ready-made bulk operations so you don't write a one-off batch class each time.
For everyday inserts, updates, and deletes in your own code, use DML_Builder instead; UTIL_BulkUpdates is for the maintenance tasks above.
// Invalidate email fields on an SObject (e.g., for sandbox data masking)
UTIL_BulkUpdates.invalidateEmailFields(Contact.Email);
// Invalidate with custom batch size and partial success
UTIL_BulkUpdates.invalidateEmailFields(Contact.Email, 200, false);
// Reassign record ownership from one profile to a new user
UTIL_BulkUpdates.updateOwner('Account', 'Old Profile', 'newowner@example.com');
// Deactivate users inactive for more than 90 days
UTIL_BulkUpdates.deactivateUsers(new Set<String>{'Standard User'}, 90);UTIL_PurgeRecords
When records should not pile up forever (old log entries, expired temporary data), UTIL_PurgeRecords deletes them in batches. You can clear every record of a type, or keep only those newer than a set number of days.
// Delete all records of an SObject type
UTIL_PurgeRecords.deleteAllRecords(LogEntry__c.SObjectType);
// Delete all records with custom batch size and partial success
UTIL_PurgeRecords.deleteAllRecords('LogEntry__c', false, 200);
// Delete records older than N days (based on CreatedDate)
UTIL_PurgeRecords.deleteOlderThanNDays('LogEntry__c', 90);
// Delete records older than N days using a custom date field
UTIL_PurgeRecords.deleteOlderThanNDays('LogEntry__c', 'EventDate__c', 365);UTIL_Limits
Salesforce caps how much each transaction can do (queries, callouts, CPU time, and so on); these are the governor limits. Before doing something expensive, you often want to ask "how much of this budget have I used, and am I close to the cap?" so you can defer work to a background job instead of hitting a hard error. UTIL_Limits answers that. It gives a named method for each limit (so your IDE can suggest them as you type) and lets you check what's left, what percentage is used, or whether you're past a threshold you set.
// Check if callouts are exhausted
if(UTIL_Limits.callouts().isExhausted())
{
// defer to async
}
// Threshold check — auto-normalises values > 1.0 (80 becomes 0.8)
if(UTIL_Limits.soqlQueries().isNearLimit(0.8))
{
// approaching query limit
}
// Remaining budget
Integer calloutsLeft = UTIL_Limits.callouts().remaining();
Integer queryRowBudget = UTIL_Limits.soqlQueryRows().remaining();
// Percentage consumed (0.0–1.0)
Decimal cpuPercent = UTIL_Limits.cpuTime().percentUsed();
// Full snapshot for logging
String debugLimits = UTIL_Limits.toDebugString();Available limit types: aggregateQueries(), callouts(), cpuTime(), dmlRows(), dmlStatements(), emailInvocations(), futureCalls(), heapSize(), mobilePushApexCalls(), publishImmediateDml(), soqlQueries(), soqlQueryRows(), soqlQueryLocatorRows(), queueableJobs(), soslQueries()
LimitCheck methods: used(), maximum(), remaining(), percentUsed(), isExhausted(), isNearLimit(threshold)
Quick Reference Tables
String Validation Quick Reference
| Check | Method | Example |
|---|---|---|
| Is empty | isEmpty(str) | '' -> true |
| Is blank | isBlank(str) | ' ' -> true |
| Is not blank | isNotBlank(str) | 'text' -> true |
| Valid email | UTIL_Email.isValidEmailAddress(str) | 'a@b.com' -> true |
| Valid ID | UTIL_SObject.validateId(str) | '001...' -> Id or null |
Date Calculation Quick Reference
| Operation | Method | Example |
|---|---|---|
| Is weekend | isWeekend(date) | isWeekend(today) |
| Is weekday | isWeekDay(date) | isWeekDay(today) |
| Add business days | addBusinessDays(date, days) | addBusinessDays(today, 5) |
| Last weekday | lastWeekDay(date) | lastWeekDay(saturday) |
| First weekday | firstWeekDay(date) | firstWeekDay(sunday) |
| To ISO 8601 | toIso8601(date) | toIso8601(today) |
| Format date | formatDate(date, pattern) | formatDate(today, 'yyyy-MM-dd') |
| Cron expression | getCronExpression(datetime) | getCronExpression(scheduledTime) |
Collection Operations Quick Reference
| Operation | Method | Example |
|---|---|---|
| Partition | UTIL_List.partition(list, size) | Split into chunks |
| Sort by field | Platform-native List.sort(comparator) | Implement Comparator<SObject> (UTIL_List.sort + UTIL_Comparators are framework-internal) |
Retry Strategy Framework (UTIL_Retry)
When a callout fails because of a temporary blip, retrying it a moment later often succeeds. UTIL_Retry does that automatically: it re-attempts the work, with a growing wait between tries, and lets you control which errors are worth retrying. The full treatment (the different wait patterns, deciding which exceptions to retry, and combining retry with the circuit breaker) lives in its own guide: Resilience - Guide. For a hands-on introduction, start with Fast Start - Resilience.
Circuit Breaker Framework (UTIL_CircuitBreaker)
If an external system keeps failing, hammering it with more calls just wastes time and makes things worse. A circuit breaker stops that: after repeated failures the framework stops calling the failing system for a short cool-off, then tries again to see if it has recovered. UTIL_CircuitBreaker implements this. Its full treatment (the open/closed states and how they change, the execute() helpers, configuration, metrics, and how it remembers state between transactions) lives in its own guide: Resilience - Guide, which also shows how it pairs with retry. For a hands-on introduction, start with Fast Start - Resilience.
Platform Cache Framework (UTIL_Cache)
Architecture
To keep frequently-read data (configuration, picklist values, a computed result) in fast memory so you don't re-query or re-compute it on every request, use a cache. Salesforce provides Platform Cache for this, but using it directly means handling missing partitions, size limits, and time-to-live rules yourself. ("Time to live", or TTL, is how long an entry stays in the cache before it expires.)
UTIL_Cache wraps Platform Cache and handles those details for you: it compresses large entries automatically, falls back gracefully when the cache is unavailable, enforces valid TTLs, and returns a clear result instead of throwing.
Key Components:
UTIL_Cache.StoreInterface - Core cache contract with put, get, contains, remove, and clear methodsUTIL_Cache.OperationResult- Detailed result object with success status and operation metadataUTIL_Cache.StatusEnum - Status codes for cache operations (SUCCESS, TTL_INVALID, WRITE_FAILURE, etc.)- Automatic Compression - ZIP compression for payloads >4KB (80-90% space savings)
- Cache Type Abstraction - Session, Org, Auto (durable Session→Org dispatch), and In-Transaction (per-request in-memory tier via the
inTransaction()factory or the opt-in gracefulautoWithTransactionFallback()composite) - TTL Enforcement - Automatic validation of minimum/maximum cache lifetimes
- Graceful Fallback - Continues operation even when cache unavailable
- Partition Management - Automatic partition detection and configuration
- Bulk Operations - Efficient batch put/get for multiple keys
Core Capabilities:
- Transparent Compression - Large payloads automatically compressed/decompressed using ZIP
- Automatic Platform Cache Setup - Detects and uses available partitions
- TTL Range Enforcement - Validates 300s minimum, 28,800s/172,800s maximum
- Cache Availability Detection - Gracefully handles missing/full partitions
- User-Scoped Caching - Session cache for user-specific data
- Org-Wide Caching - Org cache for shared data across users
- Circuit Breaker Integration - Used by circuit breakers for state persistence
KernDX vs OOTB: Platform Cache Comparison
Salesforce Out-of-the-Box Alternative
Salesforce provides Platform Cache with direct API access:
- Cache.Session - User session-scoped cache
- Cache.Org - Org-wide cache
- Cache.SessionPartition / Cache.OrgPartition - Partition-based access
Pros & Cons Comparison
The short version: UTIL_Cache does the bookkeeping (compression, fallback, TTL validation, key safety) so you do not, while the raw OOTB API gives you direct, wrapper-free access and full control. The full row-by-row breakdown is below.
Full feature comparison
| Feature | KernDX UTIL_Cache | Salesforce OOTB Cache API |
|---|---|---|
| Automatic Compression | ✅ Auto-compresses payloads >4KB (ZIP), saves 80-90% cache space | ❌ No compression, full payload cached |
| Automatic Fallback | ✅ AUTO mode with Session→Org fallback | ❌ Must manually implement fallback logic |
| TTL Validation | ✅ Enforces min/max TTL ranges (300s-28800s/172800s) | ⚠️ Accepts invalid TTLs but may silently fail |
| Sub-300s TTL Support | ✅ Wrapper tracks validity for TTLs <300s | ❌ Platform minimum is 300s |
| Availability Detection | ✅ isCacheAvailable() checks before operations | ❌ Must manually check partition existence |
| Graceful Degradation | ✅ Returns false on failure, doesn't throw exceptions | ⚠️ Throws exceptions on invalid partition/full cache |
| Error Reporting | ✅ getLastOperationResult() with detailed status | ❌ No built-in error reporting |
| User Scoping | ✅ withUserScope(true) for user-specific keys | ❌ Must manually prefix keys with userId |
| Bulk Operations | ✅ putAll() and getAll() for batch operations | ❌ Must loop manually for multiple keys |
| Partition Auto-Detection | ✅ Automatically finds default partition | ❌ Must hardcode partition names |
| Key Safety | ✅ Auto-sanitises keys (hashes long keys, handles special chars) | ⚠️ Manual key sanitisation required |
| Usage Complexity | ✅ Same simple put/get API as OOTB | ✅ Simple put/get API |
| Setup | ⚠️ Requires Platform Cache partition creation | ⚠️ Requires Platform Cache partition creation |
| Performance | ⚠️ Overhead from compression + validation (CPU for cache space trade-off) | ✅ Direct cache access, no wrapper layer in between |
| Flexibility | ⚠️ Opinionated patterns (e.g., forced compression >4KB) | ✅ Full control over cache operations |
When to Use KernDX UTIL_Cache
- ✅ Need automatic fallback between Session and Org cache
- ✅ Want graceful error handling without exceptions
- ✅ Require user-scoped cache keys without manual prefixing
- ✅ Need TTL validation to prevent silent failures
- ✅ Want detailed operation result tracking
- ✅ Building resilient systems that work even when cache is unavailable
- ✅ Need bulk cache operations for multiple keys
When to Use OOTB Platform Cache
- ✅ Maximum performance is critical (no wrapper overhead)
- ✅ Simple caching needs with known partition names
- ✅ You want complete control over cache behaviour
- ✅ Building low-level cache utilities or frameworks
- ✅ You don't need fallback or error handling logic
Example Comparison
OOTB Platform Cache (Manual Error Handling):
// Must manually handle partition errors and TTL validation
try
{
Cache.OrgPartition partition = Cache.Org.getPartition('local.MyPartition');
// No TTL validation - could silently fail
partition.put('myKey', myValue, 100); // Invalid TTL < 300s!
Object value = partition.get('myKey');
}
catch(Cache.Org.OrgCacheException e)
{
// Manual error handling required
LOG_Builder.build().error('Cache error: ' + e.getMessage()).emitAt('MyClass.myMethod');
}KernDX Platform Cache (Automatic Handling):
// Automatic partition detection, sub-300s TTL support, graceful errors
UTIL_Cache.Store cache = UTIL_Cache.org();
// Sub-300s TTL supported via wrapper validity tracking
Boolean success = cache.put('myKey', myValue, 100); // Returns true, valid for 100s
if(!success)
{
// Check detailed error status (e.g., partition unavailable, entry too large)
UTIL_Cache.OperationResult result = cache.getLastOperationResult();
LOG_Builder.build().warn('Cache failed: ' + result.status).emitAt('MyClass.myMethod');
}Cache Types
The framework supports three cache types with automatic TTL validation:
Session Cache (User-Scoped)
- Scope: Current user session only
- Lifetime: 5 minutes to 8 hours (300s - 28,800s)
- Use Cases: User preferences, temporary UI state, user-specific API tokens
- Cleanup: Automatically cleared when user session ends
UTIL_Cache.Store cache = UTIL_Cache.session();
// Store user preferences
cache.put('UserTheme', 'dark', 3600); // 1 hour TTL
// Retrieve
String theme = (String)cache.get('UserTheme');Org Cache (Org-Wide)
- Scope: All users in org
- Lifetime: 5 minutes to 48 hours (300s - 172,800s)
- Use Cases: Configuration data, picklist values, metadata, circuit breaker state
- Cleanup: Expires after TTL or manually removed
UTIL_Cache.Store cache = UTIL_Cache.org();
// Store configuration (shared across all users)
cache.put('APIEndpoints', endpointMap, 7200); // 2 hour TTL
// Retrieve
Map<String, String> endpoints = (Map<String, String>)cache.get('APIEndpoints');Auto Cache (Intelligent Selection)
- Behavior: The framework picks Session or Org cache for you, based on what's available.
auto()uses only durable cache: if neither Session nor Org cache is allocated, the operation reportsStatus.CACHE_UNAVAILABLErather than quietly storing the value somewhere weaker. If you'd rather it kept working with a weaker in-memory store when Platform Cache is missing, use the opt-inautoWithTransactionFallback()factory instead. On that fallback path it reportscacheTypeUsed = Scope.IN_TRANSACTION, so your code can tell the cache was the degraded kind. - Fallback: If primary cache unavailable, tries secondary cache type
- Use Case: When you don't care about scope, you just want caching. Choose
auto()when the data must be durable (security keys, distributed counters). ChooseautoWithTransactionFallback()when you're caching to save recomputation and would rather it still work, with a weaker store, than fail when Platform Cache is missing.
UTIL_Cache.Store cache = UTIL_Cache.auto();
// Framework chooses best available cache
cache.put('TempData', data, 600); // 10 minutes TTLBasic Usage
Storing and Retrieving Data
// Get cache instance
UTIL_Cache.Store cache = UTIL_Cache.org();
// Store data
cache.put('ConfigKey', configData, 3600); // 1 hour TTL
// Retrieve data
ConfigData data = (ConfigData)cache.get('ConfigKey');
if(data == null)
{
// Cache miss - load from database
data = loadConfigFromDatabase();
cache.put('ConfigKey', data, 3600);
}Using Custom Partitions
// Use specific partition
UTIL_Cache.Store cache = UTIL_Cache.org()
.withPartition('local.CustomPartition');
cache.put('Key', 'Value', 600);Removing Data
UTIL_Cache.Store cache = UTIL_Cache.org();
// Remove single key
cache.remove('OldKey');
// Check if key exists
Boolean exists = cache.contains('MyKey');TTL Management
The framework automatically enforces platform cache TTL constraints:
TTL Ranges
| Cache Type | Minimum TTL | Maximum TTL | Platform Constraint |
|---|---|---|---|
| Session Cache | 300 seconds (5 min) | 28,800 seconds (8 hours) | Platform enforced |
| Org Cache | 300 seconds (5 min) | 172,800 seconds (48 hours) | Platform enforced |
Automatic TTL Enforcement
UTIL_Cache.Store cache = UTIL_Cache.org();
// ✅ TTL below platform minimum - Wrapper tracks actual validity at 100s,
// Platform Cache TTL clamped to 300s. Entry expires after 100s via validUntil check.
cache.put('Key1', 'Value', 100); // Returns true, valid for 100s
// ✅ TTL within range - Used as-is
cache.put('Key2', 'Value', 3600); // Stays 3600s (1 hour)
// ❌ TTL above maximum - Automatically capped at 172,800s
cache.put('Key3', 'Value', 200000); // Becomes 172,800s (48 hours)Default TTL (No Specification)
// No TTL specified - Uses default (varies by cache type)
cache.put('Key', 'Value'); // Uses platform default TTLAutomatic Compression
The framework automatically compresses large payloads to optimise cache space utilisation, which is typically more constrained than CPU resources in Salesforce.
How Compression Works
Automatic Threshold-Based Compression:
UTIL_Cache.Store cache = UTIL_Cache.org();
// Small payload (< 4KB) - NO compression
String smallData = 'Small configuration value';
cache.put('SmallKey', smallData, 3600); // Stored as-is
// Large payload (> 4KB) - AUTOMATIC compression via ZIP
List<Account> accounts = queryLargeAccountList(); // 500 records, ~100KB serialized
cache.put('Accounts', accounts, 3600); // Automatically compressed to ~10-20KBCompression Details:
| Property | Value |
|---|---|
| Compression Method | ZIP compression via Compression.ZipWriter |
| Threshold | 4,096 bytes (4 KB) - payloads larger than this are compressed |
| Typical Compression Ratio | 80-90% space savings for JSON data |
| Performance Trade-off | CPU time for compression vs. cache space savings |
| Safety Check | Only uses compressed version if it's actually smaller than original |
Compression Algorithm
Location: UTIL_Cache.cls (lines 332-356)
// Internal implementation (simplified)
private static Blob compressData(String jsonString)
{
Compression.ZipWriter zipWriter = new Compression.ZipWriter();
zipWriter.addEntry('data.json', Blob.valueOf(jsonString));
return zipWriter.getArchive();
}
private static String decompressData(Blob compressedData)
{
Compression.ZipReader zipReader = new Compression.ZipReader(compressedData);
return zipReader.extract('data.json').toString();
}When Compression Occurs
Compression Decision Tree:
Put operation
│
├─ Serialize value to JSON
│
├─ Check size
│ │
│ ├─ Size ≤ 4KB → Store as-is (no compression)
│ │
│ └─ Size > 4KB → Attempt ZIP compression
│ │
│ ├─ Compressed size < Original size?
│ │ │
│ │ ├─ Yes → Store compressed version
│ │ │
│ │ └─ No → Store original (edge case: already compressed data)
│ │
│ └─ Compression fails? → Store original (graceful fallback)Compression Examples
Example 1: Large Account List (High Compression)
UTIL_Cache.Store cache = UTIL_Cache.org();
// Query 500 accounts with multiple fields
List<Account> accounts = QRY_Builder.selectFrom(Account.SObjectType)
.fields(new List<SObjectField>
{
Account.Id, Account.Name, Account.BillingStreet, Account.BillingCity,
Account.BillingState, Account.Phone, Account.Industry, Account.AnnualRevenue
})
.addField(Account.Description)
.withLimit(500)
.toList();
// Original JSON size: ~120 KB
// Compressed size: ~12 KB (90% reduction)
cache.put('LargeAccountList', accounts, 3600);
// Retrieval is transparent - automatic decompression
List<Account> retrieved = (List<Account>)cache.get('LargeAccountList');Example 2: Small Configuration (No Compression)
// Small config map: ~2 KB serialized JSON
Map<String, String> config = new Map<String, String>
{
'ApiEndpoint' => 'https://api.example.com',
'ApiTimeout' => '30',
'RetryCount' => '3'
};
// Below 4 KB threshold - stored without compression
cache.put('ApiConfig', config, 7200);Example 3: Edge Case - Random Data (Compression Skipped)
// Generate random string (doesn't compress well)
String randomData = UTIL_Random.randomAlphabetic(10000); // 10 KB of random data
// Compression attempted but result is larger than original (ZIP overhead)
// Framework detects this and stores ORIGINAL uncompressed version
cache.put('RandomData', randomData, 600);Compression Benefits
Cache Space Optimization:
Without compression:
- 10 MB cache partition
- 500 KB per large object graph
- Capacity: ~20 cached objects
With automatic compression (90% reduction):
- 10 MB cache partition
- 50 KB per compressed object graph
- Capacity: ~200 cached objects (10x improvement!)
Real-World Example:
// Circuit breaker state: ~100 bytes uncompressed
UTIL_Cache.Store cache = UTIL_Cache.org();
cache.put('CircuitBreaker_API_SendGrid', circuitState, 360);
// No compression (below threshold)
// Picklist values for all objects: ~150 KB uncompressed
Map<String, List<String>> allPicklists = loadAllPicklistValues();
cache.put('AllPicklists', allPicklists, 7200);
// Compressed to ~15 KB (90% savings)
// Allows caching 10x more data in same partitionPerformance Considerations
Compression Overhead:
| Operation | Without Compression | With Compression (>4KB) |
|---|---|---|
| Put (small) | ~1-2 ms | ~1-2 ms (no compression) |
| Put (large) | ~3-5 ms | ~15-25 ms (ZIP compression) |
| Get (small) | ~1-2 ms | ~1-2 ms (no decompression) |
| Get (large) | ~3-5 ms | ~8-12 ms (ZIP decompression) |
| Cache Space | 100% | 10-20% (80-90% savings) |
Trade-off Analysis:
✅ When Compression is Beneficial:
- Caching large object graphs (lists of SObjects, complex DTOs)
- Cache partition space is limited
- Data is read frequently (compression cost amortized over many reads)
- Cache hit ratio is high (decompression overhead justified)
⚠️ When to Avoid Large Payloads:
- Time-critical operations requiring sub-10ms cache access
- Frequently updated data (compression cost on every write)
- Data that doesn't compress well (already compressed, random data)
Transparent to Developers
Key Point: Compression is completely automatic and transparent:
// Put operation - compression happens automatically if payload > 4KB
cache.put('LargeData', myLargeObject, 3600);
// Get operation - decompression happens automatically
Object retrieved = cache.get('LargeData');
// Developer doesn't need to know or care about compression
// Framework handles it internally based on size thresholdMonitoring Compression
While compression is transparent, you can monitor its effectiveness:
// Before caching
String serialized = JSON.serialize(myObject);
Integer originalSize = serialized.length();
// After retrieving from cache (check wrapper metadata in tests)
// In production, compression details are abstracted away
UTIL_Cache.Store cache = UTIL_Cache.org();
cache.put('MyKey', myObject, 3600);
// The DTO_CacheEntry wrapper tracks:
// - isCompressed: Boolean flag
// - originalSize: Size before compression
// - data: Blob (if compressed) or String (if not)Advanced Patterns
Pattern 1: Cache-Aside (Lazy Loading)
public with sharing class PicklistValueService
{
private static final String CACHE_KEY = 'PicklistValues_';
private static final UTIL_Cache.Store cache =
UTIL_Cache.org();
public static List<String> getPicklistValues(String objectName, String fieldName)
{
String cacheKey = CACHE_KEY + objectName + '_' + fieldName;
// Try cache first
List<String> values = (List<String>)cache.get(cacheKey);
if(values == null)
{
// Cache miss - load from schema
values = loadPicklistValuesFromSchema(objectName, fieldName);
// Store in cache for 1 hour
cache.put(cacheKey, values, 3600);
}
return values;
}
private static List<String> loadPicklistValuesFromSchema(String objectName, String fieldName)
{
// Schema describe logic here
List<String> values = new List<String>();
// ... populate from schema
return values;
}
}Pattern 2: Write-Through Caching
public with sharing class ConfigurationManager
{
private static final UTIL_Cache.Store cache =
UTIL_Cache.org();
public static void updateConfiguration(String key, String value)
{
// Write to database
Configuration__c config = new Configuration__c(
Name = key,
Value__c = value
);
DML_Builder.newTransaction().doUpsert(config, Configuration__c.Name).execute();
// Write to cache (keep in sync)
cache.put('Config_' + key, value, 7200); // 2 hours
}
public static String getConfiguration(String key)
{
// Try cache first
String value = (String)cache.get('Config_' + key);
if(value == null)
{
// Load from database
Configuration__c config = (Configuration__c)QRY_Builder.selectFrom(Configuration__c.SObjectType)
.addField(Configuration__c.Value__c)
.condition(Configuration__c.Name).equals(key)
.getFirst();
if(config != null)
{
value = config.Value__c;
cache.put('Config_' + key, value, 7200);
}
}
return value;
}
}Pattern 3: Cache Invalidation
public with sharing class ProductCatalogService
{
private static final String CACHE_PREFIX = 'Product_';
private static final UTIL_Cache.Store cache =
UTIL_Cache.org();
public static void updateProduct(Product__c product)
{
// Update database
DML_Builder.newTransaction().doUpdate(product).execute();
// Invalidate cache
cache.remove(CACHE_PREFIX + product.Id);
}
public static Product__c getProduct(Id productId)
{
String cacheKey = CACHE_PREFIX + productId;
Product__c product = (Product__c)cache.get(cacheKey);
if(product == null)
{
product = (Product__c)QRY_Builder.selectFrom(Product__c.SObjectType)
.addFields(new List<SObjectField>{Product__c.Id, Product__c.Name, Product__c.Price__c})
.condition(Product__c.Id).equals(productId)
.getFirst();
cache.put(cacheKey, product, 1800); // 30 minutes
}
return product;
}
}Pattern 4: Bulk Cache Operations
public with sharing class BulkCacheLoader
{
private static final UTIL_Cache.Store cache =
UTIL_Cache.org();
public static void loadMultipleConfigurations(Set<String> configKeys)
{
Map<String, String> configs = new Map<String, String>();
// Load from database using QRY_Builder
List<Configuration__c> configList = QRY_Builder.selectFrom(Configuration__c.SObjectType)
.addFields(new List<SObjectField>{Configuration__c.Name, Configuration__c.Value__c})
.condition(Configuration__c.Name).isIn(configKeys)
.toList();
for(Configuration__c config : configList)
{
configs.put(config.Name, config.Value__c);
}
// Bulk put to cache
for(String key : configs.keySet())
{
cache.put('Config_' + key, configs.get(key), 3600);
}
}
}Graceful Fallback Handling
The framework gracefully handles cache unavailability:
UTIL_Cache.Store cache = UTIL_Cache.org();
// Attempt to cache data
Boolean success = cache.put('Key', 'Value', 600);
if(!success)
{
// Cache write failed (partition full, unavailable, etc.)
// Application continues without caching
LOG_Builder.build().debug('Cache unavailable, continuing without cache').emitAt('MyClass.myMethod');
}
// Always safe to attempt retrieval
Object value = cache.get('Key'); // Returns null if cache unavailableBest Practices
Choose Appropriate Cache Type
| Data Scope | Cache Type | Example |
|---|---|---|
| User-specific | Session | User preferences, UI state, user tokens |
| Org-wide | Org | Picklist values, configuration, metadata |
| Don't care | Auto | Temporary calculations, non-critical data |
Set Appropriate TTL
// ❌ BAD - Too short, defeats caching purpose
cache.put('Config', data, 300); // 5 minutes - constantly reloading
// ✅ GOOD - Balances freshness and performance
cache.put('Config', data, 3600); // 1 hour - reasonable refresh
// ❌ BAD - Too long, stale data risk
cache.put('ProductPrice', price, 172800); // 48 hours - price changes missed
// ✅ GOOD - Fresh data for volatile information
cache.put('ProductPrice', price, 900); // 15 minutes - recent pricesAlways Handle Cache Misses
// BAD - Assumes cache always works
Object data = cache.get('Key');
return data; // Might return null!
// GOOD - Handles cache miss gracefully
Object data = cache.get('Key');
if(data == null)
{
data = loadFromDatabase();
cache.put('Key', data, 3600);
}
return data;Use Consistent Key Naming
// ✅ GOOD - Consistent, readable key naming
private static final String CACHE_KEY_PREFIX = 'Product_';
String cacheKey = CACHE_KEY_PREFIX + productId;
// ❌ BAD - Inconsistent, hard to debug
cache.put('prod' + id, data);
cache.put('product_' + id, data);
cache.put(id.toString(), data);Monitor Cache Size
// Be mindful of cache partition size limits
// Session cache: typically 10 MB
// Org cache: typically 10 MB (varies by license)
// ❌ BAD - Caching large query results
List<Account> allAccounts = QRY_Builder.selectFrom(Account.SObjectType)
.addFields(new List<SObjectField>{Account.Id, Account.Name})
.toList(); // 10,000 records
cache.put('AllAccounts', allAccounts, 3600); // Might exceed cache size!
// ✅ GOOD - Cache smaller, frequently accessed subsets
List<Account> activeAccounts = QRY_Builder.selectFrom(Account.SObjectType)
.addFields(new List<SObjectField>{Account.Id, Account.Name})
.condition(Account.IsActive__c).equals(true)
.withLimit(100)
.toList();
cache.put('ActiveAccounts', activeAccounts, 3600);Invalidate on Updates
public with sharing class AccountTriggerHandler
{
private static final UTIL_Cache.Store cache =
UTIL_Cache.org();
public static void afterUpdate(List<Account> accounts)
{
// Invalidate cache for updated accounts
for(Account account : accounts)
{
cache.remove('Account_' + account.Id);
}
}
}Don't Use Cache for Counters or Semaphores
UTIL_Cache is not atomic across transactions. Salesforce Platform Cache has no compare-and-swap, no atomic-increment, and no cross-transaction lock. So if two requests read a value, add one, and write it back at the same time, one of those writes is silently lost.
The wrong pattern (loses 85-94% of writes under 100-parallel contention):
// ❌ BAD - read-modify-write is non-atomic; concurrent transactions clobber each other
UTIL_Cache.Store cache = UTIL_Cache.org();
Integer current = (Integer)cache.get('counter') ?? 0;
cache.put('counter', current + 1);Empirically: 100 parallel HTTP POSTs each running the snippet above land the counter at 6-15 (not 100). All 100 calls return WRITE_SUCCESS from their own transaction's point of view, and there is no signal telling you another transaction overwrote your put.
The right pattern (a custom object plus a FOR UPDATE row lock):
// ✅ GOOD - SOQL FOR UPDATE is the only Salesforce-native atomic-increment primitive.
//
// Pre-requisites in your subscriber org (NOT shipped with KernDX):
// 1. Create a custom object: RateCounter__c
// 2. Add a Number field: Value__c (length 18, scale 0)
// 3. Seed the counter row at admin / install time (one row per counter name) so the
// .forUpdate() lock has a row to grab on the first concurrent call. Without seeding,
// two parallel "first callers" both find the empty list, both build a new
// RateCounter__c, and race to insert — the lock only protects the second-and-later
// increments.
public static Integer atomicIncrement(String counterName)
{
RateCounter__c counter = (RateCounter__c)QRY_Builder.selectFrom(RateCounter__c.SObjectType)
.fields(new List<SObjectField>{RateCounter__c.Value__c})
.condition(RateCounter__c.Name).equals(counterName)
.forUpdate()
.getFirst();
if(counter == null)
{
throw new IllegalStateException(
'RateCounter__c row "' + counterName + '" must be seeded before increment');
}
counter.Value__c = (counter.Value__c == null ? 0 : counter.Value__c) + 1;
DML_Builder.newTransaction().doUpdate(counter).execute();
return counter.Value__c.intValue();
}The forUpdate() clause holds a row-level lock until the enclosing transaction commits, so concurrent transactions queue rather than overwrite. Use this pattern for rate counters, deduplication caches, distributed semaphores, and any other case where "the value I write must be based on the current value, atomically". The seed-the-row prerequisite avoids the "first call has no row to lock" race; if you can't seed at admin time, an alternative is to upsert against a custom External ID field (so concurrent inserts are deduplicated by the platform) and then re-query with forUpdate() for the increment, at the cost of a second round-trip on the cold path.
UTIL_Cache remains the right choice for read-mostly caches: configuration data, picklist lookups, fully-formed query results, anything where a stale read is acceptable and writes are infrequent and safe to overwrite.
Testing with Platform Cache
Testing Cache Availability
@IsTest
private static void shouldHandleCacheUnavailable()
{
LOG_Builder.ignoreTestMode = true;
// Use invalid partition to simulate cache unavailability
String invalidPartition = 'local.Invalid_' + System.currentTimeMillis();
UTIL_Cache.Store cache = UTIL_Cache.org()
.withPartition(invalidPartition);
Test.startTest();
Boolean putResult = cache.put('Key', 'Value', 600);
Object getValue = cache.get('Key');
Test.stopTest();
// Framework should handle gracefully
Assert.isFalse(putResult, 'Put should fail with invalid partition');
Assert.isNull(getValue, 'Get should return null');
}Testing Cache Functionality
@IsTest
private static void shouldCacheAndRetrieveData()
{
UTIL_Cache.Store cache = UTIL_Cache.org();
Test.startTest();
Boolean putResult = cache.put('TestKey', 'TestValue', 600);
Object getValue = cache.get('TestKey');
Test.stopTest();
// If cache is available, should work
if(putResult)
{
Assert.areEqual('TestValue', getValue, 'Should retrieve cached value');
}
else
{
// Cache unavailable in test context (acceptable)
Assert.isNull(getValue, 'Should return null when cache unavailable');
}
}Type Resolution (UTIL_TypeResolver)
Architecture
Sometimes your code only knows a class by its name as a string (perhaps the name came from a configuration record) and needs to find and create that class at runtime. In a managed package that's fiddly, because namespaces and nested classes change how the name has to be written. UTIL_TypeResolver handles that lookup for you, so the framework can find the Apex classes in your namespace once you tell it where to look.
Key Components:
INT_ClassTypeResolver- Global interface you implement to add your own resolverBaseClassResolver- Abstract base class your resolver extends; it links resolvers into a chain where each one gets a turnPackageClassResolver- The default resolver, which handles managed-package classes and namespaces- Custom Metadata Configuration -
ClassTypeResolver__mdt, the configuration record you create to register your own resolver
How It Works:
Resolution runs through a chain: each resolver gets a turn to find the class, and if it can't, it passes the request to the next one (the Chain of Responsibility pattern).
- Package Resolver - First tries to find the class inside the managed-package namespace
- Custom Resolver - If that fails, hands off to a custom resolver you registered via the configuration record
- Chaining - You can link several resolvers together for more complex cases
Basic Usage
Resolving a Type:
// Resolve a class type by name using the default resolver chain
// Resolve a standard class
UTIL_TypeResolver.INT_ClassTypeResolver resolver = UTIL_TypeResolver.getClassResolver();
Type myClassType = resolver.resolveType('UTIL_TypeResolver_TEST.MyPackagePrivateClass');
if(myClassType != null)
{
Object instance = myClassType.newInstance();
// Successfully instantiated
}Implementing a Custom Resolver
If you keep Apex classes in your own namespace and want the framework to resolve them, write your own resolver by extending the base class:
/**
* @description Subscriber org resolver implementation
*
* @see UTIL_TypeResolver
*
* @author your.name@company.com
*
* @group Utilities
*
* @date March 2025
*/
global with sharing class CMN_UTIL_ClassNameResolver extends kern.UTIL_TypeResolver.BaseClassResolver
{
/**
* @description Resolves a Type object from a class name
*
* @param className The name of the class to resolve
*
* @return Type The resolved Type object or null if not found
*/
public override Type resolveType(String className)
{
return getTypeForClassName(className) ?? (Type)nextResolver?.resolveType(className);
}
/**
* @description Resolves the Type for a given class name, handling namespaces and nested classes
*
* @param className The class name to resolve (include namespace prefix if required)
*
* @return The resolved Type object, or null if not found
*/
private static Type getTypeForClassName(String className)
{
Type classType;
if(String.isNotBlank(className))
{
String namespace = kern.UTIL_System.getNamespacePrefix(
kern.UTIL_System.getClassNamespace(className),
'.'
);
classType = Type.forName(namespace, className);
// Retry without namespace for nested classes (e.g., MyParentClass.MyChildClass)
classType = classType == null && String.isNotBlank(namespace)
? Type.forName('', className)
: classType;
}
return classType;
}
}Registering Custom Resolvers
Register your custom resolver using the ClassTypeResolver__mdt custom metadata:
- Create a new
ClassTypeResolver__mdtrecord - Set
ClassName__cto the fully qualified name of your resolver class (e.g.,CMN_UTIL_ClassNameResolver) - The framework automatically loads and chains your resolver
Testing the Custom Resolver
Write a test class to verify your custom resolver handles all resolution paths: local classes, nested classes, unknown class names, and blank input. Replace ACME_ClassTypeResolver with your resolver class name.
Tip: The Health Check page in the KernDX Home tab includes a code generator that creates both the resolver class and test class for you. Click the Setup button on the Class Type Resolver row to open it.
@SuppressWarnings('PMD.ApexUnitTestClassShouldHaveRunAs')
@IsTest(SeeAllData=false IsParallel=true)
private class ACME_ClassTypeResolver_TEST
{
@IsTest
private static void shouldResolveLocalClass()
{
ACME_ClassTypeResolver resolver = new ACME_ClassTypeResolver();
Type result = resolver.resolveType(ACME_ClassTypeResolver.class.getName());
Assert.isNotNull(result, 'Should resolve a local class');
}
@IsTest
private static void shouldResolveNestedClass()
{
ACME_ClassTypeResolver resolver = new ACME_ClassTypeResolver();
Type result = resolver.resolveType(kern.UTIL_TypeResolver.BaseClassResolver.class.getName());
Assert.isNotNull(result, 'Should resolve a nested class');
}
@IsTest
private static void shouldReturnNullWhenClassNotFound()
{
ACME_ClassTypeResolver resolver = new ACME_ClassTypeResolver();
Type result = resolver.resolveType('NonExistentClassName');
Assert.isNull(result, 'Should return null when class not found and no next resolver');
}
@IsTest
private static void shouldReturnNullWhenClassNameBlank()
{
ACME_ClassTypeResolver resolver = new ACME_ClassTypeResolver();
Type result = resolver.resolveType('');
Assert.isNull(result, 'Should return null for blank class name');
}
}Random Data Generation (UTIL_Random)
Architecture
When you need throwaway data (a random name, a fake record ID, a unique identifier) for a test or a quick prototype, UTIL_Random generates it. It offers two modes: cryptographically secure randomness for things like real unique IDs, and seeded pseudo-randomness that produces the same sequence every run so your tests stay repeatable.
Key Features:
- Primitive Types - Random boolean, integer, long, double values
- String Generation - ASCII, alphabetic, alphanumeric, numeric strings
- UUID Generation - Cryptographically secure unique identifiers
- Salesforce ID Generation - Mock Salesforce record IDs for any SObject type
Basic Random Generation
Generating Random Primitives:
// Generate random primitive values for testing and development
// Generate random boolean
Boolean randomFlag = UTIL_Random.nextBoolean();
// Generate random integer (full range)
Integer randomInt = UTIL_Random.randomInteger();
// Generate random integer within specific range
Integer diceRoll = UTIL_Random.nextInteger(1, 6); // 1-6 inclusiveGenerating Random Strings:
// Generate various types of random strings
// Generate random alphabetic string (a-z, A-Z)
String randomName = UTIL_Random.randomAlphabetic(10);
// Generate random alphanumeric string (a-z, A-Z, 0-9)
String randomCode = UTIL_Random.randomAlphanumeric(15);
// Generate random numeric string
String randomNumber = UTIL_Random.randomNumeric(8);
// Generate cryptographically secure UUID
String uniqueId = UTIL_Random.randomUUID();
// Outputs: e.g., '550e8400-e29b-41d4-a716-446655440000'Salesforce ID Generation
Generating Mock Record IDs:
// Generate mock Salesforce IDs for testing
// Generate random Account ID
Id mockAccountId = UTIL_Random.randomId(Account.SObjectType);
// Outputs: e.g., '001000000000000AAA'
// Generate random Contact ID
Id mockContactId = UTIL_Random.randomId(Contact.SObjectType);
// Use in test data creation
Account testAccount = new Account(
Id = UTIL_Random.randomId(Account.SObjectType),
Name = 'Test Account ' + UTIL_Random.randomAlphanumeric(5)
);Advanced Data Indexing (MAP_SObject)
Architecture
When you have a list of records in memory and keep filtering it by the same fields (say, "give me all the Technology accounts in the USA"), scanning the whole list each time is slow. MAP_SObject builds an index over one or more fields up front, so each later lookup is fast no matter how many records you loaded. Think of it as the in-memory equivalent of a database index.
Key Features:
- Multi-Field Indexing - Index by multiple fields in a single structure
- Case-Insensitive Mode - Optional case-insensitive string comparisons
- Reference Field Traversal - Support for relationship fields (e.g.,
Account.Name) - Hierarchical Lookups - Get items by single field or multiple field combinations
- Sub-Index Retrieval - Access portions of the index for partial matches
- Dynamic Operations - Add, retrieve, and remove operations on indexed data
Internal Architecture
This section is a deep dive into how the index works internally; you can skip it if you only want to use MAP_SObject. Under the hood it's a tree: each indexed field becomes one level, and looking up a value at any one level is a single, constant-time map read (O(1) per level).
Tree Structure Design
Node Types:
Intermediate Nodes (
MAP_SObject)- Represent a single indexed field level
- Store child nodes in
Map<String, INT_SObjectIndex> children - Each key in the map represents a distinct field value
- Values point to next level in hierarchy (or leaf nodes)
Leaf Nodes (
SObjectIndexLeaf)- Terminal nodes storing actual SObjects
- Contain
List<SObject> objects - Represent complete match path through all indexed fields
Example Structure:
For index new MAP_SObject(new List<String>{'Industry', 'Country', 'Type'}):
MAP_SObject (Industry level)
├── 'Technology' → MAP_SObject (Country level)
│ ├── 'USA' → MAP_SObject (Type level)
│ │ ├── 'Customer' → SObjectIndexLeaf [Account1, Account2]
│ │ └── 'Partner' → SObjectIndexLeaf [Account3]
│ └── 'Canada' → MAP_SObject (Type level)
│ └── 'Customer' → SObjectIndexLeaf [Account4, Account5]
└── 'Finance' → MAP_SObject (Country level)
└── 'USA' → MAP_SObject (Type level)
└── 'Customer' → SObjectIndexLeaf [Account6]Traversal Algorithm:
// Lookup: Industry='Technology', Country='USA', Type='Customer'
// Step 1: children.get('Technology') → Navigate to Technology subtree
// Step 2: children.get('USA') → Navigate to USA subtree
// Step 3: children.get('Customer') → Navigate to leaf node
// Step 4: Return objects from SObjectIndexLeafPerformance Characteristics
Time Complexity:
| Operation | Complexity | Explanation |
|---|---|---|
| put(SObject) | O(d) | d = depth (number of indexed fields) |
| get(Map) | O(d) | Navigate d levels of tree |
| get(Object) | O(1) | Single field lookup in current level's map |
| getAll(Map) | O(d + n) | d = depth, n = number of matching objects |
| keySet() | O(k) | k = number of unique values for field |
| remove() | O(d + n) | Navigate tree + remove n objects |
Space Complexity:
| Component | Complexity | Explanation |
|---|---|---|
| Overall | O(n × d × k) | n objects, d fields, k avg key length |
| Single Object | O(d) | Object stored at one leaf node |
| Intermediate Nodes | O(v) | v = unique values per field |
Governor Limit Considerations:
- Heap Size: Each intermediate node adds ~200 bytes (Map overhead)
- For 10,000 records with 3 indexed fields:
- Worst case (all unique): ~6 MB heap
- Average case (50% duplication): ~3 MB heap
- Best case (high duplication): <1 MB heap
Performance Example:
// Index 10,000 accounts by 3 fields
MAP_SObject index = new MAP_SObject(new List<String>{'Industry', 'Country', 'Type'});
List<Account> accounts = QRY_Builder.selectFrom(Account.SObjectType)
.addFields(new List<SObjectField>{Account.Id, Account.Name, Account.Industry, Account.BillingCountry, Account.Type})
.withLimit(10000)
.toList();
// Put: O(3) per record = O(30,000) total operations (fast)
index.putAll(accounts);
// Get: O(3) lookup regardless of 10,000 records (constant time per lookup)
List<SObject> results = index.getAll(new Map<String, Object>
{
'Industry' => 'Technology',
'BillingCountry' => 'USA',
'Type' => 'Customer'
});
// ↑ Only 3 map lookups to find matching subsetCase-Insensitive Implementation
Key Serialization Strategy:
The utility uses pluggable serializers to control how field values are converted to map keys:
1. Case-Sensitive (Default):
private class StringValueOfSerializer implements SerializerInterface
{
public String serialize(Object objectToSerialize)
{
return String.valueOf(objectToSerialize);
}
}2. Case-Insensitive:
private class ToLowerCaseSerializer implements SerializerInterface
{
public String serialize(Object objectToSerialize)
{
return objectToSerialize == null ? null : String.valueOf(objectToSerialize).toLowerCase();
}
}How It Works:
// Case-sensitive index (default)
MAP_SObject index1 = new MAP_SObject('Name');
index1.put(new Contact(Name='John Doe')); // Key: 'John Doe'
index1.get('john doe'); // Returns null (case mismatch)
// Case-insensitive index
MAP_SObject index2 = new MAP_SObject('Name').caseInsensitive();
index2.put(new Contact(Name='John Doe')); // Key: 'john doe' (lowercased)
index2.get('JOHN DOE'); // Returns contact (lookup key lowercased to 'john doe' → match!)Memory Impact:
- Case-insensitive mode stores lowercased keys
- Original SObject field values unchanged
- Added memory cost: about 10-20%, depending on average field length
Limitation:
caseInsensitive() can only be called on an empty index. After adding records, structure is immutable to this setting.
MAP_SObject index = new MAP_SObject('Name');
index.put(new Contact(Name='Test'));
index.caseInsensitive(); // ❌ Throws exception: cannot change after data addedBasic Indexing
Single Field Index:
// Create a simple single-field index for fast lookups
// Create index on Account Industry field
MAP_SObject industryIndex = new MAP_SObject('Industry');
// Add accounts to index
List<Account> accounts = QRY_Builder.selectFrom(Account.SObjectType)
.addFields(new List<SObjectField>{Account.Id, Account.Name, Account.Industry})
.withLimit(1000)
.toList();
industryIndex.putAll(accounts);
// Fast lookup by industry
List<SObject> techAccounts = industryIndex.getAll('Technology');
// e.g., techAccounts.size() = 42Multi-Field Index:
// Create multi-field index for complex lookups
// Create index on multiple fields
List<String> indexFields = new List<String>{'Industry', 'BillingCountry', 'Type'};
MAP_SObject multiIndex = new MAP_SObject(indexFields);
// Add accounts to index
List<Account> accounts = QRY_Builder.selectFrom(Account.SObjectType)
.addFields(new List<SObjectField>{Account.Id, Account.Name, Account.Industry, Account.BillingCountry, Account.Type})
.toList();
multiIndex.putAll(accounts);
// Retrieve accounts matching all criteria
Map<String, Object> criteria = new Map<String, Object>
{
'Industry' => 'Technology',
'BillingCountry' => 'USA',
'Type' => 'Customer'
};
List<SObject> matchingAccounts = multiIndex.getAll(criteria);
// e.g., matchingAccounts.size() = 5Case-Insensitive Indexing
String Comparison Options:
// Use case-insensitive indexing for string fields
// Create case-insensitive index
MAP_SObject nameIndex = new MAP_SObject('Name')
.caseInsensitive();
// Add contacts
List<Contact> contacts = QRY_Builder.selectFrom(Contact.SObjectType)
.addFields(new List<SObjectField>{Contact.Id, Contact.Name, Contact.Email})
.toList();
nameIndex.putAll(contacts);
// Lookup works regardless of case
Contact john1 = (Contact)nameIndex.get('john doe');
Contact john2 = (Contact)nameIndex.get('John Doe');
Contact john3 = (Contact)nameIndex.get('JOHN DOE');
Assert.areEqual(john1.Id, john2.Id, 'Case-insensitive lookup should match');
Assert.areEqual(john2.Id, john3.Id, 'Case-insensitive lookup should match');Reference Field Traversal
Indexing Relationship Fields:
// Index by relationship fields for cross-object lookups
// Create index on Account.Name via Contact
MAP_SObject accountNameIndex = new MAP_SObject('Account.Name');
// Add contacts with account relationship
List<Contact> contacts = QRY_Builder.selectFrom(Contact.SObjectType)
.addFields(new List<SObjectField>{Contact.Id, Contact.FirstName, Contact.LastName})
.relatedField('Account.Name')
.condition(Contact.AccountId).isNotNull()
.toList();
accountNameIndex.putAll(contacts);
// Retrieve all contacts for specific account
List<SObject> acmeContacts = accountNameIndex.getAll('Acme Corporation');
// e.g., acmeContacts.size() = 3Advanced Operations
Using Specification Objects:
// Use SObject specifications for flexible queries
// Create index
List<String> fields = new List<String>{'Industry', 'Rating'};
MAP_SObject index = new MAP_SObject(fields);
// Add accounts
List<Account> accounts = QRY_Builder.selectFrom(Account.SObjectType)
.addFields(new List<SObjectField>{Account.Id, Account.Name, Account.Industry, Account.Rating})
.toList();
index.putAll(accounts);
// Create specification object
Account spec = new Account(
Industry = 'Technology',
Rating = 'Hot'
);
// Retrieve matching accounts
List<SObject> hotTechAccounts = index.getAll(spec);
// e.g., hotTechAccounts.size() = 7Getting Key Sets:
// Retrieve all unique values for a specific field
// Create and populate index
MAP_SObject index = new MAP_SObject(new List<String>{'Industry', 'Type'});
List<Account> accounts = QRY_Builder.selectFrom(Account.SObjectType)
.addFields(new List<SObjectField>{Account.Id, Account.Name, Account.Industry, Account.Type})
.toList();
index.putAll(accounts);
// Get all unique industries
Set<String> industries = index.keySet('Industry');
// e.g., industries.size() = 12
// Get all unique types
Set<String> types = index.keySet('Type');
// e.g., types = {Customer, Partner, Prospect}Removing Items:
// Remove items from index based on criteria
// Create and populate index
MAP_SObject index = new MAP_SObject('Industry');
List<Account> accounts = QRY_Builder.selectFrom(Account.SObjectType)
.addFields(new List<SObjectField>{Account.Id, Account.Name, Account.Industry})
.toList();
index.putAll(accounts);
// Remove all technology accounts from index
Map<String, Object> removeSpec = new Map<String, Object>{'Industry' => 'Technology'};
List<SObject> removedAccounts = index.remove(removeSpec);
// e.g., removedAccounts.size() = 8Metadata Introspection (UTIL_SObjectDescribe)
Architecture
When your code asks Salesforce about an object's structure (its fields, labels, types, and relationships), that's a "describe" call, and Salesforce limits how many you can make per transaction. Call them repeatedly and you both slow the code down and risk hitting that limit. UTIL_SObjectDescribe wraps those describe calls and caches the answers, so repeated lookups are cheap. It also smooths over the awkward parts: managed-package namespace prefixes, relationship-vs-ID field names, and field sets.
Key Features:
- Internal Caching - Prevents redundant describe calls and governor limit consumption
- Namespace Handling - Automatic namespace prefix management for managed packages
- Deferred Loading - Lazy-loads describe data only when needed
- Relationship Field Support - Handles both relationship and ID field naming (e.g.,
AccountvsAccountId) - Global Describe Caching - Cached access to all SObject types in the org
- Field Set Access - Retrieve field sets for dynamic form rendering
- Person Account Detection - Helper method to check if Person Accounts are enabled
How It Works:
The utility maintains a static cache of UTIL_SObjectDescribe instances, preventing redundant Schema describe operations. It uses deferred loading to keep memory footprint minimal until full describe data is needed.
Basic Usage
Getting Describe Information:
// Retrieve cached describe information for an SObject
// Get describe by string name
UTIL_SObjectDescribe accountDescribe = UTIL_SObjectDescribe.getDescribe('Account');
// accountDescribe.getDescribe().getLabel() = 'Account'
// accountDescribe.getDescribe().getName() = 'Account'
// Get describe by SObjectType
UTIL_SObjectDescribe contactDescribe = UTIL_SObjectDescribe.getDescribe(Contact.SObjectType);
// contactDescribe.getDescribe().isCustom() = false
// Get describe from instance
Account account = new Account();
UTIL_SObjectDescribe instanceDescribe = UTIL_SObjectDescribe.getDescribe(account);
// e.g., instanceDescribe.getDescribe().getRecordTypeInfos().size() = 3Field Operations
Retrieving Fields:
// Access field metadata with namespace handling
UTIL_SObjectDescribe accountDescribe = UTIL_SObjectDescribe.getDescribe(Account.SObjectType);
// Get specific field
SObjectField nameField = accountDescribe.getField('Name');
// nameField.getDescribe().getLabel() = 'Account Name'
// nameField.getDescribe().getType() = STRING
// !nameField.getDescribe().isNillable() = true
// Get field with relationship notation - automatically converts to ID field
SObjectField accountField = accountDescribe.getField('Parent'); // Returns ParentId field
// accountField.getDescribe().getName() = 'ParentId'Using FieldsMap:
// Use the wrapped FieldsMap for namespace-aware field access
UTIL_SObjectDescribe describe = UTIL_SObjectDescribe.getDescribe('Contact');
// Get fields map with namespace handling
UTIL_SObjectDescribe.FieldsMap fields = describe.getFields();
// Access fields by name
SObjectField emailField = fields.get('Email');
SObjectField phoneField = fields.get('Phone');
// Check if field exists
if(fields.containsKey('CustomField__c'))
{
SObjectField customField = fields.get('CustomField__c');
// e.g., customField.getDescribe().getLabel() = 'Custom Field'
}
// Get all field names
Set<String> fieldNames = fields.keySet();
// e.g., fieldNames.size() = 65
// Iterate over all fields
for(SObjectField field : fields.values())
{
DescribeFieldResult fieldDescribe = field.getDescribe();
// e.g., 'Email (STRING)', 'Phone (PHONE)', etc.
}Finding the Name Field:
// Get the Name field of an SObject
UTIL_SObjectDescribe accountDescribe = UTIL_SObjectDescribe.getDescribe(Account.SObjectType);
// Get the field where isNameField() == true
SObjectField nameField = accountDescribe.getNameField();
// nameField.getDescribe().getName() = 'Name'
// For custom objects, this returns the Auto Number or custom name field
UTIL_SObjectDescribe customDescribe = UTIL_SObjectDescribe.getDescribe('CustomObject__c');
SObjectField customNameField = customDescribe.getNameField();
// e.g., customNameField.getDescribe().getName() = 'Name' or 'CustomObject_Name__c'Field Set Operations
Accessing Field Sets:
// Retrieve field sets for dynamic forms and layouts
UTIL_SObjectDescribe accountDescribe = UTIL_SObjectDescribe.getDescribe(Account.SObjectType);
// Get all field sets for the object
Map<String, FieldSet> fieldSets = accountDescribe.getFieldSetsMap();
// e.g., fieldSets.size() = 2
// Access specific field set
if(fieldSets.containsKey('QuickView'))
{
FieldSet quickViewFS = fieldSets.get('QuickView');
List<FieldSetMember> fields = quickViewFS.getFields();
// Build query using QRY_Builder with FieldSet fields
List<Account> results = QRY_Builder.selectFrom(Account.SObjectType)
.fieldSet('QuickViewFields') // FieldSet name
.toList();
// e.g., results.size() = 10
}Global Describe Operations
Working with Global Describe:
// Access cached global describe data
// Get raw global describe map
Map<String, SObjectType> globalDescribe = UTIL_SObjectDescribe.getRawGlobalDescribe();
// e.g., globalDescribe.size() = 847
// Check if object exists
Boolean hasAccount = globalDescribe.containsKey('Account');
// hasAccount = true
// Use wrapped global describe with namespace handling
UTIL_SObjectDescribe.GlobalDescribeMap wrappedDescribe = UTIL_SObjectDescribe.getGlobalDescribe();
// Get SObjectType with automatic namespace handling
SObjectType accountType = wrappedDescribe.get('Account');
// accountType.getDescribe().getLabel() = 'Account'
// Iterate over all SObjects
for(SObjectType objType : wrappedDescribe.values())
{
if(objType.getDescribe().isCustom())
{
// e.g., objType.getDescribe().getName() = 'Foobar__c'
}
}Namespace Handling
Working with Namespaced Fields:
// Handle namespace prefixes automatically
UTIL_SObjectDescribe describe = UTIL_SObjectDescribe.getDescribe('Contact');
// Get field with namespace prefix (e.g., in managed package)
// If field is "kern__CustomField__c", you can access it as "CustomField__c"
SObjectField field1 = describe.getField('CustomField__c', true); // Implies namespace
SObjectField field2 = describe.getField('kern__CustomField__c', false); // No namespace handling
Assert.areEqual(field1, field2, 'Both should return same field');
// Check field existence with namespace handling
UTIL_SObjectDescribe.FieldsMap fields = describe.getFields();
Boolean hasField1 = fields.containsKey('CustomField__c', true); // With namespace
Boolean hasField2 = fields.containsKey('kern__CustomField__c', false); // Without namespace
// Get key sets with/without namespace
Set<String> keysWithoutNS = fields.keySet(false); // Returns full names
Set<String> keysWithNS = fields.keySet(true); // Returns stripped namesHelper Methods
Person Account Detection:
// Check if Person Accounts are enabled in the org
// Check Person Account support
Boolean isPersonAccountEnabled = UTIL_SObjectDescribe.isPersonAccountEnabled();
if(isPersonAccountEnabled)
{
// Person Accounts are enabled - use IsPersonAccount field
}
else
{
// Person Accounts not enabled
}Getting Object Name from Type:
// Retrieve API name from SObjectType
// Get API name from SObjectType
String accountName = UTIL_SObjectDescribe.getObjectNameFromType(Account.SObjectType);
// accountName = 'Account'
// Useful for dynamic operations
List<SObjectType> objectTypes = new List<SObjectType>{Account.SObjectType, Contact.SObjectType, Lead.SObjectType};
for(SObjectType objType : objectTypes)
{
String objectName = UTIL_SObjectDescribe.getObjectNameFromType(objType);
// e.g., objectName = 'Account', 'Contact', 'Lead'
}Cache Management
Flushing the Cache:
// Clear the describe cache to free heap space
// Perform intensive describe operations
for(Integer i = 0; i < 100; i++)
{
UTIL_SObjectDescribe describe = UTIL_SObjectDescribe.getDescribe('Account');
// Process...
}
// Clear cache to free memory
UTIL_SObjectDescribe.flushCache();
// Future describe calls will rebuild cache
UTIL_SObjectDescribe freshDescribe = UTIL_SObjectDescribe.getDescribe('Account');
// Cache rebuilt on demandAdvanced Patterns
Dynamic Field Introspection:
// Dynamically analyze field metadata for validation
public with sharing class FieldValidator
{
public static Map<String, String> validateRequiredFields(SObject record)
{
Map<String, String> errors = new Map<String, String>();
UTIL_SObjectDescribe describe = UTIL_SObjectDescribe.getDescribe(record);
// Get all fields
UTIL_SObjectDescribe.FieldsMap fields = describe.getFields();
// Check required fields
for(SObjectField field : fields.values())
{
DescribeFieldResult fieldDesc = field.getDescribe();
// Skip non-required and read-only fields
if(!fieldDesc.isNillable() && fieldDesc.isCreateable())
{
Object value = record.get(fieldDesc.getName());
if(value == null)
{
errors.put(fieldDesc.getName(), fieldDesc.getLabel() + ' is required');
}
}
}
return errors;
}
}
// Usage
Account account = new Account();
Map<String, String> validationErrors = FieldValidator.validateRequiredFields(account);
if(!validationErrors.isEmpty())
{
// e.g., validationErrors = {Name=Account Name is required}
}Building Dynamic SOQL:
// Build type-safe dynamic SOQL using describe metadata
public with sharing class DynamicQueryBuilder
{
public static String buildQueryForObject(String objectName, String filterField, Object filterValue)
{
UTIL_SObjectDescribe describe = UTIL_SObjectDescribe.getDescribe(objectName);
// Get all queryable fields
List<String> fieldNames = new List<String>();
for(SObjectField field : describe.getFields().values())
{
DescribeFieldResult fieldDesc = field.getDescribe();
if(fieldDesc.isAccessible() && fieldDesc.isFilterable())
{
fieldNames.add(fieldDesc.getName());
}
}
// Build query
String query = 'SELECT ' + String.join(fieldNames, ', ');
query += ' FROM ' + describe.getDescribe().getName();
// Add filter if provided
if(String.isNotBlank(filterField) && filterValue != null)
{
SObjectField field = describe.getField(filterField);
if(field != null)
{
query += ' WHERE ' + filterField + ' = :filterValue';
}
}
return query;
}
}
// Usage
String query = DynamicQueryBuilder.buildQueryForObject('Account', 'Industry', 'Technology');
// e.g., query = 'SELECT Name, Industry, ... FROM Account WHERE Industry = :filterValue'
List<SObject> results = Database.query(query);Best Practices
- Use Caching - Always use
UTIL_SObjectDescribe.getDescribe()instead of direct Schema calls to make use of caching - Namespace Handling - Use the default
implyNamespace=trueparameter for managed package compatibility - Relationship Fields - Use relationship names (e.g.,
Account) and let the utility resolve to ID fields automatically - Global Describe - Use the wrapped
GlobalDescribeMapfor namespace-aware global describe operations - Cache Management - Only flush cache when absolutely necessary (e.g., after metadata changes in tests)
- Deferred Loading - The utility uses deferred loading, so getting a describe instance is lightweight
- Field Sets - Use field sets for dynamic forms instead of hardcoding field lists
- Person Accounts - Always check
isPersonAccountEnabled()before accessing Person Account-specific fields
Feature Flag Management (UTIL_FeatureFlag)
A feature flag is an on/off switch for a piece of functionality that you control with configuration, so you can turn a feature on for some users (or everyone) without a deployment. You check one in code with UTIL_FeatureFlag.isEnabled(...). The full treatment (the built-in targeting rules, writing your own, the Lightning component bridge, per-user evaluation, and the query cost of each strategy) lives in its own guide: Feature Flags - Guide. For a hands-on introduction, start with Fast Start - Feature Flags.
Logging Framework (LOG_Builder)
Full Documentation: See Logging - Guide for the full treatment: Apex logging, Lightning component (LWC) client-side logging, Flow logging, correlation tracking, performance monitoring, and configuration.
Salesforce's built-in System.debug() writes to debug logs that expire and that you can't search or report on. LOG_Builder instead saves each log as a real LogEntry__c record (published as a platform event), so when something fails in production the evidence is still there to query later. In short, it gives you a searchable, kept record of what your code actually did (observability), the same way from Apex, Lightning components, and Flows.
Key Capabilities:
| Feature | Description |
|---|---|
| Multi-Channel | Log from Apex, LWC, and Flows with consistent API |
| Correlation Tracking | Link related logs across async boundaries |
| Performance Monitoring | Automatic timing with configurable thresholds |
| Structured Context | Attach key-value metadata to log entries |
Quick Reference
// Error logging with context
try
{
processRecord(record);
}
catch(Exception e)
{
LOG_Builder.build().error(e).at('MyClass.myMethod').forRecord(record.Id).emit();
throw e;
}
// Correlation for async operations
String context = LOG_Builder.serializeContext();
System.enqueueJob(new MyQueueable(recordId, context));
// In async job - restore context
LOG_Builder.hydrateContext(loggerContext);For detailed usage including LWC logging, Flow integration, and performance timers, see the Logging - Guide.
Omnistudio Integration (SVC_Omnistudio)
Architecture
To run your own Apex from an Omnistudio component (an OmniScript or Integration Procedure) without writing a separate bridge class for each one, use SVC_Omnistudio. It is that bridge: you point Omnistudio at it once, tell it which of your classes to run, and it passes the inputs in and your outputs back. (It does this through Salesforce's Callable interface, the standard way to invoke Apex dynamically. Omnistudio was formerly called Vlocity.)
Key Components:
SVC_Omnistudio- The bridge class that Omnistudio callsSVC_Omnistudio.OmniCallable- The interface your class implements so Omnistudio can run itSVC_Omnistudio.Parameters- A small holder (a DTO) that carries the input, output, and option maps in and out
How It Works:
- Omnistudio Configuration - Configure Callable Apex action in Omnistudio designer
- Dynamic Instantiation - Factory instantiates specified class using type resolution
- Parameter Passing - Input, output, and options maps are passed to implementation
- Execution - Implementation processes inputs and populates output map
- Error Handling - Exceptions are logged and re-thrown for Omnistudio error handling
Implementing Callable Classes
Basic Implementation:
// Implement SVC_Omnistudio.OmniCallable for use in Omnistudio
global inherited sharing class OMN_AccountEnrichment implements SVC_Omnistudio.OmniCallable
{
public void call(SVC_Omnistudio.Parameters parameters)
{
// Get input variables
String accountId = (String)parameters.getInputVariable('accountId');
Boolean includeContacts = (Boolean)parameters.getInputVariable('includeContacts');
// Process - Use QRY_Builder for queries
Account account = (Account)QRY_Builder.selectFrom(Account.SObjectType)
.fields(new List<String>{'Id', 'Name', 'Industry', 'AnnualRevenue'})
.condition(Account.Id).equals(accountId)
.getFirst();
Map<String, Object> enrichedData = enrichAccountData(account);
// Set output variables
parameters.setOutputVariable('accountName', account.Name);
parameters.setOutputVariable('industry', account.Industry);
parameters.setOutputVariable('creditRating', enrichedData.get('creditRating'));
if(includeContacts)
{
List<Contact> contacts = QRY_Builder.selectFrom(Contact.SObjectType)
.fields(new List<String>{'Id', 'Name', 'Email'})
.condition(Contact.AccountId).equals(accountId)
.toList();
parameters.setOutputVariable('contacts', contacts);
parameters.setOutputVariable('contactCount', contacts.size());
}
}
private Map<String, Object> enrichAccountData(Account account)
{
// Enrichment logic
return new Map<String, Object>
{
'creditRating' => calculateCreditRating(account),
'riskScore' => calculateRiskScore(account)
};
}
}Complex Data Processing:
// Process complex data structures from Omnistudio
global inherited sharing class OMN_OpportunityCalculator implements SVC_Omnistudio.OmniCallable
{
public void call(SVC_Omnistudio.Parameters parameters)
{
// Access input map directly for complex structures
Map<String, Object> opportunityData = (Map<String, Object>)parameters.inputMap.get('opportunityData');
List<Object> lineItems = (List<Object>)opportunityData.get('lineItems');
// Process line items
Decimal totalAmount = 0;
Decimal totalDiscount = 0;
for(Object item : lineItems)
{
Map<String, Object> lineItem = (Map<String, Object>)item;
Decimal quantity = (Decimal)lineItem.get('quantity');
Decimal unitPrice = (Decimal)lineItem.get('unitPrice');
Decimal discount = (Decimal)lineItem.get('discount');
totalAmount += (quantity * unitPrice);
totalDiscount += discount;
}
Decimal finalAmount = totalAmount - totalDiscount;
// Return calculated values
parameters.setOutputVariable('totalAmount', totalAmount);
parameters.setOutputVariable('totalDiscount', totalDiscount);
parameters.setOutputVariable('finalAmount', finalAmount);
parameters.setOutputVariable('effectiveDiscount', (totalDiscount / totalAmount) * 100);
}
}Error Handling:
// Implement proper error handling for Omnistudio integration
global inherited sharing class OMN_ExternalIntegration implements SVC_Omnistudio.OmniCallable
{
public void call(SVC_Omnistudio.Parameters parameters)
{
String customerId = (String)parameters.getInputVariable('customerId');
try
{
// Call external service
HttpResponse response = callExternalAPI(customerId);
if(response.getStatusCode() == 200)
{
Map<String, Object> responseData = (Map<String, Object>)JSON.deserializeUntyped(response.getBody());
parameters.setOutputVariable('success', true);
parameters.setOutputVariable('data', responseData);
}
else
{
parameters.setOutputVariable('success', false);
parameters.setOutputVariable('errorMessage', 'API returned status: ' + response.getStatusCode());
}
}
catch(Exception e)
{
// Log error
LOG_Builder.build().error(e).emitAt('OMN_ExternalIntegration.call');
// Return error to Omnistudio
parameters.setOutputVariable('success', false);
parameters.setOutputVariable('errorMessage', e.getMessage());
parameters.setOutputVariable('errorType', e.getTypeName());
}
}
}Omnistudio Configuration
OmniScript Configuration:
- Add "Remote Action" element to OmniScript
- Select "Apex Class" as Remote Action type
- Enter
kern__SVC_Omnistudioas Apex Class - Enter your implementation class name (e.g.,
OMN_AccountEnrichment) - Map input variables from OmniScript to Apex input parameters
- Map output variables from Apex to OmniScript variables
Integration Procedure Configuration:
- Add "Apex Action" element to Integration Procedure
- Select
kern__SVC_Omnistudioas Callable Class - Configure class name parameter with your implementation
- Map input/output as needed
Invocable Methods for Flows
Logging from Flows (FLOW_WriteLog)
Flow Logging:
Admins build automation in Flow without writing Apex, and they need the same logging the rest of the framework uses. FLOW_WriteLog exposes logging as a Flow action, so a Flow can write a log entry (with a level of DEBUG, INFO, WARN, or ERROR) just by adding an element:
// Log detailed messages with custom log levels from Flow
// In Flow Builder:
// 1. Add "Action" element
// 2. Search for "Write Detailed Log Messages"
// 3. Configure:
// - logLevel: DEBUG, INFO, WARN, or ERROR
// - message: Detailed message text
// - shortMessage: Brief summary
// - recordId: Related record ID
// - classMethod: Context identifier (e.g., 'MyFlow.ErrorHandler')
// From Apex (for testing):
FLOW_WriteLog.DTO_Request logRequest =
new FLOW_WriteLog.DTO_Request();
logRequest.logLevel = 'ERROR';
logRequest.message = 'Account validation failed: missing required Industry field';
logRequest.shortMessage = 'Validation Error';
logRequest.recordId = accountId;
logRequest.classMethod = 'AccountProcessingFlow.ValidationStage';
FLOW_WriteLog.writeLog(
new List<FLOW_WriteLog.DTO_Request>{logRequest}
);Email from Flows (FLOW_SendEmail)
Flow-Based Email Sending:
When a Flow needs to send a templated email (with custom merge values, an org-wide From address, and the option to log it as an activity), FLOW_SendEmail exposes that as a Flow action:
// Send templated emails from Flow with custom merge fields
emailRequest.templateUniqueName = 'Customer_Welcome_Email';
emailRequest.orgWideEmailAddress = 'noreply@company.com';
emailRequest.whatId = opportunityId;
emailRequest.saveAsActivity = true;
List<FLOW_SendEmail.DTO_Response> responses =
FLOW_SendEmail.sendEmail(new List<FLOW_SendEmail.DTO_Request>{emailRequest});
if(responses[0].success)
{
// Email sent successfully
}
else
{
LOG_Builder.build().error('Email failed: ' + responses[0].errors).emitAt('EmailExample.send');
}Custom Merge Fields:
// Use custom merge fields to personalize email templates
// Create custom merge fields
DTO_NameValue mergeField1 = new DTO_NameValue();
mergeField1.name = 'InvoiceNumber';
mergeField1.value = 'INV-2025-001';
DTO_NameValue mergeField2 = new DTO_NameValue();
mergeField2.name = 'PaymentDueDate';
mergeField2.value = '2025-12-31';
// Configure email with merge fields
FLOW_SendEmail.DTO_Request emailRequest = new FLOW_SendEmail.DTO_Request();
emailRequest.toAddress = contactId;
emailRequest.templateUniqueName = 'Invoice_Email';
emailRequest.orgWideEmailAddress = 'billing@company.com';
emailRequest.whatId = accountId;
emailRequest.mergeFields = new List<DTO_NameValue>{mergeField1, mergeField2};
// In email template, use: [InvoiceNumber] and [PaymentDueDate]
// These placeholders will be replaced with actual values
List<FLOW_SendEmail.DTO_Response> responses =
FLOW_SendEmail.sendEmail(new List<FLOW_SendEmail.DTO_Request>{emailRequest});Health Check
The Health Check is a post-install diagnostic built into the Kern app. It confirms that the org-level configuration each capability depends on is in place, and offers one-click fixes for the settings it can apply for you. Run it right after installing the package, and again whenever you turn on a new capability.
Opening the Kern app
If you installed the package as a System Administrator, the Kern app is available to you straight away. Open the App Launcher (the grid icon, top-left), search for Kern, and select it. The app's Home tab runs the Health Check automatically and shows the results at the top of the page.
To give another user access to the app and its tabs, assign the Kern Administrator permission set:
sf org assign permset -n kern__Administrator -o <username>The check re-runs each time the Home tab loads. Use the refresh icon in the card header to run it again without reloading the page.
Reading the results
Results are grouped by severity so the items that need attention stay at the top:
- Action required: a hard prerequisite is missing, and the capability that depends on it will fail at runtime until you fix it. The most foundational issue sorts first.
- Review recommended: the capability works, but a recommended setting is absent. Safe to defer; worth completing before go-live.
- Passing: configured and operational. Passing checks collapse into a single row of green chips.
The card header summarises the overall state. For example, Review recommended (2 warnings), or All systems operational when everything passes.
What it checks
| Check | When missing | What it verifies |
|---|---|---|
| Organisation Cache | Action required | Organisation cache is allocated to KernDX's LibraryCache partition (the package ships the partition; you allocate the capacity). Without it, automatic integration retries, repeated-error protection, and shared configuration lookups stop working. |
| Session Cache | Review recommended | Session cache is allocated to the LibraryCache partition. Kern keeps per-user data such as encryption keys here; without it that data falls back to Org Cache. |
| Trusted Site | Action required | A Trusted URL is registered for your org domain (Setup > Trusted URLs). Only relevant if you use features that call back into your org, such as streaming channels. |
| Class Type Resolver | Review recommended | A custom type resolver is registered so the framework can locate the Apex classes in your own namespace that you reference in configuration. |
| Data Retention | Review recommended | A scheduled purge job exists for each framework data object (Log Entries, API Calls, API Issues, and Async Chain Executions). Without one, these records accumulate indefinitely. |
| Data Masking | Review recommended | Every active masking target points at a live rule, and at least one object in the org carries active masking. Warns on a dead target (active, but its rule is missing or switched off) or when almost nothing is masked yet. |
| Custom Object Coverage | Review recommended | Your own custom objects that hold likely-sensitive fields already carry masking. Warns and names any object left unprotected. |
| Chain Abort Capability | Review recommended | The abort action is enabled and at least one user holds the Kern Async Chain Abort permission set. Warns when any profile beyond System Administrator holds the abort permission, usually the result of installing the package for All Users, because everyone in those profiles can silently abort chains. |
| Chain Watchdog | Review recommended | A recurring recovery job (SCHED_ChainWatchdog) is scheduled to sweep for stalled chains. Without one, a chain whose background job died is never automatically recovered. |
Built-in fixes
Three checks can repair the configuration for you, without leaving the page:
- Class Type Resolver → Setup opens a generator that writes a ready-to-deploy resolver class and its matching test class for you to copy or download. See Type Resolution for the resolver pattern and how to register it.
- Data Retention → Apply Recommended Retention schedules a nightly purge job for every uncovered object using default retention windows. Choose Customize each job → to set the retention period and schedule per object before the jobs are created.
- Chain Watchdog → Schedule Chain Watchdog creates the recurring stalled-chain recovery job for you with the recommended defaults: hourly, a 30-minute stale threshold, and up to 50 chains per sweep. Choose Customize schedule → to adjust the job before it is created.
Administration Tools
Below the Health Check, the Home tab provides quick-launch cards for the framework's operational consoles:
- API Test Harness: test inbound and outbound API calls with Safe Mode and mocking control.
- Streaming Event Monitor: monitor platform events, change data capture, and custom streaming channels in real time.
- Chain Monitor: monitor async chain executions, view step timelines, and diagnose failures in real time.
- Data Masking Advisor: scan your objects for likely-sensitive fields and build a deployable masking configuration.
- Log Console: browse log entries grouped into recurring problems and drill into a correlated run on one timeline.
Anti-Patterns
| Anti-Pattern | Why It's Wrong | Instead |
|---|---|---|
Writing custom string manipulation instead of using UTIL_String | Duplicates framework functionality and increases test burden | Use UTIL_String for abbreviation, padding, splitting, joining, and validation |
Manual date arithmetic (e.g., date.addDays(1) loops to skip weekends) | Error-prone for edge cases like holidays and month boundaries | Use UTIL_Date.addBusinessDays() and related methods |
Direct Schema.describeSObjects() calls | No caching; repeated calls hit governor limits | Use UTIL_SObjectDescribe.getDescribe() for cached, namespace-aware metadata access |
Using System.debug() for logging | The output expires and can't be searched, reported on, or tied to a single user action | Use LOG_Builder.build().info('message').emitAt('Class.method') |
| Making HTTP callouts without retry or circuit breaker | Single failures cause hard errors with no fault tolerance | Use UTIL_HttpClient with .withRetry() and .withCircuitBreaker() |
Best Practices
Type Resolution
- Register custom resolvers via custom metadata so the framework can find the classes in your own namespace
- Always check for null before instantiating resolved types
- Use chain of responsibility for multi-namespace environments
- Document supported class name formats in resolver implementations
Random Data Generation
- Use seeded random for deterministic unit tests
- Use cryptographically secure UUID for production unique identifiers
- Don't use random data generation in production business logic
- Document random data characteristics in test class comments
SObject Indexing
- Create indexes before adding large numbers of items
- Use case-insensitive mode for user-entered data
- Consider memory usage for large datasets (thousands of records)
- Document indexed fields and their purpose
- Clear indexes when no longer needed to free memory
Metadata Introspection
- Always use
UTIL_SObjectDescribe.getDescribe()instead of direct Schema calls to make use of caching - Use the default
implyNamespace=trueparameter for managed package compatibility - Use relationship names (e.g.,
Account) and let the utility resolve to ID fields automatically - Only flush cache when absolutely necessary (e.g., after metadata changes in tests)
- Use field sets for dynamic forms instead of hardcoding field lists
- Always check
isPersonAccountEnabled()before accessing Person Account-specific fields - Use the wrapped
FieldsMapandGlobalDescribeMapfor namespace-aware operations - Use deferred loading pattern - getting a describe instance is lightweight
Feature Flags
- Default new features to disabled
- Order strategies from most specific to most general
- Remove feature flags after full rollout completion
- Document feature flag purpose and rollout plan
- Test both enabled and disabled code paths
- Monitor feature flag usage to understand adoption
Logging
- Always include class.method context in log entries
- Use appropriate log levels (DEBUG, INFO, WARN, ERROR)
- Associate record IDs when logging relates to specific data
- Never log sensitive data (passwords, tokens, PII)
- Use batch logging for related messages
- Enable test mode only when debugging tests
Omnistudio Integration
- Implement error handling in callable classes
- Return success/error indicators in output variables
- Log exceptions before returning to Omnistudio
- Document input/output variable contracts
- Test callable classes outside Omnistudio first
Invocable Methods
- Use descriptive labels and descriptions for Flow discoverability
- Validate input parameters before processing
- Return clear success/failure indicators
- Provide meaningful error messages
- Test invocable methods both from Apex and Flow
Testing
Utility classes are stateless and straightforward to test. Most utility methods accept primitive inputs and return primitive outputs, making them ideal for simple assertion-based tests. The framework provides 100% test coverage for all utility classes; your tests should focus on verifying your own usage of utility methods in your business logic.
Testing string utilities:
@IsTest
private static void shouldAbbreviateString()
{
String result = UTIL_String.abbreviate('Now is the time for all good men', 20);
Assert.areEqual('Now is the time f...', result, 'Should abbreviate to 20 chars');
}Testing feature flags:
@IsTest
private static void shouldRespectFeatureFlag()
{
TST_Factory.newFeatureFlag('MyFeature');
Boolean result = UTIL_FeatureFlag.isEnabled('MyFeature');
Assert.isTrue(result, 'Feature should be enabled');
}Testing circuit breaker patterns:
For UTIL_CircuitBreaker and UTIL_Retry, test both the success path and the failure/recovery paths. Use mock callouts or deliberately throw exceptions to verify that the circuit opens after the configured failure threshold and recovers after the reset timeout.
Related Documentation
- DML - Guide - Database operations and permissions
- Async Processing - Guide - Async processing utilities
- Web Services - Guide - API integrations (includes
UTIL_HttpClientfluent HTTP client) - Selectors - Guide - SOQL query utilities
- Security - Guide - Security and encryption patterns
- Logging - Guide - Logging framework details
- Platform Cache Overview
- Platform Event Developer Guide
- Custom Metadata Types
- Callable Interface Documentation
- Invocable Methods