Finance lowers the maximum refund from 80% to 70%. That number lives in a validation rule, a flow decision, an Apex constant and the instructions of the service agent that now answers refund questions. Someone has to find all four, and the agent is the one people forget. Keep the value in one Custom Metadata Type that every layer reads, and the policy change becomes a record edit in Setup with no deployment.
Andrew Fawcett worked through this pattern on Andy in the Cloud with a Deal_Policy__mdt type for opportunity discounts. I've applied his design to a refund policy in a service org. Below is where each layer can read the values, where it can't, and how to check what depends on a field before you touch it.
Design one type per policy
Fawcett's type has three records (Default, EMEA, AMER) and four fields: Large Deal Amount, Max Discount %, Default Discount %, Approval Threshold %. The choice I'd copy first is that max discount holds the same value in every record, because it is an org-wide ceiling, while only the approval threshold changes by region. Layers that can only read a named record take the ceiling from Default, and regional values go to the layers that can choose a record at runtime.
My version is Refund_Policy__mdt with records Default, UK and US:
| Field | Type | Default | UK | US |
|---|---|---|---|---|
Auto_Approve_Limit__c |
Number(16,2) | 100 | 150 | 200 |
Max_Refund_Percent__c |
Percent | 80 | 80 | 80 |
Default_Refund_Percent__c |
Percent | 50 | 50 | 50 |
Escalation_Days__c |
Number(3,0) | 5 | 3 | 5 |
The consuming object is Case, with Refund_Region__c (a picklist whose values match the record DeveloperNames), Refund_Amount__c, Requested_Refund_Percent__c and Needs_Escalation__c.
I'd keep one type per policy and avoid a single "settings" type with forty unrelated fields. A small type is easier to permission and review, and impact analysis on it returns a short list. Like Fawcett, I'd put the question of who may exceed the policy in a Custom Permission (Override_Refund_Policy) and never in a metadata field. Custom Metadata holds the values automation runs on; Custom Permissions say which users may bypass them. If you are still choosing between Custom Metadata and Custom Settings for this kind of data, this comparison covers the trade-offs.
Declarative layers and before-save Flow
Formula fields, default values and validation rules all accept $CustomMetadata directly, and each has to name a specific record.
A formula field Is_Auto_Approvable__c (Checkbox) uses Refund_Amount__c <= $CustomMetadata.Refund_Policy__mdt.Default.Auto_Approve_Limit__c. It can't switch to the UK record based on Refund_Region__c, because a formula can't choose a record at runtime, so treat this field as a coarse org-wide signal. The regional decision happens in Flow and Apex.
The default value of Requested_Refund_Percent__c is $CustomMetadata.Refund_Policy__mdt.Default.Default_Refund_Percent__c. The insert-field picker in the default value editor doesn't list $CustomMetadata, but the field accepts the reference if you type it by hand.
A validation rule Refund_Within_Ceiling uses Requested_Refund_Percent__c > $CustomMetadata.Refund_Policy__mdt.Default.Max_Refund_Percent__c and blocks the save with a message stating the ceiling. Because the ceiling is identical in every record, the rule never needs to know the region.
For regional values, a before-save record-triggered flow on Case (Set_Refund_Policy) does the lookup:
- Get Records on
Refund_Policy__mdtwhereDeveloperNameequals{!$Record.Refund_Region__c}, first record only. - A formula resource
varAutoApproveLimitreturns the regional value, or the Default record's value if the lookup came back empty:IF(ISBLANK({!Get_Regional_Policy.Id}), {!$CustomMetadata.Refund_Policy__mdt.Default.Auto_Approve_Limit__c}, {!Get_Regional_Policy.Auto_Approve_Limit__c}). - A second formula resource,
varNeedsEscalation, evaluates{!$Record.Refund_Amount__c} > {!varAutoApproveLimit} && NOT({!$Permission.Override_Refund_Policy}), and an Assignment writes it toNeeds_Escalation__c.
With the $CustomMetadata fallback in place, a new region picklist value that has no matching record still gets the Default limit instead of a blank one. The Flow and Custom Metadata guide covers Get Records on metadata types in more detail.
Apex and the Agentforce bridge
One class for every Apex read
In Apex, call getInstance with the DeveloperName and fall back to Default when it returns null. I'd put this in exactly one class. Trigger handlers, batch jobs and the agent action all call it, and none of them query the type themselves.
public with sharing class RefundPolicy {
public static final String DEFAULT_RECORD = 'Default';
public static Refund_Policy__mdt forRegion(String region) {
Refund_Policy__mdt policy = String.isBlank(region)
? null
: Refund_Policy__mdt.getInstance(region);
return policy != null
? policy
: Refund_Policy__mdt.getInstance(DEFAULT_RECORD);
}
}
Giving the agent the same numbers
Agentforce agents can't read $CustomMetadata references at all. There is an IdeaExchange idea asking for it; until it ships, the agent needs an action. Make that action a thin wrapper over the same class so the agent and the trigger can never disagree, and make it bulk-safe (List in, List out) even though an agent usually sends one request.
public with sharing class GetRefundPolicyForAgent {
public class Request {
@InvocableVariable(label='Region' description='Customer region code, for example UK or US. Blank uses Default.')
public String region;
}
public class Result {
@InvocableVariable(label='Applied Policy Record' description='DeveloperName of the policy record used')
public String appliedRegion;
@InvocableVariable(label='Auto Approve Limit')
public Decimal autoApproveLimit;
@InvocableVariable(label='Max Refund Percent')
public Decimal maxRefundPercent;
@InvocableVariable(label='Default Refund Percent')
public Decimal defaultRefundPercent;
@InvocableVariable(label='Escalation Days')
public Integer escalationDays;
}
@InvocableMethod(label='Get Refund Policy' description='Returns current refund policy values for a region, falling back to the Default record.')
public static List<Result> getPolicy(List<Request> requests) {
List<Result> results = new List<Result>();
for (Request req : requests) {
Refund_Policy__mdt policy = RefundPolicy.forRegion(req.region);
Result res = new Result();
res.appliedRegion = policy.DeveloperName;
res.autoApproveLimit = policy.Auto_Approve_Limit__c;
res.maxRefundPercent = policy.Max_Refund_Percent__c;
res.defaultRefundPercent = policy.Default_Refund_Percent__c;
res.escalationDays = policy.Escalation_Days__c == null
? null
: policy.Escalation_Days__c.intValue();
results.add(res);
}
return results;
}
}
Fawcett's agent sends policy questions to a dedicated subagent that calls his action before every answer and binds the outputs to Agent Script variables. I'd build mine the same way: a Refund_Desk agent hands refund questions to a refund_rules subagent whose instructions say to call Get Refund Policy first and quote only the bound values. That costs one extra action call per turn. I accept the cost, because the alternative is an agent answering from a number someone pasted into its instructions months ago, which is the drift this whole pattern exists to remove. Returning appliedRegion also lets the agent say when it fell back to Default. For more on structuring the action classes, see architecting Agentforce custom actions.
Where $CustomMetadata stops working
| Layer | Reads $CustomMetadata |
Picks record at runtime | Workaround |
|---|---|---|---|
| Formula field | Yes | No, named record only | Flow or Apex for regional values |
| Field default value | Yes (type it, picker omits it) | No | None needed for org-wide values |
| Validation rule | Yes | No | Keep ceilings in Default |
| Before-save Flow | Yes, as fallback | Yes, Get Records on DeveloperName | Not needed |
| Apex | Uses getInstance |
Yes | Not needed |
| Agentforce agent | No | Via action | @InvocableMethod bridge |
| Lightning App Builder visibility | No | No | Formula-field bridge on record pages; Apex/@wire or Dynamic Interactions on App and Home pages |
SOQL FORMULA() (Winter '27 beta) |
No | No | Bind variable from Apex |
Long text area fields on a metadata type can't be used in $CustomMetadata references at all, and they are expensive to keep: each one counts as 255 characters toward the org's 10 million character Custom Metadata allotment, however short its content. Anything a formula or flow needs to reference belongs in Text, Number, Percent or Checkbox fields.
For component visibility, Is_Auto_Approvable__c already works as a bridge. On the Case record page, an "Approve Refund" component can filter on that checkbox and so reads the metadata indirectly.
The Winter '27 FORMULA() function in SOQL WHERE clauses is beta, needs API 68 or later, and works only in sandbox and scratch orgs. It compares fields on the same object and can't be used in SELECT:
SELECT Id, CaseNumber FROM Case
WHERE FORMULA('Refund_Amount__c - Order_Value__c') > 0
Put $CustomMetadata.Refund_Policy__mdt.Default.Auto_Approve_Limit__c inside the formula string and the query fails with Invalid formula: no such field. Resolve the value in Apex first:
Decimal limitValue = RefundPolicy.forRegion('UK').Auto_Approve_Limit__c;
List<Case> overLimit = [
SELECT Id, CaseNumber, Refund_Amount__c
FROM Case
WHERE Refund_Region__c = 'UK' AND Refund_Amount__c > :limitValue
];
What to watch for
- An org can have up to 200 Custom Metadata Types (350 counting certified managed packages) and 100 fields per type. One type per policy is affordable; one type per value is not.
- Process Builder still caps
$CustomMetadatareferences at 15 per process. If old automation reads the policy, that is one more reason to finish moving it to Flow. - Apex has no per-transaction limit on Custom Metadata SOQL queries, but bulk reads still count toward the 50,000 rows returned per transaction.
- Formulas, default values and validation rules read only the record they name. If someone later makes the ceiling vary by region, those three layers will keep enforcing Default without raising any error. I have seen this happen when a regional exception went in as a record edit and nobody realised the Default value was now enforced in some places and ignored in others.
Find every consumer before you change a field
Renaming or deleting a metadata field is where this pattern can hurt you. Setup's "Where is this used?" on the field is the quick check. From the CLI, get the field's Id through the Tooling API (DeveloperName has no __c suffix), then query its dependents:
sf data query -t --json -q "SELECT Id FROM CustomField WHERE EntityDefinition.QualifiedApiName = 'Refund_Policy__mdt' AND DeveloperName = 'Max_Refund_Percent'"
sf data query -t --json -q "SELECT MetadataComponentName, MetadataComponentType FROM MetadataComponentDependency WHERE RefMetadataComponentId = '<field Id from the first query>'"
When Fawcett ran this for his Max Discount Percent field, he got back the agent action class (LoadDealPolicyForAgent, ApexClass) and the validation rule (Discount_Within_Policy, ValidationRule). For my type I'd expect GetRefundPolicyForAgent and Refund_Within_Ceiling. Both queries go in the release checklist for any change to a policy type. Before deleting a field, I'd also search the flow and agent metadata in source control, because removing a field the agent action reads changes what the agent tells customers.
Leave a Comment