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:
- The method must be
public static. - The method must accept either no parameters or exactly one parameter, and that parameter must be a
List<T>. - The method can return
voidor aList<T>. - There can be only one
@InvocableMethodper class.
Annotation Parameters
The @InvocableMethod annotation supports several parameters that control how the action appears in Flow Builder:
| Parameter | Type | Description |
|---|---|---|
label | String | The display name shown in Flow Builder. If omitted, the method name is used. |
description | String | A short description shown in Flow Builder to help admins understand what the action does. |
callout | Boolean | Set to true if the method makes an HTTP callout. This tells the runtime to allow callouts in the transaction. Defaults to false. |
category | String | Groups 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
| Parameter | Type | Description |
|---|---|---|
label | String | The display name for this variable in Flow Builder. |
description | String | Helper text shown in Flow Builder. |
required | Boolean | If true, the Flow admin must provide a value for this field. Defaults to false. |
Rules for Invocable Variable Classes
- The class must be
publicorglobal. - Member variables must be
publicand non-static. - 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@InvocableVariablemembers). - 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 returnsList<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.insertwithallOrNone = 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
- SOQL queries — Always query outside loops. Use
WHERE Id IN :collectionto fetch all records in one query. - DML operations — Collect records in a list and perform a single
insert,update, ordeleteafter the loop. - Maps for lookups — Build
Map<Id, SObject>structures from queries so you can look up records by ID in O(1) time inside loops. - Response ordering — Maintain the same order and size as the input list. Use index-based iteration if needed.
- 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 Type | Flow Variable Type |
|---|---|
String | Text |
Integer | Number (no decimals) |
Decimal | Number or Currency |
Boolean | Boolean |
Date | Date |
DateTime | Date/Time |
Time | Time |
Long | Number |
Double | Number |
Id | Text (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.
- The admin adds an Action element in Flow Builder and selects your invocable action by its label.
- Input fields appear based on your
@InvocableVariableannotations. The admin maps Flow variables or literal values to each input field. - Output fields appear based on the response class. The admin stores each output field in a Flow variable for later use.
- At runtime, the Flow engine collects all the input values and creates one
Requestobject per Flow interview that is running in the same transaction. It bundles them into aList<Request>and calls your method once. - 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
- Test with a single request to verify basic functionality.
- Test with bulk requests (200 elements) to verify bulkification and governor limits.
- Test edge cases — null inputs, blank strings, non-existent IDs, zero amounts.
- Test error paths — ensure your error handling returns meaningful messages.
- Use
Test.startTest()andTest.stopTest()to reset governor limits and verify your action runs within a fresh set of limits. - Mock callouts if your invocable action uses
callout=true. UseHttpCalloutMockas 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:
- 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.
- Visibility. The class must be
publicorglobal. If it isglobal, it is accessible across namespaces. - API version. Ensure your class uses a recent API version. Older API versions may not support all invocable features.
- Category. Use the
categoryparameter on@InvocableMethodto group related actions together. This helps admins find your action when they have many custom actions deployed. - 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/ClassNameendpoint - 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
- Accept a list of Account IDs.
- For each Account, query all related Opportunities.
- Optionally filter to Closed Won only.
- Calculate: total revenue, average deal size, largest deal, smallest deal, and opportunity count.
- 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:
-
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.
-
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. -
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. -
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.
-
Null-safe amount handling. The
Amountfield on Opportunity can be null. We treat null amounts as zero to avoid NullPointerException. -
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:
- Create a new Screen Flow.
- Add a Screen element with a Record Choice Set or multi-select lookup that lets the user pick Accounts.
- Add a Loop element to iterate over the selected Accounts (or use an Assignment to collect IDs into a collection).
- Add an Action element. Search for “Calculate Account Revenue Statistics.” It appears under the “Account Operations” category.
- 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.
- Store the outputs. Map “Total Revenue,” “Average Deal Size,” “Opportunity Count,” and the other outputs into Flow variables.
- 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, andcategoryparameters. - @InvocableVariable marks public member variables on wrapper classes so they appear as configurable fields in Flow Builder. It supports
label,description, andrequiredparameters. - 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.