SfSkills Domains Docs GitHub

Home / apex / apex-hardcoded-id-elimination

apex-hardcoded-id-elimination

Remove hardcoded Salesforce record IDs (Profile, RecordType, User, Queue, custom) from Apex and replace with describe-API, name-based SOQL, or Custom Metadata-driven lookups. NOT for record type IDs in Flow, formulas or LWC — use admin/record-type-id-management. NOT for storing the config values themselves — use apex/custom-metadata-in-apex.

Open the skill folder on GitHub


Apex Hardcoded ID Elimination

Activate when Apex contains literal Salesforce record IDs ('00e1x000000ABcD', '012xx0000004C9I', '00G3x000003abcD'). Hardcoded IDs are catastrophic in any multi-org topology: the same Profile, RecordType, Queue, or User has a different ID in sandbox vs production, in every scratch org, and after some sandbox refreshes. Code that runs in prod silently breaks the moment it deploys to a sandbox copy, a partial copy refresh, or a scratch org spun up for CI.

The fix is to look IDs up by something stable — DeveloperName, MasterLabel via describe, or a Custom Metadata mapping — and to cache the result for the rest of the transaction.


Before Starting

Questions to Ask Before Configuring

Ask these before replacing a literal Id. Skipping them ships a describe lookup against the wrong stable key or a cache that never warms in scratch orgs.

AskWhy it mattersWhat a good answer adds
"What kind of Id is this — RecordType, Profile, Queue, Group, UserRole, or a business record?"Each kind has a different canonical lookup (describe vs name-based SOQL vs Custom Metadata)The right mechanism from the table in Core Concepts, not a one-size-fits-all SOQL
"Which orgs must this code run in — prod only, named sandboxes, scratch orgs, CI?"Ids differ across every topology; scratch orgs have no pre-existing rowsWhether Custom Metadata or describe is mandatory instead of a one-time SOQL
"What is the stable API name — DeveloperName, Name, or a CMDT key?"Labels rename; DeveloperName and CMDT keys survive refreshThe predicate for getRecordTypeInfosByDeveloperName() or the cache key for Profile/Queue lookups
"Is this Id referenced from production code, tests, or both?"Tests must insert and capture .Id; production literals are P0 defectsSeparate fixtures: seeded data in tests, describe/CMDT in production
"Does the lookup run per call or once per transaction?"Uncached [SELECT Id FROM Profile WHERE Name = …] is a P1 findingA static Map<String, Id> helper sized before the first hot path

What a proper Id elimination adds over swapping literals: code survives sandbox refresh and scratch-org CI, caches avoid SOQL-101 on hot paths, and subscribers can retarget configurable Ids through Custom Metadata without a redeploy.


Core Concepts

The four canonical lookup mechanisms

ID kindMechanismWhy
RecordTypeSchema.SObjectType.X.getRecordTypeInfosByDeveloperName()Describe is metadata-driven, no SOQL cost, DeveloperName is stable
Profile / Group / Queue / UserRole[SELECT Id FROM Profile WHERE Name = :name] cached per-transactionDeveloperName / Name is stable; cache avoids SOQL-101
Configurable metadata references (another CMDT record, object, field)Custom Metadata Type with a Metadata Relationship fieldReferential integrity, no raw ID string, getInstance() reads cache with no SOQL
Configurable business-record IDs (default Account, fallback User)Custom Metadata Type with a Text__c field holding the IdSubscriber-org safe, deployable, no code change to retarget
Test data IDsinsert then capture record.IdTest data is created per run; never persistent

Schema describe for RecordType

Schema.SObjectType.Account.getRecordTypeInfosByDeveloperName() returns Map<String, Schema.RecordTypeInfo>. Always use DeveloperName, never MasterLabel — labels are translatable.

Id customerRtId = Schema.SObjectType.Account
    .getRecordTypeInfosByDeveloperName()
    .get('Customer')
    .getRecordTypeId();

This is metadata-driven, costs no SOQL, and works across every org.

Caching SOQL-derived IDs

Profile/Group/Queue lookups are SOQL. A single naive lookup inside a loop blows the SOQL-101 limit. Cache once per transaction:

private static Map<String, Id> profileIdByName;
public static Id getProfileId(String name) {
    if (profileIdByName == null) {
        profileIdByName = new Map<String, Id>();
        for (Profile p : [SELECT Id, Name FROM Profile]) {
            profileIdByName.put(p.Name, p.Id);
        }
    }
    return profileIdByName.get(name);
}

The static map lives for the transaction. Subsequent calls are free.

Custom Metadata for configurable IDs

When the "right" ID is environment-specific (a default-owner User, a routing Queue, a fallback Account), put the mapping in a Custom Metadata Type. The records deploy through the Metadata API as declarative XML components inside packages and change sets — they travel as metadata artifacts, not database data rows — so the value differs per org and changes without a code release.

Prefer a Metadata Relationship field over a raw Text__c ID. When the thing you're referencing is itself metadata — another custom metadata record, an object (EntityDefinition), a field (FieldDefinition), or an entity particle — Salesforce provides a purpose-built Metadata Relationship field type. It stores a stable reference and enforces referential integrity instead of parking a raw 15/18-char ID in a text field, which is exactly the failure mode this skill exists to remove. Custom metadata types have no Lookup relationship field type, and Master-detail is not offered either; Metadata Relationship is the only supported relationship field. Reserve a plain Text__c field (holding the record Id) for referencing data records (a specific Account or User) that metadata relationships can't target — a Text field is the only way to store a data-record Id on a custom metadata type.

