A selector is a class that owns every query against one object. Instead of SOQL scattered across triggers, controllers and services, each object gets one class whose methods return records. The payoff is not elegance — it is that when a field is added, renamed or made inaccessible, there is one file to change.
What it looks like
public with sharing class AccountSelector {
private static final List<String> FIELDS = new List<String>{
'Id', 'Name', 'Industry', 'AnnualRevenue', 'OwnerId'
};
public static List<Account> byIds(Set<Id> ids) {
return Database.query(
'SELECT ' + String.join(FIELDS, ', ') +
' FROM Account WHERE Id IN :ids WITH USER_MODE'
);
}
public static List<Account> activeInIndustry(String industry) {
return [
SELECT Id, Name, AnnualRevenue FROM Account
WHERE Industry = :industry AND Active__c = true
WITH USER_MODE ORDER BY Name
];
}
}
Callers ask for what they need by name. Nobody outside this class writes FROM Account.
The four things it actually buys you
One place for the field list. Add a field to the layout and to the selector, and every caller has it. Without a selector you find the queries by grepping and miss one.
One place to enforce security. WITH USER_MODE on every query in one file is auditable. Spread across thirty classes it is a review nobody completes, which is why CRUD and FLS enforcement tends to be patchy in practice.
Mockable queries. A test can stub the selector and supply records without inserting anything. That turns a two-second test into a two-millisecond one, and it lets you test cases that are awkward to create — a record owned by an inactive user, or a value the validation rules would now reject.
Visible query count. When every query is a named method, a code review can see that a loop calls byIds two hundred times. Inline SOQL in a loop reads as one line and is easy to miss.
Where it goes wrong
A method per caller. getAccountsForTheQuarterlyReportScreen is not a selector method, it is a service method in the wrong file. When the class has forty methods and each has one caller, you have added a layer and gained nothing. Selector methods should be shaped by the data, not by the screen.
Returning everything. getAll() is a selector method that will eventually be called on an object with four million rows. If a method cannot be given a bound filter, it should probably not exist.
Dynamic SOQL everywhere. Building every query from strings loses compile-time checking of field names, which is one of the main things that makes a rename safe. Use static SOQL where the field list is fixed, and dynamic only where it genuinely varies.
Do you need a framework for this
No. The pattern is one class per object with static methods, and that is the whole thing. Enterprise pattern libraries offer a base class with generic field-set handling and an fflib-style query factory, which is worth it on a large codebase with several teams and unnecessary on an org with twelve classes.
The cost of the library version is that a new developer has to learn it before reading a query. The cost of the plain version is a little repetition. On a small team the repetition is cheaper.
When not to bother
A trigger handler that queries one related object once does not need a selector for it. The pattern earns its keep when the same object is queried from several places, which is usually the core objects and rarely the rest. Introducing it for every object in the org on day one is how patterns get a bad reputation.
Verified in a Summer ’26 scratch org on 14 September 2026: SELECT COUNT(Id), Industry FROM Account GROUP BY Industry returned 8 groups and consumed 8 query rows, not the underlying record count. An aggregate is cheap against the 50,000-row limit and still costs one of your 100 queries.
Frequently asked
Should selectors be with sharing? Yes, and use inherited sharing if a caller legitimately needs the unfiltered version. Then the elevation is a deliberate decision at the call site. See sharing keywords.
Where do subqueries go? In the selector of the parent object, as a method whose name says what it includes.
Do aggregates belong here? Yes. An aggregate is a query, and it consumes query rows equal to the number of groups, not the number of records, which is a good reason to have it in one place where someone can notice.
