Salesforce · · 28 min read

Creating Apex for Flows

Learn how to build invocable Apex actions for Flows — @InvocableMethod, @InvocableVariable, input/output wrapper classes, bulkification, supported data types, error handling, testing, and a complete hands-on project.

Part 48: Creating Apex for Flows

Welcome back to the Salesforce series. In Part 44 we explored Apex triggers, the programmatic backbone for reacting to record changes. In Parts 45 through 47 we covered testing, debugging, and advanced class patterns. Now it is time to bridge the gap between declarative automation and programmatic logic. Flows are the standard declarative automation tool on the Salesforce platform, but there are situations where a Flow alone cannot accomplish what you need. Complex calculations, external callouts, advanced string manipulation, or multi-object operations that would be clunky inside a Flow can be neatly packaged into an Apex class and exposed to Flow Builder as an invocable action.

This is Part 48 of the series, and it focuses entirely on creating Apex that Flows can call. We will cover the two annotations that make this possible — @InvocableMethod and @InvocableVariable — along with input and output wrapper classes, bulkification, supported data types, error handling, testing strategies, and best practices. We will finish with a complete project: an invocable action that accepts Account IDs from a Flow, looks up related Opportunities, calculates revenue statistics, and returns the results to the Flow for further processing.


Why Would a Flow Need Apex?

Flows are powerful. They can query records, loop over collections, make decisions, create and update records, send emails, call sub-flows, and much more. But they have limits:

  • Complex math or string operations. Flows can do basic formulas, but advanced calculations (weighted averages, statistical functions, custom rounding logic) are awkward or impossible.
  • Callouts to external APIs. While Flows can make HTTP callouts via External Services, custom REST or SOAP integrations with nuanced error handling and response parsing are better handled in Apex.
  • Multi-object transactional logic. When you need to query across several objects, perform conditional DML on different records, and roll everything back on failure, Apex gives you fine-grained control.
  • Performance-sensitive operations. Apex lets you optimize queries, use maps for lookups, and process large collections efficiently in ways that Flow loops cannot match.
  • Reusable logic. An invocable action can be called from any Flow, any Process Builder (legacy), or even from REST API. It becomes a shared service that multiple automations consume.

The mechanism for exposing Apex to Flows is the invocable action. Let us see how it works.


The @InvocableMethod Annotation

The @InvocableMethod annotation marks a static method in a public class so that it becomes visible in Flow Builder (and other automation tools). When an admin builds a Flow and adds an Action element, every class with an @InvocableMethod appears in the list of available actions.

Basic Syntax

public class MyInvocableAction {

    @InvocableMethod(label='My Custom Action' description='Does something useful')
    public static List<String> execute(List<String> inputs) {
        List<String> results = new List<String>();
        for (String input : inputs) {
            results.add(input.toUpperCase());
        }
        return results;
    }
}

Key rules:

  1. The method must be public static.
  2. The method must accept either no parameters or exactly one parameter, and that parameter must be a List<T>.
  3. The method can return void or a List<T>.
  4. There can be only one @InvocableMethod per class.

Annotation Parameters

The @InvocableMethod annotation supports several parameters that control how the action appears in Flow Builder:

ParameterTypeDescription
labelStringThe display name shown in Flow Builder. If omitted, the method name is used.
descriptionStringA short description shown in Flow Builder to help admins understand what the action does.
calloutBooleanSet to true if the method makes an HTTP callout. This tells the runtime to allow callouts in the transaction. Defaults to false.
categoryStringGroups the action under a category heading in Flow Builder for easier discovery.
@InvocableMethod(
    label='Calculate Account Revenue'
    description='Accepts Account IDs and returns revenue statistics for each account.'
    callout=false
    category='Account Operations'
)
public static List<Response> calculateRevenue(List<Request> requests) {
    // implementation
}

When an admin opens Flow Builder, clicks Add Element, selects Action, and searches, they will see “Calculate Account Revenue” under the “Account Operations” category with the description displayed as helper text.


The @InvocableVariable Annotation

While simple invocable methods can accept a List<String> or a List<Id>, most real-world actions need structured input and output. This is where @InvocableVariable comes in. You define inner classes (or standalone classes) whose public member variables are annotated with @InvocableVariable, and those variables appear as configurable fields in Flow Builder.

Basic Syntax

public class AccountRevenueAction {

    public class Request {
        @InvocableVariable(label='Account ID' description='The ID of the Account to process' required=true)
        public Id accountId;

        @InvocableVariable(label='Include Closed Won Only' description='If true, only Closed Won opportunities are included' required=false)
        public Boolean closedWonOnly;
    }

    public class Response {
        @InvocableVariable(label='Total Revenue' description='Sum of opportunity amounts')
        public Decimal totalRevenue;

        @InvocableVariable(label='Opportunity Count' description='Number of opportunities found')
        public Integer opportunityCount;
    }

    @InvocableMethod(label='Calculate Account Revenue' description='Returns revenue stats for accounts')
    public static List<Response> calculate(List<Request> requests) {
        // implementation
    }
}

