Log Entry
Flow-Friendly Apex Is Becoming Its Own Design Discipline
Flow-friendly Apex is becoming its own design discipline.
That sounds a bit grand, but I think Summer '26 makes the point.
The headline is not just "Invocable Apex got nicer".
The headline is that Salesforce is giving developers more ways to make Apex actions understandable at configuration time:
- custom property editors for individual inputs
- picklist values for Apex action inputs
- metadata record selectors
- cleaner object-type selection for generic sObject inputs
- optional inputs without the old Include toggle
- custom and custom collection types for outputs
- enforcement around visible no-argument constructors for Apex-defined parameters
Taken together, this is a shift.
An Apex action is not just a method that Flow can call.
It is an admin-facing product surface.
Quick snapshot
- What changed: Flow Builder gets several improvements for configuring Apex actions.
- Why it matters: developers can make invocable actions safer and easier for admins to configure.
- Best use: guided inputs, valid picklists, metadata selectors, clear optional behaviour, structured outputs.
- Bad use: dumping a dozen ambiguous text fields into Flow and calling it "low code".
- Constructor warning: Apex-defined input/output classes need visible no-argument constructors in the enforcement path.
- Design rule: if an admin needs your source code open to configure the action, the action is not finished.
My short version:
Invocable Apex should feel like a well-designed Flow element, not a method signature leaking into Flow Builder.
The old Apex action smell
This is the action shape I do not want to keep seeing.
public with sharing class CreateFollowUpAction {
public class Request {
@InvocableVariable public Id recordId;
@InvocableVariable public String priority;
@InvocableVariable public String ownerMode;
@InvocableVariable public String ownerId;
@InvocableVariable public String templateName;
@InvocableVariable public Boolean notify;
}
@InvocableMethod(label='Create Follow Up')
public static void run(List<Request> requests) {
// Implementation omitted.
}
}
Technically, it is invocable.
Practically, it is hostile.
An admin configuring this in Flow Builder has to guess:
- What values are valid for
priority? - Is
ownerMode"User", "Queue", "Auto", or something else? - Is
ownerIdrequired for every mode? - Is
templateNamea label, developer name, custom metadata record, or free text? - What happens when
notifyis blank? - Does the action return anything useful?
- What failed if one record could not be processed?
That is not Flow-friendly Apex.
That is Apex exposed through Flow.
The better target
A Flow-friendly Apex action should behave like a small product.
It should have:
- a clear label
- a narrow purpose
- input labels that match admin language
- required inputs only where genuinely required
- safe defaults for optional inputs
- picklists instead of magic strings
- metadata selectors instead of free-text developer names
- structured outputs instead of "check the debug log"
- one output result per input request
- visible no-argument constructors on Apex-defined types
The action should make the happy path obvious and the unsafe path hard to choose.
Example 1: design the DTOs for Flow first
Start with the admin-facing shape, not the private implementation.
public with sharing class CreateFollowUpTaskAction {
public class Request {
public Request() {}
@InvocableVariable(
label='Source Record ID'
description='The record that needs a follow-up task.'
required=true
)
public Id sourceRecordId;
@InvocableVariable(
label='Priority'
description='Select the priority for the generated task.'
required=true
)
public String priority;
@InvocableVariable(
label='Assignee Type'
description='Choose how the task owner is selected.'
required=true
)
public String assigneeType;
@InvocableVariable(
label='Assignee ID'
description='Required only when Assignee Type is Specific User or Queue.'
)
public Id assigneeId;
@InvocableVariable(
label='Notification Template'
description='Optional metadata record that controls notification content.'
)
public Id notificationTemplateId;
@InvocableVariable(
label='Send Notification'
description='When true, send the configured notification after creating the task.'
)
public Boolean sendNotification;
}
public class Result {
public Result() {}
@InvocableVariable(label='Source Record ID')
public Id sourceRecordId;
@InvocableVariable(label='Task ID')
public Id taskId;
@InvocableVariable(label='Success')
public Boolean success;
@InvocableVariable(label='Message')
public String message;
@InvocableVariable(label='Issues')
public List<ActionIssue> issues;
}
public class ActionIssue {
public ActionIssue() {}
@InvocableVariable(label='Code')
public String code;
@InvocableVariable(label='Message')
public String message;
}
@InvocableMethod(
label='Create Follow-Up Task'
description='Creates a follow-up task for each source record and optionally sends a notification.'
category='Task Automation'
)
public static List<Result> createTasks(List<Request> requests) {
List<Result> results = new List<Result>();
List<Task> tasksToInsert = new List<Task>();
List<Integer> resultIndexes = new List<Integer>();
for (Request request : requests) {
Result result = new Result();
result.sourceRecordId = request.sourceRecordId;
result.issues = new List<ActionIssue>();
if (request.sourceRecordId == null) {
result.success = false;
result.message = 'Source Record ID is required.';
result.issues.add(issue('MISSING_SOURCE_RECORD', result.message));
results.add(result);
continue;
}
tasksToInsert.add(new Task(
WhatId = request.sourceRecordId,
Subject = 'Follow up',
Priority = request.priority,
OwnerId = resolveOwnerId(request),
Status = 'Not Started'
));
result.success = true;
results.add(result);
resultIndexes.add(results.size() - 1);
}
if (tasksToInsert.isEmpty()) {
return results;
}
try {
insert as user tasksToInsert;
} catch (DmlException error) {
for (Integer resultIndex : resultIndexes) {
Result failedResult = results[resultIndex];
failedResult.success = false;
failedResult.message = error.getMessage();
failedResult.issues.add(issue('TASK_CREATE_FAILED', error.getMessage()));
}
return results;
}
for (Integer i = 0; i < tasksToInsert.size(); i++) {
Result result = results[resultIndexes[i]];
result.taskId = tasksToInsert[i].Id;
result.message = 'Follow-up task created.';
}
return results;
}
private static ActionIssue issue(String code, String message) {
ActionIssue issue = new ActionIssue();
issue.code = code;
issue.message = message;
return issue;
}
private static Id resolveOwnerId(Request request) {
if (request.assigneeType == 'CurrentUser') {
return UserInfo.getUserId();
}
if (request.assigneeType == 'SpecificOwner') {
return request.assigneeId;
}
return UserInfo.getUserId();
}
}
There are three important design choices here.
First, every Apex-defined type has a visible no-argument constructor.
Second, the result is structured. Flow can branch on success, store taskId, and show message.
Third, the action returns one result per input request. That makes bulk behaviour understandable.
Example 2: picklists beat magic strings
The priority and assigneeType fields should not be free text.
Summer '26 lets developers define picklist values for Apex action inputs through InvocableActionExtension metadata.
For static values, the metadata can provide values and labels.
<targets>
<targetType>ActionParameter</targetType>
<targetName>CreateFollowUpTaskAction.priority</targetName>
<attributes>
<key>ProvidedValuesList</key>
<dataType>String</dataType>
<value>High|High, Normal|Normal, Low|Low</value>
</attributes>
</targets>
For assignee type:
<targets>
<targetType>ActionParameter</targetType>
<targetName>CreateFollowUpTaskAction.assigneeType</targetName>
<attributes>
<key>ProvidedValuesList</key>
<dataType>String</dataType>
<value>CurrentUser|Current User, SpecificOwner|Specific User or Queue</value>
</attributes>
</targets>
That changes the admin experience.
Instead of asking admins to type the exact string your Apex expects, Flow Builder gives them valid options.
That removes a whole class of brittle Flow bugs.
Example 3: dynamic picklists when values change
Static picklists are not always enough.
Salesforce also supports dynamic values by referencing an Apex class that extends VisualEditor.DynamicPicklist with an apex:// URI.
That is useful when the valid values come from controlled metadata or another source that can change.
<targets>
<targetType>ActionParameter</targetType>
<targetName>CreateFollowUpTaskAction.priority</targetName>
<attributes>
<key>ProvidedValuesList</key>
<dataType>String</dataType>
<value>apex://TaskPriorityPicklist</value>
</attributes>
</targets>
Use this carefully.
The dynamic picklist runs while the admin configures the action in Flow Builder. That means the logic should be fast, predictable, and not dependent on slow callouts.
Design-time code is still production code.
It just fails in front of an admin instead of an end user.
Example 4: metadata selectors beat text fields
This is one of the most useful Summer '26 improvements.
If an Apex input expects a setup or metadata reference, admins should not have to paste a developer name into a text box.
For the notification template input, configure the parameter as a metadata selector.
<targets>
<targetType>ActionParameter</targetType>
<targetName>CreateFollowUpTaskAction.notificationTemplateId</targetName>
<attributes>
<key>SetupReferenceObjectNameForAutomaticSetup</key>
<dataType>String</dataType>
<value>CustomMetadata</value>
</attributes>
<attributes>
<key>SetupReferenceObjectLabelForAutomaticSetup</key>
<dataType>String</dataType>
<value>Notification Template</value>
</attributes>
<attributes>
<key>SetupReferenceFilterConditionForAutomaticSetup</key>
<dataType>String</dataType>
<value>NamespacePrefix = null</value>
</attributes>
</targets>
Salesforce's note says supported setup reference types include CustomMetadata, NamedCredential, StaticResource, Report, and Document.
That opens up useful patterns:
- select a Named Credential for an integration action
- select a Custom Metadata record for routing rules
- select a Report for report-driven automation
- select a Static Resource for a template or mapping file
The admin picks a valid record.
The action receives the resolved metadata record ID at runtime.
That is miles better than "paste the exact API name and hope".
Example 5: custom property editors per input
Custom property editors used to feel like a bigger commitment because they wrapped the whole action experience.
Summer '26 makes this more granular. A developer can assign a custom property editor to individual Apex action inputs. One LWC can control a single parameter or multiple related parameters, and the rest can stay on the standard property editor.
That is exactly the right shape.
For assigneeType and assigneeId, a custom editor could show:
- a segmented control for Current User vs Specific User or Queue
- a lookup only when Specific User or Queue is selected
- validation that
assigneeIdexists when needed - explanatory text that does not clutter the Apex DTO
The metadata shape is targeted.
<targets>
<targetName>CreateFollowUpTaskAction.assigneeType</targetName>
<attributes>
<key>CpeName</key>
<value>c:assigneePickerEditor</value>
</attributes>
</targets>
<targets>
<targetName>CreateFollowUpTaskAction.assigneeId</targetName>
<attributes>
<key>ConfiguredBy</key>
<value>assigneeType</value>
</attributes>
</targets>
This is the discipline:
Do not build a custom editor because you can.
Build one when two or three inputs form a concept that a standard text box cannot explain.
Example 6: optional inputs should be genuinely optional
Summer '26 removes the redundant Include toggle for optional Apex action inputs.
That is good, because optional inputs should not require admin ceremony.
But it also puts more responsibility on the Apex design.
Do not make admins configure fake switches like this:
@InvocableVariable(label='Use Custom Template')
public Boolean useCustomTemplate;
@InvocableVariable(label='Template Developer Name')
public String templateDeveloperName;
Prefer one optional input with a clear default behaviour.
@InvocableVariable(
label='Notification Template'
description='Leave blank to use the default template.'
)
public Id notificationTemplateId;
Then make the Apex behaviour explicit.
private static Notification_Template__mdt resolveTemplate(Id templateId) {
if (templateId == null) {
return Notification_Template__mdt.getInstance('Default_Follow_Up');
}
return [
SELECT DeveloperName, Subject__c, Body__c
FROM Notification_Template__mdt
WHERE Id = :templateId
LIMIT 1
];
}
Optional should mean "safe to leave blank".
It should not mean "blank causes surprising behaviour".
Example 7: generic sObject actions need visible context
Generic sObject actions can be powerful, but they are also easy to make confusing.
Summer '26 improves the configuration experience by showing non-object parameters immediately and revealing object-related fields after the admin selects the object type.
That helps, but the action still needs careful design.
Good generic action:
public with sharing class StampRecordsAction {
public class Request {
public Request() {}
@InvocableVariable(
label='Records to Stamp'
description='Records that receive the configured status value.'
required=true
)
public List<SObject> records;
@InvocableVariable(
label='Status Field API Name'
description='The field to update. Select an allowed field for the chosen object type.'
required=true
)
public String statusFieldApiName;
@InvocableVariable(
label='Status Value'
description='The value to write to the selected status field.'
required=true
)
public String statusValue;
}
}
Risky generic action:
@InvocableVariable(label='Fields')
public String fields;
@InvocableVariable(label='Where Clause')
public String whereClause;
@InvocableVariable(label='Update Values')
public String updateValues;
That is not Flow-friendly.
That is asking an admin to write a half-query in a text field.
If the action is generic, the configuration experience needs even more guardrails, not fewer.
Example 8: outputs should be admin-usable
One of the quiet but important Summer '26 notes is support for custom and custom collection types in invocable action output parameters.
That matters because a lot of actions produce more than a primitive value.
Instead of returning:
@InvocableMethod(label='Validate Records')
public static List<String> validate(List<Request> requests) {
// Returns JSON strings. Please do not.
}
return something Flow can inspect.
public class ValidationResult {
public ValidationResult() {}
@InvocableVariable(label='Source Record ID')
public Id sourceRecordId;
@InvocableVariable(label='Can Continue')
public Boolean canContinue;
@InvocableVariable(label='Issues')
public List<ValidationIssue> issues;
}
public class ValidationIssue {
public ValidationIssue() {}
@InvocableVariable(label='Severity')
public String severity;
@InvocableVariable(label='Field API Name')
public String fieldApiName;
@InvocableVariable(label='Message')
public String message;
}
That lets Flow do useful things:
- loop over issues
- show a screen message
- branch on severity
- create follow-up tasks
- send a structured notification
Do not make Flow parse JSON unless there is no better option.
The no-argument constructor rule is a feature, not paperwork
The no-argument constructor requirement can look like annoying ceremony.
It is not.
Flow needs to instantiate Apex-defined parameter types. If a class only has constructors that require arguments, Flow Builder cannot safely create the object shape it needs.
This breaks:
public class Request {
public Request(Id sourceRecordId) {
this.sourceRecordId = sourceRecordId;
}
@InvocableVariable(required=true)
public Id sourceRecordId;
}
This is safer:
public class Request {
public Request() {}
public Request(Id sourceRecordId) {
this.sourceRecordId = sourceRecordId;
}
@InvocableVariable(required=true)
public Id sourceRecordId;
}
For managed packages, pay attention to visibility. Salesforce's notes call out constructor visibility in the managed-package path, so package authors should be especially deliberate.
What admins feel
This is the part developers sometimes miss.
Admins do not experience your invocable Apex as a class.
They experience it as a panel in Flow Builder.
The difference between a rough action and a good action is very tangible.
That is why this matters.
The goal is not more Apex power.
The goal is a safer builder experience.
A design checklist for Flow-friendly Apex
Before shipping an invocable action, I would ask:
- Does the label describe the business action, not the class name?
- Does the description explain when to use it?
- Are required inputs actually required?
- Are optional inputs safe when blank?
- Are magic strings replaced with picklists?
- Are metadata references configured as selectors?
- Are related inputs grouped with a CPE only when that genuinely helps?
- Does every Apex-defined type have a visible no-argument constructor?
- Does the action return a structured result?
- Does bulk input return results in a predictable order?
- Does user-facing data access respect sharing, CRUD, and FLS?
- Can an admin configure it without reading the Apex source?
If the answer to that last one is no, keep designing.
Final take
This is one of the best sleeper topics in Summer '26.
It sits exactly between developer discipline and admin experience.
The platform is giving Apex actions a better configuration surface in Flow Builder. That is useful on its own, but the bigger point is cultural: Apex actions should be designed as reusable automation products.
Not just callable methods.
Not just "the thing the developer said to use".
Good Flow-friendly Apex makes safe configuration obvious, bad configuration harder, and runtime behaviour easier to handle.
That is a very helpful direction.
Source notes
- Salesforce Summer '26 release notes
- Add Custom Property Editors to Individual Apex Action Inputs
- Define Picklist Values for Apex Action Inputs
- Streamline Metadata Selection for Apex Action Inputs
- Streamline Object Type Selection for Generic SObject Action Inputs
- Configure Optional Apex Action Inputs Without Include Toggle
- How and When Summer '26 Features Become Available
- Spring '26 API version 66.0: Require Visible No-Argument Constructors for Apex Classes
- Enforcing No-Argument Constructor on Apex Classes Used for Invocable Action Parameters