# Selector Patterns: Query Abstraction in Vanilla Apex

> This guide teaches query abstraction patterns using pure Apex - no external libraries required. These patterns improve testability, maintainability, and security compliance.

- Skill: `tools-only/selector-patterns-query-abstraction-in-vanilla-apex-2` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add tools-only/selector-patterns-query-abstraction-in-vanilla-apex-2`
- Raw SKILL.md: https://api.skillmd.com/api/skills/tools-only/selector-patterns-query-abstraction-in-vanilla-apex-2/raw
- Safety review: pending (external: skill-scanner PASS, skillspector PASS)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: tools-only (https://skillmd.com/u/tools-only)
- Updated: 2026-09-29
- Page: https://skillmd.com/skills/tools-only/selector-patterns-query-abstraction-in-vanilla-apex-2

---

# Selector Patterns: Query Abstraction in Vanilla Apex

This guide teaches query abstraction patterns using pure Apex - no external libraries required. These patterns improve testability, maintainability, and security compliance.

> **Sources**: [James Simone - Repository Pattern](https://www.jamessimone.net/blog/joys-of-apex/repository-pattern/), [Beyond the Cloud - Selector Layer](https://blog.beyondthecloud.dev/blog/why-do-you-need-selector-layer)

---

## Why Use a Selector Layer?

### The Problem

Without abstraction, SOQL queries are scattered everywhere:

```apex
// In TriggerHandler.cls
List<Account> accounts = [SELECT Id, Name FROM Account WHERE Id IN :accountIds];

// In BatchJob.cls (duplicate!)
List<Account> accounts = [SELECT Id, Name FROM Account WHERE Id IN :ids];

// In ServiceClass.cls (slightly different fields!)
List<Account> accounts = [SELECT Id, Name, Industry FROM Account WHERE Id IN :accountIds];
```

**Problems**:
1. **Duplication**: Same query logic repeated
2. **Inconsistency**: Different fields queried in different places
3. **Fragility**: Field deletion breaks multiple classes
4. **Testability**: Must create real records to test
5. **Security**: FLS/sharing often forgotten

### The Solution

Centralize queries in Selector classes:

```apex
// Single source of truth
public class AccountSelector {
    public static List<Account> byIds(Set<Id> accountIds) {
        return [
            SELECT Id, Name, Industry
            FROM Account
            WHERE Id IN :accountIds
            WITH SECURITY_ENFORCED
        ];
    }
}

// Usage everywhere
List<Account> accounts = AccountSelector.byIds(accountIds);
```

---

## Pattern 1: Basic Selector Class

The simplest approach - a class with static query methods.

```apex
/**
 * AccountSelector - Centralized queries for Account object
 *
 * @see https://blog.beyondthecloud.dev/blog/why-do-you-need-selector-layer
 */
public inherited sharing class AccountSelector {

    // ═══════════════════════════════════════════════════════════════════
    // FIELD SETS (centralized field lists)
    // ═══════════════════════════════════════════════════════════════════

    private static final List<SObjectField> STANDARD_FIELDS = new List<SObjectField>{
        Account.Id,
        Account.Name,
        Account.Industry,
        Account.AnnualRevenue,
        Account.OwnerId
    };

    // ═══════════════════════════════════════════════════════════════════
    // QUERY METHODS
    // ═══════════════════════════════════════════════════════════════════

    /**
     * Query accounts by their IDs
     */
    public static List<Account> byIds(Set<Id> accountIds) {
        if (accountIds == null || accountIds.isEmpty()) {
            return new List<Account>();
        }
        return [
            SELECT Id, Name, Industry, AnnualRevenue, OwnerId
            FROM Account
            WHERE Id IN :accountIds
            WITH SECURITY_ENFORCED
        ];
    }

    /**
     * Query accounts by Owner
     */
    public static List<Account> byOwnerId(Id ownerId) {
        return [
            SELECT Id, Name, Industry, AnnualRevenue, OwnerId
            FROM Account
            WHERE OwnerId = :ownerId
            WITH SECURITY_ENFORCED
            LIMIT 1000
        ];
    }

    /**
     * Query accounts with their contacts
     */
    public static List<Account> withContactsByIds(Set<Id> accountIds) {
        return [
            SELECT Id, Name,
                   (SELECT Id, FirstName, LastName, Email
                    FROM Contacts
                    WHERE IsActive__c = true
                    LIMIT 50)
            FROM Account
            WHERE Id IN :accountIds
            WITH SECURITY_ENFORCED
        ];
    }
}
```

**Usage**:
```apex
// Clean, readable, testable
List<Account> accounts = AccountSelector.byIds(accountIdSet);
List<Account> myAccounts = AccountSelector.byOwnerId(UserInfo.getUserId());
```

---

## Pattern 2: Selector with Sharing Modes

Control sharing rules at the selector level.

```apex
/**
 * ContactSelector with sharing mode control
 */