Annotation Parameters

ParameterTypeDescription
labelStringThe display name for this variable in Flow Builder.
descriptionStringHelper text shown in Flow Builder.
requiredBooleanIf true, the Flow admin must provide a value for this field. Defaults to false.

Rules for Invocable Variable Classes

  1. The class must be public or global.
  2. Member variables must be public and non-static.
  3. Supported data types include primitives (String, Integer, Decimal, Boolean, Date, DateTime, Time, Long, Double), Id, SObject, List<T> of supported types, and Apex-defined types (custom classes with @InvocableVariable members).
  4. The class can be an inner class of the class containing the @InvocableMethod, or it can be a standalone top-level class.

Input and Output Wrapper Classes

The standard pattern for invocable actions uses two inner classes: one for input (commonly named Request, Input, or ActionInput) and one for output (commonly named Response, Result, or ActionOutput). This pattern gives Flow admins a clean interface with named fields rather than positional parameters.

The Request-Response Pattern

public class CreateTaskForContactAction {

    public class Request {
        @InvocableVariable(label='Contact ID' required=true)
        public Id contactId;

        @InvocableVariable(label='Task Subject' required=true)
        public String subject;

        @InvocableVariable(label='Task Due Date' required=false)
        public Date dueDate;

        @InvocableVariable(label='Task Priority' required=false)
        public String priority;
    }

    public class Response {
        @InvocableVariable(label='Task ID')
        public Id taskId;

        @InvocableVariable(label='Success')
        public Boolean isSuccess;

        @InvocableVariable(label='Error Message')
        public String errorMessage;
    }

    @InvocableMethod(label='Create Task for Contact' description='Creates a task linked to a contact' category='Task Operations')
    public static List<Response> createTasks(List<Request> requests) {
        List<Response> responses = new List<Response>();
        List<Task> tasksToInsert = new List<Task>();
        Map<Integer, Response> indexToResponse = new Map<Integer, Response>();

        for (Integer i = 0; i < requests.size(); i++) {
            Request req = requests[i];
            Response res = new Response();
            responses.add(res);

            Task t = new Task();
            t.WhoId = req.contactId;
            t.Subject = req.subject;
            t.ActivityDate = req.dueDate != null ? req.dueDate : Date.today().addDays(7);
            t.Priority = String.isNotBlank(req.priority) ? req.priority : 'Normal';
            tasksToInsert.add(t);
            indexToResponse.put(i, res);
        }

        Database.SaveResult[] saveResults = Database.insert(tasksToInsert, false);

        for (Integer i = 0; i < saveResults.size(); i++) {
            Response res = indexToResponse.get(i);
            if (saveResults[i].isSuccess()) {
                res.taskId = saveResults[i].getId();
                res.isSuccess = true;
            } else {
                res.isSuccess = false;
                res.errorMessage = saveResults[i].getErrors()[0].getMessage();
            }
        }

        return responses;
    }
}

Notice several things about this pattern:

  • The method accepts List<Request> and returns List<Response>.
  • The output list must have the same number of elements as the input list, in the same order. The platform matches them positionally — the first response corresponds to the first request.
  • DML is performed in bulk outside the loop.
  • Partial failures are handled gracefully using Database.insert with allOrNone = false.

Bulkification Considerations

This is critical. When a Flow runs in a record-triggered context (for example, a record-triggered Flow on Account after insert), and a batch of 200 records is inserted, the platform does not call your invocable method 200 separate times. Instead, it calls the method once with a List<Request> containing 200 elements. Your code must handle this correctly.

The Anti-Pattern: SOQL in a Loop

// BAD — this will hit governor limits with bulk data
@InvocableMethod(label='Get Account Owner Email')
public static List<String> getOwnerEmails(List<Id> accountIds) {
    List<String> emails = new List<String>();
    for (Id accId : accountIds) {
        // SOQL inside a loop — governor limit violation
        Account acc = [SELECT Owner.Email FROM Account WHERE Id = :accId LIMIT 1];
        emails.add(acc.Owner.Email);
    }
    return emails;
}

The Correct Pattern: Bulk Query Outside the Loop

// GOOD — single query handles all records
@InvocableMethod(label='Get Account Owner Email')
public static List<String> getOwnerEmails(List<Id> accountIds) {
    Map<Id, Account> accountMap = new Map<Id, Account>(
        [SELECT Id, Owner.Email FROM Account WHERE Id IN :accountIds]
    );

    List<String> emails = new List<String>();
    for (Id accId : accountIds) {
        Account acc = accountMap.get(accId);
        emails.add(acc != null ? acc.Owner.Email : null);
    }
    return emails;
}

Bulkification Checklist

  1. SOQL queries — Always query outside loops. Use WHERE Id IN :collection to fetch all records in one query.
  2. DML operations — Collect records in a list and perform a single insert, update, or delete after the loop.
  3. Maps for lookups — Build Map<Id, SObject> structures from queries so you can look up records by ID in O(1) time inside loops.
  4. Response ordering — Maintain the same order and size as the input list. Use index-based iteration if needed.
  5. Governor limits — Remember that your method runs in the same transaction as the Flow. All SOQL queries, DML statements, and CPU time used by the Flow and your Apex share the same governor limits.

