Automation¶
TODO(David)
Write this page. Stub created for structure only.
TODO
Type-specific standards for Flows, approval processes, and legacy automation (Workflow Rules, Process Builder); retirement paths
Standards for automation: the framework that governs all automation in the org, followed by type-specific standards for Flows, approval processes, and the retirement of legacy automation.
Automation Framework¶
TODO
The org's automation architecture: tool selection (Flow vs Apex), orchestration patterns, one-automation-per-object discipline, bypass strategy, naming
Source (Step-Based Async Processing Framework; see standards-sources memory)
Scheduled Flow as the scheduler standard (packageable as metadata, unlike Apex-scheduled jobs; Wait element for callouts); environment gating so refreshed sandboxes never run production jobs
Seeds (see standards-sources memory, collected rule sets)
One consistent bypass structure in the framework (the bypass-everything permission is written, under Global Bypass); Flow is NOT the only automation tool; record deletion blocked by default on framework-managed objects, enabling it requires architecture review (or objects-fields.md)
The architecture standards that govern all automation in the org: which tool to use when, orchestration patterns, and the discipline that keeps automation reviewable.
- The Trigger Actions Framework is the standard trigger framework for every object.
- Under the Trigger Actions Framework, every trigger body is exactly one line,
new MetadataTriggerHandler().run();, with no logic and no context checks. Appendix F.2 ends with a sample PMD rule that enforces it. - Every automation layer logs through the org's logging framework, never through
System.debugorconsole.log; see Observability. - Every Flow fault path is wired to the logging framework, so a failed Flow action is recorded rather than lost.
- No automation updates the record that triggered it after the record has been saved: not an after-insert or after-update trigger action, and not an after-save Flow. The update is another DML statement on the same record, so the object's save automations run again (Triggers and Order of Execution), and automation that sets itself off in this way is a DML loop, which ends only when the transaction reaches the limit of 16 on the depth of recursive triggers (Execution Governors and Limits). A change to the triggering record is made before the save, in a before-insert or before-update trigger action or a before-save Flow, where it costs no DML statement.
- The exception is an update made while the automation framework's bypasses are active, so that no automation runs again (Bypass Mechanisms). The automation switches the bypass on, updates the record, and switches the bypass off again.
- A reviewer checks both of these standards for trigger actions. For Flows, see Flow.
Environment Check¶
Every org has a utility Apex class that can tell an automation its environment (production, a sandbox, a scratch org, or a trial org), and, in a sandbox, the sandbox's name. Trigger handlers call the class from Apex, and Flows call it through an invocable method, so the check exists in one place.
- An automation uses the check to decide whether it runs at all. This is a run gate, and it is an accepted use of an environment check. Changing how code behaves once it runs is a different control, and it is not accepted; see Logging Levels by Environment.
- An automation made for sandboxes only, such as an Apex or Flow Scheduled Job that masks personal data or reloads test data, refuses to run in production.
- The check fails safe. When it cannot tell which kind of org it is in, the automation generally does not run. An automation that skips itself in production writes a log entry that raises an alert on the observability platform, because a production job that silently does not run is an outage of its own.
For Further Reading
Why an automation needs to know the org. A sandbox refresh copies an org's metadata, and for a Full or Partial sandbox its data, but Salesforce leaves out much of what would let a sandbox act as production. Scheduled Apex jobs are not copied (Apex Scheduler). The access tokens, API keys, and passwords of Named Credentials are not copied (Considerations for Named Credentials). Email deliverability is set to system email only (Email Deliverability). The risks come from what is set up after a refresh, and from code that reaches an org it was not made for:
- Sandbox-only work in production. A job that masks personal data, deletes test records, or deactivates users moves through the same pipeline as all other code, so it reaches production too. Nothing in the platform stops it if it is scheduled or activated there; only the job can check where it is.
- An integration after its credentials are entered again. To test an integration in a sandbox, someone enters credentials again, and the Named Credential's URL still points at the production system. Salesforce recommends a review of that URL (Considerations for Named Credentials). An integration job can compare the Named Credential's host with the host expected in this org, by kind of org and sandbox name, and skip with an alert when they differ.
- An integration that keeps no secret in an External Credential. An incoming webhook URL kept in a Custom Metadata record, an older secret kept in protected Custom Metadata, and an Outbound Message endpoint are copied with the metadata.
- Packages. A packaged automation runs in every org that installs it, and a
SandboxPostCopyclass runs only when a sandbox is created or refreshed, so only the automation itself can find out where it is.
How the class tells the orgs apart. The Organization record distinguishes all four kinds of org:
| Org | IsSandbox |
TrialExpirationDate |
|---|---|---|
| Production | false |
null |
| Sandbox | true |
null |
| Scratch org | true |
set |
| Trial org | false |
set |
This combination is not found in Salesforce's own documentation. To check an org, run sf data query --query "SELECT IsSandbox, TrialExpirationDate FROM Organization" --target-org <alias>.
The class returns the kind of org, the sandbox's name (an empty string outside a sandbox), and the org's My Domain name, which a sandbox shares with the production org it was copied from. As it has an invocable method, it is available to Flows through invocable Apex.
EnvironmentCheck.cls
/**
* @description Tells an automation which kind of org it is running in, the
* sandbox's name in a sandbox, and the org's My Domain name. Trigger handlers call the static methods, and
* Flows call the invocable method. An automation uses the answer only to decide
* whether it runs at all.
*/
public inherited sharing class EnvironmentCheck {
/**
* @description The kinds of org. A scratch org is its own kind and is never a
* sandbox. UNKNOWN is the answer when the org cannot be read, and no
* automation runs on it.
*/
public enum OrgKind {
PRODUCTION,
SANDBOX,
SCRATCH_ORG,
TRIAL,
UNKNOWN
}
/**
* @description The kind of org, worked out once in each transaction.
*/
private static OrgKind kind;
/**
* @description The sandbox's name, or an empty string outside a sandbox,
* worked out once in each transaction.
*/
private static String sandboxName;
/**
* @description The org's My Domain name, read once in each transaction.
*/
private static String myDomainName;
/**
* @description Returns the kind of org, from the IsSandbox and
* TrialExpirationDate fields of the Organization record. The record is read
* once in each transaction.
* @return the kind of org, or UNKNOWN when the record cannot be read
*/
public static OrgKind getOrgKind() {
if (kind == null) {
kind = readOrgKind();
}
return kind;
}
/**
* @description Returns whether the org is production.
* @return true in production only
*/
public static Boolean isProduction() {
return getOrgKind() == OrgKind.PRODUCTION;
}
/**
* @description Returns whether the org is a sandbox. A scratch org is not a
* sandbox here, although its Organization record says IsSandbox.
* @return true in a sandbox only
*/
public static Boolean isSandbox() {
return getOrgKind() == OrgKind.SANDBOX;
}
/**
* @description Returns whether the org is a scratch org.
* @return true in a scratch org only
*/
public static Boolean isScratchOrg() {
return getOrgKind() == OrgKind.SCRATCH_ORG;
}
/**
* @description Returns the sandbox's name, as Salesforce's domain methods
* give it. It is an empty string in production, in a scratch org, and in a
* trial org, and never null, so a caller can use it without a null check.
* @return the sandbox's name, or an empty string outside a sandbox
*/
public static String getSandboxName() {
if (sandboxName == null) {
sandboxName = isSandbox() ? (DomainParser.parse(URL.getOrgDomainUrl()).getSandboxName() ?? '') : '';
}
return sandboxName;
}
/**
* @description Returns the org's My Domain name, as Salesforce's domain
* methods give it: example for https://example.my.salesforce.com. A sandbox
* of that org has the same My Domain name, so the name identifies the
* production org that a sandbox was copied from.
* @return the org's My Domain name
*/
public static String getMyDomainName() {
if (myDomainName == null) {
myDomainName = DomainParser.parse(URL.getOrgDomainUrl()).getMyDomainName();
}
return myDomainName;
}
/**
* @description Reads the Organization record and classifies the org.
* @return the kind of org, or UNKNOWN when the record cannot be read
*/
private static OrgKind readOrgKind() {
try {
// System mode: the running user of a trigger or a Flow may have no access
// to the Organization record, and the check must give the same answer
// for every user. Only two fields that describe the org are read.
Organization org = [SELECT IsSandbox, TrialExpirationDate FROM Organization WITH SYSTEM_MODE LIMIT 1];
Boolean hasTrialDate = org.TrialExpirationDate != null;
if (org.IsSandbox) {
return hasTrialDate ? OrgKind.SCRATCH_ORG : OrgKind.SANDBOX;
}
return hasTrialDate ? OrgKind.TRIAL : OrgKind.PRODUCTION;
} catch (QueryException queryError) {
return OrgKind.UNKNOWN;
}
}
/**
* @description The answer that a Flow receives.
*/
public inherited sharing class Environment {
/**
* @description The kind of org: PRODUCTION, SANDBOX, SCRATCH_ORG, TRIAL,
* or UNKNOWN.
*/
@InvocableVariable(label='Kind of Org' description='PRODUCTION, SANDBOX, SCRATCH_ORG, TRIAL, or UNKNOWN')
public String orgKind;
/**
* @description Whether the org is production.
*/
@InvocableVariable(label='Is Production' description='True in production only')
public Boolean isProduction;
/**
* @description The sandbox's name, or blank outside a sandbox.
*/
@InvocableVariable(label='Sandbox Name' description='The sandbox name, or blank outside a sandbox')
public String sandboxName;
/**
* @description The org's My Domain name.
*/
@InvocableVariable(label='My Domain Name' description='The My Domain name of the org')
public String myDomainName;
}
/**
* @description Gives a Flow the kind of org, the sandbox's name, and the My
* Domain name. A Flow
* calls it at the start, and a Decision element after it decides whether the
* Flow goes on.
* @return one Environment, for the org the Flow runs in
*/
@InvocableMethod(
label='Get Org Environment'
description='Returns the kind of org (production, sandbox, scratch org, trial org, or unknown), the sandbox name, and the My Domain name'
category='Environment'
)
public static List<Environment> getEnvironment() {
Environment result = new Environment();
result.orgKind = getOrgKind().name();
result.isProduction = isProduction();
result.sandboxName = getSandboxName();
result.myDomainName = getMyDomainName();
return new List<Environment>{ result };
}
}
Global Bypass¶
A best practice, and the ideal: one custom permission that switches off every automation for the user who holds it, for work such as a large data load. It is difficult to put in place, because every trigger, every Flow, and every validation rule has to honour it.
- The org has one custom permission for the purpose. It is granted through a permission set that is assigned directly to a user, and only for as long as it is needed (Permissions and Security).
- Every trigger honours it. Under the Trigger Actions Framework, the permission is named as the bypass permission of every object's trigger setting, and a user who holds it runs none of that object's trigger actions (Bypass Mechanisms). Under another framework, each trigger handler checks the permission where it starts.
- Every Flow honours it. A Flow that the framework runs as a trigger action is bypassed with its object's triggers. Any other record-triggered Flow checks the permission in its entry condition, with
$Permission. - Every validation rule honours it: its formula is never true for a user who holds the permission, again with
$Permission. - It is used with caution. A user who holds the permission skips every check that the org's automation and validation rules make.
Once the bypass is in place, a custom PMD rule can require it in each of these places, so that a new trigger, Flow, or validation rule cannot leave it out. No rule in the reference checks this. Reviewers do.
Sources:
- Apex Scheduler - Apex Developer Guide
- Considerations for Named Credentials - Salesforce Help
- Email Deliverability - Salesforce Help
- Execution Governors and Limits - Apex Developer Guide
- mitchspano/trigger-actions-framework: A framework for partitioning, ordering, and bypassing…
- Trigger Actions Framework
- Triggers and Order of Execution - Apex Developer Guide
Orchestration¶
Orchestration is how the automations of one object run together: which tool runs each step, and in what order.
- Under the Trigger Actions Framework, one trigger on each object runs every automation of that object, Apex trigger actions and Flow trigger actions alike, in the order that the framework's custom metadata sets. So an org that uses the framework has one trigger for each object and no record-triggered Flows: a Flow that reacts to a record change is a trigger action. A before-save, record-triggered Flow can run slightly faster than a framework-triggered Flow, but the gain is smaller than the gain of running every automation of an object through one framework, in one known order. A second trigger on an object blocks the pull request (Apex: Triggers). No rule checks for a record-triggered Flow. Reviewers do.
- Every record-triggered Flow has a trigger order, so that the Flows of one object run in a known order. A Flow with no trigger order runs in the order of its activation date, after the Flows ordered 1 to 1,000 and before those ordered 1,001 to 2,000. Salesforce advises spacing the values, such as 10, 20, and 30, so that a new Flow can go between two others without a change to the rest (Define the Run Order of Record-Triggered Flows for an Object). A record-triggered Flow with no trigger order blocks the pull request (severity 2). Enforced by
FlowMissingTriggerOrder.
Automation Bypasses¶
The org has one custom permission that switches off every automation for the user who holds it, for work such as a large data load (Global Bypass).
The Trigger Actions Framework can also bypass all the trigger actions of one object, or one trigger action, in three ways (Bypass Mechanisms). The Bypass Execution checkbox, on the object's trigger setting or on the trigger action, switches it off for everyone until the checkbox is cleared. A custom permission named as its bypass permission switches it off for each user who holds the permission, and one named as its required permission runs it only for those users. And Apex or a Flow can switch it off for the rest of one transaction, and switch it on again when the work is done.
- An automation that updates a parent or child record, as one step of a larger piece of work, may switch on the bypass of that object's trigger actions for the update, and switch it off straight after. The related object's automations then do not run for a change that they do not need to see, and the transaction keeps its limits for the work that matters. The framework gives Apex and Flow the same bypass (Bypass Mechanisms). No rule checks this. Reviewers do.
Sources:
Flow¶
The standards for Flows follow the standards for triggers: what a trigger must not do in a context, a Flow in the same context must not do either. A before trigger makes no DML statement on the record that triggered it, because it sets the record's fields before the save, and a before-save Flow does the same.
Naming¶
A Flow's API name is the name of its file and its identity in the org, and it cannot be changed after the Flow is first saved: a Flow with a new name is a new Flow. A developer types the label, and Flow Builder makes the API name from it, with one underscore for each space and each dash. So these standards state the label, and the API name follows it.
- A Flow's label has four parts, separated by dashes: a project, application, or business unit code, the object, a kind code, and the purpose, in that order, such as
SAL - Opportunity - After - Create Renewal Task. A space on either side of a dash is optional, and so is a space between the words of a part (SetPreferredLanguageorSet Preferred Language). The developer keeps the API name that Flow Builder makes from the label, hereSAL_Opportunity_After_Create_Renewal_Task. The name holds only what does not change for the life of the Flow: it has no version, status, trigger order, or ticket number, and a record-triggered Flow names its timing, not the events (create, update, or delete) that start it, because those can be changed later. A Flow whose name does not have the four parts, or whose kind code or object does not match how the Flow starts, is reported at severity 3. Enforced byFlowNameConvention. - The project, application, or business unit code is a short code for the project, application, or business unit that owns the Flow, from a list that the organisation keeps, such as
SALfor Sales,SERfor Service, andFULfor Fulfilment. An integration belongs to the business unit it serves; only an integration that serves no single business unit takes a general code, such asINT. - The object part is the object that starts the Flow or that the Flow works on, without its suffix:
Order_Event__eisOrderEventorOrder Event. A Flow with no single object, such as a subflow that several Flows call, leaves the object part out:SER - SUB - Calculate Entitlement. - The kind code says how the Flow starts:
| Kind of Flow | Code | Example label |
|---|---|---|
| Record-triggered, before save | Before |
SER - Contact - Before - Set Preferred Language |
| Record-triggered, after save | After |
SAL - Opportunity - After - Create Renewal Task |
| Record-triggered, before delete | BeforeDelete or BeforeDel |
SER - Contact - BeforeDelete - Keep Primary Contact |
| Trigger action of the automation framework, before | TAB |
SER - Contact - TAB - Set Preferred Language |
| Trigger action of the automation framework, after | TAA |
SAL - Opportunity - TAA - Create Renewal Task |
| Schedule-triggered | SCH |
SAL - Opportunity - SCH - Send Reminders |
| Platform event-triggered | EVT |
FUL - OrderEvent - EVT - Send Notification |
| Screen | SCR |
SER - Contact - SCR - Update Address |
| Autolaunched, run on its own | AL |
SAL - Opportunity - AL - Check On Close Won |
| Autolaunched, built to be called by other Flows | SUB |
SER - SUB - Calculate Entitlement |
| Orchestration | ORCH |
SAL - Quote - ORCH - Approval Process |
Under the Trigger Actions Framework, most Flows that react to a record change are trigger actions: autolaunched Flows that the framework runs, with a record variable named record. Whether a trigger action runs before or after the save is set in the framework's custom metadata, not in the Flow, so a reviewer checks TAB against TAA. A reviewer also checks AL against SUB, because a Flow's file does not say whether other Flows call it.
A Flow cannot be renamed, only replaced, so the naming rule reports and does not block. An organisation whose scanner keeps a record of the violations that a reviewer has accepted, such as CodeScan, can raise the rule to severity 2 and accept its existing Flows once, so that every new Flow is named to the standard.
- An element's label starts with the verb for its type, and the developer keeps the API name that Flow Builder makes from it, so the name starts with the same verb:
Get Primary AccountbecomesGet_Primary_Account. The words after the verb may be joined, as in a Flow's name. A Screen, a Subflow, or an Action has a label that says what it does, with no fixed verb. An element whose name does not start with the verb for its type is reported at severity 3. Enforced byFlowElementVerb.
| Element | Verb | Example label |
|---|---|---|
| Get Records | Get |
Get Primary Account |
| Create Records | Create |
Create Draft Contract |
| Update Records | Update |
Update Matching Account |
| Delete Records | Delete |
Delete Opportunity Products |
| Assignment | Set, Add, or Remove |
Set Account Fields |
| Loop | Loop |
Loop Contacts |
| Collection Sort | Sort |
Sort Contacts |
| Collection Filter | Filter |
Filter Open Cases |
| Transform | Transform |
Transform Order Lines |
- A Decision's label is the question that it answers, and it ends with a question mark, such as
Is Project Billable?. Each outcome's label is an answer, such asYes. The default outcome keeps its label when the answer it stands for is plain, as theelseof an if-else block does, and it is relabelled, such asNoorOther, when that makes the Decision easier to read. A Decision whose label is not a question is reported at severity 3. Enforced byFlowDecisionQuestion. - An element pasted in Flow Builder is renamed and reviewed. The builder gives a copy a default label and name, and copies every field and setting of the original, so the developer gives it a label that says what it does and checks each setting before the Flow is saved. An element that keeps its default copy label is reported at severity 3. Enforced by
DefaultCopy. - A resource's name starts with a prefix for its type, and an organisation may choose to leave the prefixes out. A resource has no label, so the developer types its name. In one Flow, an element, an outcome, a screen component, and a resource cannot share a name, so a prefix keeps them apart, and it tells a reader whether a resource holds one value, one record, or a collection. An underscore after the prefix is optional:
var_Renewal_DateandvarRenewalDateboth follow the standard. The names that the platform or a framework requires keep their names:recordIdandids, which a Flow receives when it is launched from a record page or a list view, andrecordandrecordPrior, which the Trigger Actions Framework passes. A resource whose name does not start with the prefix for its type is reported at severity 4. Enforced byFlowResourcePrefix.
| Resource | Prefix | Example name |
|---|---|---|
| Variable that holds one value | var |
var_Renewal_Date |
| Record variable | rec |
rec_Current_Lead |
| Collection variable, of records or values | col |
col_Open_Cases |
| Formula | frm |
frm_Days_To_Renewal |
| Constant | con |
con_Max_Retries |
| Text template | tt |
tt_Email_Body |
| Choice and choice set | ch |
ch_Yes |
Flow Structure¶
- A subflow never calls, directly or through a chain of subflows, a Flow that called it. The Flow runtime does not allow a cycle of subflows, so a Flow that holds one fails when it runs. A cycle of subflows, of any depth, blocks the pull request (severity 1). Enforced by
CyclicSubflow. - A Loop element has a next value connector. Without one the loop body never runs, which is nearly always a mistake made when the loop was connected to the elements around it. A Loop with no next value connector blocks the pull request (severity 1). Enforced by
MissingNextValueConnector. - Every element is connected to the start element. An element that no path reaches is left over from an edit, or was never connected, and it never runs. An unreachable element is reported at severity 4. Enforced by
UnreachableElement. - A Flow has no variable that it does not use. An unused variable is left over from an earlier design, or the developer meant to use it and did not. A variable that nothing in the Flow uses is reported at severity 3. Enforced by
UnusedResource.
Limits¶
- No Get Records, Create Records, Update Records, or Delete Records element sits inside a loop. An element inside a loop runs once for each item, against the transaction's limits on queries and DML statements. The loop builds a collection, and one element after the loop works on all of it. A record element inside a loop blocks the pull request (severity 2). Enforced by
DbInLoop. - No action sits inside a loop: an Apex action, a legacy Apex plug-in, or a standard action such as Send Email. An action inside a loop runs once for each item, and each run can make its own queries and DML statements, or count against a limit of its own, such as the limit on emails. The loop builds a collection, and the action runs once, after the loop, with the collection. An action inside a loop blocks the pull request (severity 2). Enforced by
AvoidFlowActionInLoop. - No loop sits inside another loop. The elements of the inner loop run once for every pair of items, so the work grows with the product of the two collections, against the transaction's limit on CPU time (Execution Governors and Limits). The inner collection is filtered or sorted before the loop, or the work moves to invocable Apex that uses a map. A loop that contains a loop is reported at severity 3. Enforced by
AvoidNestedFlowLoops. - A Get Records element has a filter. Without one it reads every record of the object, and a transaction may retrieve at most 50,000 records (Execution Governors and Limits). A Get Records element with no filter blocks the pull request (severity 2). Enforced by
FlowGetRecordsWithoutFilter. An element that reads a custom metadata type is not reported, because reading all of its records is ordinary. - A Get Records element should name the fields that the Flow uses. With "Automatically store all fields", every field of the records that it finds is stored in the Flow, and getting only the fields that are needed reduces the work and the data the Flow holds (Get Records Element). A Flow that passes the whole record to a subflow or an action that needs every field is an exception. A Get Records element that stores all fields and names none is reported and does not block (severity 4). Enforced by
FlowGetRecordsAllFields.
Flow Security¶
- A Flow runs in user context, so that the platform applies the running user's object permissions, field-level security, and sharing. System context is dangerous: with sharing, a Flow ignores the user's object permissions and field-level security, and without sharing, it also reads and changes every record. System context is sometimes necessary, such as for a guest user or for fields that the app owns, and then a reviewer approves it. A Flow that runs in system context, with or without sharing, blocks the pull request (severity 2). Enforced by
DoNotRunFlowInSystemMode. - A Flow that runs in system context never lets user-entered data choose which records a Get Records, Create Records, Update Records, or Delete Records element works on, or what it writes to them. In system context the platform's access checks do not apply, so a user could reach or change records their permissions do not allow: a privilege escalation (Data Safety When Running Screen and Autolaunched Flows in System Context). The Flow runs in user context instead; where system context is needed, for a guest user or for fields the app owns, the Flow or an Apex action it calls makes its own access checks before the element. In system context without sharing the element blocks the pull request (severity 2); in system context with sharing it is reported at severity 4. Enforced by
PreventPassingUserDataIntoElementWithoutSharingandPreventPassingUserDataIntoElementWithSharing. - From API version 68.0, a screen Flow or an autolaunched Flow should use the run context User Context—Enforces User Permissions. A Flow in plain user context takes on system context when a caller in system context runs it, such as another Flow or Apex, and so gains the caller's access. With this option, the Flow always runs with the running user's access, whatever runs it (Winter '27 (API Version 68.0)). No rule checks this. Reviewers do.
- An autolaunched Flow that Apex launches runs on API version 62.0 or later, in the default context, and the Apex class that launches it is declared
with sharing(Apex: Sharing and Access Mode Standards). From API version 62.0, such a Flow enforces the sharing rules of the user who runs the class. At an earlier version it runs in system context without sharing, even when the class is declaredwith sharing, and it can read and change every record (Winter ’25 (API Version 62.0)). A Flow that moves to API version 62.0 is tested again, because a query can return fewer records than before, and an operation can fail when the user does not have access. No rule checks this. Reviewers do. - No secret is written into a Flow, as into any metadata (Objects and Fields: Secrets in Metadata). A Salesforce session ID in a Flow blocks the pull request (severity 2). Enforced by
AvoidHardcodedSecretSalesforce, and by each copy of it that an organisation makes for another vendor.
Error Handling¶
- Every element that can fail has a fault path: Get Records, Create Records, Update Records, Delete Records, an action, and a subflow. Without one, a failure ends the Flow with an unhandled fault and the user sees a generic error. A Get Records element can fail too, for example when the running user cannot read the object. A missing fault path is reported at severity 3. Enforced by
MissingFaultHandler, and for Get Records byFlowGetRecordsWithoutFaultPath. - A Flow should check the result of a Get Records element for null before it uses it. When Get Records finds no record, its result is null, and the next element that uses the result can fail. A Decision tests the result, or one of its fields, with Is Null or Is Blank. A Get Records element that returns one record, and whose result the Flow uses with no such test, is reported and does not block (severity 3). Enforced by
FlowGetRecordsWithoutNullCheck. A Get Records element that returns a collection is not reported, because a loop over an empty collection does nothing.
Hard-Coded Values¶
- No record ID is written into a Flow. An ID belongs to one org: the same record has another ID in every sandbox, and a record that is deleted and made again gets a new one, so a Flow that carries an ID breaks when it is deployed or when the record changes. The Flow finds the record by a field that is the same in every org, such as a
DeveloperName, an external ID, or a name, or reads it from a custom metadata type or a custom setting, and it reaches the running user through$User. A record ID in a Flow blocks the pull request (severity 1), the same as in Apex. Enforced byAvoidHardcodedIdFlow, which finds an ID of 15 or 18 characters anywhere in a value, such as a formula, a text template, or a URL, and does not read descriptions. - A Flow should not hold the absolute URL of a Salesforce org, the same as Apex. Every sandbox and every production org has a host name of its own, so the URL of one org is wrong in every other org, and it breaks when the org's My Domain name changes. A link within the org is a relative URL. A Salesforce website that is the same for every org, such as Salesforce Help, may be written out, and the rule's
allowedSubdomainsproperty names those websites. A URL of a Salesforce org in a Flow is reported and does not block (severity 3). Enforced byAvoidAbsoluteSalesforceUrlFlow.
Calling Apex¶
- An Apex action that a Flow calls takes its inputs, and returns its outputs, through a class with
@InvocableVariablemembers, and that class follows the Apex standard for its constructors: from API version 66.0, it has a visible no-argument constructor, or the Flow cannot use the action (Apex: Constructors).
Flow Attributes¶
- A Flow, and every element, variable, and other resource in it, carries a description that says what it is for, as every other piece of metadata does (Readability). A description does not change how the Flow runs; it is for the next person who opens it. A Flow, or an element or resource with a name, that has no description is reported at severity 4; the outcomes of a Decision are not checked. The Flow's own description is more than 10 characters, as every description is (Descriptions), and a Flow whose description is shorter blocks the pull request (severity 2). Enforced by
MissingDescriptionandMetadataDescriptionFlow. - A Flow is built in auto-layout, which places and aligns its elements, so that every Flow reads the same way. A Flow in free-form layout is reported at severity 4. Enforced by
FlowNotAutoLayout.
Record-Triggered Flows¶
- A before-save, record-triggered Flow that changes the record that triggered it does so as a fast field update. The same change made after the save costs another DML statement and runs the object's save automations again. An after-save Flow that updates its own triggering record is reported at severity 3. Enforced by
SameRecordUpdate. - Updating the triggering record in an after-save Flow is allowed if the Flow activates the trigger framework's bypasses, which would not run any automations.
- A record-triggered Flow has entry conditions, set in its trigger definition and not only in the Flow's own logic, so that the Flow starts only for the records it is for. A record-triggered Flow with no entry conditions blocks the pull request (severity 2). Enforced by
TriggerEntryCriteria. Under the Trigger Actions Framework, record-triggered Flows are not used at all: a Flow that reacts to a record change runs as a trigger action of its object, in the order the object's trigger settings give it, and the framework's documentation says how (Trigger Actions Framework). - A record-triggered Flow makes no callout on its synchronous path, and reaches no Wait element. A trigger must finish within the save of the record, so a callout belongs on a scheduled path, as an asynchronous callout, and a wait belongs in a scheduled path or a separate Flow. Both checks follow subflows. A callout on the synchronous path of a trigger blocks the pull request (severity 2), and so does a Wait element reached during a trigger (severity 2). Enforced by
TriggerCalloutandTriggerWaitEvent. - Work that is complex or takes a long time runs on the asynchronous path of a record-triggered Flow. That path runs in a transaction of its own after the record is saved, so it does not delay the user's save, and it has the higher limits of asynchronous processing (Record-Triggered Automation). No rule checks this. Reviewers do.
Schedule-Triggered Flows¶
- A schedule-triggered Flow runs on API version 61.0 or later. In that version (Summer '24), Salesforce gave schedule-triggered Flows the higher limits of asynchronous processing, and a Flow on an earlier version keeps the lower limits, so it can fail where the same Flow on a later version would not. A schedule-triggered Flow on an earlier API version blocks the pull request (severity 1). Enforced by
ScheduledFlowMinimumApiVersion.
Screen Flows¶
- A screen after a Create, Update, or Delete Records element should not offer the Previous button. A user who goes back to a screen before the record change, and then forward again, runs the element a second time, so a record can be created or changed twice. A screen with a Previous button that a path from a record change reaches is reported and does not block (severity 3); a screen with no Previous button ends the path, and so does the record element's own fault path. Enforced by
FlowAllowBackAfterRecordChange. - A screen Flow launched from a record should declare its input variable
recordIdas a record variable of the object, not as text. A URL, a Flow quick action, and a Flow component on a record page each pass the record's ID, and the Flow receives the whole record in that variable, so it reads the record's fields with no Get Records element. On a record page, the Flow component's option "Pass Record ID into this variable" is ticked. Apex gives a record variable a record only when it passes one, so an autolaunched Flow is not checked. A screen Flow whose inputrecordIdis text is reported and does not block (severity 4). Enforced byScreenFlowTextRecordId.
Deployment¶
- A Flow is deployed active, and a Flow that is no longer needed is deleted, not deactivated. A Flow whose status in metadata is
DraftorInvalidDrafthas never been activated, so it does not run for users. Setup shows the first as Inactive and the second as Draft (Flow). A draft Flow is reported at severity 5, for the developer's information. Enforced byFlowDraft. For a Flow to arrive in production active, the org deploys Flows as active: see Deploy Flows as Active. - A Flow that has been deactivated is removed from the repository. Its status in metadata is
Obsolete, which Setup shows as Inactive (Flow). An inactive Flow is reported at severity 3. Enforced byObsoleteFlow.
Standards Not Adopted¶
Why Only Some Are Listed
Not every standard that was left out is listed here, only those that merit an explanation. Standards Not Adopted on the Code Standards page says why.
Standards that were researched and left out on purpose, each with the reason.
- No rule reports a loop that could be a Transform element. A loop that only copies fields from one collection to another is better as a Transform element, which maps the whole collection at once. But most loops that start with an Assignment count, add up, or build a collection after a Decision, so a rule would report many loops that are right.
- The Flow engine's
HardcodedIdrule is not used. It does not report a 15-character ID, it reads a value only when the whole value is an ID, and it reports some words that are not IDs, such asRegion2024Totals.AvoidHardcodedIdFlowreplaces it.
Sources:
- Data Safety When Running Screen and Autolaunched Flows in System Context - Salesforce Help
- Execution Governors and Limits - Apex Developer Guide
- Flow - Metadata API Developer Guide
- Get Records Element - Salesforce Help
- mitchspano/trigger-actions-framework: A framework for partitioning, ordering, and bypassing…
- Record-Triggered Automation - Salesforce Architects
- Trigger Actions Framework
- Winter '27 (API Version 68.0) - Salesforce Help
- Winter ’25 (API Version 62.0) - Salesforce Help
Approval Processes¶
Workflow & Process Builder¶
Salesforce stopped supporting Workflow Rules and Process Builder on December 31, 2025. An existing rule or process still runs, but it gets no customer support and no bug fixes, and Salesforce's Migrate to Flow tool helps to rebuild it as a Flow (Salesforce Workflow Rules & Process Builder End of Support).
- No automation is built in Process Builder, and every Process Builder process has been rebuilt as a Flow. A process from a managed package is not checked, because the organisation cannot change it. Any non-managed Process Builder process blocks the pull request (severity 2). Enforced by
DoNotUseProcessBuilder. - No automation is built as a Workflow Rule, and every Workflow Rule has been rebuilt as a Flow. A Workflow Rule from a managed package is not checked, because the organisation cannot change it. Any non-managed Workflow Rule blocks the pull request (severity 2). Enforced by
DoNotUseWorkflow.
Rules¶
The rules that this page mentions. The Standard column gives each rule's standard in one line, and the Section column links to the section that mentions the rule; "Not yet written" marks a rule of this page that no section mentions yet. Appendix F.6 holds the full cross-reference.
| Rule | Kind | Severity | Standard | Section |
|---|---|---|---|---|
Avoid |
Custom PMD XPath | 1 | No record ID is written into a Flow | Hard-Coded Values Standards Not Adopted |
Cyclic |
Stock Code Analyzer Flow | 1 | No subflow calls, directly or through a chain, a Flow that called it | Flow Structure |
Missing |
Stock Code Analyzer Flow | 1 | A Loop element has a next value connector | Flow Structure |
Scheduled |
Custom PMD XPath | 1 | A schedule-triggered Flow runs on API version 61.0 or later | Schedule-Triggered Flows |
Avoid |
Custom PMD XPath | 2 | No action inside a Flow loop; call it once, after the loop, with a collection | Limits |
Avoid |
Custom PMD XPath | 2 | No secret is written into metadata, with one rule for each form of secret | Flow Security |
Db |
Stock Code Analyzer Flow | 2 | No Get, Create, Update, or Delete Records element inside a Flow loop | Limits |
Do |
Custom PMD XPath | 2 | A Flow runs in user context; system context is a reviewed exception | Flow Security |
Do |
Custom PMD XPath | 2 | No automation is built in Process Builder; rebuild every process as a Flow Notes on this rule. | Workflow & Process Builder |
Do |
Custom PMD XPath | 2 | No automation is built as a Workflow Rule; rebuild every rule as a Flow Notes on this rule. | Workflow & Process Builder |
Flow |
Custom PMD XPath | 2 | A Get Records element has a filter | Limits |
Flow |
Custom PMD XPath | 2 | Every record-triggered Flow has a trigger order | Orchestration |
Metadata |
Custom PMD XPath | 2 | Every Flow, Flow test, Flow category, and Flow value map carries a description of more than 10 characters | Flow Attributes |
Prevent |
Stock Code Analyzer Flow | 2 | A Flow in system context without sharing never lets user-entered data choose the records it reads or writes | Flow Security |
Trigger |
Stock Code Analyzer Flow | 2 | A record-triggered Flow makes no callout on its synchronous path | Record-Triggered Flows |
Trigger |
Stock Code Analyzer Flow | 2 | A record-triggered Flow has entry conditions in its trigger definition | Record-Triggered Flows |
Trigger |
Stock Code Analyzer Flow | 2 | A record-triggered Flow reaches no Wait element on its synchronous path | Record-Triggered Flows |
Avoid |
Custom PMD XPath | 3 | No absolute URL of a Salesforce org in a Flow; use a relative URL | Hard-Coded Values |
Avoid |
Custom PMD XPath | 3 | No Flow loop inside another loop | Limits |
Default |
Stock Code Analyzer Flow | 3 | An element pasted in Flow Builder is renamed and reviewed | Naming |
Flow |
Custom PMD XPath | 3 | A screen after a record change does not offer the Previous button | Screen Flows |
Flow |
Custom PMD XPath | 3 | A Decision's label is the question it answers, and ends with a question mark | Naming |
Flow |
Custom PMD XPath | 3 | A Flow element's name starts with the verb for its type | Naming |
Flow |
Custom PMD XPath | 3 | A Get Records element has a fault path | Error Handling |
Flow |
Custom PMD XPath | 3 | A Flow checks the result of a Get Records element for null before it uses it | Error Handling |
Flow |
Custom PMD XPath | 3 | A Flow's name has four parts: the owner code, the object, the kind code, and the purpose | Naming |
Missing |
Stock Code Analyzer Flow | 3 | Every Flow element that can fail has a fault path | Error Handling |
Obsolete |
Custom PMD XPath | 3 | A Flow that has been deactivated is removed from the repository | Deployment |
Same |
Stock Code Analyzer Flow | 3 | A record-triggered Flow changes its own record before the save, not after it | Record-Triggered Flows |
Unused |
Stock Code Analyzer Flow | 3 | A Flow has no variable that it does not use | Flow Structure |
Flow |
Custom PMD XPath | 4 | A Get Records element names the fields that the Flow uses | Limits |
Flow |
Custom PMD XPath | 4 | A Flow is built in auto-layout Notes on this rule. | Flow Attributes |
Flow |
Custom PMD XPath | 4 | A Flow resource's name starts with the prefix for its type | Naming |
Missing |
Stock Code Analyzer Flow | 4 | A Flow, and every element, variable, and resource in it, carries a description | Flow Attributes |
Prevent |
Stock Code Analyzer Flow | 4 | A Flow in system context with sharing never lets user-entered data choose the records it reads or writes | Flow Security |
Screen |
Custom PMD XPath | 4 | A screen Flow launched from a record declares recordId as a record variable of the object |
Screen Flows |
Unreachable |
Stock Code Analyzer Flow | 4 | Every Flow element is connected to the start element | Flow Structure |
Flow |
Custom PMD XPath | 5 | Activate a Flow that has never been activated | Deployment |