Most teams that adopt the enterprise Apex layering get excited about Service and Domain and then quietly run out of steam. The Service layer reads like architecture, the Domain layer feels like real OO, and by the time you've refactored those two you're convinced the job is done. It isn't. The two layers people skip — Selector and Unit of Work — are precisely the ones that determine whether your code survives contact with a real org: a multi-million-row data model, an FLS security audit, and a nightly integration that has to move tens of thousands of records without tripping a governor limit.
I learned this the expensive way on a lending platform integrated with a core-banking system. The codebase had a perfectly respectable Service layer, and it still caught fire on a regular basis. One incident was a field addition that should have taken five minutes: a new product code on the loan object. The query SELECT Id, Status__c, Amount__c FROM Loan__c appeared in 47 different places, split across two vendor teams, and the change shipped with three of those copies missing the new field. Another incident was a bulk submission that threw Too many DML statements: 151 the first time someone tried to create more than fifty loans in one go. Neither of these is a Service-layer problem. Both are exactly what Selector and Unit of Work exist to prevent.
Treat this as the second half of enterprise Apex — the half that's about data access discipline rather than orchestration. The patterns are not complicated. The discipline is.
The Selector layer is your one query per object
The rule is simple: every SOQL query against a given SObject lives in one class. The Service layer never writes [SELECT ... FROM Loan__c] inline; it calls new LoansSelector().selectByIds(ids). That Selector class becomes the source of truth for the default field set, the where-clause patterns, the default ordering, and the FLS posture for that object.
The 47-copies problem evaporates immediately, because the default field set lives in one getSObjectFieldList() method and every standard query method draws from it. But the field-maintenance win is the obvious one. The subtler payoffs are query optimization and security. When you discover that WHERE CustomerEmail__c = :email isn't selective and needs a different filter or an index, you fix it once and every caller benefits. When the security review demands FLS enforcement, you flip it on in the base class rather than auditing dozens of scattered queries.
If you build on top of fflib_SObjectSelector, the base class hands you a newQueryFactory() that already knows your field set, your ordering, and your FLS flag. A query method becomes a thin condition on top of that:
public List<Loan__c> selectByStatus(Set<String> statuses) {
return (List<Loan__c>) Database.query(
newQueryFactory()
.setCondition('Status__c IN :statuses')
.setLimit(10000)
.toSOQL()
);
}
Two conventions matter more than they look. First, Selector methods always return a List and take a Set<Id> (or a simple filter value), never a single record by single Id. The instant someone writes Loan__c selectById(Id id) returning LIMIT 1, you've created an NPE waiting to happen and, worse, a method that does N queries when called in a loop over 200 records. Second, Selectors hold query patterns, not business rules. A method named selectActiveCustomersWithCreditScoreAbove(Decimal threshold) is a trap — threshold is a business parameter, and "active" is a definition that will change. When it changes, you'll be editing the data-access layer to express a policy decision. Keep the business filter in the Service; let the Selector accept only filters that map directly to a SOQL clause.
One more habit worth stealing: cross-Selector field composition. When LoansSelector needs Account fields through the Customer__r relationship, it asks AccountsSelector to contribute its field list rather than hardcoding Account field names. Add a field to Account and only AccountsSelector changes; every query that joins to it picks the field up for free.
FLS is not optional, and it is not free
CRUD is object-level permission; FLS is field-level. A standard user might be allowed to read the loan object but not its customer's credit score. A Selector that ignores this returns every field to everyone, and that is exactly the finding that fails a security audit at a financial-services client.
You have three tools, and the choice between them is a real decision rather than a style preference. Inline WITH SECURITY_ENFORCED is the terse option, but it throws the moment any field in the SELECT is inaccessible — one user missing FLS on one field means the whole query fails, with no partial result. Security.stripInaccessible is the forgiving option: it nulls out the fields the user can't see and hands back the records anyway, plus a getRemovedFields() you can log. The cost is that every caller now has to assume any field might be null. The modern option, on API 60 and up, is AccessLevel.USER_MODE passed to Database.queryWithBinds, which checks CRUD, FLS, and sharing in one clean call — at the price of requiring you to be on a recent API version.
There's a deeper point about selectivity that lives next to FLS because both are invisible until production. A query filtered on Status__c alone runs in under 200ms against 10,000 loans in a sandbox and times out against 8 million in production, because Status__c isn't indexed and the engine does a full scan. The fix is to lead with an indexed, selective field — a master-detail relationship like Customer__c, or a standard index — and filter the rest down from there:
// Non-selective: full scan over millions of rows
SELECT Id FROM Loan__c WHERE Status__c = 'DISBURSED'
// Selective: master-detail index narrows first, then filters
SELECT Id FROM Loan__c WHERE Customer__c = :customerId AND Status__c = 'DISBURSED'
Test this on full-copy data with the Query Plan tool before you ship, not after the timeout pages someone at 2am. And remember that Database.getQueryLocator for a batch won't auto-append WITH SECURITY_ENFORCED — you either inject it into the SOQL string yourself or rely on a with sharing Selector class to carry the right context.
Unit of Work makes DML someone else's problem
The Selector handles reads. The Unit of Work handles writes, and it solves a problem every bulkified developer already half-solves by hand. The naive version puts DML inside a loop and dies at 150 statements. The hand-bulkified version works but is long, fragile, and order-sensitive: you must insert loans first, read back their Ids, build collaterals against those Ids, insert those, then build documents, then insert those. Add one more child object and the ceremony grows.
UoW replaces insert/update with register. You declare the SObject types once, in dependency order, then register records and relationships as you build them, and commit once at the end:
fflib_SObjectUnitOfWork uow = new fflib_SObjectUnitOfWork(new List<SObjectType>{
Loan__c.SObjectType, // parent inserts first
Collateral__c.SObjectType, // children after
Document__c.SObjectType
});
for (LoanRequest r : requests) {
Loan__c loan = new Loan__c(Customer__c = r.customerId, Amount__c = r.amount, Status__c = 'SUBMITTED');
uow.registerNew(loan); // not inserted yet
for (CollateralData cd : r.collaterals) {
Collateral__c c = new Collateral__c(Type__c = cd.type, Value__c = cd.value);
uow.registerNew(c, Collateral__c.Loan__c, loan); // link resolved at commit
}
}
uow.commitWork();
registerRelationship (here the three-argument registerNew) is the move that earns its keep: the child holds a reference to a parent that doesn't have an Id yet, and UoW backfills the foreign key after the parent insert. At commitWork() you get one insert per SObject type regardless of volume — three DML statements whether you submitted one loan or two hundred loans with a thousand collaterals and two thousand documents behind them.
The declared type order is load-bearing. UoW commits in the order you list, so a parent must come before its children or the foreign key fails. The same instance handles registerDirty for updates, registerDeleted for deletes, and registerPublishAfterTransaction for platform events that should fire only once the DML has committed — which is the right default, because a subscriber that re-queries will see the new state.
What Unit of Work does not do
The dangerous misconception is that UoW makes you immune to limits. It collapses statement count, not row count. The 10,000-records-per-transaction ceiling is untouched, so a 12,000-loan job still has to be chunked into a batch with 200-record scopes, committing one UoW per execute. And don't expect UoW to carry state across batch executions — each execute builds a fresh UoW that knows nothing about the last one; if you need continuity, that's Database.Stateful and class fields, not the UoW.
Commit semantics are all-or-nothing by default. If loans insert and collaterals fail a validation rule, Salesforce rolls back the whole transaction, which is exactly what you want when atomicity is non-negotiable — a loan with no collateral has no business existing. When you genuinely want partial success on a high-volume sync, UoW won't give it to you; you drop to Database.update(records, false), collect the SaveResult failures, and log them (ideally back through a UoW) for retry.
The hardest interaction is callouts. You cannot make a synchronous callout after you've done DML in the same transaction — you'll hit You have uncommitted work pending. So callout before you commit, or push the callout into a Queueable. And once an external system is involved, atomicity is gone for good: if the core-banking system has already moved money and your UoW commit then fails, the bank has a transaction your org doesn't. That's where a Saga comes in — break the work into steps (lock and create a pending record, call out, finalize) with an explicit compensation for each, and use an intermediate state like DISBURSING so a confused user doesn't double-submit. UoW is the tool for each atomic step inside that saga, not a substitute for designing the saga.
The through-line of both patterns is the same principle: concentrate the thing that's easy to get subtly, expensively wrong — every read in one Selector, every write in one Unit of Work — so that when the field set changes, the security model tightens, or the volume goes up by two orders of magnitude, you're editing one well-understood place instead of hunting for 47 of them.
Working on advanced Apex — enterprise patterns, integrations, or large-data design? Sapota's Salesforce team builds and reviews production Apex on real engagements. Get in touch ->
See our full platform services for the stack we cover.
Top comments (0)