SfSkills Domains Docs GitHub

Home / apex / custom-metadata-in-apex

custom-metadata-in-apex

Use when Apex must read, interpret, or deploy Custom Metadata Type configuration, or when deciding between Custom Metadata Types, Custom Settings, and Custom Labels.

Open the skill folder on GitHub


Custom Metadata In Apex

Use this skill when configuration belongs in metadata and Apex is either the consumer or the initiator of a metadata deployment path. The main goal is to keep behavior configurable without designing Custom Metadata Types as if they were ordinary row data.

references/code-examples.md carries the deployable version of everything below: the __mdt type and its fields, two records, a selector that reads them, a test that swaps the configuration with no DML, and a deployer plus Metadata.DeployCallback with a test that never enqueues a real deployment.


Before Starting

Gather this context before working on anything in this domain:


Questions to Ask Before Configuring

Ask these before writing the type or the class. Each maps to a gotcha in references/gotchas.md; skipping them produces code that compiles, deploys, and quietly returns the wrong configuration.

AskWhy it mattersWhat a good answer adds
"What is the longest string any of these fields will ever hold?"getAll() and getInstance() return "only the first 255 characters… so longer text fields get truncated" (Apex Reference Guide L204569–204572) — silently, with no exceptionWhich reads may use the cache accessors and which must be SOQL, decided per field rather than per class
"Does anything ever need to change a record at runtime, or only read one?"The write path is Metadata.Operations.enqueueDeployment(), which is asynchronous, counts as an async Apex job, and cannot delete (Apex Developer Guide L28194–28197, L28226–28229)Whether this build needs a deployer class, a callback class, and a partial-success branch at all — or none of them
"Will this type ship inside a managed package?"visibility on the type (Public / Protected / PackageProtected) and protected on each record only mean anything inside a package; unpackaged, "both developer-controlled and subscriber-controlled access behave the same" (Metadata API Developer Guide L41363–41377, L41321–41322)Both switches set deliberately, and a decision recorded about whether the data is safe for every profile — including guest — to read
"Who is allowed to change each field after release — the publisher on upgrade, or the customer?"fieldManageability is per field and one-way: a SubscriberControlled field "can't be updated with a package upgrade" (Metadata API Developer Guide L43406–43413)A fieldManageability value in every .field-meta.xml instead of the default, and no field the publisher can never correct
"What should happen when the record simply is not there?"All three getInstance overloads return null when nothing matches, and getAll() returns an empty map (Apex Reference Guide L204569, L204588, L204618–204620)The named fallback record, the safe default value, and whether a missing record is logged or thrown
"Which of these tests are asserting the deployment, and which are asserting the logic?"Metadata objects stay visible to tests without SeeAllData=true, unlike custom settings data (Apex Developer Guide L40707–40709, L13515–13516) — so a test can pass on org state nobody declaredOne named test that asserts the org contract, and every other test injecting rows through a @TestVisible seam
"Will this type ever carry a namespace?"Metadata.CustomMetadata.fullName drops the __mdt suffix, and in a namespace both halves must be qualified — myPackage__MDType1__mdt.myPackage__Component1 (Apex Developer Guide L28199–28203, Apex Reference Guide L170852–170855)A fullName built from a constant plus a namespace prefix, instead of one that works unpackaged and fails after packaging

What a proper configuration adds over just doing it: the reads that could truncate are SOQL and the rest use the cache, a missing record degrades to a named fallback instead of an NPE, tests state which rows they depend on instead of borrowing the org's, the deployment path branches on SucceededPartial rather than reporting a half-applied change as done, and each field's post-release ownership is written into its metadata rather than discovered at the first upgrade.


Core Concepts

Custom Metadata Is App Configuration

Custom Metadata Types are for durable application configuration. In Apex, that usually means querying __mdt records or using generated accessors such as getInstance() and getAll() where that makes the intent clearer. Treat those records as part of the app contract instead of as business data.

Three Read Paths, Chosen On Purpose