Supported Data Types

Invocable methods and variables support a specific set of data types. Understanding these is important because Flow Builder maps its variable types to these Apex types.

Primitive Types

Apex TypeFlow Variable Type
StringText
IntegerNumber (no decimals)
DecimalNumber or Currency
BooleanBoolean
DateDate
DateTimeDate/Time
TimeTime
LongNumber
DoubleNumber
IdText (record ID)

SObject Types

You can use a specific sObject type (like Account, Contact, Opportunity) or the generic SObject type. In Flow Builder, a specific sObject type maps to a record variable of that type.

public class Request {
    @InvocableVariable(label='Account Record' required=true)
    public Account accountRecord;
}

Collection Types

You can use List<T> where T is any supported primitive or sObject type. In Flow Builder, this maps to a collection variable.

public class Request {
    @InvocableVariable(label='Contact IDs' required=true)
    public List<Id> contactIds;
}

public class Response {
    @InvocableVariable(label='Matching Contacts')
    public List<Contact> matchingContacts;
}

Apex-Defined Types

Starting with a relatively recent platform update, you can use custom Apex classes as invocable variable types. The custom class must have its members annotated with @InvocableVariable. This is useful for passing complex structured data.

public class AddressInfo {
    @InvocableVariable(label='Street')
    public String street;

    @InvocableVariable(label='City')
    public String city;

    @InvocableVariable(label='State')
    public String state;

    @InvocableVariable(label='Zip Code')
    public String zipCode;
}

public class GeocodingAction {

    public class Request {
        @InvocableVariable(label='Address to Geocode' required=true)
        public AddressInfo address;
    }

    public class Response {
        @InvocableVariable(label='Latitude')
        public Decimal latitude;

        @InvocableVariable(label='Longitude')
        public Decimal longitude;
    }

    @InvocableMethod(label='Geocode Address' callout=true category='Location Services')
    public static List<Response> geocode(List<Request> requests) {
        // implementation with external callout
    }
}

In Flow Builder, Apex-defined types appear as a special “Apex-Defined” data type. The admin creates a variable of that type and can set each of the annotated fields individually.


Error Handling Within Invocable Actions

Error handling in invocable actions requires careful thought because errors can affect the entire Flow transaction.

Approach 1: Let Exceptions Propagate

If you throw an unhandled exception, the Flow catches it and displays an error to the user (or logs it for autolaunched Flows). The entire transaction rolls back.

@InvocableMethod(label='Divide Numbers')
public static List<Decimal> divide(List<Request> requests) {
    List<Decimal> results = new List<Decimal>();
    for (Request req : requests) {
        if (req.divisor == 0) {
            throw new IllegalArgumentException('Cannot divide by zero. Check your Flow inputs.');
        }
        results.add(req.dividend / req.divisor);
    }
    return results;
}

This approach is simple but gives the Flow admin no way to handle the error gracefully.

Approach 2: Return Error Information in the Response

A more robust approach includes success/failure status and error messages in the response wrapper. This lets the Flow admin check the result and take alternative paths.

public class Response {
    @InvocableVariable(label='Is Success')
    public Boolean isSuccess;

    @InvocableVariable(label='Error Message')
    public String errorMessage;

    @InvocableVariable(label='Result')
    public Decimal result;
}

@InvocableMethod(label='Safe Divide Numbers')
public static List<Response> safeDivide(List<Request> requests) {
    List<Response> responses = new List<Response>();

    for (Request req : requests) {
        Response res = new Response();
        try {
            if (req.divisor == 0) {
                res.isSuccess = false;
                res.errorMessage = 'Division by zero is not allowed.';
            } else {
                res.result = req.dividend / req.divisor;
                res.isSuccess = true;
            }
        } catch (Exception e) {
            res.isSuccess = false;
            res.errorMessage = e.getMessage();
        }
        responses.add(res);
    }

    return responses;
}

Approach 3: Use FlowException for Fault Paths

You can throw a Flow.FlowException to send the error to the Flow’s fault connector, giving the admin a way to route error handling visually.

@InvocableMethod(label='Validate Account Data')
public static void validateAccounts(List<Request> requests) {
    for (Request req : requests) {
        if (String.isBlank(req.accountName)) {
            throw new Flow.FlowException('Account name cannot be blank.');
        }
    }
}

Which Approach to Use?

  • Use Approach 1 (propagate exceptions) for truly unexpected errors that should halt the transaction.
  • Use Approach 2 (return error info) when you want the Flow to decide what to do with failures — this is the most flexible and admin-friendly option.
  • Use Approach 3 (FlowException) when you want to leverage the Flow’s built-in fault path handling.

For most production invocable actions, Approach 2 is recommended because it gives Flow admins the most control.


How Flows Call Invocable Actions

