Log Entry
"LDS vs GraphQL vs Apex: How I Choose an LWC Data Layer"
LWC gives us enough ways to load Salesforce data that choosing one can become more work than writing the first version.
I do not start with "GraphQL or Apex?"
I start with the shape of the operation:
- Is it a standard record form?
- Is it a reactive read or an explicit command?
- Does it span several objects?
- Does the whole change need one transaction?
- Who owns cache refresh after it finishes?
Those questions usually make the answer fairly boring.
That is good. Data access should be boring.
Quick snapshot
My rule is:
Start at the highest platform level that honestly represents the operation. Drop to Apex when the transaction or business logic requires Apex, not because Apex is familiar.
Salesforce's own LWC data guidelines follow a similar progression: base components, LDS adapters and functions, GraphQL, then Apex when the UI APIs are not flexible enough.
1. Use an LDS base component when the UI is standard
If I need a recognisable Salesforce form, I first look at:
lightning-record-formlightning-record-edit-formlightning-record-view-form
They already understand field metadata, labels, help text, validation rules, permissions, and LDS caching.
That is a lot of behaviour to recreate just to obtain a more bespoke-looking form.
I move away from a base form when the interaction is genuinely custom: a wizard, a dense operational console, conditional sections, a non-record workflow, or a save operation involving several related records.
"I want the spacing slightly different" is not normally enough reason to take ownership of the entire data layer.
2. Use LDS adapters when the component is record-shaped
For a custom component that still revolves around a record, LDS wire adapters and imperative functions are usually the cleanest fit.
import { LightningElement, api, wire } from 'lwc';
import { getRecord, getFieldValue } from 'lightning/uiRecordApi';
import NAME_FIELD from '@salesforce/schema/Account.Name';
const FIELDS = [NAME_FIELD];
export default class AccountHeading extends LightningElement {
@api recordId;
@wire(getRecord, { recordId: '$recordId', fields: FIELDS })
account;
get name() {
return getFieldValue(this.account.data, NAME_FIELD);
}
}
This gives the component a reactive, permission-aware record stream without an Apex controller whose only job is to run one SOQL query.
It also participates in the LDS cache. If another LDS-backed component updates that record, this component can receive the new value without inventing a page-level cache protocol.
Use a wire for data that should react to configuration changes. Use an imperative function such as updateRecord() when a user action should explicitly perform a mutation.
The distinction matters:
Reads can be provisioned. Commands should be commanded.
3. Use GraphQL when the screen needs a data shape
GraphQL becomes attractive when the screen needs related, filtered, ordered, or paginated data rather than one record.
For example, an account workspace might need:
- the account name and status
- its open opportunities
- the latest cases
- aggregate or page information
Building that from several independent wire adapters creates several loading states and several possible moments where the screen is only half current.
GraphQL lets the component request the shape it needs through one endpoint. The LWC GraphQL adapter still works with LDS, so it retains platform-managed caching and access enforcement for supported data.
That makes GraphQL a strong fit for read-heavy workspaces and search surfaces.
It is not automatically the right fit for every component. A one-field badge does not need a GraphQL operation merely because GraphQL is available.
I use it when the query shape is doing real work.
4. Use Apex when the unit of work is a business transaction
Apex earns its place when "save" means more than one supported record mutation.
Suppose an action must:
- create an order
- reserve stock
- create an audit record
- fail the whole operation if any step fails
That is a server-side business transaction. I want one service to own its validation, permissions, DML, rollback behaviour, and response.
public with sharing class OrderController {
@AuraEnabled
public static OrderResult placeOrder(OrderRequest request) {
OrderService.PlaceOrderResult result = OrderService.place(request);
return OrderResult.fromServiceResult(result);
}
}
The controller should be thin. The service should contain the reusable business operation. The LWC should not recreate transaction rules by calling three unrelated mutations and hoping the third one succeeds.
Apex is also appropriate when the work needs:
- APIs not exposed through UI API
- callouts or credential-backed integrations
- complex calculation over substantial data
- savepoints, rollback, or partial-success DML
- explicit system-mode work with a reviewed privilege boundary
The cache ownership test
One of the least glamorous differences is also one of the most important.
LDS-backed data is managed. Apex data is not.
After an Apex mutation, ask:
- Does an Apex wire need
refreshApex()? - Did records held in LDS become stale?
- Should
notifyRecordUpdateAvailable(recordIds)be called? - Is the page mixing Apex and LDS versions of the same data?
If nobody can answer, the component has a cache-consistency bug waiting to happen.
I avoid loading the same conceptual data through Apex and GraphQL in one component unless there is a clear boundary. Two sources of truth on one screen create wonderfully intermittent defects.
The security test
LDS and the LWC GraphQL adapter handle sharing, object permissions, and field permissions for supported operations.
Apex gives me more control, which also gives me more responsibility.
An @AuraEnabled method needs a deliberate sharing model and deliberate database access mode. It must also treat every value from the browser as untrusted input.
The browser hiding a button is user experience.
The server rejecting an unauthorised command is security.
Those are different jobs.
The decision I want left in the code
I like a component's imports to explain its architecture.
lightning/uiRecordApisays this is a record-shaped interaction.lightning/graphqlsays this screen owns a query shape.@salesforce/apex/...says this operation crosses into server-side business logic.
None of them is a maturity level. Apex is not more professional because it is code, and GraphQL is not more modern merely because it is GraphQL.
The best choice is the one that makes the transaction, cache, security, and failure model easiest to explain six months later.
That is the whole test.