Why do you need a Selector Layer?
Introduction
Hi there 👋
“Do I really need selectors if I can just write queries using SOQL?”
“I don’t see any benefits to using selectors; they only make the code more complicated.”
There’s some truth to these statements. In this post, I’ll cover why Query Builders/Selectors can be useful and why you should consider using them.
Selectors != FFLib Selectors
FFLib Selectors are the most well-known way to implement Selectors in Apex, and that’s really unfortunate. I’m not surprised that developers dislike selectors if the only approach they’ve used is FFLib Selectors.
From my perspective, FFLib Selectors have plenty of issues, ranging from poor design and incorrect assumptions to becoming "god classes" that contain all the queries in your org.
I don’t want to repeat myself, so you can find more details about FFLib Selector issues here:
But there are other ways to create Selectors. Let me show you how simple it is with SOQL Lib.
Query Builder
Query Builder provides functional constructs for SOQL queries in Apex.
What does it mean?
Instead of creating a query string on your own, you have an interface that takes care of SOQL creation for you.
❌
public void List<Account> getAccount(String fields) {
String query = 'SELECT ' + fields + ' FROM Account';
return Database.query(query);
}
✅
public void List<Account> getAccount(String fields) {
return SOQL.of(Account.SObjectType).with(fields).toList();
}
The query builder is a crucial component for creating Selectors. Selectors are just a concept, and there are many ways to implement them.
Selector Layer
The idea behind the Selector Layer is quite simple: gather all your object queries scattered across the project and consolidate them into one place called the selector class.
By doing this, you will have a selector class for each object type. For example: AccountsSelector.cls
, ContactsSelector.cls
, or OpportunitiesSelector.cls
.
The Selector Layer aims to address the following issues:
- Query inconsistencies: When the same queries are made from different places for the same information or criteria (or subtle variants), it can lead to inconsistencies in your application.
- Query data inconsistencies: Developers may end up repeating queries over the same record set simply to retrieve additional fields. By setting default query fields, you can ensure that
System.SObjectException: SObject row was retrieved via SOQL without querying the requested field: X.Y.
does not occur. - Security inconsistencies: Enforce Field-Level Security and Sharing Rules for all of your queries when needed.
Selectors are just a concept that can be implemented in many different ways:
FFLib Approach
All Queries in a Selector Class (Not Recommended)
public inherited sharing class SOQL_Opportunity extends SOQL {
public SOQL_Opportunity() {
super(Opportunity.SObjectType);
// default settings
with(Opportunity.Id, Opportunity.AccountId)
.systemMode()
.withoutSharing();
}
public List<Opportunity> byRecordType(String rt) {
return whereAre(Filter.recordType().equal(rt)).toList();
}
public List<Opportunity> byAccountId(Id accountId) {
return with(Opportunity.AccountId)
.whereAre(Filter.with(Opportunity.AccountId).equal(accountId))
.toList();
}
public Integer toAmount(Id opportunityId) {
return (Integer) byId(opportunityId).toValueOf(Opportunity.Amount);
}
}
FFLib Selectors assume that queries across the project are similar and should therefore be kept in the Selector class for reuse. However, the reality is that most queries in a project are unique and involve complex conditions in the WHERE
clause. Even when conditions are similar, additional SOQL clauses like LIMIT
, ORDER BY
, or OFFSET
may be required, making queries difficult to reuse.
This approach often results in Selector classes that become massive and hard to read. Selectors for commonly used objects like Account
, Contact
, or Opportunity
can end up with hundreds of lines of code, reducing maintainability.
Inheritance and Builder Pattern
This approach is more flexible. It allows you to keep only generic queries (e.g., byParentId
, byIndustry
) in the Selector class. These methods can be combined with other query statements, enabling you to keep your class small while maintaining a high level of flexibility.
public inherited sharing class SOQL_Account extends SOQL implements SOQL.Selector {
public static SOQL_Account query() {
return new SOQL_Account();
}
private SOQL_Account() {
super(Account.SObjectType);
// default settings
with(Account.Id, Account.Name, Account.Type)
.systemMode()
.withoutSharing();
}
public SOQL_Account byIndustry(String industry) {
with(Account.Industry)
.whereAre(Filter.with(Account.Industry).equal(industry));
return this;
}
public SOQL_Account byParentId(Id parentId) {
with(Account.ParentId)
.whereAre(Filter.with(Account.ParentId).equal(parentId));
return this;
}
}
List<Account> accounts = SOQL_Account.query()
.byRecordType(recordType)
.byIndustry('IT')
.with(Account.Industry, Account.AccountSource)
.setLimit(10)
.toList();
Map<Id, Account> accountById = (Map<String, Account>) SOQL_Account.query()
.byRecordType(recordType)
.byIndustry('IT')
.with(Account.Industry, Account.AccountSource)
.setLimit(10)
.toMap();
Benefits
We understand the difference between a query builder and Selectors. Let’s discuss why combining these two approaches can be incredibly useful for your project.
Dynamic Queries
Dynami queries is very common scenario on every Salesforce project. When you cannot determined the fields or conditions at compile time and must be decided dynamically at runtime.
There is at least couple of places where queries are build in dynamic way:
- Fields – Dynamic queries allow you to specify fields at runtime.
- Object – When working with multiple SObjectType, dynamic queries enable runtime determination of the object type.
- WHERE clause conditions – Dynamic conditions are helpful when filters need to be applied based on user input or complex runtime logic
- Apex Batch – even in Salesforce Batch documentation we can find many examples with dynamic query.
Why you should use Query Builder?
Instead of creating query strings on your own, which can make your code messy – especially since such instances can appear in many different places in your code.
For instance:
❌
public void List<Account> getAccount(String fields) {
String query = 'SELECT ' + fields + ' FROM Account';
return Database.query(query);
}
Example above is extremely simple. Let’s take a look on more complex code.
❌
private static Account getAccount(String uniqueId, String parentId) {
Map<String, Object> queryBinds = new Map<String, Object>{
'uniqueId' => uniqueId,
'parentId' => parentId
};
String query = 'SELECT Id, Name FROM Account WHERE ';
List<String> filters = new List<String>();
if (String.isNotBlank(uniqueId)) {
filters.add('UniqueId__c = :uniqueId');
}
if (String.isNotBlank(parentId)) {
filters.add('ParentId = :parentId');
}
query += String.join(filters, ' OR ') + 'LIMIT 1';
List<Account> accounts = Database.queryWithBinds(query, queryBinds, AccessLevel.USER_MODE);
if (account.isEmpty()) {
return null;
}
return accounts.get(0);
}
You can improve make your code cleaner and easier to read:
✅
public void List<Account> getAccount(String fields) {
return SOQL.of(Account.SObjectType).with(fields).toList();
}
✅
private static Account getAccount(String uniqueId, String parentId) {
return (Account) SOQL.of(Account.SObjectType)
.with(Account.Id, Account.Name)
.whereAre(SOQL.FilterGroup
.add(SOQL.Filter
.with(Account.UniqueId__c)
.equal(uniqueId)
.ignoreWhen(String.isBlank(uniqueId))
)
.add(SOQL.Filter
.with(Account.ParentId)
.equal(parentId)
.ignoreWhen(String.isBlank(parentId))
)
.anyConditionMatching()
)
.setLimit(1)
.toObject();
}
Another reason to use a standardized solution is to prevent SOQL Injection. The SOQL Lib Query Builder utilizes Database.queryWithBinds
, which effectively mitigates SOQL Injection risks.
Mocking
The Selector Layer provides an additional level of abstraction, allowing you to control the execution of SOQL. Instead of executing a “real” query in Apex unit tests, you can return mocked results. I would argue that this is the biggest benefit of using Selectors.
Mocking is part of a larger strategy that can improve test performance and code coverage in your Salesforce org.
Unit Test without mocking
Sometimes, creating test data requires a lot of patience. Imagine you’re a developer who wrote code like this:
public with sharing class MyController {
public List<String> getAccountNames() {
List<String> accountNames = new List<String>();
for (Account acc : [SELECT Name FROM Account]) {
accountNames.add(acc.Name);
}
return accountNames;
}
}
Here is your test. Simple, right?
@IsTest
private class MyControllerTest {
@IsTest
static void getAccountNamesTest() {
insert new List<Account>{
new Account(Name = 'Acc1'),
new Account(Name = 'Acc2'),
new Account(Name = 'Acc3'),
}; // DML operation
List<String> accountNames = null;
Test.startTest();
accountNames = MyController.getAccountNames();
Test.stopTest();
Assert.isNotNull(accountNames);
Assert.areEquals(3, accountNames.size());
}
}
You think that’s all for today – it’s time for a game or a nap – but your test isn’t working.
Why?
- There are validation rules you don’t know about, but you must meet to create test data.
- There’s an Account trigger that adds additional validations.
- Record Trigger Flow that doesn’t work.
- And, of course, there’s a REST API callout that you need to mock.
So, to cover your simple method, you’d need to understand half of the system and the business logic behind it – and spend hours doing so. Ouch.
Unit Test with mocking
With mocking, you can focus on what’s important – your logic.
Your code is about extracting Account.Name
from a query, and that’s all you want to test. You don’t need to worry about validation rules, triggers, or REST API callouts. Those should have their own separate unit tests.
public with sharing class MyController {
public List<String> getAccountNames() {
List<String> accountNames = new List<String>();
for (Account acc : SOQL_Account.query().with(Account.Name).toList()) {
accountNames.add(acc.Name);
}
return accountNames;
}
}
// or even
public with sharing class MyController {
public List<String> getAccountNames() {
return new List<String>(SOQL_Account.query().toValuesOf(Account.Name));
}
}
@IsTest
private class MyControllerTest {
@IsTest
static void getAccountNamesTest() {
SOQL.setMock(SOQL_Account.MOCK_ID, new List<Account>{
new Account(Name = 'Acc1'),
new Account(Name = 'Acc2'),
new Account(Name = 'Acc3'),
});
List<String> accountNames = null;
Test.startTest();
accountNames = MyController.getAccountNames();
Test.stopTest();
Assert.isNotNull(accountNames);
Assert.areEquals(3, accountNames.size());
}
}
Why mocking is important?
- Performance – DML operations like
insert
can take time when Apex Triggers and Record-Triggered Flows are executed, especially if they perform time-consuming operations. Mocking allows you to skip these steps and focus solely on testing your logic. - Development Time – As mentioned above, Apex Triggers and Record-Triggered Flows are executed during test record
insert
. Automation tool logic can include validations, complexif
conditions, and references. In many cases, it takes significant time just toinsert
some records. Developers should focus on testing their logic. Mocking makes your code simpler and faster, reducing frustration related to preparing test data. - You Need It – External objects (
__x
) cannot be inserted in unit tests, so developers must mock them. Whileif (Test.isRunningTest())
is an option, it’s not an elegant or clean solution. - Custom Metadata – Unit tests should be independent. This means they should work consistently across different environments and should not rely on external factors, such as custom metadata records. Mocking custom metadata using Custom Metadata Selectors ensures clean and reliable tests.
Control Field-Level Security
The best practice is to execute SOQL queries WITH USER_MODE
to enforce field-level security and object permissions of the running user. The Selector Layer can apply WITH USER_MODE
by default to all queries for a given SObjectType
, so developers don’t need to worry about it. Developers can also apply WITH SYSTEM_MODE
to all SOQL queries from a specific selector if needed.
public with sharing class MyController {
public List<Account> getAccountsInUserMode() {
// WITH USER_MODE by default
return SOQL.of(Account.SObjectType).toList();
}
public List<Account> getAccountsInSystemMode() {
return SOQL.of(Account.SObjectType).systemMode().toList();
}
}
How it can looks like in a Selector?
public inherited sharing class SOQL_Account extends SOQL implements SOQL.Selector {
public static SOQL_Account query() {
return new SOQL_Account();
}
private SOQL_Account() {
with(Account.Id);
// default field-level security and sharing mode for Account Selector
systemMode(); // <========
withoutSharing();
}
}
public with sharing class MyController {
public List<Account> getAccountsInSystemMode() {
return SOQL_Account.query().toList();
}
}
All queries Account
queries will be executed in system mode. Of course developers can still override default FLS mode.
public with sharing class MyController {
public List<Account> getAccountsInSystemMode() {
return SOQL_Account.query().userMode().toList();
}
}
It also easily applies to dynamic queries:
private static Account getAccount(String uniqueId, String parentId) {
return (Account) SOQL.of(Account.SObjectType)
.with(Account.Id, Account.Name)
.whereAre(SOQL.FilterGroup
.add(SOQL.Filter
.with(Account.UniqueId__c)
.equal(uniqueId)
.ignoreWhen(String.isBlank(uniqueId))
)
.add(SOQL.Filter
.with(Account.ParentId)
.equal(parentId)
.ignoreWhen(String.isBlank(parentId))
)
.anyConditionMatching()
)
.systemMode() // <========
.withoutSharing()
.setLimit(1)
.toObject();
}
Control Sharing Rules
Controlling sharing rules in Apex can be a bit tricky, but there are a few options available to manage them:
Query Mode | Apex Class Mode | Sharing Rules |
---|---|---|
WITH USER_MODE |
without sharing |
Enforced |
WITH USER_MODE |
with sharing |
Enforced |
WITH SYSTEM_MODE |
without sharing |
Ignored |
WITH SYSTEM_MODE |
with sharing |
Enforced |
Sharing rules can be enforced not only by the with sharing
keyword specified at the top of a class. Developers can also use WITH USER_MODE
, which enforces both field-level security and sharing rules.
Sharing rules will be enforced
public with sharing class MyController {
public List<Account> getAccounts() {
return [SELECT Id FROM Account WITH USER_MODE];
}
}
public with sharing class MyController {
public List<Account> getAccounts() {
return [SELECT Id FROM Account WITH SYSTEM_MODE];
}
}
public without sharing class MyController {
public List<Account> getAccounts() {
return [SELECT Id FROM Account WITH USER_MODE];
}
}
Sharing rules will be ignored
public without sharing class MyController {
public List<Account> getAccounts() {
return [SELECT Id FROM Account WITH USER_MODE];
}
}
Now that we understand how sharing rules can be executed in Apex, we can ask an important question:
How do we write an Apex class where one method enforces sharing rules and another does not?
If all queries in a class are executed either with sharing
or without sharing
, there’s no issue. The problem arises when you have a with sharing
class, but some of the queries need to be executed without sharing
. This requires workarounds, such as using an additional without sharing
class.
While this approach is straightforward, it’s not very elegant. Imagine having to use tricks like that in your controller.
public with sharing class MyController {
public List<Account> getAccountWithSharing() {
return [SELECT Id FROM Account];
}
public List<Account> getAccountWithoutSharing() {
return new WithoutSharing().getAccountWithoutSharing();
}
public without sharing class WithoutSharing {
public List<Account> getAccountWithoutSharing() {
return [SELECT Id FROM Account];
}
}
}
With Selectors it’s really simple.
public inherited sharing class SOQL_Account extends SOQL implements SOQL.Selector {
public static SOQL_Account query() {
return new SOQL_Account();
}
private SOQL_Account() {
with(Account.Id);
userMode();
}
}
public with sharing class MyController {
public List<Account> getAccountWithSharing() {
// USER_MODE, inherited sharing by default
return SOQL_Account.query().toList();
}
public List<Account> getAccountWithoutSharing() {
return SOQL_Account.query().systemMode().withoutSharing().toList();
}
}
You can still override the sharing mode provided by the Selector class. This approach gives developers a lot of flexibility.
Let’s try to mimic the field-level security and sharing rules of the MyController
class using standard SOQL.
With Selector
public with sharing class MyController {
public List<Account> userModeInheritedSharing() {
return SOQL_Account.query().toList();
}
public List<Account> userModeWithSharing() {
return SOQL_Account.query().withSharing().toList();
}
public List<Account> systemModeInheritedSharing() {
return SOQL_Account.query().systemMode().toList();
}
public List<Account> systemModeWithSharing() {
return SOQL_Account.query().systemMode().withSharing().toList();
}
public List<Account> systemModeWithoutSharing() {
return SOQL_Account.query().systemMode().withoutSharing().toList();
}
}
Avoid duplicates
Many queries are repeated across projects. While duplicated code isn’t inherently a problem—and may even be the right approach if the SOQL queries evolve in different directions—certain global queries are commonly repeated, such as:
-
Global Queries:
byId
:WHERE Id = :id
byIds
:WHERE Id IN :ids
byRecordType
:WHERE RecordType.DeveloperName = :recordTypeName
-
Object-Specific Queries:
byAccountId
:SELECT Id FROM Contact WHERE AccountId = :accountId
byContactId
:SELECT Id FROM User WHERE ContactId = :contactId
There’s no need to reinvent the wheel. If we can establish a set of global, simple queries that can be placed in Selectors, that’s a huge win!
public inherited sharing class SOQL_Account extends SOQL implements SOQL.Selector {
public static SOQL_Account query() {
return new SOQL_Account();
}
private SOQL_Account() {
super(Account.SObjectType);
// default settings
with(Account.Id, Account.Name, Account.Type)
.systemMode()
.withoutSharing();
}
public SOQL_Account byIndustry(String industry) {
with(Account.Industry)
.whereAre(Filter.with(Account.Industry).equal(industry));
return this;
}
public SOQL_Account byParentId(Id parentId) {
with(Account.ParentId)
.whereAre(Filter.with(Account.ParentId).equal(parentId));
return this;
}
}
So next time you need account industry just invoke:
String accountIndustry = SOQL_Account.query()
.byId(accountId)
.toValueOf(Account.Industry);
Centralised Logic
By centralizing all your database queries into a Selector Layer, you make your codebase more organized and maintainable. This ensures that any changes to the queries only need to be made in one place, rather than being scattered throughout your application.
Let’s take a look at the example below:
All generic or global queries can be stored in SOQL_Contact
. In the event of future adjustments, you’ll only need to make changes in one place.
public inherited sharing class SOQL_Contact extends SOQL implements SOQL.Selector {
public static SOQL_Contact query() {
return new SOQL_Contact();
}
private SOQL_Contact() {
super(Account.SObjectType);
with(Contact.Id, Contact.Name);
}
public SOQL_Contact byAccountId(Id accountId) {
whereAre(Filter.with(Contact.AccountId).equal(accountId));
}
}
I was on a project where every query for specific object needed additional condition OwnerId = UserInfo.getUserId();
. To apply it to every query in the system it required a lot of work.
With selector layer you can do just something like that:
public inherited sharing class SOQL_Account extends SOQL implements SOQL.Selector {
public static SOQL_Account query() {
return new SOQL_Account();
}
private SOQL_Account() {
super(Account.SObjectType);
// default settings
with(Account.Id, Account.Name);
whereAre(Filter.with(Account.OwnerId).equal(UserInfo.getUserId()));
}
}
Default configuration
The Selector class can provide default SOQL configurations, such as default fields, FLS settings, sharing rules, and even default conditions for all queries of a specific object!
I worked on a project where sharing rules were misconfigured, and every SOQL query required a default condition: OwnerId = :UserInfo.getUserId()
. This was easy to achieve with Selectors by adding a default condition to all SOQL queries.
What can be set as default?
- A set of fields that we always want to include.
- Field-Level Security mode.
- Sharing Rules mode.
- Default conditions.
- Anything else you need.
public inherited sharing class SOQL_Account extends SOQL implements SOQL.Selector {
public static SOQL_Account query() {
return new SOQL_Account();
}
private SOQL_Account() {
super(Account.SObjectType);
// default settings
with(Account.Id, Account.Name)
.systemMode()
.withoutSharing();
}
}
In the Selector class, you can additionally include:
- Custom Error Handling – Handle specific SOQL exceptions or errors in a centralized way, such as logging failed queries or retrying operations.
- Analytics Logs – Track query execution times, query limits, or performance bottlenecks for better monitoring and optimization.
Caching
Did you know that?
- Retrieving data from the cache takes less than 10ms.
- Read operations (SOQL) account for approximately 70% of an org’s activity.
Cache can significantly boost yours org’s performance. You can it use for objects like:
Profile
BusinessHours
OrgWideEmailAddress
User
To use cached records you can use Cached Selectors.
public with sharing class SOQL_ProfileCache extends SOQLCache implements SOQLCache.Selector {
public static SOQL_ProfileCache query() {
return new SOQL_ProfileCache();
}
private SOQL_ProfileCache() {
super(Profile.SObjectType);
cacheInOrgCache();
with(Profile.Id, Profile.Name, Profile.UserType);
maxHoursWithoutRefresh(24);
}
public override SOQL.Queryable initialQuery() {
return SOQL.of(Profile.SObjectType);
}
public SOQL_ProfileCache byName(String name) {
whereEqual(Profile.Name, name);
return this;
}
}
Cached records can be stored in:
- Apex Transaction
- Platform Cache
- Session Cache
Enhanced SOQL
Developers perform different SOQL results transformation. You can use many of predefined method that will reduce your code complexity.
toId()
doExist()
toValueOf(SObjectField fieldToExtract)
toValuesOf(SObjectField fieldToExtract)
toInteger()
toObject()
toList()
toAggregated()
toMap()
toMap(SObjectField keyField)
toMap(SObjectField keyField, SObjectField valueField)
toAggregatedMap(SObjectField keyField)
toAggregatedMap(SObjectField keyField, SObjectField valueField)
toQueryLocator()
Build map with custom key:
❌
public static Map<Id, Id> getContactIdByAccontId() {
Map<Id, Id> contactIdToAccountId = new Map<Id, Id>();
for (Contact contact : [SELECT Id, AccountId FROM Contact]) {
contactIdToAccountId.put(contact.Id, contact.AccountId)
}
return contactIdToAccountId;
}
✅
public static Map<String, String> getContactIdByAccontId() {
return SOQL_Contact.query().toMap(Contact.Id, Contact.AccountId);
}
Extract unique values from query:
❌
public static Set<String> getAccountNames() {
Set<String> accountNames = new Set<String>();
for (Account account : [SELECT Name FROM Account]) {
accountNames.add(account.Name);
}
return accountNames;
}
✅
public static Set<String> getAccountNames() {
return SOQL_Account.query().toValuesOf(Account.Name);
}
Query builder provides also error handling.
❌
public static Account getPartnerAccountByName(String name) {
Account acc = [
SELECT Id, Name
FROM Account
WHERE Name LIKE :name
];
return acc;
// System.QueryException: List has no rows for assignment to SObject
}
✅
public static Account getPartnerAccountByName(String name) {
return SOQL.of(Account.SObjectType)
.with(Account.Id, Account.Name)
.whereAre(SOQL.Filter.with(Account.Name).like(name))
.toObject();
// null will be returned
}
Summary
Let’s wrap this up. There are plenty of benefits to using Selectors, including:
- Dynamic Queries
- Mocking
- Field-Level Security Control
- Sharing Rules Control
- Avoiding Duplicates
- Centralized Logic
- Default Configuration
- Results Caching
- Enhanced SOQL
You might say, "Okay, but I need to learn the framework." Yes and no.
SOQL Lib was designed to make things easy for you. It’s as close to standard SOQL as possible. You don’t even need to look at the SOQL Lib Documentation because all the methods you can use are exposed by the Queryable
interface, which sits on top of the SOQL.cls
class.
Don’t know how to write a query? Just open SOQL.cls
and check which methods are available. That’s all you need.
Based on these methods, you can create your query:
SOQL.of(Account.SObjectType)
.with(Account.Name, Account.Industry)
.mineScope()
.setLimit(10)
.toList();
If you encounter any issues, you can refer to the SOQL API or check out the examples.