Part 46: Static Resources, Custom Metadata, Custom Settings, and Labels in Apex
Welcome back to the Salesforce series. In the previous posts, we have been writing Apex code that interacts directly with sObjects, runs SOQL queries, and handles DML operations. But production-quality Apex rarely lives in isolation. Real applications need configuration data, file assets, user-specific preferences, and multilingual text — and they need all of these without hardcoding values into the source code.
This post covers four platform features that every Apex developer uses regularly: Static Resources, Custom Metadata Types, Custom Settings, and Custom Labels. Each one solves a different problem, and understanding when to reach for each is a skill that separates junior developers from senior ones.
By the end of this post, you will know how to store files and test data in Static Resources, drive application behavior with Custom Metadata, manage user- and org-level preferences with Custom Settings, and support multiple languages with Custom Labels. We will finish with a hands-on project that ties all four together.
What Are Static Resources?
A Static Resource is a file (or a collection of files in a ZIP archive) that you upload to Salesforce and reference in your code. Static Resources are stored as metadata, which means they can be included in change sets, packages, and version control deployments.
Use Cases
Static Resources are used for a variety of purposes:
- JavaScript libraries — Upload third-party JS files (like D3.js or a charting library) and reference them in Visualforce pages or Lightning components.
- CSS stylesheets — Store custom stylesheets that you load into Visualforce pages.
- Images — Logos, icons, and other visual assets that your application needs.
- Test data files — CSV or JSON files that contain sample data for unit tests. This is one of the most common uses in Apex development.
- Document templates — HTML or text templates used for generating documents.
Size Limits
Salesforce enforces two limits on Static Resources:
- 5 MB per individual file — A single Static Resource cannot exceed 5 MB.
- 250 MB total per org — The combined size of all Static Resources in your org cannot exceed 250 MB.
For most use cases, these limits are more than sufficient. If you are working with very large files, consider using Salesforce Files or an external storage service instead.
Creating and Uploading a Static Resource
To create a Static Resource:
- Navigate to Setup > search for Static Resources in the Quick Find box.
- Click New.
- Give the resource a Name (this is the API name you will use in code).
- Click Choose File and select the file from your computer.
- Set the Cache Control — choose Public if the file can be cached by CDNs and proxy servers, or Private if it should only be cached for the current user’s session.
- Click Save.
You can also deploy Static Resources through the Salesforce CLI or as part of a metadata deployment. In a Salesforce DX project, Static Resources live in the force-app/main/default/staticresources/ directory.
Accessing Static Resources in Visualforce
In Visualforce pages, you use the $Resource global variable or the URLFOR function:
<!-- Simple reference to a single file -->
<apex:image url="{!$Resource.CompanyLogo}" />
<!-- Reference a file inside a ZIP archive -->
<apex:includeScript value="{!URLFOR($Resource.MyJSLibrary, 'js/main.js')}" />
<!-- Reference a CSS file inside a ZIP archive -->
<apex:stylesheet value="{!URLFOR($Resource.MyStyles, 'css/app.css')}" />
The URLFOR function takes two arguments: the Static Resource name and the path to a file within a ZIP archive.
Accessing Static Resources in LWC
In Lightning Web Components, you import Static Resources using the @salesforce/resourceUrl module:
import { LightningElement } from 'lwc';
import COMPANY_LOGO from '@salesforce/resourceUrl/CompanyLogo';
import MY_JS_LIBRARY from '@salesforce/resourceUrl/MyJSLibrary';
import { loadScript, loadStyle } from 'lightning/platformResourceLoader';
export default class MyComponent extends LightningElement {
logoUrl = COMPANY_LOGO;
renderedCallback() {
loadScript(this, MY_JS_LIBRARY + '/js/main.js')
.then(() => {
console.log('Script loaded successfully');
})
.catch(error => {
console.error('Error loading script', error);
});
}
}
Accessing Static Resources in Apex — Loading Test Data
This is arguably the most important use of Static Resources for Apex developers. Instead of creating test records manually with dozens of lines of DML code, you can store test data in a CSV file, upload it as a Static Resource, and load it with Test.loadData.
Suppose you have a CSV file called TestAccounts.csv with the following content:
Name,Industry,AnnualRevenue
Acme Corp,Technology,5000000
Global Industries,Manufacturing,12000000
Summit Partners,Finance,8000000
Upload this file as a Static Resource named TestAccounts. Then, in your test class:
@isTest
private class AccountServiceTest {
@isTest
static void testAccountProcessing() {
// Load test data from the Static Resource
List<Account> testAccounts = Test.loadData(Account.sObjectType, 'TestAccounts');
// The records are already inserted — no DML needed
System.assertEquals(3, testAccounts.size());
System.assertEquals('Acme Corp', testAccounts[0].Name);
// Now test your business logic against this data
Test.startTest();
AccountService.processAccounts(testAccounts);
Test.stopTest();
// Assert results
List<Account> updatedAccounts = [
SELECT Name, Rating FROM Account WHERE Id IN :testAccounts
];
for (Account acc : updatedAccounts) {
System.assertNotEquals(null, acc.Rating, 'Rating should be populated');
}
}
}
Key points about Test.loadData:
- The method inserts the records into the database automatically. You do not need to call
insertyourself. - The returned list contains the inserted records with their IDs populated.
- The CSV column headers must match the API names of the fields on the target object.
Test.loadDatarespects required fields and validation rules, so your CSV must include all required fields.- This approach keeps test data separate from test logic, making tests cleaner and easier to maintain.
Accessing Static Resources in Apex — Using PageReference
You can also access Static Resource content programmatically in Apex using PageReference:
public class StaticResourceReader {
public static String getResourceContent(String resourceName) {
// Build a PageReference to the Static Resource
PageReference resourceRef = PageReference.forResource(resourceName);
Blob resourceBody = resourceRef.getContent();
return resourceBody.toString();
}
public static List<Map<String, String>> parseCSV(String resourceName) {
String csvContent = getResourceContent(resourceName);
List<Map<String, String>> records = new List<Map<String, String>>();
List<String> lines = csvContent.split('\n');
if (lines.isEmpty()) return records;
// First line is headers
List<String> headers = lines[0].split(',');
for (Integer i = 1; i < lines.size(); i++) {
List<String> values = lines[i].split(',');
Map<String, String> record = new Map<String, String>();
for (Integer j = 0; j < headers.size() && j < values.size(); j++) {
record.put(headers[j].trim(), values[j].trim());
}
records.add(record);
}
return records;
}
}
What Is Custom Metadata?
Custom Metadata Types are a framework for defining and deploying application metadata. If that sounds abstract, think of it this way: Custom Metadata Types let you create configuration records that behave like metadata rather than data. This distinction has significant implications for how you use them.
How Custom Metadata Differs from Custom Objects
On the surface, Custom Metadata Types look similar to custom objects. They have fields, and you create records that hold values. But the differences are substantial:
| Feature | Custom Objects | Custom Metadata Types |
|---|---|---|
| Data vs Metadata | Records are data | Records are metadata |
| Deployable | Records are NOT included in change sets or packages (only the object definition is) | Records ARE included in change sets, packages, and metadata deployments |
| SOQL Governor Limits | Queries against custom objects count toward the SOQL query limit | Queries against Custom Metadata Types do not count toward the SOQL query limit |
| Packaging | Object definition is packageable; records are not | Both the type definition and records are packageable |
| Editable in Production | Records can be edited freely in production | Records can be edited in production (if not in a managed package with protected visibility) |
| Relationship Fields | Supports all relationship types | Supports relationships to other Custom Metadata Types and to EntityDefinition (sObjects) |
The fact that Custom Metadata records are deployable and do not consume SOQL governor limits makes them the go-to choice for application configuration.
Creating a Custom Metadata Type
To create a Custom Metadata Type:
- Navigate to Setup > search for Custom Metadata Types in the Quick Find box.
- Click New Custom Metadata Type.
- Enter a Label (e.g.,
Integration Endpoint), a Plural Label, and an Object Name (API name). The API name will have an__mdtsuffix. - Set the Visibility — choose Public if other packages should be able to see and use this type.
- Click Save.
- On the type’s detail page, add Custom Fields just as you would for a custom object.
- Click Manage Records to create individual records.
Accessing Custom Metadata in Apex
You query Custom Metadata Types using standard SOQL, but with the __mdt suffix instead of __c:
public class IntegrationConfig {
// Query all endpoint records
public static List<Integration_Endpoint__mdt> getAllEndpoints() {
return [
SELECT MasterLabel, DeveloperName, Endpoint_URL__c,
Timeout_Milliseconds__c, Is_Active__c
FROM Integration_Endpoint__mdt
WHERE Is_Active__c = true
];
}
// Query a specific endpoint by DeveloperName
public static Integration_Endpoint__mdt getEndpoint(String developerName) {
return [
SELECT MasterLabel, DeveloperName, Endpoint_URL__c,
Timeout_Milliseconds__c, Is_Active__c
FROM Integration_Endpoint__mdt
WHERE DeveloperName = :developerName
LIMIT 1
];
}
// Use the getInstance method (no SOQL query needed)
public static Integration_Endpoint__mdt getEndpointNoSOQL(String developerName) {
return Integration_Endpoint__mdt.getInstance(developerName);
}
// Get all records as a map (no SOQL query needed)
public static Map<String, Integration_Endpoint__mdt> getAllEndpointsMap() {
return Integration_Endpoint__mdt.getAll();
}
}
Important details about querying Custom Metadata:
- SOQL queries on
__mdtobjects do not count toward the 100-query governor limit. This is one of their biggest advantages. - The
getInstance()andgetAll()methods also do not consume SOQL queries. - You can use
getAll()to retrieve all records as aMap<String, CustomMetadataType__mdt>where the key is theDeveloperName. - Custom Metadata records are read-only in Apex in a standard transaction. You cannot insert, update, or delete them with DML. To create or modify records programmatically, you must use the
Metadata.DeployCallbackinterface.
Common Use Cases for Custom Metadata
Field Mappings — Map fields from one object to another. This is especially useful in integration scenarios where external system field names differ from Salesforce field names.
public class FieldMapper {
public static Map<String, String> getFieldMapping(String mappingGroup) {
List<Field_Mapping__mdt> mappings = [
SELECT Source_Field__c, Target_Field__c
FROM Field_Mapping__mdt
WHERE Mapping_Group__c = :mappingGroup
];
Map<String, String> fieldMap = new Map<String, String>();
for (Field_Mapping__mdt mapping : mappings) {
fieldMap.put(mapping.Source_Field__c, mapping.Target_Field__c);
}
return fieldMap;
}
}
Integration Endpoints — Store base URLs, authentication settings, and timeout values for external service calls.
Feature Flags — Enable or disable features without deploying code. Create a Custom Metadata Type called Feature_Flag__mdt with a Is_Enabled__c checkbox field, then check it in your Apex code.
public class FeatureManager {
public static Boolean isFeatureEnabled(String featureName) {
Feature_Flag__mdt flag = Feature_Flag__mdt.getInstance(featureName);
return flag != null && flag.Is_Enabled__c;
}
}
Process Configuration — Store parameters like batch sizes, retry counts, notification recipients, and other values that business analysts or admins might need to change without a code deployment.
What Are Custom Settings?
Custom Settings are another way to store configuration data in Salesforce, but they serve a different purpose than Custom Metadata Types. The key difference is that Custom Settings support hierarchy-based overrides — you can define a default value at the org level and then override it for specific profiles or individual users.
Types of Custom Settings
Salesforce offers two types of Custom Settings:
List Custom Settings
List Custom Settings store a set of reusable static data that is accessible across your org. Think of them as a simple lookup table. Each record in a List Custom Setting is identified by a unique name, and there is no hierarchy.
Example use case: storing a list of country codes and their corresponding currency symbols.
Hierarchy Custom Settings
Hierarchy Custom Settings use a built-in hierarchy to determine which value applies to the current user. The hierarchy has three levels:
- Organization Default — The baseline value that applies to everyone.
- Profile Override — A value that overrides the org default for users with a specific profile.
- User Override — A value that overrides both the org default and the profile value for a specific user.
When you access a Hierarchy Custom Setting in Apex, Salesforce automatically returns the most specific value available. If the current user has a user-level override, that value is returned. If not, Salesforce checks for a profile-level override. If neither exists, the org default is returned.
This hierarchy makes Custom Settings ideal for scenarios where most users should see one behavior, but certain users or roles need a different experience.
Custom Settings vs Custom Metadata
| Feature | Custom Settings | Custom Metadata Types |
|---|---|---|
| Hierarchy support | Yes (Hierarchy type) | No |
| Deployable records | No — records are data, not metadata | Yes — records deploy with the type |
| SOQL required | No — accessed via getInstance() methods with no SOQL cost | No — can use getInstance() or SOQL (which also does not count against limits) |
| Editable in Apex | Yes — you can insert, update, and delete records via DML | No — read-only in standard Apex transactions |
| Caching | Cached in the application cache; very fast reads | Cached after first access in a transaction |
| User/Profile specificity | Yes (Hierarchy type) | No |
| Visible in tests without SeeAllData | Only if created in the test setup | Yes — metadata is visible in tests by default |
| Best for | User/profile-specific preferences, feature toggles per user | App configuration, field mappings, integration settings |
The general guidance is: use Custom Metadata for application configuration that should be the same for all users, and use Custom Settings for preferences or toggles that need to vary by user or profile.
Creating a Custom Setting
To create a Custom Setting:
- Navigate to Setup > search for Custom Settings in the Quick Find box.
- Click New.
- Enter a Label and Object Name.
- Choose the Setting Type: List or Hierarchy.
- Set the Visibility — Public makes it visible to all subscriber orgs in a managed package.
- Click Save.
- Add custom fields to the setting.
- Click Manage to create records (for List type) or set org defaults and profile/user overrides (for Hierarchy type).
Accessing Custom Settings in Apex
The access pattern differs between List and Hierarchy Custom Settings.
Hierarchy Custom Settings
public class AppSettings {
// Get the value for the current running user
// Salesforce automatically resolves the hierarchy
public static App_Preferences__c getCurrentUserSettings() {
return App_Preferences__c.getInstance();
}
// Get the org-wide default values
public static App_Preferences__c getOrgDefaults() {
return App_Preferences__c.getOrgDefaults();
}
// Get values for a specific profile
public static App_Preferences__c getProfileSettings(Id profileId) {
return App_Preferences__c.getInstance(profileId);
}
// Get values for a specific user
public static App_Preferences__c getUserSettings(Id userId) {
return App_Preferences__c.getInstance(userId);
}
// Check if a feature is enabled for the current user
public static Boolean isFeatureEnabled() {
App_Preferences__c settings = App_Preferences__c.getInstance();
return settings != null && settings.Feature_Enabled__c;
}
// Update settings programmatically
public static void disableFeatureForUser(Id userId) {
App_Preferences__c userSetting = App_Preferences__c.getInstance(userId);
if (userSetting == null || userSetting.Id == null) {
// No user-level override exists, create one
userSetting = new App_Preferences__c(
SetupOwnerId = userId,
Feature_Enabled__c = false
);
insert userSetting;
} else {
userSetting.Feature_Enabled__c = false;
update userSetting;
}
}
}
List Custom Settings
public class CountrySettings {
// Get all records as a map (key is the record Name)
public static Map<String, Country_Code__c> getAllCountries() {
return Country_Code__c.getAll();
}
// Get a specific record by name
public static Country_Code__c getCountry(String countryName) {
return Country_Code__c.getInstance(countryName);
}
// Get all values as a list
public static List<Country_Code__c> getCountryList() {
return Country_Code__c.getAll().values();
}
// Check if a country exists
public static Boolean countryExists(String countryName) {
return Country_Code__c.getInstance(countryName) != null;
}
}
Caching Behavior
One of the most important aspects of Custom Settings is their caching behavior. When you call getInstance(), getOrgDefaults(), or getAll(), Salesforce retrieves the data from the platform cache, not from the database. This means:
- No SOQL query is consumed. This is critical in code that runs near the governor limit.
- Reads are very fast because the data is already in memory.
- The cache is automatically refreshed when records are modified through the UI or DML.
This caching behavior makes Custom Settings an excellent choice for values that are read frequently but changed rarely.
Common Use Cases for Custom Settings
- Feature toggles per user — Enable beta features for specific users while keeping them hidden from everyone else.
- Org-wide configuration — Store values like maximum retry counts, batch sizes, or notification thresholds.
- Profile-specific behavior — Show or hide UI elements based on the user’s profile.
- Integration credentials — Store non-sensitive integration parameters (for sensitive credentials, use Named Credentials instead).
- Test configuration — Toggle test-specific behavior without modifying code.
What Are Custom Labels?
Custom Labels are text values that you define in Salesforce and reference in Apex, Visualforce, LWC, validation rules, and flows. Their primary purpose is to support multilingual applications — you define a label once in your base language, then add translations for every language your org supports.
But Custom Labels are useful even if you never translate anything. They allow you to manage user-facing text centrally. If a business analyst decides that an error message needs rewording, you can change the Custom Label in Setup without touching any code.
Creating Custom Labels
To create a Custom Label:
- Navigate to Setup > search for Custom Labels in the Quick Find box.
- Click New Custom Label.
- Enter a Short Description (this becomes the API name, with spaces replaced by underscores).
- Enter a Name (the API-friendly name).
- Select the Language for the base translation.
- Set Is Protected if the label should be hidden from subscriber orgs in a managed package.
- Enter the Value — this is the actual text.
- Click Save.
Limits
- You can create up to 5,000 Custom Labels per org.
- Each label value can be up to 1,000 characters long.
Accessing Custom Labels in Apex
In Apex, you access Custom Labels using the System.Label class:
public class NotificationService {
public static void sendErrorNotification(String detail) {
// Access a Custom Label
String errorTitle = System.Label.Integration_Error_Title;
String errorBody = System.Label.Integration_Error_Body;
// Use String.format to insert dynamic values
String formattedMessage = String.format(
errorBody,
new List<String>{ detail, String.valueOf(Datetime.now()) }
);
// Use in an exception
throw new IntegrationException(
System.Label.Integration_Failed_Message + ': ' + detail
);
}
}
You can also use labels in dynamic ways:
public class LabelHelper {
// Build a user-facing message with multiple labels
public static String buildWelcomeMessage(String userName) {
return System.Label.Welcome_Greeting + ' ' + userName + '. ' +
System.Label.Welcome_Instructions;
}
// Use labels in validation
public static void validateAmount(Decimal amount) {
if (amount <= 0) {
throw new ValidationException(System.Label.Invalid_Amount_Error);
}
if (amount > 1000000) {
throw new ValidationException(System.Label.Amount_Exceeds_Maximum);
}
}
}
Accessing Custom Labels in LWC
In Lightning Web Components, you import Custom Labels using the @salesforce/label module:
import { LightningElement } from 'lwc';
import GREETING from '@salesforce/label/c.Welcome_Greeting';
import ERROR_TITLE from '@salesforce/label/c.Integration_Error_Title';
export default class MyComponent extends LightningElement {
labels = {
greeting: GREETING,
errorTitle: ERROR_TITLE
};
}
Then reference them in the template:
<template>
<h1>{labels.greeting}</h1>
<lightning-card title={labels.errorTitle}>
<!-- Card content -->
</lightning-card>
</template>
Accessing Custom Labels in Visualforce
In Visualforce, use the $Label global variable:
<apex:page>
<h1>{!$Label.Welcome_Greeting}</h1>
<p>{!$Label.Welcome_Instructions}</p>
</apex:page>
Using Custom Labels in Validation Rules
You can reference Custom Labels in validation rule error messages using the $Label merge field:
$Label.c.Invalid_Amount_Error
This is especially useful when your org supports multiple languages — the validation rule error message will automatically display in the user’s language.
Translation Workbench Integration
Custom Labels integrate with the Salesforce Translation Workbench:
- Enable Translation Workbench in Setup if it is not already enabled.
- Go to Setup > Custom Labels > select a label.
- In the Translations related list, click New.
- Select the Language you want to translate into.
- Enter the Translation Value.
- Click Save.
When a user whose language preference is set to (for example) French accesses your application, Salesforce automatically serves the French translation of every Custom Label. If no translation exists for a given label, Salesforce falls back to the base language value.
You can also manage translations in bulk using the Translation Workbench export/import feature, which is practical for apps that support many languages.
Comparison Table
Here is a side-by-side comparison of all four features to help you decide which one to use:
| Feature | Static Resources | Custom Metadata Types | Custom Settings | Custom Labels |
|---|---|---|---|---|
| Primary purpose | Store files (JS, CSS, images, test data) | App configuration and metadata-driven logic | User/profile-specific preferences | Multilingual text and centralized messages |
| Data type | Binary files, ZIP archives | Structured records with typed fields | Structured records with typed fields | Single text values |
| Deployable | Yes | Yes (type and records) | Type only (records are data) | Yes |
| SOQL cost | N/A (not queried via SOQL) | No cost (SOQL or getInstance) | No cost (getInstance methods) | N/A (accessed via System.Label) |
| Editable in Apex | No | No (standard DML) | Yes (insert, update, delete) | No |
| Hierarchy support | No | No | Yes (Hierarchy type) | No |
| Translation support | No | No | No | Yes |
| Visible in tests | Yes (by name) | Yes (automatically) | Only if created in test | Yes (automatically) |
| Per-user variation | No | No | Yes | Yes (via user language) |
| Admin editable | Yes (upload new file) | Yes (Manage Records) | Yes (Manage) | Yes (Setup) |
| Max count/size | 5 MB per file, 250 MB total | 10 million characters per org | Varies by type | 5,000 labels |
| Best for | Test data, JS/CSS libraries, images | Integration endpoints, feature flags, field maps | Feature toggles per user, org defaults | Error messages, UI text, multilingual apps |
Decision Guide
- Need to store a file? Use a Static Resource.
- Need app configuration that deploys across environments? Use Custom Metadata.
- Need different values for different users or profiles? Use Custom Settings (Hierarchy type).
- Need a simple lookup table? Use Custom Settings (List type).
- Need user-facing text that might be translated? Use Custom Labels.
- Need a feature flag? Use Custom Metadata if the flag should be the same for all users. Use Custom Settings if different users should see different states.
PROJECT: Create Dynamic Code That Can Change Based on Custom Metadata and a Custom Setting
Now let us bring everything together in a practical project. We will build an Integration Routing Service that:
- Reads endpoint URLs and configuration from a Custom Metadata Type.
- Checks a Custom Setting to enable or disable the integration per user.
- Uses Custom Labels for all user-facing error messages.
- Loads test data from a Static Resource in the test class.
Step 1 — Define the Custom Metadata Type
Create a Custom Metadata Type called Integration_Endpoint__mdt with these fields:
| Field Label | API Name | Type | Description |
|---|---|---|---|
| Endpoint URL | Endpoint_URL__c | URL | The base URL of the external service |
| Timeout (ms) | Timeout_Milliseconds__c | Number | Request timeout in milliseconds |
| Is Active | Is_Active__c | Checkbox | Whether this endpoint is currently active |
| HTTP Method | HTTP_Method__c | Text(10) | GET, POST, PUT, DELETE |
| Service Name | (use DeveloperName) | — | Unique identifier for this endpoint |
Create the following records:
| DeveloperName | Endpoint URL | Timeout (ms) | Is Active | HTTP Method |
|---|---|---|---|---|
| Account_Sync | https://api.example.com/accounts | 30000 | true | POST |
| Contact_Sync | https://api.example.com/contacts | 30000 | true | POST |
| Order_Export | https://api.example.com/orders | 60000 | true | PUT |
Step 2 — Define the Custom Setting
Create a Hierarchy Custom Setting called Integration_Preferences__c with these fields:
| Field Label | API Name | Type | Description |
|---|---|---|---|
| Integration Enabled | Integration_Enabled__c | Checkbox | Master toggle for integrations |
| Log Level | Log_Level__c | Text(10) | DEBUG, INFO, WARN, ERROR |
| Max Retries | Max_Retries__c | Number | Number of retry attempts on failure |
Set the org default:
- Integration Enabled:
true - Log Level:
INFO - Max Retries:
3
Step 3 — Define Custom Labels
Create the following Custom Labels:
| Name | Value |
|---|---|
| Integration_Disabled_Error | Integration is currently disabled for your account. Please contact your administrator. |
| Endpoint_Not_Found_Error | The requested integration endpoint could not be found: {0} |
| Integration_Success_Message | Record synchronized successfully with {0}. |
| Integration_Retry_Message | Attempt {0} of {1} failed. Retrying… |
| Integration_Max_Retries_Error | Integration failed after {0} attempts. Last error: {1} |
Step 4 — Build the Integration Routing Service
public class IntegrationRoutingService {
// Inner class to hold the result of an integration call
public class IntegrationResult {
public Boolean success;
public String message;
public Integer statusCode;
public String responseBody;
public IntegrationResult(Boolean success, String message) {
this.success = success;
this.message = message;
}
}
/**
* Route a record to the appropriate external endpoint.
* Configuration is driven entirely by Custom Metadata and Custom Settings.
*/
public static IntegrationResult routeRecord(String serviceName, String payload) {
// Step A: Check the Custom Setting — is integration enabled for this user?
Integration_Preferences__c prefs = Integration_Preferences__c.getInstance();
if (prefs == null || !prefs.Integration_Enabled__c) {
return new IntegrationResult(false, System.Label.Integration_Disabled_Error);
}
// Step B: Look up the endpoint from Custom Metadata
Integration_Endpoint__mdt endpoint = Integration_Endpoint__mdt.getInstance(serviceName);
if (endpoint == null || !endpoint.Is_Active__c) {
String errorMsg = String.format(
System.Label.Endpoint_Not_Found_Error,
new List<String>{ serviceName }
);
return new IntegrationResult(false, errorMsg);
}
// Step C: Read retry configuration from Custom Setting
Integer maxRetries = (prefs.Max_Retries__c != null)
? Integer.valueOf(prefs.Max_Retries__c)
: 3;
String logLevel = (prefs.Log_Level__c != null)
? prefs.Log_Level__c
: 'INFO';
// Step D: Attempt the callout with retry logic
return executeWithRetry(endpoint, payload, maxRetries, logLevel);
}
private static IntegrationResult executeWithRetry(
Integration_Endpoint__mdt endpoint,
String payload,
Integer maxRetries,
String logLevel
) {
String lastError = '';
for (Integer attempt = 1; attempt <= maxRetries; attempt++) {
try {
HttpRequest req = new HttpRequest();
req.setEndpoint(endpoint.Endpoint_URL__c);
req.setMethod(endpoint.HTTP_Method__c);
req.setTimeout(Integer.valueOf(endpoint.Timeout_Milliseconds__c));
req.setHeader('Content-Type', 'application/json');
req.setBody(payload);
Http http = new Http();
HttpResponse res = http.send(req);
if (res.getStatusCode() >= 200 && res.getStatusCode() < 300) {
String successMsg = String.format(
System.Label.Integration_Success_Message,
new List<String>{ endpoint.MasterLabel }
);
IntegrationResult result = new IntegrationResult(true, successMsg);
result.statusCode = res.getStatusCode();
result.responseBody = res.getBody();
return result;
} else {
lastError = 'HTTP ' + res.getStatusCode() + ': ' + res.getBody();
}
} catch (Exception e) {
lastError = e.getMessage();
}
// Log retry attempt
if (logLevel == 'DEBUG' || logLevel == 'INFO') {
System.debug(String.format(
System.Label.Integration_Retry_Message,
new List<String>{
String.valueOf(attempt),
String.valueOf(maxRetries)
}
));
}
}
// All retries exhausted
String failMsg = String.format(
System.Label.Integration_Max_Retries_Error,
new List<String>{ String.valueOf(maxRetries), lastError }
);
IntegrationResult result = new IntegrationResult(false, failMsg);
result.statusCode = 500;
return result;
}
/**
* Convenience method: route an Account record
*/
public static IntegrationResult syncAccount(Account acc) {
String payload = JSON.serialize(acc);
return routeRecord('Account_Sync', payload);
}
/**
* Convenience method: route a Contact record
*/
public static IntegrationResult syncContact(Contact con) {
String payload = JSON.serialize(con);
return routeRecord('Contact_Sync', payload);
}
}
Step 5 — Build the Test Class with Static Resource Test Data
First, create a CSV file called TestIntegrationAccounts.csv:
Name,Industry,AnnualRevenue,BillingCity,BillingCountry
Test Corp Alpha,Technology,5000000,San Francisco,US
Test Corp Beta,Manufacturing,12000000,Chicago,US
Test Corp Gamma,Finance,8000000,New York,US
Upload this as a Static Resource named TestIntegrationAccounts.
Now write the test class:
@isTest
private class IntegrationRoutingServiceTest {
@testSetup
static void setupTestData() {
// Create the Custom Setting org default for tests
Integration_Preferences__c orgDefaults = new Integration_Preferences__c(
SetupOwnerId = UserInfo.getOrganizationId(),
Integration_Enabled__c = true,
Log_Level__c = 'DEBUG',
Max_Retries__c = 2
);
insert orgDefaults;
}
@isTest
static void testSuccessfulAccountSync() {
// Load test accounts from the Static Resource
List<Account> testAccounts = Test.loadData(
Account.sObjectType, 'TestIntegrationAccounts'
);
System.assertEquals(3, testAccounts.size(),
'Should have loaded 3 accounts from CSV');
// Set up the HTTP mock
Test.setMock(HttpCalloutMock.class, new MockHttpSuccess());
Test.startTest();
IntegrationRoutingService.IntegrationResult result =
IntegrationRoutingService.syncAccount(testAccounts[0]);
Test.stopTest();
System.assertEquals(true, result.success,
'Integration should succeed with mock');
System.assertEquals(200, result.statusCode,
'Status code should be 200');
}
@isTest
static void testIntegrationDisabledForUser() {
// Create a user-level override that disables integration
Integration_Preferences__c userPref = new Integration_Preferences__c(
SetupOwnerId = UserInfo.getUserId(),
Integration_Enabled__c = false,
Log_Level__c = 'ERROR',
Max_Retries__c = 0
);
insert userPref;
Test.startTest();
IntegrationRoutingService.IntegrationResult result =
IntegrationRoutingService.routeRecord('Account_Sync', '{}');
Test.stopTest();
System.assertEquals(false, result.success,
'Integration should fail when disabled');
System.assert(result.message.contains('disabled'),
'Error message should mention disabled');
}
@isTest
static void testEndpointNotFound() {
Test.startTest();
IntegrationRoutingService.IntegrationResult result =
IntegrationRoutingService.routeRecord('Nonexistent_Service', '{}');
Test.stopTest();
System.assertEquals(false, result.success,
'Integration should fail for unknown endpoint');
}
@isTest
static void testRetryOnFailure() {
Test.setMock(HttpCalloutMock.class, new MockHttpFailure());
Test.startTest();
IntegrationRoutingService.IntegrationResult result =
IntegrationRoutingService.routeRecord('Account_Sync', '{}');
Test.stopTest();
System.assertEquals(false, result.success,
'Integration should fail after retries exhausted');
}
// Mock class for successful responses
private class MockHttpSuccess implements HttpCalloutMock {
public HttpResponse respond(HttpRequest req) {
HttpResponse res = new HttpResponse();
res.setStatusCode(200);
res.setBody('{"status":"success"}');
return res;
}
}
// Mock class for failed responses
private class MockHttpFailure implements HttpCalloutMock {
public HttpResponse respond(HttpRequest req) {
HttpResponse res = new HttpResponse();
res.setStatusCode(500);
res.setBody('{"error":"Internal Server Error"}');
return res;
}
}
}
What This Project Demonstrates
This project shows how all four features work together in a real-world scenario:
- Custom Metadata (
Integration_Endpoint__mdt) drives which endpoints exist, their URLs, HTTP methods, and timeouts. An admin can add a new integration endpoint by creating a new Custom Metadata record — no code changes needed. - Custom Settings (
Integration_Preferences__c) control whether integrations are enabled and how they behave. Because it is a Hierarchy Custom Setting, an admin can disable integrations for a specific user (perhaps during testing) without affecting anyone else. - Custom Labels provide all user-facing messages. If the org needs to support French, Spanish, or any other language, the translations are added to the labels, and the code does not change at all.
- Static Resources provide clean, maintainable test data. Instead of 20 lines of Account creation code in the test class, a single call to
Test.loadDataloads everything from a CSV file.
The result is code that is highly configurable, maintainable, and testable — exactly what production Apex should look like.
Key Takeaways
- Static Resources are your go-to for storing files and test data. Use
Test.loadDatato keep test classes clean. - Custom Metadata Types are for application configuration that needs to be deployable and consistent across environments. They do not consume SOQL governor limits.
- Custom Settings are for preferences that vary by user or profile. Their caching behavior makes them extremely efficient for frequently-read values.
- Custom Labels are for any text that a user might see. Even if you do not plan to translate your app today, using labels from the start makes future internationalization painless.
- Never hardcode configuration values, endpoint URLs, error messages, or feature flags directly into Apex. Use these four platform features instead.
What Is Next?
In Part 47, we will tackle Building a Mapping Application in Apex. We will apply everything we have learned so far — SOQL, DML, custom metadata, collections, and HTTP callouts — to build a practical application that maps data between Salesforce objects and external systems. It is one of the most common patterns in enterprise Salesforce development, and we will build it step by step. See you there.