agentleFS
grzmolgrzmol/vibe-force/skills/sf-fflib-selector-layer/SKILL.md

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.

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
  1. Very specific:

    Every signature below was read this session from apex-enterprise-patterns/fflib-apex-common @ master commit dab5977: …

    line 8
  2. Very specific:

    Read from the five fflib_SObjectSelector constructors and the field initialisers.

    line 33
  3. Very specific:

    All five return fflib_SObjectSelector, so they chain but do not return your subclass type - assign before casting.

    line 59
  4. Very specific:

    Never mixed: setDataAccess disables the legacy flags.

    line 237
  5. Very specific:

    Keep it in the selector class so all SOQL stays in one layer, return the wrapper (never AggregateResult, which is unmockable in a …

    line 325

≈ “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.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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

TaskWhere
New selector class for an SObjectpattern 1 + references/selector-recipes.md
Add a filtered query methodpattern 4
Add a child subselect or parent fieldspattern 5 + recipes 4-6
Field sets in a querypattern 3
FLS/CRUD posture decisionpattern 6 + references/selector-security.md
Dynamic/optional filters from user inputpattern 7
Batch Apex start() methodpattern 8
Aggregate or COUNT() querypattern 9
Reviewing a selectorreferences/selector-antipatterns.md
fflib_QueryFactory method lookupreferences/query-factory-api.md
Registering the selector, factory wiringskill sf-fflib-foundations
Mocking a selector in a testskill sf-fflib-testing

Quick reference: constructor matrix

Read from the five fflib_SObjectSelector constructors and the field initialisers.

ConstructorincludeFieldSetFieldsenforceCRUDenforceFLSsortSelectFieldsDataAccess
fflib_SObjectSelector()falsetruefalsetrueLEGACY
(Boolean includeFieldSetFields)argtruefalsetrueLEGACY
(Boolean, DataAccess)argfalsefalsefalsearg
(Boolean, Boolean enforceCRUD, Boolean enforceFLS) (deprecated)argargargtrueLEGACY
(Boolean, Boolean, Boolean, Boolean sortSelectFields) (deprecated)argargargargLEGACY

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 mutatorEffect
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.getQueryLocator

Both 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:

RuleReason
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 setConditionDatabase.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 loopskill sf-governor-limits
Selective filters on indexed fieldsskill 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());
}
MethodEffect
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

PostureConstructorWhat is enforcedGenerated SOQL
Modern (default choice)super(false, DataAccess.USER_MODE)object + field read permission by the platform... WITH USER_MODE
Explicit elevationsuper(false, DataAccess.SYSTEM_MODE)nothing; documents intent... WITH SYSTEM_MODE
Legacy assertionssuper(false, true, true)fflib_SecurityUtils describe checks per field, throws FlsException/CrudExceptionno mode clause
Nonesuper()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-dev

scripts/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

Download now (ZIP)

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
What did you use it for? e.g. Reviewing pull requests in a Rails app

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.