Salesforce · · 27 min read

Static Resources, Custom Metadata, Custom Settings, and Labels in Apex

Using Salesforce platform features in Apex — static resources for files, custom metadata types for app configuration, custom settings for org/user preferences, and custom labels for multilingual support.

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:

  1. Navigate to Setup > search for Static Resources in the Quick Find box.
  2. Click New.
  3. Give the resource a Name (this is the API name you will use in code).
  4. Click Choose File and select the file from your computer.
  5. 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.
  6. 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 insert yourself.
  • 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.loadData respects 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:

FeatureCustom ObjectsCustom Metadata Types
Data vs MetadataRecords are dataRecords are metadata
DeployableRecords 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 LimitsQueries against custom objects count toward the SOQL query limitQueries against Custom Metadata Types do not count toward the SOQL query limit
PackagingObject definition is packageable; records are notBoth the type definition and records are packageable
Editable in ProductionRecords can be edited freely in productionRecords can be edited in production (if not in a managed package with protected visibility)
Relationship FieldsSupports all relationship typesSupports 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:

  1. Navigate to Setup > search for Custom Metadata Types in the Quick Find box.
  2. Click New Custom Metadata Type.
  3. Enter a Label (e.g., Integration Endpoint), a Plural Label, and an Object Name (API name). The API name will have an __mdt suffix.
  4. Set the Visibility — choose Public if other packages should be able to see and use this type.
  5. Click Save.
  6. On the type’s detail page, add Custom Fields just as you would for a custom object.
  7. 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 __mdt objects do not count toward the 100-query governor limit. This is one of their biggest advantages.
  • The getInstance() and getAll() methods also do not consume SOQL queries.
  • You can use getAll() to retrieve all records as a Map<String, CustomMetadataType__mdt> where the key is the DeveloperName.
  • 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.DeployCallback interface.

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:

  1. Organization Default — The baseline value that applies to everyone.
  2. Profile Override — A value that overrides the org default for users with a specific profile.
  3. 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

FeatureCustom SettingsCustom Metadata Types
Hierarchy supportYes (Hierarchy type)No
Deployable recordsNo — records are data, not metadataYes — records deploy with the type
SOQL requiredNo — accessed via getInstance() methods with no SOQL costNo — can use getInstance() or SOQL (which also does not count against limits)
Editable in ApexYes — you can insert, update, and delete records via DMLNo — read-only in standard Apex transactions
CachingCached in the application cache; very fast readsCached after first access in a transaction
User/Profile specificityYes (Hierarchy type)No
Visible in tests without SeeAllDataOnly if created in the test setupYes — metadata is visible in tests by default
Best forUser/profile-specific preferences, feature toggles per userApp 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:

  1. Navigate to Setup > search for Custom Settings in the Quick Find box.
  2. Click New.
  3. Enter a Label and Object Name.
  4. Choose the Setting Type: List or Hierarchy.
  5. Set the VisibilityPublic makes it visible to all subscriber orgs in a managed package.
  6. Click Save.
  7. Add custom fields to the setting.
  8. 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:

  1. Navigate to Setup > search for Custom Labels in the Quick Find box.
  2. Click New Custom Label.
  3. Enter a Short Description (this becomes the API name, with spaces replaced by underscores).
  4. Enter a Name (the API-friendly name).
  5. Select the Language for the base translation.
  6. Set Is Protected if the label should be hidden from subscriber orgs in a managed package.
  7. Enter the Value — this is the actual text.
  8. 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:

  1. Enable Translation Workbench in Setup if it is not already enabled.
  2. Go to Setup > Custom Labels > select a label.
  3. In the Translations related list, click New.
  4. Select the Language you want to translate into.
  5. Enter the Translation Value.
  6. 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:

FeatureStatic ResourcesCustom Metadata TypesCustom SettingsCustom Labels
Primary purposeStore files (JS, CSS, images, test data)App configuration and metadata-driven logicUser/profile-specific preferencesMultilingual text and centralized messages
Data typeBinary files, ZIP archivesStructured records with typed fieldsStructured records with typed fieldsSingle text values
DeployableYesYes (type and records)Type only (records are data)Yes
SOQL costN/A (not queried via SOQL)No cost (SOQL or getInstance)No cost (getInstance methods)N/A (accessed via System.Label)
Editable in ApexNoNo (standard DML)Yes (insert, update, delete)No
Hierarchy supportNoNoYes (Hierarchy type)No
Translation supportNoNoNoYes
Visible in testsYes (by name)Yes (automatically)Only if created in testYes (automatically)
Per-user variationNoNoYesYes (via user language)
Admin editableYes (upload new file)Yes (Manage Records)Yes (Manage)Yes (Setup)
Max count/size5 MB per file, 250 MB total10 million characters per orgVaries by type5,000 labels
Best forTest data, JS/CSS libraries, imagesIntegration endpoints, feature flags, field mapsFeature toggles per user, org defaultsError 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:

  1. Reads endpoint URLs and configuration from a Custom Metadata Type.
  2. Checks a Custom Setting to enable or disable the integration per user.
  3. Uses Custom Labels for all user-facing error messages.
  4. 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 LabelAPI NameTypeDescription
Endpoint URLEndpoint_URL__cURLThe base URL of the external service
Timeout (ms)Timeout_Milliseconds__cNumberRequest timeout in milliseconds
Is ActiveIs_Active__cCheckboxWhether this endpoint is currently active
HTTP MethodHTTP_Method__cText(10)GET, POST, PUT, DELETE
Service Name(use DeveloperName)Unique identifier for this endpoint

Create the following records:

DeveloperNameEndpoint URLTimeout (ms)Is ActiveHTTP Method
Account_Synchttps://api.example.com/accounts30000truePOST
Contact_Synchttps://api.example.com/contacts30000truePOST
Order_Exporthttps://api.example.com/orders60000truePUT

Step 2 — Define the Custom Setting

Create a Hierarchy Custom Setting called Integration_Preferences__c with these fields:

Field LabelAPI NameTypeDescription
Integration EnabledIntegration_Enabled__cCheckboxMaster toggle for integrations
Log LevelLog_Level__cText(10)DEBUG, INFO, WARN, ERROR
Max RetriesMax_Retries__cNumberNumber 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:

NameValue
Integration_Disabled_ErrorIntegration is currently disabled for your account. Please contact your administrator.
Endpoint_Not_Found_ErrorThe requested integration endpoint could not be found: {0}
Integration_Success_MessageRecord synchronized successfully with {0}.
Integration_Retry_MessageAttempt {0} of {1} failed. Retrying…
Integration_Max_Retries_ErrorIntegration 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.loadData loads 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

  1. Static Resources are your go-to for storing files and test data. Use Test.loadData to keep test classes clean.
  2. Custom Metadata Types are for application configuration that needs to be deployable and consistent across environments. They do not consume SOQL governor limits.
  3. Custom Settings are for preferences that vary by user or profile. Their caching behavior makes them extremely efficient for frequently-read values.
  4. 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.
  5. 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.