public class ContactSelector {

    // ═══════════════════════════════════════════════════════════════════
    // USER MODE (respects sharing rules - default)
    // ═══════════════════════════════════════════════════════════════════

    public inherited sharing class UserMode {
        public static List<Contact> byAccountIds(Set<Id> accountIds) {
            return [
                SELECT Id, FirstName, LastName, Email, AccountId
                FROM Contact
                WHERE AccountId IN :accountIds
                WITH USER_MODE
            ];
        }
    }

    // ═══════════════════════════════════════════════════════════════════
    // SYSTEM MODE (bypasses sharing - use carefully!)
    // ═══════════════════════════════════════════════════════════════════

    public without sharing class SystemMode {
        public static List<Contact> byAccountIds(Set<Id> accountIds) {
            return [
                SELECT Id, FirstName, LastName, Email, AccountId
                FROM Contact
                WHERE AccountId IN :accountIds
                WITH SYSTEM_MODE
            ];
        }
    }
}

// Usage
List<Contact> visibleContacts = ContactSelector.UserMode.byAccountIds(ids);
List<Contact> allContacts = ContactSelector.SystemMode.byAccountIds(ids);
```

---

## Pattern 3: Mockable Selector (for Unit Tests)

Enable query mocking without database calls.

```apex
/**
 * OpportunitySelector with mocking support
 *
 * @see https://www.jamessimone.net/blog/joys-of-apex/repository-pattern/
 */
public inherited sharing class OpportunitySelector {

    // Test-visible mock data
    @TestVisible
    private static List<Opportunity> mockData;

    /**
     * Query opportunities by Account IDs
     * Returns mock data in tests if set
     */
    public static List<Opportunity> byAccountIds(Set<Id> accountIds) {
        if (Test.isRunningTest() && mockData != null) {
            return mockData;
        }
        return [
            SELECT Id, Name, StageName, Amount, CloseDate, AccountId
            FROM Opportunity
            WHERE AccountId IN :accountIds
            WITH SECURITY_ENFORCED
        ];
    }

    /**
     * Set mock data for testing
     */
    @TestVisible
    private static void setMockData(List<Opportunity> opportunities) {
        mockData = opportunities;
    }
}
```

**Test Usage**:
```apex
@IsTest
private class OpportunitySelectorTest {
    @IsTest
    static void testByAccountIds_returnsMockData() {
        // Arrange - no database records needed!
        List<Opportunity> mockOpps = new List<Opportunity>{
            new Opportunity(Name = 'Test Opp', StageName = 'Prospecting', Amount = 1000)
        };
        OpportunitySelector.setMockData(mockOpps);

        // Act
        List<Opportunity> result = OpportunitySelector.byAccountIds(new Set<Id>());

        // Assert
        System.assertEquals(1, result.size());
        System.assertEquals('Test Opp', result[0].Name);
    }
}
```

---

## Pattern 4: Query Builder (Dynamic SOQL)

For complex, dynamic queries that vary at runtime.

```apex
/**
 * Dynamic query builder for flexible SOQL construction
 *
 * @see https://www.jamessimone.net/blog/joys-of-apex/you-need-a-strongly-typed-query-builder/
 */
