ApexEloquent: Data access, DML, IEntry, and Mock
This document is a usage guide focused on how to write ApexEloquent's data-access layer in production code. For the full API signatures, see API Reference: IEloquent / Eloquent / MockEloquent and API Reference: IEntry / Entry / MockEntry.
For how to build queries, see Building Queries with Scribe. For the rest of the ApexEloquent topics, see the ApexEloquent Guide.
What IEloquent Is
IEloquent is the interface that abstracts data access (SOQL / DML). Production uses Eloquent (a wrapper over standard SOQL / DML); tests use MockEloquent (a behavior mock without a DB) — both are swapped in without changing the calling code.
public with sharing class FindActiveOpportunitiesUsecase {
private final IEloquent fetchEloquent;
public FindActiveOpportunitiesUsecase() {
this(null);
}
@TestVisible
private FindActiveOpportunitiesUsecase(IEloquent fetchEloquent) {
this.fetchEloquent = fetchEloquent ?? new Eloquent();
}
public List<IEntry> invoke() {
Scribe scribe = Scribe.of(Opportunity.class)
.field('Id')
.field('Name')
.whereEqual('IsClosed', false);
return this.fetchEloquent.get(scribe);
}
}
For "two constructors that simultaneously support a production default and test-side DI", see Layered Constructor Pattern.
Data Retrieval
Retrieval Methods
In business logic, the default is get(scribe) returning List<IEntry>. 0 hits yields an empty list. When you want just one record, first (returns null on 0 hits) or firstOrFail (throws on 0 hits) is handy.
getAsSObject conversion is a last resort — use it only when you have a concrete reason to hand off an SObject instance (e.g. directly passing it to an external API like Messaging.SingleEmailMessage). Similarly, rawSoql(soql) is the last resort for SOQL that Scribe can't express; it disables the MockEntry SELECT-omission detection.
For the full list of signatures, see API Reference: IEloquent.
The Benefits of Staying on IEntry
IEntry wraps SObject and gives you four benefits unique to ApexEloquent.
- False-positive detection for unselected fields: if production code accesses a field that wasn't called out with
field()in theScribe, an exception fires at test time (viaMockEntry). Early conversion toSObjectstrips that protection, and the classic "tests pass but production reads empty" incident creeps back in. - Freedom in building mock data:
MockEntry.set()accepts non-writable fields — relationship fields, formula fields, rollups, auto-number — that you can't normally write. When the logic depends on these, test data you can't build with rawSObjectbecomes trivial viaIEntry. - Retrieve → edit → update flows stay on IEntry: mutate with
entry.put('Industry__c', value), hand it straight toeloquent.doUpdate(List<IEntry>). No conversion toSObjectneeded. - No distinction between SObject and AggregateResult: results of normal queries and aggregate queries (
AggregateResult) both come back asIEntry.entry.get('Industry')for an object field,entry.get('totalAmount')for an aggregate alias — same shape. The consumer doesn't have to care whether it's anSObjectorAggregateResult.
Working with IEntry
Reading and Writing Fields
List<IEntry> accountEntries = eloquent.get(accountScribe);
for(IEntry accountEntry : accountEntries) {
// Id and Name have dedicated getters (no cast needed)
Id accountId = accountEntry.getId();
String name = accountEntry.getName();
// Other fields require a cast
String industry = (String) accountEntry.get('Industry');
Boolean isActive = (Boolean) accountEntry.get('Active__c');
// Writing
accountEntry.put('Industry', 'Technology');
}
getId / getName are dedicated getters that don't need casting. Other fields go through get(fieldName) and require a cast on the return. Writes are put(fieldName, value). For parent and child record access see Parent Fields, Child Subqueries, and Many-to-Many; for signatures see API Reference: IEntry.
Variable Naming Tips
When using abstracted types (IEntry / Scribe / IEloquent), let the variable name make the underlying SObject explicit — it reads better.
// Bad: short names like r or e force the reader to chase the type
for(IEntry e : eloquent.get(scribe)) { ... }
// Good: consistent {SObjectName}Entry form
for(IEntry accountEntry : eloquent.get(accountScribe)) { ... }
Running DML
IEloquent also wraps standard DML. The four methods doInsert / doUpdate / doUpsert / doDelete each have SObject / IEntry / List<SObject> / List<IEntry> overloads.
// IEntry retrieved via Scribe can be updated as-is
List<IEntry> oppEntries = this.eloquent.label(LBL_FETCH).get(oppScribe);
for(IEntry oppEntry : oppEntries) {
oppEntry.put('Industry__c', 'Technology');
}
this.eloquent.label(LBL_UPDATE).doUpdate(oppEntries);
Default to the List<> versions to keep things bulk-aligned. Single-record versions are for cases where exactly one record is guaranteed. For each overload's signature, see API Reference: IEloquent.
Execution Mode (v3 line)
In the v3 line, SOQL / DML default to user mode — honouring the running user's field and object permissions. Only work that must complete no matter who triggered it (aggregation, stamping, data migration) opts out explicitly with systemMode().
this.eloquent.systemMode().label(LBL_UPDATE).doUpdate(entries);
Calling it once applies to every subsequent operation on that instance (sticky). On MockEloquent it is a no-op, so unit tests can be written without thinking about the mode.
⚠️ systemMode() only lifts field and object permissions. Sharing (record visibility) is a separate axis — to lift that, the calling class must be declared without sharing.
Testing with MockEloquent
MockEloquent is the test implementation of IEloquent. By DI-ing it into a Usecase via the Layered Constructor Pattern, you can verify behavior without ever touching the DB.
Constructors
new MockEloquent() for empty, new MockEloquent(entry) to return one record, new MockEloquent(List<IEntry>) to return multiple.
MockEntry oppEntry = MockEntry.of(Opportunity.class)
.autoId(1)
.set('Name', 'Test Opp')
.set('Amount', 1000);
IEloquent fetchEloquent = new MockEloquent(new List<IEntry>{ oppEntry });
Verifying DML with the Spy
Instead of really running doInsert / doUpdate / doUpsert / doDelete, MockEloquent records the records it was handed. Tests can then check after the fact that the DML you expected actually happened.
| Method | Content |
|---|---|
upsertedRecordsAt(String label) | Records passed to doInsert / doUpdate / doUpsert under that label (List<SObject>) |
deletedCountAt(String label) | doDelete count under that label (Integer) |
In tests that don't use labels (lenient), pass
'default'. TheupsertedRecords/deletedCountfields remain for backward compatibility but are@deprecated; new code should use the*At(...)methods.
MockEloquent mock = (new MockEloquent())
.attach(CopyAccountIndustryToOpportunityUsecase.LBL_FETCH, new List<IEntry>{ oppEntry });
(new CopyAccountIndustryToOpportunityUsecase(oppIds, mock)).invoke();
// Did exactly 1 update happen as expected?
List<SObject> updated = mock.upsertedRecordsAt(CopyAccountIndustryToOpportunityUsecase.LBL_UPDATE);
Assert.areEqual(1, updated.size());
// Did the expected value land?
Assert.areEqual('Technology', ((Opportunity) updated[0]).Industry__c);
Mocking failure paths
In Salesforce, reproducing a failure costs an order of magnitude more than reproducing success. To make real DML actually fail you have to add a validation rule, strip a field permission, contend for a row lock. It is slow, and because it leans on org state, it breaks easily.
MockEloquent lets you declare the failure by name instead.
Failure is specified along four axes
The failOn* family looks like a lot of methods, but it is really a combination of four axes. Once you see them, reading and writing gets much easier.
| Axis | What you specify | Default |
|---|---|---|
| What fails | failOnDoUpdate() / failOnGet() / … one per method | — |
| Why it fails | the Exception you pass | a generic test exception |
| Where it fails | .whenLabel(label) | aimed at label-less calls ('default') |
| How often it fails | stack failOn* / .repeat() | just the next call |
The four are orthogonal; add only what you need.
MockEloquent mock = (new MockEloquent())
.failOnDoUpdate(new DmlException('Simulated failure')) // what + why
.whenLabel(YourUsecase.LBL_UPDATE); // where
try {
(new YourUsecase(input, mock)).invoke();
Assert.fail('An exception should have been thrown');
} catch(DmlException e) {
Assert.isTrue(TraceFlow.isLastAbort());
}
Why narrowing "where" matters
With whenLabel you drop just that one call site and let the rest run normally. "Aggregation succeeded, only the final save failed" writes out directly. In a Usecase firing several DMLs, without this a test can't tell you which one fell over.
A configuration without whenLabel is aimed at label-less calls ('default'). If production code uses labels, whenLabel is effectively mandatory (forgetting it is detected — see below).
A call whose label doesn't match does not consume the configuration. A later matching call picks it up, so ordering doesn't matter when you plant them.
"How often" stacks
failOn* entries queue up. Plant several against the same method and they fail in order.
// The 1st and 2nd doUpdate fail; the 3rd succeeds
MockEloquent mock = (new MockEloquent())
.failOnDoUpdate(new DmlException('first'))
.failOnDoUpdate(new DmlException('second'));
That is how you write the success side of a retry. Queues are independent per label, so entries carrying whenLabel stack the same way.
.repeat(), by contrast, repeats the entry you just planted forever (no count).
// Fails no matter how many times it's called
MockEloquent mock = (new MockEloquent())
.failOnDoUpdate(new DmlException('Always fails'))
.whenLabel(YourUsecase.LBL_UPDATE)
.repeat();
Use it for the abort side of "retry up to 3 times, then give up". Not having to count calls makes the intent clearer.
Queues are per label
Failure configurations queue up per label (an entry without whenLabel is aimed at label-less calls, 'default').
So you can plant different failures against different labels on the same method, in any order.
// Fail the fetch for one reason and the save for another
MockEloquent mock = (new MockEloquent())
.failOnGet(new QueryException('fetch failed')).whenLabel(YourUsecase.LBL_FETCH)
.failOnDoUpdate(new DmlException('save failed')).whenLabel(YourUsecase.LBL_UPDATE);
Retries can keep the same label
The "once only" rule on labels counts successful operations. A failed operation releases its label, so production code that catches and retries under the same label tests as-is.
// Fails the first time, succeeds the second — same label throughout
MockEloquent mock = (new MockEloquent())
.attach(YourUsecase.LBL_UPDATE, entries)
.failOnDoUpdate(new DmlException('first attempt')).whenLabel(YourUsecase.LBL_UPDATE);
Add .repeat() and it keeps failing however many times you retry, which is how you check the "give up at the limit" path.
Pitfall: keep the return value
The test-setup builders (attach / failOn* / whenLabel / failSave / repeat) all return a new instance rather than mutating this. Drop the return value and the configuration disappears.
// ❌ Does nothing — mock itself has no configuration on it
MockEloquent mock = new MockEloquent();
mock.failOnDoUpdate(new DmlException('...'));
// ✅ Keep the chain, or reassign
MockEloquent mock = (new MockEloquent())
.attach(YourUsecase.LBL_FETCH, entries)
.failOnDoUpdate(new DmlException('...'))
.whenLabel(YourUsecase.LBL_UPDATE);
Pitfall: a forgotten whenLabel is detected
In a test that uses labels (strict mode), forgetting whenLabel aims the configuration at label-less calls. But strict mode requires a label on every operation, so that configuration can never fire.
Silently doing nothing would fail in a baffling way — the exception never arrives, so your Assert.fail trips instead. So it throws a diagnostic exception on the spot, naming the label you should have used.
failSave: not an exception, just "some of it wasn't saved"
Where failOn* means "calling it throws", failSave is a different kind of failure: the call itself succeeds but the named record is not saved. It reproduces the partial failure of an allOrNone DML.
Id badId = MockEntry.of(Account.class).autoId(2).getId();
MockEloquent mock = (new MockEloquent())
.failSave(badId, 'Rejected by a validation rule');
allOrNone = false— that record'sSaveResultcarriessuccess = falseand your message, and it is not recorded by the spy (it was never saved)allOrNone = true— as with real all-or-nothing DML, the whole operation throws before anything is recorded
The target is named by record Id rather than index, so pair it with MockEntry.autoId(). Because it doesn't depend on list order, it survives records being added later.
You can verify ETL / incremental-sync paths that "log the failed records and carry on" without touching org configuration.
For the full list of
failOn*methods, see API Reference: MockEloquent.
Distinguishing Multiple Queries on the Same IEloquent: Label Multiplexing (Important)
MockEloquent does not evaluate the Scribe's WHERE conditions. It returns the injected IEntry list as-is. This means cases like "I want one IEloquent to distinguish between last month's query and this year's query" can't be handled out of the box.
IEloquent.label(String), added in v2.1, resolves this. By multiplexing a single IEloquent with labels, you can use the same instance — sorted by label — instead of DI-ing a separate IEloquent per purpose.
public with sharing class AggregateAccountActivityUsecase {
@TestVisible static final String LBL_LAST_MONTH = 'lastMonthEvent';
@TestVisible static final String LBL_THIS_YEAR = 'thisYearEvent';
@TestVisible static final String LBL_ACCOUNT_UPDATE = 'accountUpdate';
private final Set<Id> accountIds;
private final IEloquent eloquent;
public AggregateAccountActivityUsecase(Set<Id> accountIds) {
this(accountIds, null);
}
@TestVisible
private AggregateAccountActivityUsecase(
Set<Id> accountIds,
IEloquent eloquent
) {
this.accountIds = accountIds;
this.eloquent = eloquent ?? new Eloquent();
}
public void invoke() {
List<IEntry> lastMonthEvents = this.eloquent.label(LBL_LAST_MONTH).get(lastMonthScribe);
List<IEntry> thisYearEvents = this.eloquent.label(LBL_THIS_YEAR).get(thisYearScribe);
// ... aggregate ...
this.eloquent.label(LBL_ACCOUNT_UPDATE).doUpdate(updatedAccounts);
}
}
In tests, preload each label's query result on a single MockEloquent via .attach(LBL_LAST_MONTH, ...) / .attach(LBL_THIS_YEAR, ...), and pull DML outcomes via mock.upsertedRecordsAt(LBL_ACCOUNT_UPDATE). You preserve the same per-purpose isolation as separate-DI, but the constructor signature collapses to a single dependency.
Note: the first time .label() is called, opt-in strict mode turns on for that instance, and every subsequent operation must carry a label (the "forgot to label" footgun is closed at runtime). Details in API Reference: IEloquent.
Building MockEntry
MockEntry is the test-side implementation of IEntry. It lets you build test data that you can't build with raw SObject.
Basics
MockEntry oppEntry = MockEntry.of(Opportunity.class)
.autoId(1)
.set('Name', 'Test Opp')
.set('Amount', 1000);
MockEntry.of(Type) creates the entry. .set writes to a field (non-writable fields are allowed). .autoId auto-generates an 18-character Id. .alias names the entry so you can retrieve the Id later. For the signature list, see API Reference: MockEntry.
Baking in the SELECT contract with fetchedBy
Entries returned through MockEloquent's attach pick up a SELECT contract automatically from the Scribe passed to get(scribe) — the mechanism that throws when you touch a field you never selected.
Entries handed straight to the SUT have no contract, though: they never went through a Scribe, so there is no way for them to know which fields count as selected. fetchedBy attaches one after the fact.
MockEntry card = MockEntry.of(BusinessCard__c.class)
.autoId(1)
.set('CompanyName__c', 'Acme')
.fetchedBy(RematchCompanyCardsHandler.scope());
This matters mostly for batches. Records reaching execute(bc, scope) never pass through IEloquent, so production is covered by the platform (they are real query results) while tests check nothing — you built those entries yourself. Pull the query construction into a @TestVisible method and the test can hand over the same Scribe production uses (see API Reference: Scribe).
Retrieving Generated Ids with alias
Sometimes you want to use the Id generated by autoId in a test assertion.
MockEntry oppEntry = MockEntry.of(Opportunity.class)
.alias('opp')
.autoId(1);
Id oppId = oppEntry.getAliasId('opp'); // pull out the generated Id
// In the test:
Opportunity updated = (Opportunity) mock.upsertedRecordsAt(MyUsecase.LBL_UPDATE)[0];
Assert.areEqual(oppId, updated.Id);
Bulk-Generating with times()
List<MockEntry> contactEntries = MockEntry.of(Contact.class)
.autoId('{#}')
.set('LastName', 'Contact-{#}')
.alias('con_{#}')
.times(3);
// → 3 records: con_1 / con_2 / con_3, each with Id and LastName expanded
Setting Multiple Fields at Once with template
When you have "default values to reuse across multiple tests", hand them over as a Map to avoid repeating .set in every test.
Map<String, Object> defaults = new Map<String, Object>{
'StageName' => 'Prospecting',
'CloseDate' => Date.today().addDays(30),
'Amount' => 1000
};
MockEntry oppEntry = MockEntry.of(Opportunity.class)
.template(defaults)
.set('Name', 'Specific name for this test'); // override or add per-field
Mocking Aggregate Results
When mocking aggregate-query results, use MockEntry.asAggregateResult(). This reflects the property that "aggregate results aren't tied to an SObject type", removing the need to pick a specific SObject type via MockEntry.of(SomeType.class) (and reducing cognitive noise for the reader).
MockEloquent eventEloquent = new MockEloquent(
new List<IEntry>{
MockEntry.asAggregateResult()
.set('WhatId', oppAId)
.set('eventCount', 3),
MockEntry.asAggregateResult()
.set('WhatId', oppBId)
.set('eventCount', 1)
}
);
Passing Decimal values to set (same as real SOQL aggregate results) means the logic-side ((Decimal) entry.get('eventCount')).intValue() cast keeps working as-is.
Mocking Parent-Child Structures
To view parent from a child, use setParent. To view children from a parent, use setChildren. The code's indentation directly mirrors the relation structure — re-reading later, "what children hang off this parent" is visible at a glance.
// Hang multiple Contact / Opportunity under a parent Account
MockEntry accountEntry = MockEntry.of(Account.class)
.alias('acc').autoId(1)
.set('Name', 'Acme Corporation')
.setChildren('Contacts', new List<MockEntry>{
MockEntry.of(Contact.class).autoId(1).set('FirstName', 'John'),
MockEntry.of(Contact.class).autoId(2).set('FirstName', 'Jane')
})
.setChildren('Opportunities', new List<MockEntry>{
MockEntry.of(Opportunity.class).autoId(1).set('Name', 'Deal 1').set('Amount', 100000),
MockEntry.of(Opportunity.class).autoId(2).set('Name', 'Deal 2').set('Amount', 150000)
});
The reverse (viewing parent Account from child Opportunity) uses setParent:
MockEntry oppEntry = MockEntry.of(Opportunity.class)
.autoId(1)
.set('Name', 'Major Deal')
.setParent('AccountId',
MockEntry.of(Account.class).set('Name', 'Acme Corporation').set('Type', 'Customer')
);
The Id linkage between parent and child (e.g. filling Contact.AccountId with the parent's Id) is handled internally by MockEntry — you don't fill it by hand.
⚠️ If you specified the child relationship name in Scribe via relationName('CustomOpportunities__r'), use the same string as the first argument of setChildren. For the full picture of relation operations (including many-to-many and junction objects), see Parent Fields, Child Subqueries, and Many-to-Many.
Read Next
- Parent Fields, Child Subqueries, and Many-to-Many: retrieving and mocking relations
- Building Queries with Scribe: the full picture of the query builder
- Layered Constructor Pattern: the design for DI'ing
IEloquentby purpose - Test Strategy: where Usecase unit tests centered on
MockEloquentsit - ApexEloquent Guide: back to the ApexEloquent Guide index