ApexEloquent API Reference: IEloquent / Eloquent / MockEloquent
IEloquent is ApexEloquent's data-access contract (the interface). Production uses Eloquent; tests use MockEloquent, both DI'd via the Layered Constructor Pattern.
For usage and typical scenarios, see Data Access, DML, IEntry, and Mock.
This page covers both the v3 and v2 lines. The only difference between them is the execution mode, so that part is called out separately under "Execution Mode (v3 line)".
The Three Relationships
IEloquent (interface) ← the contract Usecase depends on
↑
├── Eloquent ← production. Hits standard SOQL / DML directly
└── MockEloquent ← tests. No DB; provides Spy + failOn*
The Usecase holds an IEloquent-typed field, into which it receives new Eloquent() in production and new MockEloquent(...) in tests via the Layered Constructor.
IEloquent (Interface)
Every contract method is implemented by both Eloquent and MockEloquent.
Labeling
| Signature | Return | Purpose |
|---|---|---|
label(String labelName) | IEloquent | Tags the next operation with a label so the chain stays composable. A single IEloquent can be multiplexed by label — fold "last-month / this-year / Account update" purposes onto one instance instead of DI-ing a separate IEloquent per purpose |
// Distinguish two SOQLs and one DML on the same IEloquent
IEntry job = this.eloquent.label('jobLoad').firstOrFail(jobScribe);
List<IEntry> details = this.eloquent.label('detailLoad').get(detailScribe);
this.eloquent.label('finalDml').doUpdate(toUpdate);
Passing null or blank to label(...) throws.
'default' is the name assigned internally to label-less operations. label('default') itself goes through, but it points at the same slot as an unlabelled call, so the intent doesn't read. Use a different name. (whenLabel('default'), on the other hand, is explicitly rejected as redundant.)
Opt-in strict mode: the first .label(...) call on an IEloquent instance switches it into strict mode (sticky for the rest of the instance's lifetime):
- Every subsequent operation must be preceded by
.label(...)(label-less operations throw) - Each label can be consumed at most once per instance (reusing throws)
- Instances that never call
.label()stay in lenient mode (full backward compatibility)
This closes the "I added a new DML and forgot to label it" footgun at runtime: the offending call throws instead of silently landing in the default bucket.
Execution Mode (v3 line only)
| Signature | Returns | Purpose |
|---|---|---|
userMode() | IEloquent | Run subsequent SOQL / DML as AccessLevel.USER_MODE (the v3 default) |
systemMode() | IEloquent | Run subsequent SOQL / DML as AccessLevel.SYSTEM_MODE, ignoring FLS and object permissions |
In the v3 line Eloquent became inherited sharing and SOQL / DML default to user mode (honouring the running user's field and object permissions). Only system processes — aggregation, stamping, data migration, anything that must complete no matter who triggered it — opt out explicitly with systemMode().
// A system process: mark the calling class without sharing, then be explicit here
this.eloquent.systemMode().label(LBL_UPDATE).doUpdate(entries);
- Sticky: calling it once applies to every subsequent operation on that instance (unlike
label(), which resets per operation) - A no-op on
MockEloquent: it simply returns itself, so unit tests can ignore the mode entirely - ⚠️
systemMode()only lifts FLS and object permissions. Sharing (record visibility) is a separate axis — to lift that, the calling class must be declaredwithout sharing
Query Methods
| Signature | Return | Purpose |
|---|---|---|
get(Scribe scribe) | List<IEntry> | Execute the query. Empty list on 0 hits |
first(Scribe scribe) | IEntry | The first record. null on 0 hits |
firstOrFail(Scribe scribe) | IEntry | The first record. Throws ApexEloquentException on 0 hits |
firstOrFail(Scribe scribe, Exception orFail) | IEntry | The first record. On 0 hits, throws the exception you passed, as-is |
getAsSObject(Scribe scribe) | List<SObject> | SObject list (last resort) |
firstAsSObject(Scribe scribe) | SObject | SObject version of the first record |
firstOrFailAsSObject(Scribe scribe) | SObject | SObject version of the first record; throws on 0 hits |
rawSoql(String soql) | List<IEntry> | Bypass Scribe and run raw SOQL (last resort; SELECT-omission detection is disabled) |
When firstOrFail(scribe, orFail) earns its keep: when zero rows is a business error you want to surface to the screen. The exception you hand it is thrown verbatim, so you skip first + null check + your own throw.
What matters is where it gets caught. Hand it a business exception and it lands in your catch (UsecaseException).
IEntry job;
try {
job = this.eloquent.label(LBL_JOB).firstOrFail(
jobScribe,
new UsecaseException('The requested job was not found.')
);
} catch(UsecaseException ex) {
this.t.skip('Business error: ' + ex.getMessage());
}
The no-argument firstOrFail(scribe) throws ApexEloquentException, which never reaches that catch.
DML Methods
| Signature | Return |
|---|---|
doInsert(SObject record) | SObject |
doInsert(List<SObject> records) | List<SObject> |
doUpdate(SObject record) | SObject |
doUpdate(IEntry entry) | IEntry |
doUpdate(List<SObject> records) | List<SObject> |
doUpdate(List<IEntry> entries) | List<IEntry> |
doUpdate(SObject record, Boolean allOrNone) | Database.SaveResult |
doUpdate(IEntry entry, Boolean allOrNone) | Database.SaveResult |
doUpdate(List<SObject> records, Boolean allOrNone) | List<Database.SaveResult> |
doUpdate(List<IEntry> entries, Boolean allOrNone) | List<Database.SaveResult> |
doUpsert(SObject record) | SObject |
doUpsert(IEntry entry) | IEntry |
doUpsert(List<SObject> records) | List<SObject> |
doUpsert(List<IEntry> entries) | List<IEntry> |
doUpsertByExternalId(SObject record, Schema.SObjectField externalIdField, Boolean allOrNone) | Database.UpsertResult |
doUpsertByExternalId(IEntry entry, Schema.SObjectField externalIdField, Boolean allOrNone) | Database.UpsertResult |
doUpsertByExternalId(List<SObject> records, Schema.SObjectField externalIdField, Boolean allOrNone) | List<Database.UpsertResult> |
doUpsertByExternalId(List<IEntry> entries, Schema.SObjectField externalIdField, Boolean allOrNone) | List<Database.UpsertResult> |
doDelete(SObject record) | void |
doDelete(IEntry entry) | void |
doDelete(List<SObject> records) | void |
doDelete(List<IEntry> entries) | void |
Default to bulk; use the single-record versions only when exactly one record is guaranteed.
doUpdate with allOrNone: the return type is Database.SaveResult / List<Database.SaveResult>, so partial-success per-record outcomes can be inspected through the IEloquent abstraction. Useful in ETL / incremental sync / batch migration paths that log failed records and continue.
doUpsertByExternalId: brings the standard Database.upsert(records, externalIdField, allOrNone) API under the IEloquent abstraction. Essential for "upsert by external Id key" in ETL / incremental sync / batch migration, handled through a shared production / Mock contract.
Eloquent (Production)
The production class implementing IEloquent. It hits standard SOQL / DML directly. No additional public methods (interface-only).
IEloquent eloquent = new Eloquent();
List<IEntry> opps = eloquent.get(scribe);
eloquent.doUpdate(opps);
MockEloquent (Mock Extension)
On top of the IEloquent contract, it adds test-side Spy properties and the failOn series.
Constructors
| Signature | Behavior |
|---|---|
new MockEloquent() | Empty. If you use labels, feed data with attach(...) |
new MockEloquent(IEntry entry) | Preloads one record as the label-less ('default') result |
new MockEloquent(List<IEntry> entries) | Same, list version |
Data passed to the constructor only feeds label-less ('default') operations. If production code uses label(...), supply it with attach(label, ...) instead.
MockEloquent does not evaluate Scribe's WHERE conditions — it hands back the list you gave it, as-is. To tell condition-varied queries apart, label each query and feed them separately with attach(label, ...) (below).
An unattached label throws
Call get / first / firstOrFail under label('X') without a matching attach('X', ...) and the test throws. The error lists the labels that were attached, so a typo shows up immediately.
This guards a specific false positive: mistype a label, get zero rows, fall into the "nothing to do, skip" branch, and the test goes green while verifying nothing.
When you genuinely want to test the zero-row path, attach an empty list to say so.
MockEloquent mock = (new MockEloquent())
.attach(MyUsecase.LBL_FETCH, new List<IEntry>());
Upgrading from an older version can turn some tests red here. Those are the tests that were green while verifying nothing. Rather than mechanically adding empty attaches, check what data should have been injected in the first place.
Spy Properties and Per-Label Accessors
| Property / Method | Type | Content |
|---|---|---|
upsertedRecords | List<SObject> | Records passed to doInsert / doUpdate / doUpsert / doUpsertByExternalId for the 'default' bucket (※ @deprecated: new code should prefer upsertedRecordsAt('default')) |
deletedCount | Integer | Total doDelete invocations for the 'default' bucket (※ @deprecated: new code should prefer deletedCountAt('default')) |
upsertedRecordsAt(String label) | List<SObject> | Accumulated DML records for the given label |
deletedCountAt(String label) | Integer | Delete invocation count for the given label |
attach(String label, IEntry entry) | MockEloquent | Preload a single record as the given label's query data (chainable; re-attaching the same label overwrites) |
attach(String label, List<IEntry> entries) | MockEloquent | Same, list version |
failSave(Id recordId, String errorMessage) | MockEloquent | Make only the named record fail to save (chainable) |
// Label-less, traditional usage (backward compatible)
MockEloquent updateEloquent = new MockEloquent();
(new MyUsecase(input, fetchEloquent, updateEloquent)).invoke();
Assert.areEqual(1, updateEloquent.upsertedRecords.size());
Opportunity updated = (Opportunity) updateEloquent.upsertedRecords[0];
Assert.areEqual('Technology', updated.Industry__c);
// Label-multiplexed — one MockEloquent serves query preloading and DML verification, sorted by label
MockEloquent mock = (new MockEloquent())
.attach('jobLoad', jobEntry);
(new FinalizeJobUsecase(jobId, mock)).invoke();
Assert.areEqual(1, mock.upsertedRecordsAt('finalDml').size());
Backward compatibility note: the existing upsertedRecords / deletedCount public fields continue to work and internally reflect the 'default' bucket. They are JSDoc-marked @deprecated; migrate to *At('default') over time. The legacy constructor new MockEloquent(List<IEntry>) also still works — the data it accepts feeds the 'default' source.
failOn Series (Exception Simulation)
Each method has two overloads: no-argument and Exception-accepting. Without an Exception, a default exception is thrown.
| Method | Corresponding Contract |
|---|---|
failOnGet() / failOnGet(Exception e) | get(scribe) |
failOnFirst() / failOnFirst(Exception e) | first(scribe) |
failOnFirstOrFail() / failOnFirstOrFail(Exception e) | firstOrFail(scribe) |
failOnGetAsSObject() / failOnGetAsSObject(Exception e) | getAsSObject(scribe) |
failOnFirstAsSObject() / failOnFirstAsSObject(Exception e) | firstAsSObject(scribe) |
failOnFirstOrFailAsSObject() / failOnFirstOrFailAsSObject(Exception e) | firstOrFailAsSObject(scribe) |
failOnRawSoql() / failOnRawSoql(Exception e) | rawSoql(soql) |
failOnDoInsert() / failOnDoInsert(Exception e) | doInsert(*) |
failOnDoUpdate() / failOnDoUpdate(Exception e) | doUpdate(*) |
failOnDoUpsert() / failOnDoUpsert(Exception e) | doUpsert(*) |
failOnDoUpsertByExternalId() / failOnDoUpsertByExternalId(Exception e) | doUpsertByExternalId(*) |
failOnDoDelete() / failOnDoDelete(Exception e) | doDelete(*) |
Scoping Failures to a Label with whenLabel()
| Signature | Purpose |
|---|---|
whenLabel(String label) | Scopes the most recent failOn*() to fire only for a specific label (chainable) |
// Only the DML labeled 'finalDml' fails; the others run normally
MockEloquent mock = (new MockEloquent())
.failOnDoUpdate(new DmlException('Simulated finalDml failure'))
.whenLabel('finalDml');
Reads as "fail on get when label is X" — useful in label-multiplexed Usecases where you want exactly one side effect to fail.
MockEloquent mock = (new MockEloquent())
.failOnDoUpdate(new DmlException('Simulated DML failure'))
.whenLabel(MyUsecase.LBL_UPDATE);
try {
(new MyUsecase(input, mock)).invoke();
Assert.fail('Expected exception');
} catch(DmlException e) {
Assert.isTrue(TraceFlow.isLastAbort());
}
Expressing "Always Fails" with repeat()
| Signature | Purpose |
|---|---|
repeat() | Call after a failOn* to repeat the failure forever afterward (count cannot be specified) |
failOn() alone fails just the next call. failOn entries queue up per method, so putting two in a row fails the first and second call and lets the third succeed (the retry-then-succeed path).
Add .repeat() and the entry you just planted fails on every subsequent invocation. That is how you check the abort side of "retry up to 3 times, then give up" — without counting calls.
Queues are kept per label. A configuration without whenLabel targets label-less calls ('default').
⚠️ Every test-setup builder (attach / failOn* / whenLabel / failSave / repeat) returns a new instance rather than mutating this. Drop the return value and the configuration is lost, so keep the chain or reassign.
💡 The once-per-label rule counts successful operations. A failed operation releases its label, so production code that catches and retries under the same label tests just fine.
⚠️ In a test that uses labels, forgetting whenLabel leaves the configuration aimed at 'default', where it can never fire. That is detected and reported as an exception, naming the label you should have used.
The failure patterns you can express
Combining failOn* / whenLabel / repeat gives five shapes.
| Written as | What happens |
|---|---|
failOnGet(exA).failOnGet(exB) | Fails with exA, then exB, and succeeds on the third call |
failOnGet(ex).repeat() | Fails every time |
failOnGet(ex).whenLabel('opp') | 'opp' fails once; a retry succeeds |
failOnGet(exA).whenLabel('opp').failOnGet(exB).whenLabel('opp') | 'opp' fails twice in a row, succeeds on the third |
failOnGet(ex).whenLabel('opp').repeat() | 'opp' fails every time |
// "fail once, succeed on retry"
MockEloquent mock = (new MockEloquent())
.attach('opp', new List<IEntry>{ oppEntry })
.failOnGet(new QueryException('boom'))
.whenLabel('opp');
// 1st call: throws
// 2nd call: the failure released the label, so the attached data comes back
Because queues are per label, failures aimed at different labels on the same method never interfere.
MockEloquent mock = (new MockEloquent())
.failOnGet(new QueryException('fetch failed')).whenLabel('fetch')
.failOnDoUpdate(new DmlException('save failed')).whenLabel('update');
Add failSave — where the call succeeds but a specific record is not saved — and that is the whole of what MockEloquent can express.
failSave(): partial save failure
failSave differs in kind from failOn*. Rather than throwing, the call succeeds while the named record is left unsaved — 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, not index, so pair it with MockEntry.autoId(). It survives records being added later.
Read Next
- Data Access, DML, IEntry, and Mock: the usage guide
- API Reference: Scribe: the query-assembly side
- API Reference: IEntry / Entry / MockEntry: the
IEntryside that's returned - Layered Constructor Pattern: the design for DI'ing
IEloquentinto a Usecase - ApexEloquent Guide: back to the guide index