Blog › ICP guides
Salesforce Apex developer on retainer: SOQL, triggers, LWC, Flow, and CRM platform engineering on monthly retainer
September 25, 2026 · ~21 min read
At 10:57 PM EST on a Tuesday, a Salesforce org threw a System.LimitException: Too many SOQL queries: 101 error and silently killed the nightly batch job that was processing 11,000 Account records for a CRM consulting client. The batch had run without incident for three months. That week, a business stakeholder had added a new account assignment rule — a simple workflow, or so it appeared — and the first nightly run after the deployment failed at record 101. By 6 AM, when the operations team opened Salesforce, 10,899 Account records had not been updated. Nobody had received an alert because the batch class did not implement error notification, and the Apex Jobs page buried the failure in a status column that required manual inspection.
The Apex developer on retainer diagnosed it in one session. They opened the Developer Console, ran a heap-safe Execute Anonymous script on a 200-record subset, and watched Limits.getQueries() climb to 101 on the 101st record. The trigger handler's handleAfterUpdate() method was calling SELECT Id, OwnerId FROM User WHERE Id = :acc.OwnerId inside the for (Account acc : Trigger.new) loop. Each record triggered one SOQL query. Salesforce's governor limit is 100 SOQL queries per synchronous transaction — at record 101, the transaction was aborted. The fix was surgical: extract all owner Ids into a Set<Id> before the loop, issue one SELECT Id, OwnerId, Name FROM User WHERE Id IN :ownerIds query, build a Map<Id, User> ownerMap, and replace the inner SOQL with a ownerMap.get(acc.OwnerId) lookup. SOQL query count per trigger invocation dropped from 11,000 to 1. The nightly batch ran to completion in under five minutes. The diff was eleven lines.
Apex language fundamentals: SOQL, DML, triggers, and governor limits
Apex is a strongly typed, Java-like language that runs on Salesforce's multi-tenant platform, which means every transaction runs inside a governor limit sandbox that prevents any single customer's code from monopolizing shared infrastructure. The core database query language is SOQL — Salesforce Object Query Language — a SQL-like syntax scoped to Salesforce objects: SELECT Id, Name, AnnualRevenue FROM Account WHERE Industry = 'Technology' ORDER BY AnnualRevenue DESC LIMIT 200 OFFSET 0. SOQL supports relationship traversal across parent-child relationships: SELECT Id, Name, (SELECT Id, Subject FROM Cases) FROM Account WHERE OwnerId IN :ownerSet retrieves parent Accounts with a nested subquery for related Cases. SOSL — Salesforce Object Search Language — differs from SOQL in that it searches across multiple objects and fields simultaneously: FIND 'Acme' IN ALL FIELDS RETURNING Account(Id, Name), Contact(Id, FirstName, LastName) is SOSL syntax, used for multi-object text search rather than structured record retrieval. Each performs against the platform's limit counters differently: SOQL queries count against the 100 SOQL query limit, while SOSL searches count against a separate 20 SOSL query limit.
DML operations — insert, update, delete, upsert — are how Apex writes to the database. The platform's DML governor limits are 150 DML statements per transaction and 10,000 DML rows. Bulk DML is the correct pattern for multi-record operations: insert accountList; is one DML statement regardless of list size, whereas issuing insert account; inside a loop is one DML statement per record and hits the 150-statement limit at record 151. The Database.insert(recordList, allOrNone) variant provides partial success semantics — when allOrNone is false, records that fail validation rules are skipped and the method returns a List<Database.SaveResult> where each entry has a isSuccess() flag and a getErrors() list; the transaction continues for the records that succeeded. The default statement-level DML (plain insert list;) is equivalent to allOrNone = true — any failure rolls back the entire list. Choosing between them is a retainer-level architectural decision that depends on whether the business process requires all-or-nothing atomicity (billing record creation — a partial insert leaves orphaned data) or best-effort bulk processing (mass account update from an integration — skip the records that fail validation and process the rest).
Trigger design is the category where governor limit architecture is most visible and where a retainer-level investment pays the largest dividend. Apex triggers fire on DML events — before and after insert, update, delete, undelete — and the trigger context variables carry the full record set in the operation: Trigger.isInsert, Trigger.isBefore, Trigger.new (list of new records), Trigger.newMap (Map of Id to new record), Trigger.oldMap (Map of Id to old record for updates and deletes). The bulkification principle — the single most important Apex concept for governor limit safety — is that all queries and DML must operate on the full Trigger.new list, never on individual records inside a loop. The pattern: collect all the Ids needed from the trigger records into a Set<Id> or Set<String> before any loop, issue one SOQL query with an IN :idSet bind variable, build a Map<Id, SObject> from the result, and access the map inside the loop. The Limits class provides runtime introspection: Limits.getQueries() returns the number of SOQL queries consumed so far in the current transaction, and Limits.getLimitQueries() returns 100 — the ceiling. A retainer engagement covering trigger architecture instruments every trigger handler with System.debug('SOQL used: ' + Limits.getQueries() + '/' + Limits.getLimitQueries()) checkpoints, runs each handler in a 200-record test scenario (Salesforce processes records in batches of 200 through triggers), and confirms that SOQL query count is constant regardless of the record count in the batch.
Testing: @TestSetup, Test.startTest/stopTest, and HttpCalloutMock
Salesforce requires a minimum of 75% Apex code coverage before any deployment to production — but coverage percentage is a floor, not a quality signal. A test suite that reaches 75% by running through happy paths with seeAllData = true (accessing the org's real data instead of test-created data) will pass in a developer sandbox with the right records loaded and fail in a CI scratch org with a clean data state, or pass in production today and fail after a data migration changes the records the tests depend on. A retainer engagement covering test quality begins with a full audit of test class annotations: any class with @isTest(seeAllData=true) is a candidate for rewrite. The correct pattern is explicit test data creation using @TestSetup — a static method annotated with @TestSetup runs once per test class before any test method, and all records it creates are visible to all test methods in the class in a rolled-back state. This means the @TestSetup method creates the full data fixture — the Account hierarchy, the related Contacts, the Opportunity records, the custom metadata that the trigger handler reads — and each test method starts from a clean, known state without querying real org data.
Test.startTest() and Test.stopTest() serve a specific and frequently misunderstood purpose in Apex testing. Calling Test.startTest() resets the governor limit counters — the SOQL query count, DML statement count, CPU time counter, and heap usage counter — to zero for the code between startTest() and stopTest(). This means that test setup code (querying records, creating the data fixture) does not consume governor limits against the code under test. The counter reset is essential for testing trigger handlers that process large volumes: without Test.startTest(), the SOQL queries issued by the @TestSetup method count against the 100-query limit available to the trigger handler. The Test.stopTest() call additionally processes any asynchronous work enqueued between the two calls — Queueable jobs, Batch Apex, Future methods, and Scheduled Apex — synchronously within the test transaction, making them testable without System.schedule and Test.isRunningTest() workarounds. A retainer engagement covering test architecture ensures that every test method that exercises trigger logic or asynchronous processing has the correct Test.startTest() and Test.stopTest() boundaries.
External callouts from Apex — REST and SOAP requests to third-party APIs — are blocked in test context by default: the platform throws a System.CalloutException: You have uncommitted work pending error if a callout is attempted in a test without a mock. The HttpCalloutMock interface is the standard solution: implement the interface with a respond(HttpRequest req) method that returns a crafted HttpResponse, then register the mock with Test.setMock(HttpCalloutMock.class, new MyMockImplementation()) before the code under test is called. For multi-callout scenarios — a single transaction makes requests to two different endpoints — MultiStaticResourceCalloutMock maps endpoint URL patterns to static resource files containing the JSON or XML response bodies. For callout sequences where the number of requests is variable (pagination loops), a stateful mock implementation that tracks call count and returns different responses on successive invocations is the retainer-level approach. A well-written callout mock also asserts on the request body and headers that the code under test sent — confirming that the integration contract is satisfied, not just that the code handles the response.
LWC, Aura, and Flow: @wire, @api, @track, NavigationMixin, and Flow Builder
Lightning Web Components (LWC) is the modern Salesforce UI framework introduced in 2019, built on web standards — custom elements, shadow DOM, and ES modules — rather than the proprietary Aura framework it supplements. The @wire decorator is the primary mechanism for binding Salesforce data to LWC component properties without writing imperative Apex controller calls. The pattern: @wire(getRecord, { recordId: '$recordId', fields: [ACCOUNT_NAME_FIELD, ACCOUNT_REVENUE_FIELD] }) account; binds the result of the getRecord wire adapter — called automatically when the component loads and again whenever recordId changes — to the account property. The $recordId syntax (dollar sign prefix) marks the property as reactive: when this.recordId changes, the wire adapter re-fetches automatically. The wire result has data and error properties; the template uses if:true={account.data} and if:true={account.error} to handle both states. For custom Apex wire adapters, the server-side method is annotated with @AuraEnabled(cacheable=true) — the cacheable=true flag is required for @wire usage and enables the Lightning Data Service cache layer, which prevents redundant server calls when multiple components on the same page wire to the same data.
The @api decorator exposes a property or method as public on an LWC component, making it settable by a parent component in the template or callable by a parent component in JavaScript. The @track decorator in LWC (post-Spring '20) is largely vestigial — all primitive and object properties in LWC are reactive by default, and @track is only needed when you need deep reactivity on nested object mutations (mutating a property of a property of a property). The communication pattern for child-to-parent events is CustomEvent: this.dispatchEvent(new CustomEvent('save', { detail: { recordId: this.recordId, fields: this.formData } })); dispatches an event that bubbles up the component tree, and the parent template listens with onsave={handleSave}. The NavigationMixin provides programmatic navigation: a component class that extends NavigationMixin(LightningElement) gains the this[NavigationMixin.Navigate]({ type: 'standard__recordPage', attributes: { recordId: newId, objectApiName: 'Account', actionName: 'view' } }) method for navigating to a record page, a list view, a named page, or a web URL. Aura components — the older framework — use a different imperative pattern: var action = component.get('c.handleSave'); action.setParams({ recordId: component.get('v.recordId') }); action.setCallback(this, function(response) { var state = response.getState(); if (state === 'SUCCESS') { var result = response.getReturnValue(); } }); $A.enqueueAction(action);. Migrating Aura components to LWC is a recurring retainer engagement category: the data access pattern (Aura action callbacks vs. LWC wire adapters and async/await imperative calls), event model (Aura application events vs. LWC CustomEvent and Lightning Message Service), and DOM access (component.find('myInput').get('v.value') in Aura vs. this.template.querySelector('[data-id="myInput"]').value in LWC) all require rewriting, not just restyling.
Flow Builder is Salesforce's declarative automation tool, and a Flow architect on retainer bridges the declarative-and-code boundary that organizations inevitably reach. Record-triggered flows replace the deprecated Workflow Rules and Process Builder tools and run after a record save; they support an immediate path for synchronous automation and a scheduled path for time-based operations (send a follow-up email 3 days after an Opportunity is moved to Closed Won). Screen flows present UI screens to users — input fields, display text, lookup components — and are embedded in Lightning pages, Quick Actions, or utility bars using the standard lightning-flow LWC. Subflow invocations allow one flow to call another as a reusable component: a master opportunity flow calls a shared Create_Follow_Up_Task subflow, and the subflow is maintained in one place while being invoked from dozens of parent flows. Fault paths — the error-handling branches in a flow — are a retainer-level design concern because Flow suppresses errors by default in auto-launched flows if no fault path is configured, meaning a failed DML in a Flow silently rolls back the automation without surfacing an error to the user or administrator. A well-designed fault path uses assignment elements to capture the fault message from the {!$Flow.FaultMessage} variable and either creates a log record, sends an email alert, or surfaces the message on the screen via a fault path screen element. For complex Apex logic that Flow cannot express natively, the @InvocableMethod annotation makes an Apex method callable from Flow: @InvocableMethod(label='Create Renewal Opportunity' description='Creates a renewal opportunity from a closed won opportunity') with a parameter class annotated @InvocableVariable(required=true) for each input field. The older Process.PluginResult interface served the same purpose for Process Builder and is maintained for backward compatibility in orgs that have not migrated to Flow.
How HourTab tracks Salesforce Apex developer retainer hours
Salesforce Apex retainers produce the same invisibility problem as all platform-engineering retainers, amplified by the fact that Salesforce governor limits are not visible to business stakeholders at all. A client who hires an Apex developer on retainer sees the Salesforce org working — batch jobs completing, records updating, components loading — and has no way to connect that reliability to the 10-hour bulkification session that moved the org from 11,000 SOQL queries per trigger invocation to 1. The work log entry “bulkified AccountTriggerHandler, 10h” describes the duration and leaves the client unable to explain to their own leadership why a 10-hour billing item appeared on the retainer invoice for a change that produced an eleven-line diff. The gap between what was done (eleven lines changed) and what was produced (elimination of nightly batch failures at scale) is unbridgeable without a structured explanation of what Salesforce governor limits are, why the query-inside-loop pattern violates them at scale, and what the bulkified pattern costs in complexity terms — context that takes five minutes to write once, per log entry, and prevents twenty minutes of client status calls per billing cycle.
HourTab gives Salesforce developers a public retainer-hours URL they paste into the first message of every client engagement. The client opens the URL and sees the current burn-down: hours purchased, hours used, hours remaining, and a work log of every session. For Apex retainers specifically, the work log entries carry more information than the burn-down chart alone can convey. Each entry should name the governor limit involved (SOQL 100 query limit, DML 10,000 row limit, CPU 10,000 ms limit, heap 6 MB limit), the Limits API measurement that confirmed it (Limits.getQueries() = 101 at record 101), the trigger or class that was changed, the bulkification or architectural pattern applied, and the before-and-after metric (SOQL per invocation: 11,000 before, 1 after; batch job: LimitException failure before, successful completion in 4m 12s after). A structured entry like that takes five minutes to write at the end of a session and converts a line-item on an invoice into a documented architectural improvement that the client can reference in their own internal reporting. When the retainer renewal conversation comes around, the work log is the evidence — not just that hours were consumed, but that each session produced a measurable, named improvement to the org's reliability or capability.
Track Salesforce Apex developer retainer hours without the status emails
HourTab gives Apex developers and Flow architects a public URL per client retainer. One link, no login, live burn-down. Your clients stop asking “how many hours do I have left?” and your work log becomes the proof of value that gets the retainer renewed.
See HourTab pricing →FAQ: Salesforce Apex developer retainers
What does a Salesforce Apex developer on retainer typically do?
A Salesforce Apex developer on monthly retainer provides ongoing governor limit architecture (SOQL bulkification, DML row limit management, CPU time and heap optimization), trigger design with Trigger.isInsert/isBefore/newMap/oldMap context variables, test class authorship using @TestSetup, Test.startTest/stopTest boundaries, and HttpCalloutMock for external API testing, LWC component architecture with @wire(getRecord), @api/@track property design, CustomEvent dispatching, and NavigationMixin.Navigate patterns, Aura component maintenance and LWC migration, and Flow Builder automation design covering record-triggered flows, screen flows, subflow invocations, fault paths, and @InvocableMethod Apex action integration. The retainer covers the continuous platform engineering between visible feature releases: trigger bulkification audits, governor limit monitoring, LWC component refactors, and @InvocableMethod redesigns that produce no new feature but eliminate a class of runtime failures or automation gaps.
What Salesforce Apex work is most underlogged in a retainer?
Trigger SOQL bulkification (replacing per-record SOQL inside Trigger.new loops with a single Map-based query before the loop; 8–18 hours invisible in the elimination of System.LimitException errors in nightly batch jobs), test class hardening (rewriting seeAllData=true test classes with @TestSetup data factories, Test.startTest/stopTest governor limit isolation, and HttpCalloutMock for callout paths; 12–24 hours invisible in consistent deployment success across all org types), and @InvocableMethod DML architecture (extracting per-record DML inside @InvocableMethod List input processing to a single Database.insert(recordList, false) call; 6–14 hours invisible in the elimination of MIXED_DML_OPERATION errors on user-context Flow triggers) are the three most systematically underlogged categories in Salesforce Apex retainers.
What are typical Salesforce Apex developer retainer rates?
Entry-level Apex developers (1–2 years, basic SOQL/DML, Trigger context variables, @TestSetup) bill at $90–$160/hr. Mid-level Apex engineers (2–5 years, trigger bulkification, Database.insert allOrNone, Limits API, @wire LWC, @api/@track, CustomEvent, NavigationMixin, @InvocableMethod, Test.startTest/stopTest, HttpCalloutMock) bill at $150–$275/hr. Senior Apex architects (5+ years, batch Apex scoping, Queueable chaining, Platform Events, Apex Enterprise Patterns, full LWC/Aura architecture, Flow design with fault paths, multi-org DX package deployment) bill at $220–$420/hr. Firm and agency rates run $185–$345/hr. Monthly retainer ranges: $3,000–$8,000/mo for advisory-only retainers (15–30 hrs), $12,000–$30,000/mo for full platform engagements covering feature development, governor limit architecture, LWC component design, and Flow automation.
What should a Salesforce Apex developer retainer agreement include?
A Salesforce Apex developer retainer agreement should specify org scope (production, sandbox, scratch org access; change set vs. Salesforce DX deployment method), governor limit advisory scope (SOQL bulkification, DML row limit management, Limits.getCpuTime() monitoring, heap size management at 6 MB limit), trigger framework scope (one-trigger-per-object enforcement, trigger handler pattern, recursive prevention), test class scope (@TestSetup factories, Test.startTest/stopTest boundaries, HttpCalloutMock and StaticResourceCalloutMock, System.runAs() permission testing), Lightning component scope (LWC @wire adapters, @api/@track design, NavigationMixin patterns, Lightning Data Service, Aura migration), Flow architecture scope (record-triggered and screen flows, @InvocableMethod integration, subflow invocations, fault path design), and hour logging format (governor limit named, Limits API measurement cited, before/after SOQL or DML count, test assertion that verified the fix).
How should Salesforce Apex developer retainer hours be logged?
Log each Apex retainer session with: advisory category (trigger SOQL bulkification, DML row limit management, CPU time optimization, heap size management, @TestSetup data factory design, Test.startTest/stopTest boundaries, HttpCalloutMock authorship, @wire LWC adapter design, @api/@track property architecture, CustomEvent and Lightning Message Service, NavigationMixin.Navigate, Aura-to-LWC migration, @InvocableMethod Flow action authorship, record-triggered flow design, scheduled path automation, subflow invocation, fault path error handling), specific class/trigger/component/flow, diagnostic output (Limits.getQueries() = 101 at record 101; System.LimitException: Too many SOQL queries: 101; Developer Console heap trace: 5.9 MB of 6 MB at line 87 of BatchProcessor), fix and rationale (Map-based SOQL before loop using Trigger.newMap.keySet() — SOQL per invocation reduced from 11,000 to 1; Database.insert(list, false) replacing per-record DML — DML statements from 11,000 to 1), and before/after metric (SOQL per trigger: 11,000 → 1; batch job: LimitException failure → successful completion in 4m 12s; test deployment: failing in scratch org → passing across all 3 sandbox types and production). Include the Salesforce API version and org edition context.