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. A brand-new record has no notion of SELECT, so wrapping it detects nothing. (Since v3.10.0 / v2.8.0,doInsertdoes haveIEntryoverloads, for when a new row needsIEntrybenefits such as aMockEntrycarrying formula values. Note that they return a new Entry holding the generated Id rather than writing it back to the one you passed in.)
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().
As an example, take a Usecase that copies the parent Account's industry onto an Opportunity.
public with sharing class CopyAccountIndustryToOpportunity {
@TestVisible static final String LBL_FETCH = 'oppFetch';
@TestVisible static final String LBL_UPDATE = 'oppUpdate';
private final Set<Id> opportunityIds;
private final IEloquent eloquent;
private Trace t = Trace.of('Copy parent Account industry to Opportunity');
// public ctor: production, business input only
public CopyAccountIndustryToOpportunity(Set<Id> opportunityIds) {
this(opportunityIds, null);
}
// private (@TestVisible) ctor: tests inject the IEloquent
@TestVisible
private CopyAccountIndustryToOpportunity(
Set<Id> opportunityIds,
IEloquent eloquent
) {
this.opportunityIds = opportunityIds;
this.eloquent = eloquent ?? new Eloquent();
}
public void invoke() {
this.t.start();
if(this.opportunityIds == null || this.opportunityIds.isEmpty()) {
this.t.skip('No target opportunities; nothing to do.');
return;
}
// Fetch the opportunities together with their parent Account's industry
Scribe oppScribe = Scribe.of(Opportunity.class)
.field('Id')
.parentField(Scribe.asParent('AccountId').field('Industry'))
.whereIn('Id', this.opportunityIds);
List<IEntry> oppEntries = this.eloquent.label(LBL_FETCH).get(oppScribe);
// Copy the parent Account's industry onto each opportunity
for(IEntry oppEntry : oppEntries) {
IEntry accountEntry = oppEntry.getParent('AccountId');
oppEntry.put('Industry__c', accountEntry.get('Industry'));
}
this.eloquent.label(LBL_UPDATE).doUpdate(oppEntries);
this.t.finish('Copied industry onto ' + oppEntries.size() + ' opportunities.');
}
}
This is the Layered Constructor Pattern:
- Simple production API:
new CopyAccountIndustryToOpportunity(opportunityIds).invoke() - Flexible test API:
new CopyAccountIndustryToOpportunity(opportunityIds, mock).invoke() - No half-built objects: every dependency is set at construction time.
This Usecase DI's a single
IEloquentand multiplexes it by label: one label for the fetch (LBL_FETCH) and one for the update (LBL_UPDATE). Labelling by role lets a test say "return this from the fetch" and "verify the update like this" independently.It also walks the fetched
IEntrywithgetParent, rewrites it withput, and hands it straight todoUpdate: the "stay inIEntryend to end" flow from Step 2, in practice.
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_WhenOpportunityHasAccount_ThenIndustryCopied() {
Trace t = Trace.of('Happy path: industry is copied from the parent Account to the Opportunity');
t.start();
// Arrange: mock one opportunity whose parent Account has Industry = Technology
MockEntry oppEntry = MockEntry.of(Opportunity.class)
.alias('opp')
.autoId(1)
.setParent('AccountId',
MockEntry.of(Account.class).set('Industry', 'Technology'));
Id oppId = oppEntry.getAliasId('opp');
Set<Id> oppIds = new Set<Id>{ oppId };
MockEloquent mock = (new MockEloquent())
.attach(CopyAccountIndustryToOpportunity.LBL_FETCH, new List<IEntry>{ oppEntry });
// Act
(new CopyAccountIndustryToOpportunity(oppIds, mock)).invoke();
// Assert: the industry was copied onto the opportunity
List<SObject> updated = mock.upsertedRecordsAt(CopyAccountIndustryToOpportunity.LBL_UPDATE);
Assert.areEqual(1, updated.size());
Assert.areEqual('Technology', ((Opportunity) updated[0]).Industry__c);
Assert.isTrue(TraceFlow.isLastFinish());
// Assert: the query is filtered by the input Opportunity Ids (v3.11.0 / v2.9.0+)
mock.scribeAt(CopyAccountIndustryToOpportunity.LBL_FETCH)
.assertCondition(new InConditionClause('Id', oppIds));
t.finish();
}
@isTest
static void testInvoke_WhenNoOpportunityIds_ThenSkipped() {
Trace t = Trace.of('Edge case: the Usecase skips when there are no target opportunities');
t.start();
// Arrange
Set<Id> oppIds = new Set<Id>();
MockEloquent mock = new MockEloquent();
// Act
(new CopyAccountIndustryToOpportunity(oppIds, mock)).invoke();
// Assert
Assert.isTrue(TraceFlow.isLastSkip());
t.finish();
}
The TraceFlow assertions confirm which code path ran, not just the return value. That distinguishes "skipped because there was nothing to do" from "finished normally".
The final scribeAt(...).assertCondition(...) pins down the shape of the query. MockEloquent does not evaluate WHERE — it returns whatever you attached, as-is. So even a query that forgot its whereIn('Id', ...) and fetches every record gets the right data in this test and passes green. No amount of polishing the mock data exposes that hole, so put at least one assertion of this shape in the unit tests of any Usecase that fires a query. If a value received through the constructor should be used to filter, write assertCondition; if it must not be, write assertNoCondition — never leave both unwritten.
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 CopyAccountIndustryToOpportunity(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_WhenOpportunityInserted_ThenIndustryCopied() {
Trace t = Trace.of('Happy path: inserting an Opportunity copies the parent Account industry');
t.start();
// Arrange: build an Account with an industry and its child Opportunity as a tree
SOrchestrator orchestrator = SOrchestrator.start()
.add(SBlueprint.of(Account.class)
.alias('acc')
.template(Blueprints.accBasic())
.set('Industry', 'Technology')
.withChildren(
SBlueprint.of(Opportunity.class)
.alias('opp')
.template(Blueprints.oppBasic())
));
// Act: create() inserts Account then Opportunity; the Opportunity insert fires the trigger
Test.startTest();
orchestrator.create();
Test.stopTest();
// Assert: the parent Account's industry was copied onto the opportunity
Opportunity opp = (Opportunity) orchestrator.getByAlias('opp');
Opportunity refetched = [
SELECT Id, Industry__c
FROM Opportunity
WHERE Id = :opp.Id
];
Assert.areEqual('Technology', refetched.Industry__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 (201 or more),
SBlueprint.of(Opportunity.class).template(Blueprints.oppBasic()).alias('opp_{#}').times(201)
// capture the consumption right after the Act, before stopTest(),
Test.startTest();
orchestrator.create();
Integer soqlUsed = Limits.getQueries();
Test.stopTest();
// and add governor headroom to the Assert
Assert.isTrue(
soqlUsed < Limits.getLimitQueries() / 2,
'SOQL should stay under half the limit even in bulk. Measured: ' + soqlUsed
);
Use 201 records or more. Salesforce invokes triggers in chunks of 200, so a test that stops at 200 lets an implementation that assumes "every record arrives in one call" slip through. And always read Limits before Test.stopTest(): stopTest() restores the governor counters to their pre-startTest() state, so reading them afterwards gives an assertion that passes no matter what. Deep child trees can hit the 10,000 DML row limit, so keep the children minimal. To name which Usecase is doing the consuming, TraceFlow.usageOf(name) pins it down per Usecase (see TraceUsage: Governor Usage per Context).
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.