Log Entry
Bulk-Safe Invocable Apex
The easiest invocable Apex action to write is often the most dangerous one to keep.
It looks like this:
@InvocableMethod
public static void recalculate(List<Request> requests) {
Id accountId = requests[0].accountId;
// Do the work for one record.
}
It works in the first demo because the Flow sends one item.
Then it becomes shared, reused, called from automation, and quietly retains an assumption that was never part of the contract.
An invocable method receives a list because bulk is part of the interface.
Quick snapshot
A Flow-friendly, bulk-safe Apex action should:
- accept every request in the supplied collection
- validate each request deliberately
- collect identifiers before querying
- query once per required data shape, not once per request
- perform collection DML
- preserve a clear input-to-output relationship
- return useful business failures without losing successful results
- document whether duplicates are allowed
My rule:
If the method accepts
List<Request>, its design must explain every element in that list.
Start with a real request and result contract
Do not expose a loose collection of primitive parameters if the action has a meaningful domain operation.
public class RecalculateAccountHealthAction {
public class Request {
@InvocableVariable(required=true)
public Id accountId;
}
public class Result {
@InvocableVariable
public Id accountId;
@InvocableVariable
public Boolean success;
@InvocableVariable
public String errorCode;
@InvocableVariable
public String message;
}
@InvocableMethod(
label='Recalculate Account Health'
description='Recalculates health for each supplied account.'
)
public static List<Result> run(List<Request> requests) {
return execute(requests);
}
}
The label and description are part of the admin-facing API. So are the field names.
input1, value, and output are not acceptable merely because the method compiles.
Validate without throwing away the whole interview group
Some errors are request errors, not transaction errors.
A missing account ID should normally produce a result for that request rather than a null-pointer exception that makes every request fail.
List<Result> results = new List<Result>();
Set<Id> accountIds = new Set<Id>();
for (Request request : requests) {
if (request == null || request.accountId == null) {
results.add(failure(null, 'MISSING_ACCOUNT_ID', 'Account is required.'));
continue;
}
accountIds.add(request.accountId);
results.add(null);
}
Whether partial results are appropriate depends on the action. If the whole collection represents one indivisible command, fail it as one transaction.
The important thing is to choose, document, and test the behaviour rather than allowing the first exception to choose it for you.
Query by the collection
The invocable equivalent of a trigger query-in-a-loop is still a query in a loop.
Bad:
for (Request request : requests) {
Account account = [
SELECT Id, Health_Score__c
FROM Account
WHERE Id = :request.accountId
];
}
Better:
Map<Id, Account> accountById = new Map<Id, Account>([
SELECT Id, Health_Score__c
FROM Account
WHERE Id IN :accountIds
WITH USER_MODE
]);
The same applies to child records. Query them once, then group them in memory by parent ID.
Preserve correlation
Flow needs to know which result belongs to which request.
The simplest contract is positional: output at index n corresponds to input at index n.
If you use that contract, protect it. Do not build results by iterating a Set<Id> or a map's values and assume the ordering will happen to match the requests.
I normally keep the original request list, build lookup maps for the work, then make a final pass over the original list to construct ordered results.
for (Request request : requests) {
if (request == null || request.accountId == null) {
orderedResults.add(
failure(null, 'MISSING_ACCOUNT_ID', 'Account is required.')
);
continue;
}
Account account = accountById.get(request.accountId);
orderedResults.add(
account == null
? failure(request.accountId, 'NOT_FOUND', 'Account was not found.')
: success(account)
);
}
Including the input identifier in the result makes the contract easier to inspect and debug even when positional correlation is guaranteed.
Decide what duplicates mean
If the same Account ID appears twice, should the action:
- calculate twice and return two results?
- calculate once and return the same outcome twice?
- reject the duplicate?
There is no universal answer.
For an idempotent calculation, I usually deduplicate the expensive work but preserve one result per request. For a command such as "create a shipment", two requests may represent two intentional shipments and must not be silently collapsed.
Do not let Set<Id> accidentally define the business semantics.
Keep the invocable class thin
An invocable class is an adapter between Flow's contract and application logic.
@InvocableMethod(label='Recalculate Account Health')
public static List<Result> run(List<Request> requests) {
List<AccountHealthService.Outcome> outcomes =
AccountHealthService.recalculate(toServiceRequests(requests));
return toFlowResults(outcomes);
}
This keeps the service reusable from Apex, an LWC controller, scheduled work, or a repair script without pretending every caller is a Flow.
It also makes unit tests easier. Service tests cover the business logic. Action tests cover input mapping, output mapping, annotations, and bulk behaviour.
Test the contract at realistic size
I want at least these cases:
- one valid request
- a full collection of valid requests
- null or missing inputs
- inaccessible or missing records
- duplicate identifiers
- mixed success and failure, if supported
- a database failure
- output order matching input order
A test with one item proves almost nothing about a list-based API.
The real definition of Flow-friendly
Flow-friendly Apex is not Apex that appears in the action picker.
It is Apex whose configuration is understandable, whose bulk behaviour is predictable, and whose results let the Flow make the next decision without parsing exception prose.
That requires slightly more design than adding @InvocableMethod.
It saves considerably more design everywhere the action is used.