Apex Stem: A Step-by-Step Guide to Adoption
This guide walks through how to gradually adopt Apex Stem in an existing Salesforce codebase, one step at a time. You don't have to rewrite everything — pick the smallest piece that improves your situation today, and grow from there.
The four steps below mirror the cards on the home page, but with real code you can copy and adapt.
The samples run on ApexEloquent v2.1 and later (they use
label()/attach()). In v3, SOQL and DML default to user mode, honouring FLS — so keep the running user's field permissions in mind. Work that must complete regardless of who triggered it (aggregation, stamping) opts out with.systemMode().
Step 1: Replace one SOQL with Scribe
Take a query you currently write inline and route it through ApexEloquent's typed query builder, Scribe. Behavior is unchanged, but you gain:
- SELECT-omission detection in tests: tests fail if your code reads a field you forgot to SELECT.
- Mockability: the query goes through
IEloquent, which can be swapped withMockEloquentin unit tests.
Before
List<Account> accounts = [
SELECT Id, Name, Industry
FROM Account
WHERE Industry = :industry
];
After
Scribe accountScribe = Scribe.of(Account.class)
.field('Id')
.field('Name')
.field('Industry')
.whereEqual('Industry', industry);
List<IEntry> accountEntries = new Eloquent().get(accountScribe);
Note that fields are passed as strings ('Id', 'Industry'), not as SObjectField references. This keeps the builder dynamic — you can compose queries at runtime without fighting the type system.
Raw SOQL (
[SELECT ...]) is fine for test assertions and other quick uses. For production code, preferScribeso you keep the mockability/safety net.
Step 2: Keep results as IEntry
It's tempting to call getAsSObject() and work with plain Account records. Resist that for business logic. Keep results as List<IEntry> and access fields via entry.get('FieldName').
Why
IEntry gives you three things that early conversion to SObject destroys:
- SELECT-omission detection. If you forgot to
field('Industry')in your Scribe and then readentry.get('Industry'), the test fails. With rawSObject, the access would silently returnnull. - Mockable formulas, rollups, and non-writable fields.
MockEntry.set()can write to formula fields, rollups, auto-numbers, and other normally-read-only fields. Your business logic can be unit-tested against those values without firing real formulas. - Get-edit-update stays in IEntry.
entry.put('Foo__c', value)mutates the entry;eloquent.doUpdate(entries)acceptsList<IEntry>directly.
Reading fields
for(IEntry accountEntry : accountEntries) {
Id id = accountEntry.getId(); // dedicated getter
String name = accountEntry.getName(); // dedicated getter
String industry = (String) accountEntry.get('Industry'); // cast required
}
Why not drop down to SObject
getAsSObject() will hand you an SObject, but the moment you drop down, SELECT-omission detection stops working downstream.
// ❌ Whatever receives this can touch an unselected field and just get null — quietly
List<EstimateItems__c> items = (List<EstimateItems__c>) eloquent.getAsSObject(scribe);
// ✅ Pass IEntry along and an unselected access throws right there
List<IEntry> items = eloquent.get(scribe);
A typed SObject (EstimateItems__c) leaves the same hole. The rule extends to not typing your own method and class parameters as SObject either.
When SObject is OK
- Passing to external APIs that require
SObject(e.g.,Messaging.SingleEmailMessage,Database.SaveResulthandling,Approval.process). Trigger.newand other SObject-native contexts that never pass throughEloquent.
"The cast is annoying" and "SObject is quicker to write right now" are not reasons. Break the rule once and nothing holds the line afterwards.
💡 When in doubt, ask: did this record come from a query? If yes,
IEntry; if you justnewed it,SObject. That is also whydoInserthas noIEntryoverload — a brand-new record has no notion of SELECT, so wrapping it detects nothing.
Step 3: Carve out a Usecase
When the SOQL + logic combination grows past a few lines, give it a name. An Apex Stem Usecase is an object whose only public surface is invoke().
public with sharing class CountActiveAccountsByIndustryUsecase {
private final String industry;
private final IEloquent fetchEloquent;
private Trace t = Trace.of('Count active accounts by industry');
// public ctor: production, business input only
public CountActiveAccountsByIndustryUsecase(String industry) {
this(industry, null);
}
// private (@TestVisible) ctor: tests inject the IEloquent
@TestVisible
private CountActiveAccountsByIndustryUsecase(String industry, IEloquent fetchEloquent) {
this.industry = industry;
this.fetchEloquent = fetchEloquent ?? new Eloquent();
}
public Integer invoke() {
this.t.start();
if(String.isBlank(this.industry)) {
this.t.skip('Industry is blank; nothing to count.');
return 0;
}
Scribe accountScribe = Scribe.of(Account.class)
.field('Id')
.whereEqual('Industry', this.industry)
.whereEqual('Active__c', true);
Integer count = this.fetchEloquent.get(accountScribe).size();
this.t.finish('Counted ' + count + ' active accounts.');
return count;
}
}
This is the Layered Constructor Pattern:
- Simple production API:
new CountActiveAccountsByIndustryUsecase('Tech').invoke() - Flexible test API:
new CountActiveAccountsByIndustryUsecase('Tech', mockEloquent).invoke() - No half-built objects: every dependency is set at construction time.
If a Usecase needs two SOQL queries against the same object (e.g., one for last month, one for this year), inject two separate
IEloquentinstances.MockEloquentdoesn't evaluateWHERE, so you need distinct mocks per query to return different result sets.
Step 4: Test at the right layer
The architecture maps 1-to-1 to two test strategies:
- Usecase layer → unit tests with
MockEloquent— no DB, fast, exhaustive logic coverage. - Handler layer → integration tests with
SBlueprint— real DML, validates the wiring.
Unit test for a Usecase
@isTest
static void testInvoke_WhenIndustryIsTech_ThenReturnsCount() {
Trace t = Trace.of('Returns count of active Tech accounts');
t.start();
// Arrange — MockEloquent returns 3 entries regardless of WHERE
IEloquent mockEloquent = new MockEloquent(new List<IEntry>{
MockEntry.of(Account.class).autoId('{#}').times(3)
});
// Act
Integer count = new CountActiveAccountsByIndustryUsecase('Technology', mockEloquent).invoke();
// Assert
Assert.areEqual(3, count);
Assert.isTrue(TraceFlow.isLastFinish());
t.finish();
}
@isTest
static void testInvoke_WhenIndustryIsBlank_ThenSkipped() {
Trace t = Trace.of('Returns 0 and skips when industry is blank');
t.start();
Integer count = new CountActiveAccountsByIndustryUsecase('', new MockEloquent()).invoke();
Assert.areEqual(0, count);
Assert.isTrue(TraceFlow.isLastSkip());
t.finish();
}
The TraceFlow assertions confirm which code path ran, not just the return value. That distinguishes "skipped because nothing to do" from "finished with zero hits".
The Handler under test
Before the integration test, you need the Handler that calls the Usecase. The trigger file declares all seven events and does nothing but call the Handler on one line.
trigger Opportunity on Opportunity(
before insert, before update, before delete,
after insert, after update, after delete, after undelete
) {
(new TriggerOppHandler()).execute();
}
The Handler extends TriggerHandler (ApexTools) and overrides only the hooks it needs. All it does is decide conditions and call a Usecase — no business logic.
public with sharing class TriggerOppHandler extends TriggerHandler {
protected override void afterInsert(Map<Id, SObject> newRecordsMap) {
(new CopyAccountIndustryToOpportunityUsecase(newRecordsMap.keySet())).invoke();
}
}
Hooks you don't override (beforeUpdate and friends) do nothing. There is no need to fill them with empty methods.
To act "only when a particular field changed", use the base class's
getUpdateRecordIdsWithChangedFields(...). If you find yourself hand-writingTrigger.isAfteror comparing new/old yourself, that is a sign the base class isn't in place.
Integration test for a Handler with ApexBlueprint
@isTest
static void testAfterInsert_WhenChildLinked_ThenParentCountUpdated() {
Trace t = Trace.of('After insert, parent count is updated');
t.start();
// Arrange — create real records via ApexBlueprint
SOrchestrator orchestrator = SOrchestrator.start()
.add(SBlueprint.of(Account.class)
.alias('parent')
.template(Blueprints.accBasic())
.set('Industry', 'Technology'));
orchestrator.create();
Account parent = (Account) orchestrator.getByAlias('parent');
// Act — fire the trigger by inserting/updating real data
// (your Handler-specific DML here)
// Assert — re-query and verify the wiring worked
Account refetched = [
SELECT Id, ActiveAccountCount__c
FROM Account
WHERE Id = :parent.Id
];
Assert.areEqual(1, refetched.ActiveAccountCount__c);
t.finish();
}
Real DML flows through real triggers, so this test validates the whole chain: Handler → Usecase → ApexEloquent → DB. Use it sparingly — 1 to 3 representative cases per Handler is plenty. Exhaustive logic coverage stays in the Usecase unit tests.
Make one of those representatives a bulk test
When triggers set off other triggers, make one of your representative cases a test that pushes a production-like volume through a single DML and checks the governor headroom.
The reason is simple: MockEloquent issues no real SOQL, so query inefficiency is completely invisible from unit tests. An implementation that walks down a hierarchy firing whereIn stays green in units and then throws Too many SOQL queries: 101 in a production bulk run. A single-scenario integration test like the one above never approaches the 100-SOQL ceiling with a handful of records either.
// Add times() to the Arrange hierarchy to mass-produce,
SBlueprint.of(Opportunity.class).template(Blueprints.oppBasic()).alias('opp_{#}').times(30)
// and add governor headroom to the Assert
Assert.isTrue(
Limits.getQueries() < Limits.getLimitQueries() / 2,
'SOQL should stay under half the limit even in bulk. Measured: ' + Limits.getQueries()
);
Keep the volume to the minimum that reproduces the problem (mind the 10,000 DML row limit). To name which Usecase is doing the consuming, TraceFlow.lastUsage() pins it down per Usecase (see the ApexTrace guide).
The whole picture is laid out in Test Strategy.
Where to go from here
- The Stack overview: each library's role and links to its source.
To dig into the design itself:
- Handler-Usecase Architecture: the two layers, the five entry-point kinds, and where it overlaps with Salesforce's own recommendation.
- Layered Constructor Pattern: the two constructors from Step 3, taken as a topic of their own.
- Test Strategy: Step 4's judgement calls, systematised — triage on failure, how to shape CI/CD, and the pitfalls.
To dig into each library:
- ApexEloquent guide: deeper coverage of Scribe (aggregates, parent fields, subqueries, MockEntry advanced patterns).
- ApexBlueprint guide: SBlueprint templates,
use()for sibling references,withChildren()for nested trees. - ApexTrace guide: TraceFlow, nest constraints, and TraceUsage for measuring governor consumption.
- ApexTools guide: the
TriggerHandlerbase class used in Step 4, and the DI-able HTTP request wrapper.
Start with one library, one query, one Usecase. The stack grows with you, not against you.