ApexEloquent: Data access, DML, IEntry, and Mock

Apex Stem Docs
Apex StemApexEloquentIEloquentMockEloquentIEntryTestingSalesforceApex
How to fetch data and run DML through IEloquent, work with IEntry, and write tests with MockEloquent. Covers Spy properties, failOn helpers, and integration with the Layered Constructor Pattern.

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.

APEX
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.

  1. False-positive detection for unselected fields: if production code accesses a field that wasn't called out with field() in the Scribe, an exception fires at test time (via MockEntry). Early conversion to SObject strips that protection, and the classic "tests pass but production reads empty" incident creeps back in.
  2. 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 raw SObject becomes trivial via IEntry.
  3. Retrieve → edit → update flows stay on IEntry: mutate with entry.put('Industry__c', value), hand it straight to eloquent.doUpdate(List<IEntry>). No conversion to SObject needed.
  4. No distinction between SObject and AggregateResult: results of normal queries and aggregate queries (AggregateResult) both come back as IEntry. 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 an SObject or AggregateResult.

Working with IEntry

Reading and Writing Fields

APEX
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.

APEX
// 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.

APEX
// 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().

APEX
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.

APEX
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.

MethodContent
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'. The upsertedRecords / deletedCount fields remain for backward compatibility but are @deprecated; new code should use the *At(...) methods.

APEX
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.

AxisWhat you specifyDefault
What failsfailOnDoUpdate() / failOnGet() / … one per method
Why it failsthe Exception you passa generic test exception
Where it fails.whenLabel(label)aimed at label-less calls ('default')
How often it failsstack failOn* / .repeat()just the next call

The four are orthogonal; add only what you need.

APEX
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.

APEX
// 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).

APEX
// 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.

APEX
// 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.

APEX
// 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.

APEX
// ❌ 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.

APEX
Id badId = MockEntry.of(Account.class).autoId(2).getId();
 
MockEloquent mock = (new MockEloquent())
  .failSave(badId, 'Rejected by a validation rule');
  • allOrNone = false — that record's SaveResult carries success = false and 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.

APEX
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

APEX
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.

APEX
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.

APEX
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()

APEX
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.

APEX
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).

APEX
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.

APEX
// 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:

APEX
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.