public inherited sharing class QueryBuilder {

    private String objectName;
    private Set<String> fields = new Set<String>();
    private List<String> conditions = new List<String>();
    private Map<String, Object> bindings = new Map<String, Object>();
    private String orderByClause;
    private Integer limitCount;

    /**
     * Constructor
     */
    public QueryBuilder(String objectName) {
        this.objectName = objectName;
    }

    /**
     * Add fields to select
     */
    public QueryBuilder selectFields(List<String> fieldList) {
        fields.addAll(fieldList);
        return this;
    }

    /**
     * Add fields using SObjectField tokens (type-safe!)
     */
    public QueryBuilder selectFields(List<SObjectField> fieldTokens) {
        for (SObjectField token : fieldTokens) {
            fields.add(String.valueOf(token));
        }
        return this;
    }

    /**
     * Add WHERE condition with binding
     */
    public QueryBuilder whereEquals(String field, Object value) {
        String bindName = 'bind' + bindings.size();
        conditions.add(field + ' = :' + bindName);
        bindings.put(bindName, value);
        return this;
    }

    /**
     * Add WHERE IN condition
     */
    public QueryBuilder whereIn(String field, Set<Id> ids) {
        String bindName = 'bind' + bindings.size();
        conditions.add(field + ' IN :' + bindName);
        bindings.put(bindName, ids);
        return this;
    }

    /**
     * Add ORDER BY
     */
    public QueryBuilder orderBy(String field, Boolean ascending) {
        orderByClause = field + (ascending ? ' ASC' : ' DESC');
        return this;
    }

    /**
     * Add LIMIT
     */
    public QueryBuilder setLimit(Integer count) {
        limitCount = count;
        return this;
    }

    /**
     * Build and execute the query
     */
    public List<SObject> execute() {
        String query = buildQuery();
        return Database.queryWithBinds(query, bindings, AccessLevel.USER_MODE);
    }

    /**
     * Build query string (for debugging)
     */
    public String buildQuery() {
        List<String> parts = new List<String>();

        // SELECT
        if (fields.isEmpty()) {
            fields.add('Id');
        }
        parts.add('SELECT ' + String.join(new List<String>(fields), ', '));

        // FROM
        parts.add('FROM ' + objectName);

        // WHERE
        if (!conditions.isEmpty()) {
            parts.add('WHERE ' + String.join(conditions, ' AND '));
        }

        // ORDER BY
        if (orderByClause != null) {
            parts.add('ORDER BY ' + orderByClause);
        }

        // LIMIT
        if (limitCount != null) {
            parts.add('LIMIT ' + limitCount);
        }

        return String.join(parts, ' ');
    }
}
```

**Usage**:
```apex
// Fluent API for dynamic queries
List<SObject> results = new QueryBuilder('Account')
    .selectFields(new List<SObjectField>{Account.Id, Account.Name, Account.Industry})
    .whereEquals('Industry', 'Technology')
    .whereIn('Id', accountIds)
    .orderBy('Name', true)
    .setLimit(100)
    .execute();

// Debug the generated query
System.debug(new QueryBuilder('Account').selectFields(...).buildQuery());
// "SELECT Id, Name, Industry FROM Account WHERE Industry = :bind0 AND Id IN :bind1 ORDER BY Name ASC LIMIT 100"
```

---

## Pattern 5: Bulkified Query Pattern

The Map-based lookup pattern for bulk operations.

```apex
/**
 * BulkQueryHelper - Reusable bulk query patterns
 */
public inherited sharing class BulkQueryHelper {

    /**
     * Get Accounts by ID as a Map (O(1) lookup)
     */
    public static Map<Id, Account> getAccountMapByIds(Set<Id> accountIds) {
        return new Map<Id, Account>([
            SELECT Id, Name, Industry
            FROM Account
            WHERE Id IN :accountIds
            WITH SECURITY_ENFORCED
        ]);
    }

    /**
     * Get Contacts grouped by AccountId
     */
    public static Map<Id, List<Contact>> getContactsByAccountId(Set<Id> accountIds) {
        Map<Id, List<Contact>> contactsByAccount = new Map<Id, List<Contact>>();

        for (Contact c : [
            SELECT Id, FirstName, LastName, Email, AccountId
            FROM Contact
            WHERE AccountId IN :accountIds
            WITH SECURITY_ENFORCED
        ]) {
            if (!contactsByAccount.containsKey(c.AccountId)) {
                contactsByAccount.put(c.AccountId, new List<Contact>());
            }
            contactsByAccount.get(c.AccountId).add(c);
        }

        return contactsByAccount;
    }
}
```

**Usage in Trigger**:
```apex
// ❌ WRONG: Query per record
for (Opportunity opp : Trigger.new) {
    Account a = [SELECT Name FROM Account WHERE Id = :opp.AccountId];
}

// ✅ CORRECT: Bulk query with Map lookup
Set<Id> accountIds = new Set<Id>();
for (Opportunity opp : Trigger.new) {
    accountIds.add(opp.AccountId);
}

Map<Id, Account> accountMap = BulkQueryHelper.getAccountMapByIds(accountIds);

for (Opportunity opp : Trigger.new) {
    Account a = accountMap.get(opp.AccountId);
    if (a != null) {
        // Use account data
    }
}
```

---

## Best Practices Summary

| Practice | Benefit |
|----------|---------|
| Centralize in Selector classes | One place to update field lists |
| Use `WITH SECURITY_ENFORCED` | Automatic FLS enforcement |
| Return empty List, not null | Prevents NullPointerException |
| Use `inherited sharing` | Respects caller's sharing context |
| Make fields list a constant | Easy to update across queries |
| Add null/empty checks | Prevent unnecessary queries |
| Support mocking in tests | Faster tests, no database dependencies |

---

## When to Use Each Pattern

| Scenario | Pattern |
|----------|---------|
| Simple, static queries | Pattern 1: Basic Selector |
| Need sharing mode control | Pattern 2: Sharing Modes |
| Heavy unit testing | Pattern 3: Mockable Selector |
| Dynamic filters at runtime | Pattern 4: Query Builder |
| Trigger/batch bulk operations | Pattern 5: Bulkified Query |