Understanding how the Flow runtime invokes your Apex method helps you design better actions.

  1. The admin adds an Action element in Flow Builder and selects your invocable action by its label.
  2. Input fields appear based on your @InvocableVariable annotations. The admin maps Flow variables or literal values to each input field.
  3. Output fields appear based on the response class. The admin stores each output field in a Flow variable for later use.
  4. At runtime, the Flow engine collects all the input values and creates one Request object per Flow interview that is running in the same transaction. It bundles them into a List<Request> and calls your method once.
  5. Your method returns a List<Response>. The Flow engine distributes each response back to the corresponding Flow interview.

This is why bulkification matters. In a record-triggered Flow that fires on a batch insert of 200 records, your method receives 200 requests in a single call.

Screen Flows vs Record-Triggered Flows

  • In a Screen Flow, typically only one interview runs at a time, so your method receives a list with a single element. But you should still write bulkified code because the same class might be reused in a record-triggered context.
  • In a Record-Triggered Flow, multiple interviews can be batched together. Always assume the list can have up to 200 elements (or more in certain contexts).

Testing Invocable Actions

Testing invocable actions follows the same patterns as any other Apex test, with a few specifics worth noting.

Basic Test Structure

@isTest
private class AccountRevenueActionTest {

    @TestSetup
    static void setupData() {
        Account acc = new Account(Name = 'Test Account');
        insert acc;

        List<Opportunity> opps = new List<Opportunity>();
        opps.add(new Opportunity(
            Name = 'Opp 1',
            AccountId = acc.Id,
            StageName = 'Closed Won',
            Amount = 50000,
            CloseDate = Date.today()
        ));
        opps.add(new Opportunity(
            Name = 'Opp 2',
            AccountId = acc.Id,
            StageName = 'Closed Won',
            Amount = 30000,
            CloseDate = Date.today()
        ));
        opps.add(new Opportunity(
            Name = 'Opp 3',
            AccountId = acc.Id,
            StageName = 'Prospecting',
            Amount = 20000,
            CloseDate = Date.today().addDays(30)
        ));
        insert opps;
    }

    @isTest
    static void testCalculateRevenue_ClosedWonOnly() {
        Account acc = [SELECT Id FROM Account LIMIT 1];

        AccountRevenueAction.Request req = new AccountRevenueAction.Request();
        req.accountId = acc.Id;
        req.closedWonOnly = true;

        List<AccountRevenueAction.Request> requests = new List<AccountRevenueAction.Request>{ req };

        Test.startTest();
        List<AccountRevenueAction.Response> responses = AccountRevenueAction.calculate(requests);
        Test.stopTest();

        System.assertEquals(1, responses.size(), 'Should return one response per request');
        System.assertEquals(80000, responses[0].totalRevenue, 'Total should be 50000 + 30000');
        System.assertEquals(2, responses[0].opportunityCount, 'Should count only Closed Won opps');
        System.assert(responses[0].isSuccess, 'Should be successful');
    }

    @isTest
    static void testCalculateRevenue_AllOpportunities() {
        Account acc = [SELECT Id FROM Account LIMIT 1];

        AccountRevenueAction.Request req = new AccountRevenueAction.Request();
        req.accountId = acc.Id;
        req.closedWonOnly = false;

        List<AccountRevenueAction.Request> requests = new List<AccountRevenueAction.Request>{ req };

        Test.startTest();
        List<AccountRevenueAction.Response> responses = AccountRevenueAction.calculate(requests);
        Test.stopTest();

        System.assertEquals(100000, responses[0].totalRevenue, 'Total should include all opps');
        System.assertEquals(3, responses[0].opportunityCount, 'Should count all opps');
    }

    @isTest
    static void testCalculateRevenue_InvalidAccount() {
        AccountRevenueAction.Request req = new AccountRevenueAction.Request();
        req.accountId = '001000000000000AAA'; // non-existent ID
        req.closedWonOnly = false;

        List<AccountRevenueAction.Request> requests = new List<AccountRevenueAction.Request>{ req };

        Test.startTest();
        List<AccountRevenueAction.Response> responses = AccountRevenueAction.calculate(requests);
        Test.stopTest();

        System.assertEquals(0, responses[0].totalRevenue, 'Should return 0 for non-existent account');
        System.assertEquals(0, responses[0].opportunityCount, 'Should return 0 count');
    }

    @isTest
    static void testCalculateRevenue_BulkInput() {
        Account acc = [SELECT Id FROM Account LIMIT 1];

        List<AccountRevenueAction.Request> requests = new List<AccountRevenueAction.Request>();
        for (Integer i = 0; i < 200; i++) {
            AccountRevenueAction.Request req = new AccountRevenueAction.Request();
            req.accountId = acc.Id;
            req.closedWonOnly = true;
            requests.add(req);
        }

        Test.startTest();
        List<AccountRevenueAction.Response> responses = AccountRevenueAction.calculate(requests);
        Test.stopTest();

        System.assertEquals(200, responses.size(), 'Should return 200 responses');
        for (AccountRevenueAction.Response res : responses) {
            System.assertEquals(80000, res.totalRevenue, 'Each response should have correct total');
        }
    }
}

