Straight answer: mark a static method @InvocableMethod, give it exactly one parameter that is a List, and return a List holding one entry per input entry, in the same order. Flow batches records into a single call, so a return list that is shorter, longer, or reordered hands the wrong output to the wrong record, and nothing errors. That one rule causes more broken Apex actions than everything else combined.
The signature the platform will actually accept
The Apex Developer Guide’s rules for @InvocableMethod are narrow, and most of them fail at compile or deploy time rather than at run time, which is the good outcome. The method must be static and public or global, and its class must be an outer class. You get one invocable method per class, not one per interface, one per class. The only annotation you may combine it with is Deprecated.
There can be at most one input parameter, and its type must be one of: a list of primitives or nested lists of primitives, a list of a specific sObject or List<sObject>, List<List<sObject>>, or a list of your own Apex class whose members carry @InvocableVariable.
@InvocableMethod(
label='Assign Territory'
description='Sets the owning territory for each account'
category='Sales Ops'
)
public static List<Response> assign(List<Request> requests) { ... }
On the wrapper classes: only global and public variables can be invocable variables. A static, local, final, protected, or private member is rejected, and so is a property — public String code { get; set; } will not surface in Flow Builder. From API version 66.0 onward, an Apex class used for invocable action parameters also needs a visible no-argument constructor, so if you wrote a wrapper with only a convenience constructor, add the empty one back.
The list contract is the bug you are going to hit
Flow does not call your action once per record. When a record-triggered flow processes 200 Contacts, the platform collapses those 200 interviews into a single invocation of your method with a 200-entry input list, then distributes the results back by position. The documentation is unusually direct about the consequence: “For a correct bulkification implementation, the Inputs and Outputs must match on both the size and the order.” The same page states that the i-th output entry must correspond to the i-th input entry.
Break that and there is no exception, no fault path, no debug log entry. Record 12 gets record 11’s answer. Somebody notices in three weeks when a report looks wrong.
Three ways people break it, in rough order of frequency:
1. Skipping inputs that don’t need work
public static List<Response> assign(List<Request> requests) {
List<Response> out = new List<Response>();
for (Request r : requests) {
if (r.accountId == null) {
continue; // 200 in, 187 out — everything after shifts
}
out.add(process(r));
}
return out;
}
The continue reads like a harmless guard. It is the defect. Every entry after the first skipped one is misaligned by however many you dropped.
2. Returning map.values()
Building results into a Map<Id, Response> and returning its values is tidy and wrong. A Map’s iteration order is not your input order, and duplicate Ids in the input collapse to one entry. Both problems show up only under bulk.
3. Adding an output only when a query found a row
Query returns 47 rows for 50 inputs, loop over the query results, add 47 outputs. Same misalignment, harder to spot because the code contains no visible skip.
The fix in all three cases is the same discipline: add exactly one output per input, including for the do-nothing case. If a record needs no work, return an empty or default response for it rather than nothing.
A bulk-safe implementation
One query outside the loop, one output appended per input, and per-row failures reported in the response instead of thrown.
public with sharing class TerritoryAssignAction {
public class Request {
@InvocableVariable(label='Account Id' required=true)
public Id accountId;
@InvocableVariable(label='Country Code')
public String countryCode;
public Request() {}
}
public class Response {
@InvocableVariable(label='Territory Name')
public String territoryName;
@InvocableVariable(label='Success')
public Boolean success;
@InvocableVariable(label='Error Message')
public String errorMessage;
public Response() {}
}
@InvocableMethod(label='Assign Territory' category='Sales Ops')
public static List<Response> assign(List<Request> requests) {
Set<String> codes = new Set<String>();
for (Request r : requests) {
if (r.countryCode != null) {
codes.add(r.countryCode.toUpperCase());
}
}
Map<String, Territory_Rule__c> rulesByCode = new Map<String, Territory_Rule__c>();
for (Territory_Rule__c rule : [
SELECT Country_Code__c, Territory_Name__c
FROM Territory_Rule__c
WHERE Country_Code__c IN :codes
]) {
rulesByCode.put(rule.Country_Code__c, rule);
}
List<Response> results = new List<Response>();
for (Request r : requests) {
Response res = new Response();
res.success = false;
if (r.countryCode == null) {
res.errorMessage = 'No country code supplied';
} else {
Territory_Rule__c rule = rulesByCode.get(r.countryCode.toUpperCase());
if (rule == null) {
res.errorMessage = 'No rule for ' + r.countryCode;
} else {
res.territoryName = rule.Territory_Name__c;
res.success = true;
}
}
results.add(res); // unconditional — this is the point
}
return results;
}
}
Note that results.add(res) sits outside every branch. That placement is the invariant. If you find yourself indenting it, stop.
Throwing an exception fails the whole batch, not one record
You have two error strategies and they behave very differently under bulk.
Throw. An uncaught exception from the invocable method fails the action. If the Action element has no fault connector, the interview fails and the transaction rolls back. Connect a fault path off the element and you can read {!$Flow.FaultMessage}, log it, and carry on. But because the call was batched, one bad record takes down the other 199 with it — they were all in the same invocation and the same transaction.
Return the error. The success and errorMessage fields above let each interview branch on its own outcome with a Decision element. Nothing rolls back, good records still get processed, and failures land somewhere a human can see them.
My default is: return errors for anything data-dependent (missing rule, bad country code, record locked), throw only for genuine programming faults where continuing would write wrong data. The tradeoff is real, though — returned errors are invisible unless the flow actually checks the flag, and it is easy to ship a flow that ignores success entirely. A thrown exception is loud. A returned error is polite and easy to miss.
Test the Apex without the flow, then test the flow
An invocable method is an ordinary static method. Call it directly from a test class with a hand-built list and you get real assertions, fast, without touching Flow Builder.
@IsTest
private class TerritoryAssignActionTest {
@IsTest
static void returnsOneOutputPerInputInOrder() {
insert new Territory_Rule__c(
Country_Code__c = 'FR', Territory_Name__c = 'EMEA West'
);
List<TerritoryAssignAction.Request> reqs =
new List<TerritoryAssignAction.Request>();
for (Integer i = 0; i < 200; i++) {
TerritoryAssignAction.Request r = new TerritoryAssignAction.Request();
r.countryCode = (Math.mod(i, 2) == 0) ? 'FR' : null; // half will fail
reqs.add(r);
}
Test.startTest();
List<TerritoryAssignAction.Response> res =
TerritoryAssignAction.assign(reqs);
Test.stopTest();
System.assertEquals(reqs.size(), res.size(), 'Output size must match input');
System.assertEquals(true, res[0].success);
System.assertEquals(false, res[1].success);
System.assertEquals(true, res[198].success);
}
}
The size assertion is the one that earns its keep. Make it the first line of every invocable test you write, and the misalignment bug becomes impossible to ship. Pass a list of 200 with mixed valid and invalid entries — a list of one will pass no matter how broken the alignment logic is.
Once the Apex is proven, use Flow Tests (Setup → Flows → open the flow → Tests) to cover the wiring: that you mapped the right fields into the request and read the right fields out. Flow Tests cover record-triggered and autolaunched flows, and per Salesforce’s documentation they do not support flows that run when a record is deleted, so a delete-triggered flow still needs manual verification.
Frequently asked
Why did every record in my batch get the same value?
Almost always a return list of length one. Your method computed a single result and returned new List<Response>{ res }. Flow matched it to input 0 and left the rest empty or repeated. Assert the size in a test with 200 inputs.
Can I have two invocable methods in one Apex class?
No. One per class. If you have two related actions, that is two classes, which is also better for testing, since each has one entry point.
Do I need callout=true on the annotation?
Set it if the method makes an HTTP callout. It tells the platform the action performs an external call, which matters for where the action is allowed to run. Its default is false.
Does the Apex action get its own governor limits?
No. It runs in the flow’s transaction and shares the same budget: 100 SOQL queries, 150 DML statements, 10,000 ms CPU synchronously. A query inside your loop consumes the flow’s allowance, not a private one.
Can I return something other than a list?
You can return null if the action produces no output, but if you return values they must be a list matching the input’s size and order. There is no supported single-object return.
How do I pass a collection variable into an Apex action?
Two ways, and they mean different things. A wrapper field typed as a list lets one input entry carry a collection. Useful when a single interview needs to send many child records. Mapping a flow collection to the action’s top-level input instead makes each element a separate input entry, so you get one output per element back.
Reference: InvocableMethod Annotation and InvocableVariable Annotation, Salesforce Apex Developer Guide; Execution Governors and Limits. Flow Test coverage per “Automatically Test Record-Triggered, Autolaunched, and Data Cloud-Triggered Flows”, Salesforce Help.