Apex for LWC, Flow and Integrations · Lesson 36 of 41
Apex for LWC and Flow: @AuraEnabled and @InvocableMethod
Most Apex written today serves someone else: a Lightning Web Component that needs data, or a Flow that needs an action no standard element provides. Two annotations open that door – @AuraEnabled for components and @InvocableMethod for Flow – and each has a few rules that separate a working demo from a production-quality API.
In this lesson
- @AuraEnabled controllers: reads and writes
- cacheable=true and when you can't use it
- Errors LWC can show – and a testing gotcha
- Invocable methods for Flow, bulk-safe
An LWC controller
// CaseController.cls
public with sharing class CaseController {
public class CaseRow { // shape the data for the UI
@AuraEnabled public Id id;
@AuraEnabled public String subject;
@AuraEnabled public String priority;
@AuraEnabled public Boolean isUrgent;
}
@AuraEnabled(cacheable=true) // read-only: usable with @wire
public static List<CaseRow> getOpenCases(Id accountId) {
List<CaseRow> rows = new List<CaseRow>();
for (Case c : [SELECT Id, Subject, Priority FROM Case
WHERE AccountId = :accountId AND IsClosed = false
WITH USER_MODE ORDER BY CreatedDate DESC LIMIT 50]) {
CaseRow r = new CaseRow();
r.id = c.Id;
r.subject = c.Subject;
r.priority = c.Priority;
r.isUrgent = c.Priority == 'High';
rows.add(r);
}
return rows;
}
@AuraEnabled // changes data: not cacheable
public static void closeCase(Id caseId, String resolution) {
if (String.isBlank(resolution)) {
AuraHandledException e = new AuraHandledException('Please describe how the case was resolved.');
e.setMessage('Please describe how the case was resolved.'); // see "testing gotcha" below
throw e;
}
try {
update as user new Case(Id = caseId, Status = 'Closed', Description = resolution);
} catch (DmlException e) {
throw new AuraHandledException(e.getDmlMessage(0));
}
}
}
// in the component's JavaScript
import { wire } from 'lwc';
import getOpenCases from '@salesforce/apex/CaseController.getOpenCases';
import closeCase from '@salesforce/apex/CaseController.closeCase';
@wire(getOpenCases, { accountId: '$recordId' }) cases; // cacheable → @wire
async handleClose() {
try {
await closeCase({ caseId: this.selectedId, resolution: this.resolution }); // imperative call
} catch (error) {
this.errorMessage = error.body.message; // the AuraHandledException text
}
}
- Methods must be
staticandpublicorglobal; parameter names must match the keys the component sends. cacheable=trueallows@wireand client caching, but the method must not change data. Refresh wired data after a save withrefreshApex.- Return wrappers (with
@AuraEnabledproperties) or sObjects – only what the UI needs. - Throw
AuraHandledExceptionfor messages users should see; other exceptions reach the component as a generic error.
The component side of this is covered in depth in our Lightning Web Components course and the async/await with Apex lesson.
Invocable methods for Flow
An @InvocableMethod appears in Flow Builder as an Apex Action. Flow calls it in bulk – when 200 records trigger a flow, your method receives 200 requests in one list – so the signature is always list in, list out:
// AssignTerritoryAction.cls
public with sharing class AssignTerritoryAction {
public class Request {
@InvocableVariable(label='Billing country' required=true)
public String country;
@InvocableVariable(label='Annual revenue')
public Decimal annualRevenue;
}
public class Result {
@InvocableVariable(label='Territory')
public String territory;
@InvocableVariable(label='Needs review')
public Boolean needsReview;
}
@InvocableMethod(label='Assign Territory' description='Chooses a sales territory from country and revenue' category='Sales')
public static List<Result> assign(List<Request> requests) {
List<Result> results = new List<Result>();
for (Request req : requests) { // one result per request, same order
Result r = new Result();
String c = req.country == null ? '' : req.country.trim().toLowerCase();
r.territory = c == 'india' ? 'APAC-IN' : (c == 'germany' || c == 'france') ? 'EMEA' : 'GLOBAL';
r.needsReview = (req.annualRevenue ?? 0) >= 10000000;
results.add(r);
}
return results;
}
}
// IntegrationApexTest.cls
@IsTest
private class IntegrationApexTest {
@IsTest
static void controllerReturnsOnlyOpenCasesAsRows() {
Account acc = new Account(Name = 'Acme');
insert acc;
insert new List<Case>{
new Case(Subject = 'Login issue', Priority = 'High', Status = 'New', AccountId = acc.Id),
new Case(Subject = 'Old question', Priority = 'Low', Status = 'Closed', AccountId = acc.Id)
};
List<CaseController.CaseRow> rows = CaseController.getOpenCases(acc.Id);
Assert.areEqual(1, rows.size());
Assert.isTrue(rows[0].isUrgent);
}
@IsTest
static void closeCaseNeedsAResolution() {
try {
CaseController.closeCase(null, ' ');
Assert.fail('expected an AuraHandledException');
} catch (AuraHandledException e) {
Assert.areEqual('Please describe how the case was resolved.', e.getMessage());
}
}
@IsTest
static void auraHandledExceptionHidesItsMessageWithoutSetMessage() {
try {
throw new AuraHandledException('Visible to the user');
} catch (AuraHandledException e) {
Assert.areEqual('Script-thrown exception', e.getMessage(), 'the testing gotcha');
}
}
@IsTest
static void invocableHandlesABatchOfRequests() {
List<AssignTerritoryAction.Request> reqs = new List<AssignTerritoryAction.Request>();
for (String country : new List<String>{ 'India', ' germany ', 'Brazil' }) {
AssignTerritoryAction.Request r = new AssignTerritoryAction.Request();
r.country = country;
r.annualRevenue = country == 'Brazil' ? 25000000 : 500000;
reqs.add(r);
}
List<AssignTerritoryAction.Result> results = AssignTerritoryAction.assign(reqs);
Assert.areEqual('APAC-IN', results[0].territory);
Assert.areEqual('EMEA', results[1].territory);
Assert.areEqual('GLOBAL', results[2].territory);
Assert.isTrue(results[2].needsReview);
}
}
✓ IntegrationApexTest.auraHandledExceptionHidesItsMessageWithoutSetMessage ✓ IntegrationApexTest.closeCaseNeedsAResolution ✓ IntegrationApexTest.controllerReturnsOnlyOpenCasesAsRows ✓ IntegrationApexTest.invocableHandlesABatchOfRequests Tests: 4 passed, 0 failed, 4 total
The AuraHandledException testing gotcha
Inside Apex – including tests – getMessage() on an AuraHandledException returns "Script-thrown exception", not your text (the third test proves it). The component still receives your message. If tests need to assert the message, call setMessage() with the same text before throwing, as closeCase does.
Practice
Try it yourself
A method marked
@AuraEnabled(cacheable=true)inserts a log record and fails at runtime. Why?Show answer
Cacheable methods must not perform DML. Remove
cacheable=true(and call it imperatively) or move the logging elsewhere.Why does an invocable method take a List rather than a single Request?
Show answer
Flow bulkifies calls: when many records run the same flow, their action calls are combined into one invocation. The method must handle the whole list with bulk-safe code and return results in the same order.
Quick check
What does cacheable=true allow?
Using the method with @wire and caching results on the client – for read-only methods.
How should an @AuraEnabled method report an error to the user?
Throw an AuraHandledException with a clear message.
What annotation exposes Apex as a Flow action?
@InvocableMethod (with @InvocableVariable on request/result properties).
Summary
- @AuraEnabled static methods serve LWC; use cacheable=true only for reads.
- Return shaped wrappers, query in user mode, throw AuraHandledException for user messages.
- Invocable methods are list in, list out – one result per request, in order.
- Test controllers and actions directly, like any other Apex.
Finished this lesson?
Mark it complete to track your progress, then carry on with the next lesson.