Testing Tips

  1. Test with a single request to verify basic functionality.
  2. Test with bulk requests (200 elements) to verify bulkification and governor limits.
  3. Test edge cases — null inputs, blank strings, non-existent IDs, zero amounts.
  4. Test error paths — ensure your error handling returns meaningful messages.
  5. Use Test.startTest() and Test.stopTest() to reset governor limits and verify your action runs within a fresh set of limits.
  6. Mock callouts if your invocable action uses callout=true. Use HttpCalloutMock as you would for any other callout test.

Making an Invocable Action Available in Flow Builder

Once your class is deployed to the target org, the invocable action appears automatically in Flow Builder. There are no additional configuration steps. However, there are some things to be aware of:

  1. Namespace. If your class is in a managed package, the action appears with the namespace prefix. Admins search for it using the label you defined.
  2. Visibility. The class must be public or global. If it is global, it is accessible across namespaces.
  3. API version. Ensure your class uses a recent API version. Older API versions may not support all invocable features.
  4. Category. Use the category parameter on @InvocableMethod to group related actions together. This helps admins find your action when they have many custom actions deployed.
  5. Labels and descriptions. Write clear, concise labels and descriptions. These are the only documentation the Flow admin sees inside Flow Builder. A well-labeled action reduces support requests.

Where Invocable Actions Can Be Used

Invocable actions are not limited to Flows. They can also be called from:

  • Flow Builder (Screen Flows, Record-Triggered Flows, Autolaunched Flows, Scheduled Flows)
  • REST API via the /services/data/vXX.0/actions/custom/apex/ClassName endpoint
  • Einstein Bot actions
  • Legacy Process Builder (not recommended for new work)

Best Practices

1. Single Responsibility

Each invocable action should do one thing well. Do not create a monolithic action that handles multiple unrelated operations based on a “mode” parameter. Instead, create separate classes for separate concerns.

// BAD — one class doing too many things
public class AccountUtilityAction {
    public class Request {
        @InvocableVariable public String operation; // "calculateRevenue" or "updateRating" or "mergeAccounts"
        // ... many fields, most irrelevant depending on operation
    }
}

// GOOD — separate classes with clear purposes
public class CalculateAccountRevenueAction { ... }
public class UpdateAccountRatingAction { ... }
public class MergeAccountsAction { ... }

2. Always Bulkify

Even if you think the action will only be used in Screen Flows (one record at a time), write bulk-safe code. The same class might be reused in a record-triggered Flow tomorrow.

3. Use Meaningful Labels and Descriptions

The label and description are your documentation for Flow admins. Be specific and clear.

// BAD
@InvocableMethod(label='Process Data')

// GOOD
@InvocableMethod(
    label='Calculate Weighted Pipeline Score'
    description='Computes a weighted score for each Account based on open Opportunity stages and amounts. Returns a score between 0 and 100.'
    category='Account Scoring'
)

4. Return Error Information

Always include success/failure status and error messages in your response wrapper. This gives Flow admins the ability to handle errors declaratively rather than having the entire Flow crash.

5. Validate Inputs

Check for null or invalid inputs at the start of your method and return meaningful error messages rather than letting the code throw a NullPointerException deep in the logic.

for (Request req : requests) {
    Response res = new Response();
    if (req.accountId == null) {
        res.isSuccess = false;
        res.errorMessage = 'Account ID is required but was not provided.';
        responses.add(res);
        continue;
    }
    // proceed with valid input
}

6. Keep Governor Limits in Mind

Your invocable action shares governor limits with the Flow that called it. The Flow may have already consumed some SOQL queries, DML statements, or CPU time before your method runs. Write efficient code and avoid unnecessary queries.

7. Document Assumptions

If your action assumes that certain fields are populated on the input records, or that certain custom metadata exists, document those assumptions in the description annotation and in code comments.


PROJECT: Account Revenue Calculator Invocable Action

Let us build a complete, production-quality invocable action from scratch. The scenario: admins want to use a Screen Flow that lets a user select one or more Accounts and see revenue statistics. The Flow collects the Account IDs and passes them to our invocable action. Our action queries related Opportunities, calculates statistics, and returns the results to the Flow.

Requirements

  1. Accept a list of Account IDs.
  2. For each Account, query all related Opportunities.
  3. Optionally filter to Closed Won only.
  4. Calculate: total revenue, average deal size, largest deal, smallest deal, and opportunity count.
  5. Return the results to the Flow with error handling.

The Complete Class

public class AccountRevenueAction {

    public class Request {
        @InvocableVariable(
            label='Account ID'
            description='The ID of the Account to analyze'
            required=true
        )
        public Id accountId;

        @InvocableVariable(
            label='Closed Won Only'
            description='Set to true to include only Closed Won opportunities. Defaults to false.'
            required=false
        )
        public Boolean closedWonOnly;
    }

