Flow ↔ Apex — Invocable Methods
Core concept — Flow-to-Apex is a bulk contract
When a Flow calls an Invocable Apex method, Flow does NOT call once per record. It calls once with a List<T>.
// Wrong — treats input as if it were one record per call.
@InvocableMethod(label='Geocode Address')
public static Result geocode(String street) { ... } // COMPILE ERROR
// Right — Flow passes a List even if the caller "looks" single-record.
@InvocableMethod(label='Geocode Address')
public static List<Result> geocode(List<Request> requests) { ... }
This is identical to the bulk contract of a trigger. Flow is responsible for bulking up the records; your invocable is responsible for handling the list.
Consequence
- An invocable that does SOQL inside a per-request loop will hit the 100-query limit at ~50 records.
- An invocable that does DML inside a per-request loop will hit the 150-DML-statement limit at ~75 records.
- Always query once for the whole input list, DML once for the whole result list.
The contract surface
An @InvocableMethod has six settable parameters. Treat them as a public API — changes break every flow consuming the action.
@InvocableMethod(
label='Calculate Shipping Rate',
description='Returns a shipping rate for each provided address.',
category='Logistics',
callout=false, // true if you do HTTP callouts
iconName='standard:shipment'
)
public static List<RateResult> calculateRates(List<RateRequest> requests) { ... }
Three more things Flow authors see:
- Input wrapper class — fields marked
@InvocableVariable(required=true label='...' description='...'). - Output wrapper class — same annotation; description appears in Flow's Output pane.
- The wrapper class itself — must be a top-level or nested public class; top-level is better for reuse.
Recommended Workflow
- Confirm the routing — this step is genuinely Apex (
automation-selection.md), and inside a flow that should remain declarative. - Design the bulk contract first — input list shape, output list shape, one-to-one or one-to-many mapping.
- Author request + response DTOs with
@InvocableVariableannotations. Includedescriptionon every field — it shows up in Flow Builder. - Implement the method bulk-safe — bulk query, loop over inputs to assemble work, bulk DML at the end.
- Handle nulls explicitly — Flow can pass null collections when the caller forgot to provide inputs; return an empty list, don't throw.
- Wire error surfacing — either throw
AuraHandledException(if the calling Flow should fault) or populate anerroroutput field (if the flow should branch on failure). - Test and document the contract. Write a test class (single-record, bulk, null-collection, partial-failure, governor-stress at N=200) and publish a short markdown block for Flow authors — input/output names, types, and fault behavior.
Key patterns
Pattern 1 — Bulk-safe shipping calculator
public class ShippingInvocable {
public class RateRequest {
@InvocableVariable(required=true label='Postal Code')
public String postalCode;
@InvocableVariable(required=true label='Weight (kg)')
public Decimal weightKg;
}
public class RateResult {
@InvocableVariable(label='Rate (USD)')
public Decimal rateUsd;
@InvocableVariable(label='Carrier')
public String carrier;
@InvocableVariable(label='Error Message')
public String error;
}
@InvocableMethod(
label='Calculate Shipping Rate',
description='Returns a shipping rate per postal code / weight.',
category='Logistics',
callout=false
)
public static List<RateResult> calculate(List<RateRequest> requests) {
if (requests == null || requests.isEmpty()) {
return new List<RateResult>();
}
// Bulk query — one SOQL regardless of input size.
Set<String> codes = new Set<String>();
for (RateRequest r : requests) codes.add(r.postalCode);
Map<String, Shipping_Rate__mdt> rateMap =
new Map<String, Shipping_Rate__mdt>();
for (Shipping_Rate__mdt rate :
[SELECT Postal_Code__c, Rate_Usd__c, Carrier__c
FROM Shipping_Rate__mdt
WHERE Postal_Code__c IN :codes]) {
rateMap.put(rate.Postal_Code__c, rate);
}
List<RateResult> results = new List<RateResult>();
for (RateRequest r : requests) {
RateResult rr = new RateResult();
Shipping_Rate__mdt rate = rateMap.get(r.postalCode);
if (rate == null) {
rr.error = 'No rate configured for ' + r.postalCode;
} else {
rr.rateUsd = rate.Rate_Usd__c * r.weightKg;
rr.carrier = rate.Carrier__c;
}
results.add(rr);
}
return results;
}
}
Why this shape:
- One SOQL for any input size.
- Output list order matches input list order — Flow's Loop element relies on this invariant.
- Errors go in an
errorfield so the Flow can branch on it; no exception is thrown.
Pattern 2 — Action with a callout
@InvocableMethod(
label='Geocode Address',
description='Calls the geocoding vendor and returns lat/lng.',
category='Address Hygiene',
callout=true // CRITICAL: required for callout actions
)
public static List<GeoResult> geocode(List<GeoRequest> requests) { ... }
Setting callout=true does two things:
- Forces the calling Flow to be called from an async context (Scheduled Path or autolaunched called from
Queueable). - Reserves the 10-second vs 60-second CPU limit appropriately.
Pattern 3 — Calling Flow from Apex
The inverse direction: Apex needs to run a flow.
Map<String, Object> inputs = new Map<String, Object>{
'recordId' => oppId,
'stageName' => 'Negotiation'
};
Flow.Interview.MyFlow interview = new Flow.Interview.MyFlow(inputs);
interview.start();
Object out = interview.getVariableValue('outputStatus');
Or the generic form when the flow name is dynamic:
Flow.Interview flow = Flow.Interview.createInterview('MyFlowName', inputs);
flow.start();
Both forms run the flow in the current transaction and share governor limits (see flow-transactional-boundaries).
Bulk safety
- Design every invocable as if it will receive 200 inputs, because a trigger-initiated flow batch can route that many through a single action call.
- Output list length must match input list length. Flow's Loop element walks inputs and outputs in parallel; drift causes silent data loss.
- Keep state on the input wrapper, not in class-level statics. Two flows using the same invocable can run in the same transaction; static caches leak data across calls.
- Never do SOQL / DML inside a per-request loop. Query once, DML once.
Error handling
Three strategies, in order of preference:
- Soft error via output field. Populate
result.error = 'message'; Flow branches on{!Result.error != null}. Best for business-rule failures that the admin should handle. - Flow Fault Path via thrown exception. Throw
AuraHandledExceptionwith a user-safe message; Flow's Fault connector captures it. Best for system failures that require admin logging / rollback. - Fatal exception. Throw a plain
Exception; Flow errors out and the transaction rolls back. Use only when the work MUST be atomic with the caller.
Never catch-and-swallow in an invocable. Admins debugging flows can't see Apex logs; swallowed errors become silent data corruption.
Well-Architected mapping
- Reliability — bulk-safe contracts make invocables survive under load without mysterious
LimitExceptions. Null-input handling avoidsNullPointerExceptions when flows pass empty collections. - Security — invocables run with the
with sharingposture declared on the class; default is inherited. Usewith sharingunless you have a specific reason. Enforce FLS on reads withWITH USER_MODE(API 57.0+; at 67.0+ user mode is the default and no keyword is needed), and on writes withSecurity.stripInaccessible(AccessType.CREATABLE, records).getRecords().WITH SECURITY_ENFORCEDis the idiom for classes pinned at API ≤56.0 only — it was removed in API 67.0 and no longer compiles there. The gate is theapiVersionin the class's.cls-meta.xml, not the org's release: a Summer '26 org still runs a 58.0-pinned class with the old clause. - Performance — a well-bulked invocable is cheaper per record than equivalent Flow logic because Apex can batch SOQL/DML more aggressively than Flow elements.
Testing
Every invocable must have tests covering:
- Happy path, single record — one input, one output, fields set as expected.
- Happy path, bulk (N=200) — 200 inputs, 200 outputs, no governor limits hit, order preserved.
- Null input collection —
calculate(null)returns[]without throwing. - Empty input collection —
calculate(new List<Request>())returns[]. - Partial failure — some inputs resolve, others populate the
errorfield. - Sharing context — run the test as a non-admin to verify
with sharingrespects record-level access.
See skills/apex/apex-testing-patterns for test factory patterns.
@IntegrationTest for live Agentforce / Data 360 callouts (Developer Preview)
Standard @IsTest methods mock callouts and roll back all DML — they cannot assert on real Agentforce or Data 360 responses. Summer '26 adds @IntegrationTest (class or method) plus @TearDown cleanup methods. Integration tests may perform live callouts and commit mid-transaction via IntegrationTest.commitTestOnly().
Constraints as documented in the Summer '26 developer guide: Developer Preview, scratch orgs only, feature flag ApexIntegrationTests in the scratch org definition, tests run asynchronously one at a time via Tooling runTestsAsynchronous. Use for invocable actions that call Agentforce or Data 360 in ways @IsTest cannot simulate — not as a replacement for bulk-safe unit tests of the invocable contract itself.
Gotchas
See references/gotchas.md.
Official Sources Used
- Salesforce Developer —
@InvocableMethodAnnotation: https://developer.salesforce.com/docs/atlas.en-us.apexref.meta/apexref/apex_classes_annotation_InvocableMethod.htm - Salesforce Developer —
@InvocableVariableAnnotation: https://developer.salesforce.com/docs/atlas.en-us.apexref.meta/apexref/apex_classes_annotation_InvocableVariable.htm - Salesforce Developer — Flow.Interview Class: https://developer.salesforce.com/docs/atlas.en-us.apexref.meta/apexref/apex_class_Flow_Interview.htm
- Salesforce Help — Customize Flow Behavior with Apex: https://help.salesforce.com/s/articleView?id=sf.flow_ref_elements_apex.htm
- Salesforce Architects — Well-Architected Framework: https://architect.salesforce.com/design/architecture-framework/well-architected