sf-fflib-selector-layer
Skill2 starsChanged 2 months agoMITView on GitHub
Deep coverage of the fflib selector layer - extending fflib_SObjectSelector, getSObjectType/getSObjectFieldList/getOrderBy, the constructor flag and DataAccess.USER_MODE matrix, selectSObjectsById and queryLocatorById, the full fflib_QueryFactory fluent API (selectField, setCondition, addOrdering, setLimit, setOffset, subselectQuery, toSOQL, deepClone), parent field traversal and child subselects via configureQueryFactoryFields and addQueryFactorySubselect, FLS and CRUD enforcement old versus WITH USER_MODE, dynamic filters without SOQL injection using Database.queryWithBinds, QueryLocator selectors for Batch Apex, and selector anti-patterns. Use it when writing or reviewing any class extending fflib_SObjectSelector, when a selector query must add a filter, a child subselect, a field set, or a parent field, when fflib_QueryFactory.InvalidFieldException or NonReferenceFieldException or InvalidSubqueryRelationshipException appears, or when deciding how a selector enforces field-level security.
In this repository34 other files it gives its agents.
- CLAUDE.mdCLAUDE.md
- sf-agentforce-developmentSkill
- sf-apex-developmentSkill
- sf-apex-testingSkill
- sf-async-apex-patternsSkill
- sf-cli-operationsSkill
- sf-code-analyzer-qualitySkill
- sf-data-cloudSkill
- sf-data-managementSkill
- sf-debugging-logsSkill
- sf-deployment-strategiesSkill
- sf-fflib-domain-service-uowSkill
- sf-fflib-foundationsSkill
- sf-fflib-operationsSkill
- sf-fflib-testingSkill
- sf-flow-automationSkill
- sf-governor-limitsSkill
- sf-integration-patternsSkill
- sf-local-developmentSkill
- sf-lwc-developmentSkill
- sf-lwc-jest-testingSkill
- sf-minimal-changeSkill
- sf-org-security-auditSkill
- sf-packaging-releaseSkill
- sf-post-deploy-verificationSkill
- sf-process-builder-migrationSkill
- sf-project-structureSkill
- sf-scratch-orgs-sandboxesSkill
- sf-security-modelSkill
- sf-setup-automationSkill
- sf-soql-sosl-optimizationSkill
- sf-technical-debt-auditSkill
- sf-ui-test-automationSkill
- sf-workflow-orchestrationSkill
The file
We split this skill into the rules it gives an agent
5 rules. About 0 are common, 0 are distinctive, and 5 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈0
Distinctive · estimate
This author's own choices. Worth a look.
5
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 8
Every signature below was read this session from apex-enterprise-patterns/fflib-apex-common @ master commit dab5977: …
- Very specific: line 33
Read from the five fflib_SObjectSelector constructors and the field initialisers.
- Very specific: line 59
All five return fflib_SObjectSelector, so they chain but do not return your subclass type - assign before casting.
- Very specific: line 237
Never mixed: setDataAccess disables the legacy flags.
- Very specific: line 325
Keep it in the selector class so all SOQL stays in one layer, return the wrapper (never AggregateResult, which is unmockable in a …
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
fflib Selector Layer
Every signature below was read this session from apex-enterprise-patterns/fflib-apex-common
@ master commit dab5977: sfdx-source/apex-common/main/classes/fflib_SObjectSelector.cls,
fflib_QueryFactory.cls, fflib_ISObjectSelector.cls, fflib_SecurityUtils.cls,
fflib_SObjectDescribe.cls. Selector examples follow fflib-apex-common-samplecode @ master
commit 4657d63. Items marked [unverified] were not read from source.
When to use
| Task | Where |
| New selector class for an SObject | pattern 1 + references/selector-recipes.md |
| Add a filtered query method | pattern 4 |
| Add a child subselect or parent fields | pattern 5 + recipes 4-6 |
| Field sets in a query | pattern 3 |
| FLS/CRUD posture decision | pattern 6 + references/selector-security.md |
| Dynamic/optional filters from user input | pattern 7 |
| Batch Apex start() method | pattern 8 |
| Aggregate or COUNT() query | pattern 9 |
| Reviewing a selector | references/selector-antipatterns.md |
fflib_QueryFactory method lookup | references/query-factory-api.md |
| Registering the selector, factory wiring | skill sf-fflib-foundations |
| Mocking a selector in a test | skill sf-fflib-testing |
Quick reference: constructor matrix
Read from the five fflib_SObjectSelector constructors and the field initialisers.
| Constructor | includeFieldSetFields | enforceCRUD | enforceFLS | sortSelectFields | DataAccess |
fflib_SObjectSelector() | false | true | false | true | LEGACY |
(Boolean includeFieldSetFields) | arg | true | false | true | LEGACY |
(Boolean, DataAccess) | arg | false | false | false | arg |
(Boolean, Boolean enforceCRUD, Boolean enforceFLS) (deprecated) | arg | arg | arg | true | LEGACY |
(Boolean, Boolean, Boolean, Boolean sortSelectFields) (deprecated) | arg | arg | arg | arg | LEGACY |
The (Boolean, DataAccess) constructor is the modern one and it also turns select-field sorting
off - your generated SOQL will list fields in Set<String> iteration order, not alphabetically.
Tests that assert on a SOQL string must account for that.
setDataAccess(DataAccess access) mutates a live selector and, for any value other than LEGACY,
forces ignoreCRUD() and m_enforceFLS = false: legacy checks and native modes are mutually
exclusive by construction.
| Fluent mutator | Effect |
includeFieldSetFields() | m_includeFieldSetFields = true |
ignoreCRUD() | m_enforceCRUD = false |
enforceFLS() (deprecated) | m_enforceFLS = true |
unsortedSelectFields() | m_sortSelectFields = false |
setDataAccess(DataAccess) | sets mode, disables legacy CRUD/FLS when not LEGACY |
All five return fflib_SObjectSelector, so they chain but do not return your subclass type - assign
before casting.
Core patterns
1. Selector anatomy
public inherited sharing class AccountsSelector extends fflib_SObjectSelector
implements IAccountsSelector
{
public static IAccountsSelector newInstance() {
return (IAccountsSelector) Application.Selector.newInstance(Account.SObjectType);
}
public AccountsSelector() {
super(false, fflib_SObjectSelector.DataAccess.USER_MODE);
}
public Schema.SObjectType getSObjectType() {
return Account.SObjectType;
}
public List<Schema.SObjectField> getSObjectFieldList() {
return new List<Schema.SObjectField> {
Account.Id,
Account.Name,
Account.AccountNumber,
Account.LastInvoiceDate__c
};
}
public List<Account> selectById(Set<Id> idSet) {
return (List<Account>) selectSObjectsById(idSet);
}
}public interface IAccountsSelector extends fflib_ISObjectSelector {
List<Account> selectById(Set<Id> idSet);
}Required members: getSObjectType() and getSObjectFieldList() are abstract on the base class.
Optional overrides: getSObjectFieldSetList() (returns null by default) and getOrderBy().
getOrderBy() default, computed once and cached in m_orderBy: the object's Name field if it exists
and is not encrypted, else CreatedDate, else Id. Override it to return a comma-delimited clause;
configureQueryFactory splits on ,, then on space, recognises ASC/DESC and detects
NULLS LAST case-insensitively:
public override String getOrderBy() {
return 'LastInvoiceDate__c DESC NULLS LAST, Name ASC';
}2. The two base query methods
// SELECT <fields> FROM Account WHERE id in :idSet ORDER BY <getOrderBy()> [WITH USER_MODE]
public virtual List<SObject> selectSObjectsById(Set<Id> idSet) // Database.query
public virtual Database.QueryLocator queryLocatorById(Set<Id> idSet) // Database.getQueryLocatorBoth call protected String buildQuerySObjectById(), which is literally
newQueryFactory().setCondition('id in :idSet').toSOQL(). The bind :idSet resolves against the
local scope of the method that calls Database.query, which is why the parameter is named idSet.
If you re-use buildQuerySObjectById() from your own method, the local variable must also be named
idSet or the query throws an unbound-variable QueryException.
getSObjectName() returns the API name via the cached fflib_SObjectDescribe. sObjectType() and
getSObjectType2() are public aliases of the abstract getSObjectType() and exist so mock
registration can read the type (see skill sf-fflib-foundations).
3. Field sets
public ProductsSelector(Boolean includeFieldSetFields) {
super(includeFieldSetFields, fflib_SObjectSelector.DataAccess.USER_MODE);
}
public override List<Schema.FieldSet> getSObjectFieldSetList() {
return new List<Schema.FieldSet> { SObjectType.Product2.FieldSets.OpportunityDiscount };
}Field set fields are added only when both m_includeFieldSetFields is true and
getSObjectFieldSetList() is non-null (configureQueryFactory). selectFieldSet throws
fflib_QueryFactory.InvalidFieldSetException if the field set belongs to another SObject, and with
allowCrossObject = false it rejects any member whose getFieldPath() contains a dot.
Multi-currency: configureQueryFactory appends CurrencyIsoCode automatically when
UserInfo.isMultiCurrencyOrganization() and the SObject has the field. You do not list it.
4. Custom query methods
public List<Account> selectByNameAndRating(Set<String> names, String rating) {
return (List<Account>) Database.query(
newQueryFactory()
.setCondition('Name IN :names AND Rating = :rating')
.setLimit(200)
.toSOQL());
}Rules that keep custom methods honest:
| Rule | Reason |
Return a concrete List<Account>, never List<SObject> | callers should not cast |
Start from newQueryFactory(), never a raw new fflib_QueryFactory(...) | you lose the field list, order by and security posture |
Name locals exactly as the bind tokens in setCondition | Database.query binds from local scope |
Guard empty input: if (idSet.isEmpty()) { return new List<Account>(); } | saves a SOQL statement against the 100 limit |
| One SOQL per method, never inside a loop | skill sf-governor-limits |
| Selective filters on indexed fields | skill sf-soql-sosl-optimization |
newQueryFactory(false) omits the selector's own field list, which is how you build a narrow
projection query (the samplecode's selectOpportunityInfo does exactly this).
5. Parent fields and child subselects
public List<Opportunity> selectByIdWithProducts(Set<Id> idSet) {
fflib_QueryFactory oppFactory = newQueryFactory();
// Child subselect: (SELECT ... FROM OpportunityLineItems)
fflib_QueryFactory lineFactory =
new OpportunityLineItemsSelector()
.addQueryFactorySubselect(oppFactory, 'OpportunityLineItems');
// Parent fields on the child, contributed by other selectors' field lists
new PricebookEntriesSelector().configureQueryFactoryFields(lineFactory, 'PricebookEntry');
new ProductsSelector().configureQueryFactoryFields(lineFactory, 'PricebookEntry.Product2');
return (List<Opportunity>) Database.query(
oppFactory.setCondition('id in :idSet').toSOQL());
}| Method | Effect |
addQueryFactorySubselect(parent) | uses the deprecated subselectQuery(SObjectType) overload; logs a WARN debug |
addQueryFactorySubselect(parent, 'RelationshipName') | preferred; resolves the ChildRelationship by name |
addQueryFactorySubselect(parent, name, Boolean includeSelectorFields) | subselect with a hand-picked field list |
configureQueryFactoryFields(qf, 'PricebookEntry') | adds this selector's field list prefixed with the relationship path, plus CurrencyIsoCode in MC orgs |
Ad-hoc parent traversal without another selector is just selectField('Account.Owner.Name'):
fflib_QueryFactory.getFieldPath walks each segment through fflib_SObjectDescribe, substitutes
getRelationshipName(), and throws NonReferenceFieldException when a mid-path segment is not a
lookup or master-detail field. For polymorphic lookups (Lead.Owner -> Group|User) pass the
intended type: selectField('Owner.Name', User.SObjectType).
Subselects go one level deep; the source comment states this explicitly and the child factory is
created through a private constructor that carries the ChildRelationship. subselectQuery(String)
throws InvalidSubqueryRelationshipException for an unknown relationship name. A subselect never
emits WITH USER_MODE - toSOQL() only appends the mode clause when relationship == null.
6. Security posture
| Posture | Constructor | What is enforced | Generated SOQL |
| Modern (default choice) | super(false, DataAccess.USER_MODE) | object + field read permission by the platform | ... WITH USER_MODE |
| Explicit elevation | super(false, DataAccess.SYSTEM_MODE) | nothing; documents intent | ... WITH SYSTEM_MODE |
| Legacy assertions | super(false, true, true) | fflib_SecurityUtils describe checks per field, throws FlsException/CrudException | no mode clause |
| None | super() | CRUD read only (enforceCRUD defaults true) | no mode clause |
API version boundary, from the Apex Developer Guide "Apex Security and Sharing Model" versioned
behavior changes: at 67.0 and later Apex runs in user context by default (object permissions and
FLS enforced unless you opt into system mode), WITH SECURITY_ENFORCED is not allowed in an
Apex SOQL SELECT, and a class with no sharing declaration behaves as with sharing. That does not
make the selector setting redundant: fflib_QueryFactory.toSOQL() emits a mode clause only for
USER_MODE/SYSTEM_MODE, so a LEGACY selector on a pre-67.0-versioned class still runs in system
context. Pin the posture explicitly with DataAccess, and never write WITH SECURITY_ENFORCED
except in a comment describing legacy code.
Never mixed: setDataAccess disables the legacy flags. Record-level sharing is not a selector
setting - fflib_SObjectSelector is declared with sharing, so selectSObjectsById and
queryLocatorById enforce sharing, while a query you issue from your own subclass method runs under
your subclass's sharing declaration. Declare inherited sharing on selectors unless you have a
reason not to. Full matrix, before/after code and proving tests: references/selector-security.md
and skill sf-security-model.
7. Dynamic filters without SOQL injection
public List<Account> search(String nameFragment, Set<String> ratings, Integer maxRows) {
fflib_QueryFactory qf = newQueryFactory();
List<String> clauses = new List<String>();
Map<String, Object> binds = new Map<String, Object>();
if (String.isNotBlank(nameFragment)) {
clauses.add('Name LIKE :nameLike');
binds.put('nameLike', '%' + String.escapeSingleQuotes(nameFragment) + '%');
}
if (ratings != null && !ratings.isEmpty()) {
clauses.add('Rating IN :ratings');
binds.put('ratings', ratings);
}
if (!clauses.isEmpty()) {
qf.setCondition(String.join(clauses, ' AND '));
}
qf.setLimit(maxRows == null ? 200 : Math.min(maxRows, 2000));
return (List<Account>) Database.queryWithBinds(
qf.toSOQL(), binds, AccessLevel.USER_MODE);
}Database.queryWithBinds(String, Map<String, Object>, System.AccessLevel) takes bind values from the
map instead of local scope, so the selector never concatenates a value into the query. Bind keys are
evaluated case-insensitively and must be valid identifiers. Only field values may come from the
caller - a caller-supplied field or object name must be validated against
fflib_SObjectDescribe.getDescribe(getSObjectType()).getFieldsMap() before it reaches
selectField.
8. QueryLocator selectors for Batch Apex
public Database.QueryLocator queryLocatorReadyToInvoice() {
return Database.getQueryLocator(
newQueryFactory()
.setCondition(Opportunity.InvoicedStatus__c + ' = \'Ready\'')
.toSOQL());
}queryLocatorById(Set<Id>) exists on the base class for the id-filtered case. Batch jobs must call
the selector from start() and re-query per chunk in execute() only through another selector
method - never with inline SOQL. Database.getQueryLocator ignores LIMIT-based paging
expectations; use setLimit only for non-batch queries. Batch integration: skills
sf-async-apex-patterns and sf-fflib-operations.
9. Aggregates live outside the selector contract
fflib_ISObjectSelector.selectSObjectsById returns List<SObject>, and fflib_QueryFactory has no
COUNT()/GROUP BY support - toSOQL() always emits SELECT <fields>. Aggregates therefore need a
hand-written query inside the selector, returning a typed wrapper:
public class OpenPipelineByStage {
public String stageName;
public Decimal totalAmount;
public Integer recordCount;
}
public List<OpenPipelineByStage> selectOpenPipelineByStage(Set<Id> ownerIds) {
List<OpenPipelineByStage> results = new List<OpenPipelineByStage>();
for (AggregateResult ar : [
SELECT StageName stage, SUM(Amount) total, COUNT(Id) cnt
FROM Opportunity
WHERE OwnerId IN :ownerIds AND IsClosed = false
GROUP BY StageName
WITH USER_MODE]) {
OpenPipelineByStage row = new OpenPipelineByStage();
row.stageName = (String) ar.get('stage');
row.totalAmount = (Decimal) ar.get('total');
row.recordCount = (Integer) ar.get('cnt');
results.add(row);
}
return results;
}Keep it in the selector class so all SOQL stays in one layer, return the wrapper (never
AggregateResult, which is unmockable in a meaningful way), and add WITH USER_MODE by hand because
the query factory is not involved.
10. Caching
fflib_SObjectDescribe.getDescribe(...) caches describes for the transaction; call flushCache()
only in tests that mutate metadata expectations. For record caching, do it in the service layer
(a Map<Id, SObject> for the transaction) or in Platform Cache - never a static record map inside
a selector, because it survives across Test.startTest() boundaries and hides stale data from the
next caller in the same transaction.
Anti-patterns
Selector calling a service or domain. Creates a cycle and makes the selector impossible to mock.
// Wrong
public List<Account> selectActive() {
AccountsService.recalculate(...); // NO
return (List<Account>) Database.query(newQueryFactory().toSOQL());
}Raw new fflib_QueryFactory(Account.SObjectType) in a selector method. Silently drops the field
list, the order by, and the security mode. Always newQueryFactory().
getSObjectFieldList() returning every field. A 400-field object at 200 rows is a heap problem
and pushes long text fields into memory for no reason. List the fields the layer above actually uses;
add a second selector method with newQueryFactory(false) for wide one-off reads.
One selector class per query. AccountByNameSelector, AccountByRatingSelector... The registry
is keyed by SObjectType, so only one can be registered. One selector per SObjectType, many methods.
Returning List<SObject> from a custom method. Forces every caller to cast and defeats compile-time
checking.
Unbounded query with no LIMIT and a non-selective filter. See skill
sf-soql-sosl-optimization; a selector is exactly where a QUERY_TIMEOUT or
Non-selective query against large object type surfaces.
Interpolating caller input into setCondition. setCondition('Name = \'' + name + '\'') is
injection. Use bind tokens plus Database.queryWithBinds.
Using the deprecated enforceCRUD/enforceFLS constructor in new code. The legacy path resolves
every field through DescribeFieldResult on each selectField call, which the source itself calls
"computationally expensive". Use DataAccess.USER_MODE.
Full list with failing/fixed pairs: references/selector-antipatterns.md.
Verification
# 1. Selector compiles and the field list is valid against the org's schema.
sf project deploy start --source-dir force-app/main/default/classes/selectors \
--target-org vf-dev --wait 20
# 2. Inspect the SOQL a selector actually generates, including the mode clause.
sf apex run --target-org vf-dev --file scripts/apex/dump-selector-soql.apex
# 3. Run only the selector tests (they need DML, so they are org tests).
sf apex run test --target-org vf-dev --synchronous \
--tests AccountsSelectorTest --tests OpportunitiesSelectorTest \
--code-coverage --result-format human
# 4. Confirm the query plan is selective before shipping a new filter.
sf data query --target-org vf-dev --query \
"SELECT Id FROM Account WHERE LastInvoiceDate__c = LAST_N_DAYS:7" --result-format csv
# 5. Gates: analyzer (ApexCRUDViolation/ApexSOQLInjection) and coverage.
node "${CLAUDE_PLUGIN_ROOT}/scripts/checks/vf-check.mjs" analyzer
node "${CLAUDE_PLUGIN_ROOT}/scripts/checks/vf-check.mjs" apex --target-org vf-devscripts/apex/dump-selector-soql.apex:
AccountsSelector sel = new AccountsSelector();
System.debug(sel.newQueryFactory().setCondition('id in :idSet').toSOQL());
System.debug('DataAccess=' + sel.getDataAccess()
+ ' CRUD=' + sel.isEnforcingCRUD()
+ ' FLS=' + sel.isEnforcingFLS()
+ ' fieldSets=' + sel.isIncludeFieldSetFields());Expected output for a DataAccess.USER_MODE selector ends in ... WITH USER_MODE ORDER BY Name ASC
NULLS FIRST - if WITH USER_MODE is missing, the constructor is wrong. Use vf-check apex output
in <project>/.vibeforce/reports/ as the coverage evidence.
References
- references/query-factory-api.md - complete
fflib_QueryFactorymethod table with generated SOQL - references/selector-recipes.md - twelve complete selector classes
- references/selector-security.md - enforcement matrix, before/after, proving tests
- references/selector-antipatterns.md - failing code and the fix
- Skill
sf-fflib-foundations- registering the selector inApplication.cls - Skill
sf-fflib-domain-service-uow- who is allowed to call a selector - Skill
sf-fflib-testing-Application.Selector.setMock - Skill
sf-fflib-operations- selectors in triggers, batch and packaging - Skill
sf-soql-sosl-optimization,sf-governor-limits,sf-security-model,sf-data-management - SOQL
WITH USER_MODE: https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_classes_enforce_usermode.htm - Dynamic SOQL and
Database.queryWithBinds: https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_dynamic_soql.htm System.AccessLevel: https://developer.salesforce.com/docs/atlas.en-us.apexref.meta/apexref/apex_class_System_AccessLevel.htm
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