    public class Response {
        @InvocableVariable(label='Is Success')
        public Boolean isSuccess;

        @InvocableVariable(label='Error Message')
        public String errorMessage;

        @InvocableVariable(label='Account Name')
        public String accountName;

        @InvocableVariable(label='Total Revenue')
        public Decimal totalRevenue;

        @InvocableVariable(label='Average Deal Size')
        public Decimal averageDealSize;

        @InvocableVariable(label='Largest Deal')
        public Decimal largestDeal;

        @InvocableVariable(label='Smallest Deal')
        public Decimal smallestDeal;

        @InvocableVariable(label='Opportunity Count')
        public Integer opportunityCount;
    }

    @InvocableMethod(
        label='Calculate Account Revenue Statistics'
        description='Queries Opportunities for the given Account and returns revenue statistics including total, average, min, max, and count.'
        category='Account Operations'
    )
    public static List<Response> calculate(List<Request> requests) {
        // Step 1: Collect all Account IDs and validate inputs
        Set<Id> allAccountIds = new Set<Id>();
        Map<Integer, Response> earlyResponses = new Map<Integer, Response>();

        for (Integer i = 0; i < requests.size(); i++) {
            Request req = requests[i];
            if (req.accountId == null) {
                Response res = new Response();
                res.isSuccess = false;
                res.errorMessage = 'Account ID is required but was not provided.';
                earlyResponses.put(i, res);
            } else {
                allAccountIds.add(req.accountId);
            }
        }

        // Step 2: Query all Accounts in bulk
        Map<Id, Account> accountMap = new Map<Id, Account>(
            [SELECT Id, Name FROM Account WHERE Id IN :allAccountIds]
        );

        // Step 3: Query all Opportunities in bulk for all Accounts
        List<Opportunity> allOpps = [
            SELECT Id, AccountId, Amount, StageName
            FROM Opportunity
            WHERE AccountId IN :allAccountIds
        ];

        // Step 4: Organize Opportunities by Account ID
        Map<Id, List<Opportunity>> oppsByAccount = new Map<Id, List<Opportunity>>();
        for (Opportunity opp : allOpps) {
            if (!oppsByAccount.containsKey(opp.AccountId)) {
                oppsByAccount.put(opp.AccountId, new List<Opportunity>());
            }
            oppsByAccount.get(opp.AccountId).add(opp);
        }

        // Step 5: Process each request and build responses
        List<Response> responses = new List<Response>();

        for (Integer i = 0; i < requests.size(); i++) {
            // If we already flagged an error for this index, use that response
            if (earlyResponses.containsKey(i)) {
                responses.add(earlyResponses.get(i));
                continue;
            }

            Request req = requests[i];
            Response res = new Response();

            Account acc = accountMap.get(req.accountId);
            if (acc == null) {
                res.isSuccess = false;
                res.errorMessage = 'Account with ID ' + req.accountId + ' was not found.';
                responses.add(res);
                continue;
            }

            res.accountName = acc.Name;

            // Get opportunities for this Account
            List<Opportunity> accountOpps = oppsByAccount.get(req.accountId);
            if (accountOpps == null) {
                accountOpps = new List<Opportunity>();
            }

            // Apply Closed Won filter if requested
            Boolean filterClosedWon = req.closedWonOnly != null && req.closedWonOnly;
            List<Opportunity> filteredOpps = new List<Opportunity>();
            for (Opportunity opp : accountOpps) {
                if (!filterClosedWon || opp.StageName == 'Closed Won') {
                    filteredOpps.add(opp);
                }
            }

            // Calculate statistics
            res.opportunityCount = filteredOpps.size();

            if (filteredOpps.isEmpty()) {
                res.totalRevenue = 0;
                res.averageDealSize = 0;
                res.largestDeal = 0;
                res.smallestDeal = 0;
            } else {
                Decimal total = 0;
                Decimal largest = 0;
                Decimal smallest = null;

                for (Opportunity opp : filteredOpps) {
                    Decimal amount = opp.Amount != null ? opp.Amount : 0;
                    total += amount;
                    if (amount > largest) {
                        largest = amount;
                    }
                    if (smallest == null || amount < smallest) {
                        smallest = amount;
                    }
                }

                res.totalRevenue = total;
                res.averageDealSize = total / filteredOpps.size();
                res.largestDeal = largest;
                res.smallestDeal = smallest;
            }

            res.isSuccess = true;
            responses.add(res);
        }

        return responses;
    }
}

Key Design Decisions

Let us walk through the design choices in this class:

  1. Input validation first. We loop through all requests and flag invalid ones before doing any queries. This prevents wasting SOQL queries on data we cannot process.

  2. Bulk queries. We collect all Account IDs into a Set<Id>, then run a single SOQL query for Accounts and a single SOQL query for Opportunities. No matter how many requests come in, we use exactly two queries.

  3. Map-based organization. After querying Opportunities, we organize them into a Map<Id, List<Opportunity>> keyed by Account ID. This lets us look up each Account’s Opportunities in constant time.

  4. In-memory filtering. Rather than running separate SOQL queries for “all opps” and “Closed Won opps,” we query all Opportunities once and filter in memory. This saves a SOQL query.

  5. Null-safe amount handling. The Amount field on Opportunity can be null. We treat null amounts as zero to avoid NullPointerException.

  6. Positional response matching. We iterate through requests by index and build the response list in the same order. Early-error responses are stored in a map keyed by index and inserted at the correct position.

