Service / Repository / Unit of Work in Apex — Keeping the Domain Layer Clean
When Salesforce orgs get big, keeping things tidy is critical. The best way to do that is by separating responsibilities clearly.
Your domain layer should model business rules, the Repository should handle data access (SOQL/DML), Services should coordinate your use cases, and the Unit of Work should manage commits in one bulk-safe transaction.
This pattern makes your Apex easier to test, reuse, and scale, while also staying governor-limit friendly.
Core Concept: Separation of Concerns (SoC)
Separation of Concerns means splitting your application into parts, where each part focuses on a single job — no overlap, no confusion.
Why this matters:
-
Readability: You can easily see what each piece does.
-
Maintainability: Changing one part doesn’t break others.
-
Testability: You can test components independently.
-
Scalability: Your app can grow without becoming a tangled mess.
Let’s break down how this looks in practice.
Domain Layer
Core idea: Keep your business logic inside simple Apex classes — no SOQL, no DML, no platform dependencies.
Responsibilities:
-
Enforce business rules and constraints (like totals or valid state changes).
-
Provide meaningful methods (
collect(),cancel()), not just data. -
Stay completely framework-independent — usable in tests with no database setup.
Not responsible for:
Data access, logging, callouts, caching, or DML operations.
public class Invoice {
public Id id; public Decimal total; public Decimal collected; public String status = 'Open';
public Invoice(Decimal total){ if(total<=0) throw new AuraHandledException('Total > 0'); this.total=total; }
public void collect(Decimal amount){
if(amount<=0) throw new AuraHandledException('Amount > 0');
collected += amount; if(collected >= total) status = 'Paid';
}
}
Repository
Core idea: Abstract away data access so your business logic never has to think about SOQL or DML.
Responsibilities:
-
Fetch and save domain objects.
-
Hide schema details, field names, and relationships.
-
Keep queries selective and bulk-safe (no SOQL inside loops).
Not responsible for:
Business rules, orchestration, or integrations.
public interface InvoiceRepository {
Invoice getById(Id invoiceId);
void save(List<Invoice> invoices); // bulk-friendly
}
public with sharing class InvoiceRepositoryImpl implements InvoiceRepository {
public Invoice getById(Id invoiceId){
Invoice__c row = [SELECT Id, Total__c, Collected__c, Status__c FROM Invoice__c WHERE Id=:invoiceId LIMIT 1];
Invoice d = new Invoice(row.Total__c); d.id=row.Id; d.collected=row.Collected__c; d.status=row.Status__c; return d;
}
public void save(List<Invoice> invoices){
List<Invoice__c> up = new List<Invoice__c>();
for(Invoice d : invoices){
up.add(new Invoice__c(Id=d.id, Total__c=d.total, Collected__c=d.collected, Status__c=d.status));
}
Database.saveResult[] sr = Database.update(up, false); // or insert+upsert as needed
}
}
Service (Application Layer)
Core idea: The Service layer runs the actual use case — it ties together repositories, domain logic, and infrastructure like integrations or caching.
Responsibilities:
-
Handle input validation and workflow logic (e.g., “apply payment”).
-
Coordinate transactional work using Unit of Work.
-
Trigger integrations, telemetry, or caching via abstractions.
Not responsible for:
Raw SOQL/DML or UI logic — delegate that elsewhere.
public with sharing class PaymentService {
private final InvoiceRepository repo;
public PaymentService(InvoiceRepository repo){ this.repo = repo; }
public void applyPayment(Id invoiceId, Decimal amount, UnitOfWork uow){
Invoice inv = repo.getById(invoiceId); // load
inv.collect(amount); // domain rule
uow.registerDirty(new Invoice__c(Id=inv.id, Collected__c=inv.collected, Status__c=inv.status));
uow.registerNew(new Payment__c(Invoice__c=invoiceId, Amount__c=amount)); // side record
}
}
Unit of Work
Core idea: Handle all DML in one place — stage your inserts, updates, and deletes, then commit everything at once.
This keeps your operations bulk-safe and transactionally consistent.
Responsibilities:
-
Track new, updated, and deleted records.
-
Commit all changes in one go (optionally handle ordering or upserts).
-
Offer a single commit boundary for cross-cutting hooks.
Not responsible for:
Business rules or queries.
-
Real-World Example: Converting Leads into Opportunities
Imagine a scenario where you need to turn a list of qualified Leads into new Opportunities and mark those Leads as converted.
Here’s how that flow works:
-
The UI or automation (like LWC, Batch, or Trigger) calls the Service Layer.
-
The Service Layer validates the input, fetches Leads, creates new Opportunities, updates the Leads, and registers everything in the Unit of Work.
-
Finally, it commits everything at once.
Repositories handle the data.
Unit of Work manages the DML transaction.// === 1. Repository Layer === // LeadRepository.cls public with sharing class LeadRepository { public List<Lead> getLeadsByIds(Set<Id> leadIds) { return [SELECT Id, Name, Company, Status FROM Lead WHERE Id IN :leadIds]; } // ... other Lead specific DML/Query methods } // === 2. Unit of Work Layer === // ApplicationUnitOfWork.cls (This would typically be a reusable class) public class ApplicationUnitOfWork { private List<SObject> toInsert = new List<SObject>(); private List<SObject> toUpdate = new List<SObject>(); private List<SObject> toDelete = new List<SObject>(); public void registerNew(SObject record) { toInsert.add(record); } public void registerDirty(SObject record) { toUpdate.add(record); } public void registerRemoved(SObject record) { toDelete.add(record); } public void commitWork() { if (!toInsert.isEmpty()) insert toInsert; if (!toUpdate.isEmpty()) update toUpdate; if (!toDelete.isEmpty()) delete toDelete; // Clear lists after commit to allow reuse (or create new UOW per transaction) toInsert.clear(); toUpdate.clear(); toDelete.clear(); } } // === 3. Service Layer === // LeadToOpportunityService.cls public with sharing class LeadToOpportunityService { public static List<Opportunity> convertLeadsToOpportunities(List<Id> leadIds) { // Instantiate dependencies LeadRepository leadRepo = new LeadRepository(); ApplicationUnitOfWork uow = new ApplicationUnitOfWork(); // 1. Fetch Leads (using Repository) List<Lead> leads = leadRepo.getLeadsByIds(new Set<Id>(leadIds)); if (leads.isEmpty()) { throw new AuraHandledException('No valid leads found for conversion.'); } List<Opportunity> newOpps = new List<Opportunity>(); for (Lead l : leads) { // 2. Business Logic: Create new Opportunities Opportunity newOpp = new Opportunity( Name = l.Company + ' - New Business', StageName = 'Prospecting', CloseDate = Date.today().addDays(30), LeadSource = l.LeadSource // Map more fields as needed ); newOpps.add(newOpp); // 3. Business Logic: Update original Lead (e.g., mark as converted) l.Status = 'Converted'; // Assuming a custom 'Converted' status uow.registerDirty(l); // Register Lead for update } // 4. Register new Opportunities for insert (using Unit of Work) uow.registerNew(newOpps); // 5. Commit all DML operations in one go uow.commitWork(); return newOpps; } }
Quick Responsibility Checklist ✅
-
No SOQL/DML in Domain or Controller
-
Repositories use bulk-safe, selective queries
-
Each Service method handles one use case
-
Unit of Work commits once per transaction
-
Triggers and controllers stay thin
In Summary
Keep your Apex code clean, modular, and scalable by separating concerns:
-
Domain handles business logic.
-
Repository handles data access.
-
Service runs your use cases.
-
Unit of Work manages one safe commit.
-
Controllers/Triggers act as simple entry points.
This structure makes your org easier to test, safer under governor limits, and much simpler to maintain — exactly what you want for large Salesforce implementations.
-