ReadUse whenCost
<Type>__mdt.getAll()you want the whole small table as a Map<String, sObject> keyed by DeveloperNamereads the application cache; every field truncated at 255 characters
<Type>__mdt.getInstance(name)you want one record by DeveloperName, record Id, or qualified API namesame cache, same truncation; returns null on no match
[SELECT … FROM <Type>__mdt WHERE … ORDER BY …]you need filtering, ordering, or a field longer than 255 charactersfull field values; no SOQL-query-limit cost at all (Apex Developer Guide L19616–19619)

"SOQL costs a governor limit" is the wrong reason to prefer the accessors here — it does not. Truncation is the real difference.

Read Paths And Write Paths Differ

Reading custom metadata in Apex is straightforward. Updating records is different. Do not design business logic around ordinary DML on __mdt. Metadata deployment is the real write boundary, and packaging visibility or subscriber-control rules determine what can actually be changed. The write path is a container of Metadata.CustomMetadata components handed to Metadata.Operations.enqueueDeployment() with a Metadata.DeployCallback, and it can create and update but never delete.

Tests See Metadata Differently Than Data

Apex tests can see custom metadata records without SeeAllData=true. That is useful, but it also means tests can quietly rely on org metadata unless the dependency is explicit and deliberate. The way out is not SeeAllData=true; it is a @TestVisible static the test assigns in-memory __mdt sObjects to, with no DML anywhere.

CMT, Settings, And Labels Solve Different Problems

Use Custom Metadata Types for deployable, versioned configuration. Use Custom Labels for translatable user-facing text. Use Custom Settings only when the org still depends on older hierarchy or runtime semantics that are a better fit than CMT.


Common Patterns

Configuration Reader Wrapper

When to use: Business logic needs stable access to config and should not scatter raw __mdt queries everywhere.

How it works: Create a small service that loads the relevant record, applies defaults, and exposes intent-level methods such as isFeatureEnabled() or getEndpointKey(). One @TestVisible private static Map holds the loaded rows, and that map is the only injection seam. Worked end to end in references/code-examples.md § 3–4; templates/apex/TriggerControl.cls is the shipped example of the same shape.

Why not the alternative: Direct __mdt queries in many classes duplicate field knowledge and test assumptions.

Strategy Table In Metadata

When to use: Routing, thresholds, or feature rules differ by region, channel, product, or environment.

How it works: Store rule dimensions and outputs in custom metadata, then have Apex resolve the best matching record and execute behavior from that result. When the row names an Apex class to instantiate rather than a value to use, that is the factory variant — apex/apex-design-patterns owns it, including the Type.forName resolution and the dangling-class-name fallback.

Metadata Deployment Boundary

When to use: Admin tooling or packaged setup must create or update metadata records.

How it works: Keep deployment-oriented logic separate from transaction-level business services. One class builds and validates the Metadata.DeployContainer; one class implements Metadata.DeployCallback and owns the outcome; nothing else in the codebase enqueues. Runtime services stay read-oriented and never pretend that insert and update on __mdt are ordinary persistence.


Decision Guidance