The Complete Test Class

@isTest
private class AccountRevenueActionTest {

    @TestSetup
    static void setupData() {
        List<Account> accounts = new List<Account>();
        accounts.add(new Account(Name = 'Acme Corp'));
        accounts.add(new Account(Name = 'Globex Inc'));
        accounts.add(new Account(Name = 'Empty Account'));
        insert accounts;

        List<Opportunity> opps = new List<Opportunity>();

        // Acme Corp opportunities
        opps.add(new Opportunity(
            Name = 'Acme Deal 1', AccountId = accounts[0].Id,
            StageName = 'Closed Won', Amount = 100000, CloseDate = Date.today()
        ));
        opps.add(new Opportunity(
            Name = 'Acme Deal 2', AccountId = accounts[0].Id,
            StageName = 'Closed Won', Amount = 50000, CloseDate = Date.today()
        ));
        opps.add(new Opportunity(
            Name = 'Acme Deal 3', AccountId = accounts[0].Id,
            StageName = 'Prospecting', Amount = 75000, CloseDate = Date.today().addDays(30)
        ));

        // Globex Inc opportunities
        opps.add(new Opportunity(
            Name = 'Globex Deal 1', AccountId = accounts[1].Id,
            StageName = 'Closed Won', Amount = 200000, CloseDate = Date.today()
        ));

        // Empty Account has no opportunities

        insert opps;
    }

    @isTest
    static void testSingleAccount_AllOpps() {
        Account acme = [SELECT Id FROM Account WHERE Name = 'Acme Corp' LIMIT 1];

        AccountRevenueAction.Request req = new AccountRevenueAction.Request();
        req.accountId = acme.Id;
        req.closedWonOnly = false;

        Test.startTest();
        List<AccountRevenueAction.Response> responses =
            AccountRevenueAction.calculate(new List<AccountRevenueAction.Request>{ req });
        Test.stopTest();

        AccountRevenueAction.Response res = responses[0];
        System.assert(res.isSuccess, 'Should succeed');
        System.assertEquals('Acme Corp', res.accountName);
        System.assertEquals(225000, res.totalRevenue);
        System.assertEquals(3, res.opportunityCount);
        System.assertEquals(100000, res.largestDeal);
        System.assertEquals(50000, res.smallestDeal);
        System.assertEquals(75000, res.averageDealSize);
    }

    @isTest
    static void testSingleAccount_ClosedWonOnly() {
        Account acme = [SELECT Id FROM Account WHERE Name = 'Acme Corp' LIMIT 1];

        AccountRevenueAction.Request req = new AccountRevenueAction.Request();
        req.accountId = acme.Id;
        req.closedWonOnly = true;

        Test.startTest();
        List<AccountRevenueAction.Response> responses =
            AccountRevenueAction.calculate(new List<AccountRevenueAction.Request>{ req });
        Test.stopTest();

        AccountRevenueAction.Response res = responses[0];
        System.assert(res.isSuccess);
        System.assertEquals(150000, res.totalRevenue);
        System.assertEquals(2, res.opportunityCount);
    }

    @isTest
    static void testAccountWithNoOpportunities() {
        Account empty = [SELECT Id FROM Account WHERE Name = 'Empty Account' LIMIT 1];

        AccountRevenueAction.Request req = new AccountRevenueAction.Request();
        req.accountId = empty.Id;
        req.closedWonOnly = false;

        Test.startTest();
        List<AccountRevenueAction.Response> responses =
            AccountRevenueAction.calculate(new List<AccountRevenueAction.Request>{ req });
        Test.stopTest();

        AccountRevenueAction.Response res = responses[0];
        System.assert(res.isSuccess);
        System.assertEquals(0, res.totalRevenue);
        System.assertEquals(0, res.opportunityCount);
    }

    @isTest
    static void testNullAccountId() {
        AccountRevenueAction.Request req = new AccountRevenueAction.Request();
        req.accountId = null;
        req.closedWonOnly = false;

        Test.startTest();
        List<AccountRevenueAction.Response> responses =
            AccountRevenueAction.calculate(new List<AccountRevenueAction.Request>{ req });
        Test.stopTest();

        System.assert(!responses[0].isSuccess, 'Should fail for null Account ID');
        System.assert(responses[0].errorMessage.contains('required'), 'Should have meaningful error');
    }

