Apex Controller
An Apex Controller is a server-side Apex class that backs a Salesforce UI page, exposing methods that the front-end can call to load and persist data.
Definition
An Apex Controller is a server-side Apex class that backs a Salesforce UI page, exposing methods that the front-end can call to load and persist data. The role of a controller is to bridge the UI runtime (Visualforce, Aura, or Lightning Web Components) and the Salesforce data model. Controllers are where the SOQL queries, DML operations, business logic, and platform integrations live, while the UI layer focuses on rendering and user interaction.
The term means different things across the three UI frameworks. In Visualforce, a controller is a class declared on the page tag and tightly bound to the page lifecycle. In Aura, a server-side controller is a class with @AuraEnabled methods called from the client controller. In LWC, the same concept appears as an Apex class with @AuraEnabled methods wired through @wire or imperative calls. Across all three, the controller pattern is the same: a thin server-side class that exposes methods, enforces security, and returns data.
How Apex Controllers actually run in a Salesforce org
Three flavors: Visualforce, Aura, LWC
The Apex Controller has three distinct shapes depending on which UI framework consumes it. Visualforce controllers can be standard controllers (handed to you by the platform for a given sObject), extensions (your custom class layered on top of a standard controller), or custom controllers (entirely your code, declared via the controller attribute on apex:page). Aura and LWC controllers do not pair with a single page. They are stateless Apex classes with @AuraEnabled methods, callable from any client component. The shift from Visualforce to Lightning collapsed the controller from a stateful object to a pure RPC endpoint.
The @AuraEnabled annotation
@AuraEnabled is what makes an Apex method callable from a Lightning client. Without it, the method is invisible to Aura or LWC. The cacheable=true variant tells the framework the method has no side effects, so the Lightning Data Service can cache the response and reuse it across components. Mutations (insert, update, delete) must use plain @AuraEnabled without cacheable, because cached writes break the data contract. Many performance issues trace back to missing cacheable on a read method.
Security enforcement: with sharing, without sharing, inherited sharing
An Apex Controller runs in the system context by default unless declared otherwise. with sharing makes the class respect the running user's sharing rules. without sharing ignores sharing and runs in full system access. inherited sharing takes the mode from the calling class. Forgetting to declare with sharing on a controller that reads sensitive data is one of the most common security review findings on AppExchange submissions. Default to with sharing and only opt out when you specifically need elevated access.
Governor limits inside controller methods
Every Apex Controller method runs inside an Apex transaction with governor limits: 100 SOQL queries, 150 DML statements, 50,000 rows queried, 10,000 ms of CPU time, 6 MB of heap. Methods that aggregate data across many records hit these limits more often than methods that operate on a single record. The fix is usually bulkification: read or write in batches, use SOQL for-loops to stream, and avoid nested loops with queries inside. Profile slow controllers in the Developer Console replay viewer before reaching for batch Apex.
Returning data: AuraEnabled return types
@AuraEnabled methods can return primitives (String, Integer, Boolean), sObjects (Account, Case, custom objects), Lists or Maps of those, or custom Apex classes you define. Custom classes must have @AuraEnabled-annotated properties to be serializable to the client. Returning a Map<sObject, Decimal> is supported but verbose to consume in JavaScript. Returning a wrapper class with named properties is usually cleaner. Wrappers also let you decorate the response with computed fields that do not exist on the database.
Error handling and AuraHandledException
When an Apex Controller method throws an exception, the framework serializes it and sends a message to the client. The default message exposes internal stack traces, which is both a security concern and a usability concern. Wrap risky code in a try/catch, log the real exception, and rethrow a new AuraHandledException with a clean, user-facing message. The client receives the AuraHandledException's getMessage() and never sees the stack. Most Lightning UI errors readers complain about (System.NullPointerException visible on screen) are missing AuraHandledException wrappers.
Testing controllers
Controllers are tested like any other Apex class with @IsTest methods. The Test.setMock pattern injects fake responses for HTTP callouts. The Test.startTest / Test.stopTest envelope flushes governor counts and triggers async execution. AuraEnabled methods are called directly from the test class, no client simulation needed. The platform requires 75 percent code coverage to deploy to production, but coverage alone is not quality. A test that calls a method and ignores the result counts toward coverage and contributes nothing to confidence. Assert the return values, the DML effects, and the exception paths.
Writing an Apex Controller for a Lightning component
Writing an Apex Controller for a Lightning component means creating a server-side class with @AuraEnabled methods that the client calls through @wire or imperative invocations. The class lives in the same DX project as the component bundle and deploys alongside it.
- Create the Apex class
From the Developer Console: File, New, Apex Class. From the CLI: sf apex generate class --name MyController. Place it in force-app/main/default/classes alongside the LWC bundle that calls it.
- Declare the sharing context
Open the class and add public with sharing class MyController. with sharing should be the default. Only switch to without sharing for cases where you intentionally need elevated access, and document the reason in a class header comment.
- Write the AuraEnabled method
Add a method with the @AuraEnabled annotation. For read-only methods, add (cacheable=true). For methods that mutate data, leave it as plain @AuraEnabled. The method signature is what the client imports by name from the @salesforce/apex/ClassName.method module path.
- Enforce object and field permissions
Inside the method, check sObject access with Schema.sObjectType.MyObject.isAccessible() and field access with Schema.sObjectType.MyObject.fields.MyField.isAccessible(). The platform does not enforce field-level security automatically unless you use WITH SECURITY_ENFORCED in SOQL or Stripe.stripInaccessible.
- Wrap errors in AuraHandledException
Wrap the method body in try/catch. On exception, log the original message and throw a new AuraHandledException with a user-facing message. The client will receive only the friendly message, not the stack trace.
- Write the unit test
Create a test class with @IsTest, instantiate sample data with TestSetup, call the controller method, and assert the return value and any DML side effects. Aim for both happy-path and exception-path coverage.
Annotation that marks an Apex method as callable from Lightning components. Without it, the method is invisible to the client.
Annotation modifier that flags a method as a pure read. The Lightning Data Service caches the response and reuses it across components.
Class-level modifier that enforces the running user's sharing rules. The recommended default for any controller that handles user-scoped data.
Class-level modifier that runs the class in full system access, ignoring sharing. Use intentionally and document the reason.
Class-level modifier that takes the sharing context from the calling class. Useful for helper classes invoked from controllers in both modes.
Exception type that returns a clean, user-facing message to the Lightning client without exposing the stack trace.
- Without with sharing, a controller runs in system mode and can return records the user should not see. Default to with sharing and opt out only when needed.
- cacheable=true methods cannot perform DML or invoke any operation with side effects. The Lightning Data Service will reject the call at runtime.
- Returning sObjects directly serializes every accessible field, including sensitive ones. Use SELECT field-list SOQL and wrapper classes to control the response shape.
- Throwing System.NullPointerException or other runtime exceptions exposes the stack trace to the client. Always wrap and rethrow as AuraHandledException.
- Aura @AuraEnabled and LWC @AuraEnabled use the same annotation but different invocation paths. The class itself does not need to know which framework calls it, but the cache behavior differs.
Trust & references
Straight from the source - Salesforce's reference material on Apex Controller.
- Apex Developer GuideSalesforce Developers
- Using the with sharing, without sharing, and inherited sharing KeywordsSalesforce Developers
Hands-on resources to go deeper on Apex Controller.
About the Author
Dipojjal Chakrabarti is a B2C Solution Architect with 29 Salesforce certifications and over 13 years in the Salesforce ecosystem. He runs salesforcedictionary.com to help admins, developers, architects, and cert/interview candidates sharpen their fundamentals. More about Dipojjal.
Test your knowledge
Q1. What is an Apex Controller?
Q2. Which of these does Salesforce provide automatically for every sObject?
Q3. What is a Controller Extension?
Discussion
Loading discussion…