SituationRecommended ApproachReason
Versioned config that should move through metadata deploymentCustom Metadata TypeBest fit for deployable app configuration
User-facing translatable text onlyCustom LabelsBetter than forcing text into metadata rows
Legacy per-user or hierarchy semanticsEvaluate hierarchy/custom settings carefullyCMT is not always a drop-in replacement
Runtime transaction wants to create config as row dataRedesign around metadata deployment or normal objects__mdt is not standard business-data storage
A configuration value can exceed 255 charactersRead it with SOQL, or split it across rowsgetAll() / getInstance() truncate silently
Config rows must be created and deleted routinelyCustom object or List Custom SettingThe Apex write path cannot delete custom metadata at all
Config must hold an API key or tokenNamed Credential or encrypted fieldOutside a managed package, protected records are readable by every profile including guest

  1. Classify the requirement as read-only or read-write. Answer the first two rows of Questions to Ask. A read-only requirement needs the type, the records, a selector, and a test class — and none of RetryPolicyDeployer / RetryPolicyDeployCallback from references/code-examples.md §§ 5–7. Adding the write path when nobody asked for it buys an async job, a callback, and a partial-success branch for nothing.
  2. Design the type against the field-length and manageability questions. Shape objects/<Type>__mdt/ and its fields/*.field-meta.xml on references/code-examples.md § 1 and the shipped templates/apex/cmdt/Trigger_Setting__mdt/. Every field gets an explicit fieldManageability; the lookup field gets unique + externalId; no field is allowed to grow past 255 characters unless its read path is already SOQL.
  3. Ship at least one record, including the fallback. Write the .md-meta.xml files as in § 2, with <value xsi:nil="true"/> where a field must be cleared rather than left alone. A type with no rows is not configurable, it is broken.
  4. Write the selector with exactly one @TestVisible seam. Follow § 3: getAll() memoised into a static Map, a named fallback record, and a separate uncached getInstance() path for anything that must read the org's real value.
  5. Write the tests before deploying. § 4 for the read side — inject in-memory rows, never insert, never SeeAllData=true, and name the one test that deliberately asserts the org contract. § 7 for the write side — assert DeployContainer.getMetadata(), call the callback directly with a hand-built Metadata.DeployResult, and cover SucceededPartial as well as Succeeded.
  6. Run the checker over the source tree, then deploy in order. python3 skills/apex/custom-metadata-in-apex/scripts/check_custom_metadata_in_apex.py --manifest-dir force-app/main/default, then type + fields, then records, then Apex — the commands are in § 10.
  7. Verify with the SOQL in § 10, not with the deploy result. Confirm the fallback record exists and is active, the unique lookup field has no duplicates, and every value is in range. After a runtime deployment, remember the write is asynchronous: an unchanged value means the job has not finished, not that it failed.

Review Checklist

Run through these before marking work in this area complete:


Salesforce-Specific Gotchas

Non-obvious platform behaviors that cause real production problems. Full versions, with the guide line ranges, in references/gotchas.md.

  1. getAll() and getInstance() truncate every field at 255 characters - silently, so the failure surfaces as a bad comparison somewhere else.
  2. SOQL against __mdt has no query-limit cost - which means "save a query" is not a reason to prefer the cache accessors.
  3. Tests can see custom metadata without SeeAllData=true - helpful, but easy to misuse if the dependency is never named.
  4. Write paths are metadata deployments, not normal DML - code that treats __mdt like __c data eventually breaks, and the deploy path cannot delete at all.
  5. SucceededPartial is a real status - a callback that tests only for Succeeded reports a half-applied change as a failure, or worse, as a success.
  6. An omitted field is not a cleared field - on an update, leaving a <values> block out preserves the old value.
  7. Protected and subscriber-controlled behavior matters in packages - a design that works unpackaged can fail once namespace and visibility rules apply, and unpackaged protected protects nothing.
  8. fieldManageability is chosen once - a SubscriberControlled field can never again be fixed by a package upgrade.
  9. Custom Labels are not structured configuration - once rules need keys, thresholds, or multiple rows, labels are the wrong storage model.

Output Artifacts

ArtifactDescription
Configuration decisionRecommendation for CMT vs labels vs settings vs ordinary data
Apex config reviewFindings on read boundaries, test assumptions, and deployment risks
Metadata pattern scaffoldReader service or metadata-deploy boundary example
Deployable slice__mdt type + fields + records + selector + test, per references/code-examples.md
Checker reportJSON findings from scripts/check_custom_metadata_in_apex.py

Reference Files

FileRead it when
references/code-examples.mdYou are building it: the __mdt type, its fields, its records, the selector, the DML-free test, the Metadata.Operations deployer, the DeployCallback, their tests, package.xml, deploy order, and the verification query
references/gotchas.mdYou are reviewing, or something already behaves oddly — 12 grounded platform behaviours from 255-character truncation to SucceededPartial and namespace-qualified full names
references/llm-anti-patterns.mdYou are checking generated Apex, or you are the agent that just generated it — six mistakes with detection hints, including two claims LLMs repeat that the guides do not support
references/examples.mdYou want the short worked scenarios: a feature-flag reader, a routing strategy table, and the .md-meta.xml rows behind it
references/well-architected.mdYou are justifying the design, or you need the source behind a specific claim — the pillar framing, the trade-offs, and the nine sources with the claim each supports

Package files