    @isTest
    static void testNonExistentAccount() {
        AccountRevenueAction.Request req = new AccountRevenueAction.Request();
        req.accountId = '001000000000000AAA';
        req.closedWonOnly = false;

        Test.startTest();
        List<AccountRevenueAction.Response> responses =
            AccountRevenueAction.calculate(new List<AccountRevenueAction.Request>{ req });
        Test.stopTest();

        System.assert(!responses[0].isSuccess, 'Should fail for non-existent Account');
        System.assert(responses[0].errorMessage.contains('not found'));
    }

    @isTest
    static void testBulkRequests() {
        Account acme = [SELECT Id FROM Account WHERE Name = 'Acme Corp' LIMIT 1];
        Account globex = [SELECT Id FROM Account WHERE Name = 'Globex Inc' LIMIT 1];

        List<AccountRevenueAction.Request> requests = new List<AccountRevenueAction.Request>();

        for (Integer i = 0; i < 100; i++) {
            AccountRevenueAction.Request req = new AccountRevenueAction.Request();
            req.accountId = (Math.mod(i, 2) == 0) ? acme.Id : globex.Id;
            req.closedWonOnly = true;
            requests.add(req);
        }

        Test.startTest();
        List<AccountRevenueAction.Response> responses = AccountRevenueAction.calculate(requests);
        Test.stopTest();

        System.assertEquals(100, responses.size(), 'Should return one response per request');

        for (Integer i = 0; i < 100; i++) {
            System.assert(responses[i].isSuccess, 'All requests should succeed');
            if (Math.mod(i, 2) == 0) {
                System.assertEquals(150000, responses[i].totalRevenue, 'Acme Closed Won total');
            } else {
                System.assertEquals(200000, responses[i].totalRevenue, 'Globex Closed Won total');
            }
        }
    }

    @isTest
    static void testMixedValidAndInvalidRequests() {
        Account acme = [SELECT Id FROM Account WHERE Name = 'Acme Corp' LIMIT 1];

        List<AccountRevenueAction.Request> requests = new List<AccountRevenueAction.Request>();

        // Valid request
        AccountRevenueAction.Request req1 = new AccountRevenueAction.Request();
        req1.accountId = acme.Id;
        req1.closedWonOnly = false;
        requests.add(req1);

        // Invalid request — null ID
        AccountRevenueAction.Request req2 = new AccountRevenueAction.Request();
        req2.accountId = null;
        requests.add(req2);

        // Valid request
        AccountRevenueAction.Request req3 = new AccountRevenueAction.Request();
        req3.accountId = acme.Id;
        req3.closedWonOnly = true;
        requests.add(req3);

        Test.startTest();
        List<AccountRevenueAction.Response> responses = AccountRevenueAction.calculate(requests);
        Test.stopTest();

        System.assertEquals(3, responses.size());
        System.assert(responses[0].isSuccess, 'First request should succeed');
        System.assert(!responses[1].isSuccess, 'Second request should fail');
        System.assert(responses[2].isSuccess, 'Third request should succeed');
    }
}

How to Use This Action in a Flow

Here is how an admin would wire this up in Flow Builder:

  1. Create a new Screen Flow.
  2. Add a Screen element with a Record Choice Set or multi-select lookup that lets the user pick Accounts.
  3. Add a Loop element to iterate over the selected Accounts (or use an Assignment to collect IDs into a collection).
  4. Add an Action element. Search for “Calculate Account Revenue Statistics.” It appears under the “Account Operations” category.
  5. Map the inputs. Set “Account ID” to the current Account’s ID from the loop variable. Set “Closed Won Only” to a Boolean variable or a checkbox from the screen.
  6. Store the outputs. Map “Total Revenue,” “Average Deal Size,” “Opportunity Count,” and the other outputs into Flow variables.
  7. Add a Display Screen that shows the results to the user.

Alternatively, in a record-triggered Flow, you could use this action to automatically populate custom fields on the Account whenever an Opportunity is created or updated.


Summary

Creating Apex for Flows bridges the gap between declarative simplicity and programmatic power. Here is what we covered:

  • @InvocableMethod marks a static method as available in Flow Builder. It supports label, description, callout, and category parameters.
  • @InvocableVariable marks public member variables on wrapper classes so they appear as configurable fields in Flow Builder. It supports label, description, and required parameters.
  • Input and output wrapper classes (Request/Response pattern) give Flow admins a structured interface with named fields.
  • Bulkification is mandatory. The platform calls your method once with a list of all requests. Use bulk SOQL queries and maps to process efficiently.
  • Supported data types include primitives, sObjects, collections, and Apex-defined types.
  • Error handling should return status and error messages in the response wrapper so Flow admins can handle failures declaratively.
  • Testing follows standard Apex testing patterns with emphasis on bulk scenarios, edge cases, and error paths.
  • Best practices include single responsibility, meaningful labels, input validation, and governor limit awareness.

In the project, we built a complete AccountRevenueAction that demonstrates all of these concepts — bulk queries, map-based lookups, in-memory filtering, null-safe calculations, positional response matching, and comprehensive test coverage.


In Part 49, we will explore Using the Platform Cache in Apex — how to store frequently accessed data in the org cache and session cache to reduce SOQL queries and improve performance across your Apex code. See you there.