Custom Metadata Types: The Swiss Army Knife You're Underusing
The first time custom metadata types properly clicked for me, I was staring at an Apex class that was 300-odd lines of nothing but constants. Fee percentages. Routing thresholds. A nested block of if-statements that mapped a product code to a business unit. Every time the business changed a number — and in a lending shop they change numbers constantly — someone raised a ticket, a developer edited a constant, and we shipped it through three sandboxes to turn a 4.5 into a 4.75.
That class is the reason I stopped treating custom metadata as an afterthought. Most admins know it exists. Most of them build one lookup table with it and move on. It's the most underused feature on the platform, and it quietly solves a whole category of problem that people keep solving badly — with hardcoded values, with custom settings, or worst of all, with real records.
Custom Settings Are Data. Metadata Is Metadata.
This is the distinction that everything else hangs off, so I want to be blunt about it. Custom settings are data. They live in your org the way an Account record does. They do not travel with your deployments. If you build your config in a hierarchy custom setting, you have to script the values across every environment separately, and the day someone forgets, production behaves differently from UAT and nobody knows why for a week.
Custom metadata is metadata. It deploys with your source. It sits in version control alongside the Apex that reads it. When you promote a change set or run a pipeline, the config goes with it. That single property is why I moved trigger bypass switches onto custom metadata years ago — the same reasoning I laid out in the trigger framework I bring to every engagement, where the bypass records ship with the code that respects them.
The corollary matters just as much for anyone running a proper pipeline. Because the records are metadata, they are part of the deployable artefact. You get the same review, the same diff, the same rollback story you get for a class. If you have invested in a deployment process that treats source as the source of truth, custom metadata is the only configuration mechanism that respects it end to end.
Governor Limits Don't Apply Here
Here is the property almost nobody uses on purpose. Reading custom metadata does not cost you a SOQL query.
The getAll(), getInstance(), and getInstanceByDeveloperName() methods on the type don't issue SOQL at all — they read from a cache. And SOQL queries against custom metadata types don't count towards the 100-query governor limit either. So a lookup table you hit inside a loop, on every record in a trigger, in the middle of a bulk operation, is effectively free.
The simplest form is the map:
public with sharing class DiscountEngine {
public static Decimal getDiscount(String segment, Decimal amount) {
for (Discount_Tier__mdt tier : Discount_Tier__mdt.getAll().values()) {
if (tier.Segment__c == segment
&& amount >= tier.Min_Amount__c
&& amount <= tier.Max_Amount__c) {
return tier.Discount_Percent__c;
}
}
return 0;
}
}
getAll() returns a Map<String, Discount_Tier__mdt> keyed by developer name, fully cached, no query consumed. There is one trap worth knowing before you lean on this: getAll() and getInstance() do not return long text area or rich text fields. If your config has a field like that, those values come back null and you have to fall back to SOQL to fetch them. I have watched a developer lose the better part of an afternoon to that exact behaviour, convinced the field wasn't saving.
If you do need to filter server-side rather than pull everything, SOQL is fine and still doesn't touch your limits:
SELECT DeveloperName, Discount_Percent__c, Min_Amount__c, Max_Amount__c
FROM Discount_Tier__mdt
WHERE Segment__c = :segment
I still reach for getAll() first, because a query you never run is faster than one you optimise. This is one of the few places on the platform where the free option is also the clean one — the same instinct behind the SOQL patterns I use to stay under governor limits.
Driving Business Logic From a Table
Back to that 300-line class of constants. The org was an APRA-regulated lender, and the thing eating our delivery time wasn't the logic — the logic barely changed. It was the numbers. Rate bands, tier cutoffs, the discount a given customer segment qualified for. The business owned those numbers and revised them every six weeks or so, and each revision was a code change dressed up as a config change.
We moved the whole lot into a Discount_Tier__mdt type. Twenty-three records, one per band, each with a segment, a min and max amount, and a percentage. The Apex shrank to the DiscountEngine above. The 300-line class became a table you could read at a glance, and a BA named Hannah who could not read Apex could absolutely read a list of rate bands and tell me which one was wrong.
I won't pretend it was a clean win. The compliance posture on that org meant nobody — not even Hannah — was allowed to edit metadata directly in production, so a rate change still went through a deployment. What we actually bought was reviewability, not self-service. The business could now see the config in plain language and sign off on it before it shipped, instead of trusting that a developer had typed 4.75 and not 4.57. Six weeks became a smaller, safer change rather than no change. That was the real deliverable, and I'd take it again.
The pattern generalises well beyond pricing. Field mapping tables for integrations, routing rules, feature thresholds, environment-specific endpoints — anything where the shape of the logic is stable but the values move. If you find yourself writing a switch statement over string literals, that is usually a custom metadata type wearing an Apex costume.
On Relationships Between Records
The feature that pushed custom metadata from "handy" to "genuinely a modelling tool" is metadata relationship fields. A custom metadata type can hold a field that points at another custom metadata record, or at an entity definition or field definition.
That means your flat lookup table can become an actual little model. On one integration build I had a Sync_Mapping__mdt type where each record described how a field on a source object mapped to a field on a target object. Instead of storing field names as free text — brittle, and silent when someone renames a field — the mapping referenced the field definitions directly. Rename the field, and the reference either updates or the deployment fails loudly. Both outcomes beat a null at three in the morning.
You can also relate config records to one another to express hierarchy: a parent ruleset with child rules, a default that a specific override points back to. It is not a full relational database and you shouldn't try to make it one, but for describing configuration that has structure, it is far better than parsing conventions out of a developer-name string.
When Custom Metadata Isn't the Answer
The clean answer is "put your config in metadata." In practice there are hard edges, and pretending otherwise gets people into trouble.
Custom metadata records are read-only in Apex. You cannot insert or update them with normal DML in a transaction. There is an asynchronous deployment API — Metadata.Operations.enqueueDeployment — but it is clunky, it runs out of band, and if you are reaching for it to write config at runtime, you have almost certainly picked the wrong tool. If the data changes as a result of user action during normal operation, it is data. Put it in an object or a custom setting and move on.
The other trap is the mirror image of custom metadata's biggest strength. You can edit metadata records directly in production Setup. That is convenient right up until someone does it, at which point production no longer matches your repository and your next deployment silently reverts their change. Decide deliberately who is allowed to edit config in prod, and if the answer is "nobody, it goes through the pipeline," lock it down with permissions the same way you would any other sensitive capability.
And custom metadata is not a secrets store. Named credentials and protected custom metadata exist for a reason. Don't put an API key in a plain custom metadata field and assume it is safe because it looks like config.
What I'd Do Differently
On that lender, I'd have modelled the relationships from the start instead of storing segment names as strings and matching on them. We ended up with a couple of records whose segment value had a trailing space that no one could see, and the discount engine quietly returned zero for those customers until finance noticed the numbers didn't add up. A relationship field would have made that impossible. We caught it, fixed it, and added a test — but the org wore a fortnight of slightly wrong discounts first, and I still think about that one.
If you want a second pair of eyes on whether a chunk of your config belongs in metadata, an object, or a custom setting, that sort of call is the kind of review I do. Most of the time the answer takes half an hour and saves a lot of deployments.
Hardcoded config makes every value change a code change. Custom metadata makes it a line in a table you can read, review, and ship like anything else. One of those you debug at three in the morning. The other you sign off before lunch.
Liked this?
New essays in your inbox when I publish. No cadence, no spam.
You might also like
Record-Triggered Flows vs Apex Triggers: The Decision Framework
Record-triggered flows or Apex? I've built both in production for years. Here's the exact framework I use to decide, and where each one quietly falls apart.
Read →Domain Layer Patterns in Apex That Survive Real Projects
The domain layer patterns I actually use in production Apex — how fflib domain classes plug into triggers, where validation belongs, and when it's overkill.
Read →When to Use Platform Events vs Change Data Capture
Platform Events and Change Data Capture look similar but solve different problems. Here's how I decide between them on real Salesforce integration work.
Read →