---
title: "Invocable Apex for Flow, done bulk-safe"
url: "https://trailassembly.com/invocable-apex-bulk-safe/"
topic: "Apex and Development"
published: "2026-09-11T14:08:29+00:00"
updated: "2026-10-06T13:53:10+00:00"
---

# Invocable Apex for Flow, done bulk-safe

**The short answer:** an `@InvocableMethod` receives a *list*, and the list is how Flow bulkifies. Writing one that takes a list, processes element zero, and returns a single result works perfectly in a [screen flow](https://trailassembly.com/screen-flow-validation/) and fails silently the first time a record-triggered flow passes it 200 records.

## The signature is the contract

```apex
public with sharing class CreditCheckAction {

    public class Request {
        @InvocableVariable(required=true) public Id accountId;
        @InvocableVariable public Decimal amount;
    }

    public class Result {
        @InvocableVariable public Boolean approved;
        @InvocableVariable public String reason;
    }

    @InvocableMethod(label='Run Credit Check' category='Finance')
    public static List<Result> run(List<Request> requests) {
        ...
    }
}
```

Four rules the compiler enforces and one it does not. It must be `static`, there can be only one per class, it takes exactly one parameter which is a list, and it returns a list or nothing. The rule it does not enforce is the important one: **the returned list must have the same number of elements as the input, in the same order.** Flow maps result *n* back to input *n*. Return one element for a 200-element input and 199 records get nothing, with no error.

## What bulk-safe actually means here

Collect everything first, do the work in sets, then build results in input order:

```apex
@InvocableMethod(label='Run Credit Check')
public static List<Result> run(List<Request> requests) {
    Set<Id> accountIds = new Set<Id>();
    for (Request r : requests) { accountIds.add(r.accountId); }

    Map<Id, Account> accounts = new Map<Id, Account>([
        SELECT Id, Credit_Limit__c, Outstanding__c
        FROM Account WHERE Id IN :accountIds
    ]);

    List<Result> out = new List<Result>();
    for (Request r : requests) {
        Account a = accounts.get(r.accountId);
        Result res = new Result();
        res.approved = a != null && (a.Credit_Limit__c - a.Outstanding__c) >= r.amount;
        res.reason   = a == null ? 'Account not found'
                     : res.approved ? 'Within limit' : 'Exceeds available credit';
        out.add(res);
    }
    return out;
}
```

One query for any number of requests, and a result for every input including the ones that failed. Returning a result with a reason beats throwing, because a thrown exception fails the whole flow interview rather than the one record that had a problem.

## The custom-type wrapper is worth the extra code

You can declare primitive parameters instead — `List<Id>` in, `List<Boolean>` out, and it is tempting for a simple action. It stops being simple the first time you need a second input, because adding one means a new method and a new action, and every flow using the old one has to be edited.

A wrapper class with `@InvocableVariable` fields lets you add optional inputs without breaking anything already built. Mark only genuinely required things `required=true`, and give each variable a `label` and `description`, because those are what an admin sees in Flow Builder and the default is your field name.

## Where the limits land

The invocable method runs inside the flow’s transaction and shares its limits. A record-triggered flow on 200 records calls your method once with 200 requests, so one query is one query. But a flow that calls the action inside a loop calls the method once per iteration, and there is nothing your Apex can do about that. The fix is in the flow.

That is worth telling whoever builds the flow. An action designed for bulk, invoked in a loop, hits the [101 query limit](https://trailassembly.com/soql-101/) at exactly the same point as any other query in a loop.

## Callouts need the annotation

`@InvocableMethod(callout=true)` tells the platform this action makes a callout. Without it, a flow that has already done DML fails with [uncommitted work](https://trailassembly.com/uncommitted-work-pending/) pending. With it, Flow can arrange the transaction correctly, and in some cases will not let the action run where a callout is impossible.

## Frequently asked

**Can it be `@AuraEnabled` too?** Not on the same method. Write a thin invocable wrapper around a shared service class. That is the right structure anyway.

**Why does my action not appear in Flow Builder?** Usually the class is not `global` or `public`, the method is not static, or the org has not finished compiling. Check the category and label too. It may be listed somewhere you did not look.

**Can I return nothing?** Yes, `void` is allowed and the action simply has no output. Useful for fire-and-forget work, and it removes the ordering concern entirely.

---

Source: https://trailassembly.com/invocable-apex-bulk-safe/
Trail Assembly — independent Salesforce write-ups.
