SfSkills Domains Docs GitHub

Home / flow / flow-invocable-from-apex

flow-invocable-from-apex

Author @InvocableMethod Apex classes that Flow can call as Actions. Design the input / output variable contract, bulk semantics (one list in, one list out), null handling, and error surfacing. Also covers the inverse direction: calling a flow from Apex via Flow.Interview. NOT for wiring the action into Flow Builder and mapping flow variables — use flow/flow-action-framework. NOT for exposing the same Apex over REST — use apex/apex-rest-services.

Open the skill folder on GitHub


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

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:

  1. Confirm the routing — this step is genuinely Apex (automation-selection.md), and inside a flow that should remain declarative.
  2. Design the bulk contract first — input list shape, output list shape, one-to-one or one-to-many mapping.
  3. Author request + response DTOs with @InvocableVariable annotations. Include description on every field — it shows up in Flow Builder.
  4. Implement the method bulk-safe — bulk query, loop over inputs to assemble work, bulk DML at the end.
  5. Handle nulls explicitly — Flow can pass null collections when the caller forgot to provide inputs; return an empty list, don't throw.
  6. Wire error surfacing — either throw AuraHandledException (if the calling Flow should fault) or populate an error output field (if the flow should branch on failure).
  7. 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:

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:

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

Error handling

Three strategies, in order of preference:

  1. 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.
  2. Flow Fault Path via thrown exception. Throw AuraHandledException with a user-safe message; Flow's Fault connector captures it. Best for system failures that require admin logging / rollback.
  3. 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

Testing

Every invocable must have tests covering:

  1. Happy path, single record — one input, one output, fields set as expected.
  2. Happy path, bulk (N=200) — 200 inputs, 200 outputs, no governor limits hit, order preserved.
  3. Null input collection — calculate(null) returns [] without throwing.
  4. Empty input collection — calculate(new List<Request>()) returns [].
  5. Partial failure — some inputs resolve, others populate the error field.
  6. Sharing context — run the test as a non-admin to verify with sharing respects 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

Package files