getInstance() / getAll() are SOQL-free. These static methods read from the application cache, not the database, so they cost no SOQL query and no query-row governor limits — a material reason to prefer CMDT over the cached-SOQL Map pattern used above for Profile/Group/Queue. getAll() returns a Map<String, T__mdt> keyed by DeveloperName; getInstance(...) returns one record by DeveloperName, record Id, or qualified API name.

Watch the 255-character truncation. getInstance()/getAll() return only the first 255 characters of every field. If a config value (a long endpoint, a serialized payload) exceeds 255 chars, the cached read silently truncates it — fall back to a full SOQL query against the __mdt type to get the complete value.

Seeding and visibility. Apex can create, read, and update custom metadata records but cannot delete them, and DML on custom metadata is not allowed through the Partner or Enterprise APIs — plan CMDT seeding scripts accordingly. Type visibility (Public, Protected, PackageProtected) controls which namespaces can see the type and its records: use Protected/PackageProtected when a managed package must hide environment-specific config such as API keys from subscriber-org Apex.

Test class discipline

Tests must never hardcode any record ID. Always insert seed data and capture the resulting ID. Test.startTest() / Test.stopTest() and TestDataFactory patterns belong here — see templates/apex/tests/TestDataFactory.cls.

Id vs String — the 15/18-char trap

Salesforce IDs are 15 chars (case-sensitive) or 18 chars (case-insensitive). Stored as String, the same record yields two different values that fail equality. Always use the Id data type. Id normalizes to 18-char internally; equality works.

// WRONG
String accId = '0011x00000ABCDe';      // 15-char
if (accId == acc.Id) { ... }            // acc.Id is 18-char — never matches

// RIGHT
Id accId = '0011x00000ABCDe';           // auto-normalized to 18
if (accId == acc.Id) { ... }            // works

Integration boundary

When a third-party system requires a Salesforce ID (webhook target, named credential payload), that ID must come from a name-based lookup or Custom Metadata at runtime, never a literal in code. A literal pinned to one org silently sends the wrong ID after a refresh.


Common Patterns

Pattern: RecordType resolution helper

public with sharing class RecordTypes {
    private static final Map<String, Map<String, Id>> CACHE = new Map<String, Map<String, Id>>();

    public static Id idFor(SObjectType sot, String developerName) {
        String key = String.valueOf(sot);
        if (!CACHE.containsKey(key)) {
            Map<String, Id> byName = new Map<String, Id>();
            for (Schema.RecordTypeInfo rti :
                    sot.getDescribe().getRecordTypeInfosByDeveloperName().values()) {
                byName.put(rti.getDeveloperName(), rti.getRecordTypeId());
            }
            CACHE.put(key, byName);
        }
        return CACHE.get(key).get(developerName);
    }
}

Pattern: Group / Queue lookup by DeveloperName

public static Id queueIdByDevName(String devName) {
    return [SELECT Id FROM Group WHERE DeveloperName = :devName AND Type = 'Queue' LIMIT 1].Id;
}

Wrap in a static map cache as above.

Pattern: Custom Metadata-driven config

RoutingConfig__mdt cfg = RoutingConfig__mdt.getInstance('CaseRouter');
Id defaultQueueId = cfg.DefaultQueue__c;  // populated per-org

Decision Guidance

ID is...Use
RecordTypeSchema.SObjectType.X.getRecordTypeInfosByDeveloperName()
Profile / Permission SetSOQL by Name / DeveloperName, cached
Group / Queue / UserRoleSOQL by DeveloperName, cached
Reference to another CMDT record, object, or fieldCustom Metadata Type with a Metadata Relationship field
Environment-specific config (default user, fallback record)Custom Metadata Type (Text__c holding the Id for data records)
Test seed recordInsert in test, capture .Id
External-system referenceCustom Metadata or Named Credential, never literal

  1. Grep the codebase for the Salesforce ID regex [a-zA-Z0-9]{15}(?:[a-zA-Z0-9]{3})? enclosed in quotes — every hit is a candidate.
  2. Classify each hit: RecordType, Profile/Group/Queue, configurable, or test-only.
  3. Replace RecordType literals with Schema...getRecordTypeInfosByDeveloperName() calls.
  4. Replace Profile/Group/Queue literals with cached name-based SOQL helpers.
  5. Move configurable IDs to a Custom Metadata Type and read via getInstance().
  6. Refactor tests to insert seed records and capture IDs at runtime.
  7. Run scripts/check_apex_hardcoded_id_elimination.py against the project to confirm zero residual literals outside test setup.

Review Checklist


Salesforce-Specific Gotchas

  1. 15 vs 18 char comparison fails as String. Stored as String, the same record can have two different representations. Always use the Id type.
  2. Name='System Administrator' is not portable. Some orgs renamed it; Custom Metadata or DeveloperName is safer for any cross-org code.
  3. getRecordTypeInfosByName uses translatable label. Always prefer getRecordTypeInfosByDeveloperName.
  4. getInstance()/getAll() truncate fields to 255 chars. They read the application cache, not the row. Any config value longer than 255 characters comes back truncated — query the __mdt type with SOQL to get the full value.
  5. Apex can't delete custom metadata records. Create/read/update only; DML on CMDT is also blocked through the Partner and Enterprise APIs. Don't design a seeding routine that expects to delete CMDT rows.

Output Artifacts

ArtifactDescription
Refactored Apex classAll literal IDs replaced with describe / SOQL / CMDT lookups
Cached lookup helperRecordTypes, Profiles, Queues helpers with static maps
Custom Metadata TypeConfig-driven mapping for environment-specific IDs
Refactored test classInserts seed data; captures IDs at runtime

Package files