Part 62: Separation of Concerns in Apex
Welcome back to the Salesforce series. In the previous post, we explored object-oriented concepts in Apex — abstraction, encapsulation, inheritance, polymorphism, and composition. Those are the building blocks. This post is about how to organize those building blocks into a codebase that scales.
Separation of concerns is the single most important architectural principle for enterprise Apex development. It is what separates a Salesforce org that can absorb change from one that breaks every time a new requirement arrives. It is the difference between a codebase where developers move fast with confidence and one where every change feels like defusing a bomb.
By the end of this post, you will understand what separation of concerns means, why it matters for Salesforce specifically, and how to implement the three-layer architecture — Selector, Domain, and Service — with real code examples. We will refactor a messy, monolithic class into a clean, layered structure, and you will see how each layer can be tested independently.
What Is Separation of Concerns?
Separation of concerns is the principle that each part of your code should be responsible for one thing and one thing only. The term was coined by Edsger Dijkstra in 1974, and it remains one of the most universally agreed-upon ideas in software engineering.
In plain terms, it means: do not mix unrelated responsibilities in the same class or method.
Why It Matters
When responsibilities are mixed together, three things happen:
-
Changes become risky. Modifying how you query data can accidentally break business logic. Changing a validation rule can break an integration. Everything is tangled.
-
Testing becomes painful. You cannot test business logic without also setting up query results. You cannot test a service method without triggering side effects in unrelated parts of the system.
-
Reuse becomes impossible. If your query logic is buried inside a trigger handler, no other class can reuse it without copy-pasting. If your business rules are scattered across ten different classes, enforcing consistency is a lost cause.
Separation of concerns solves all three problems by giving each responsibility a clear home.
The Three-Layer Architecture
In the Salesforce Apex world, separation of concerns is typically implemented using three layers:
| Layer | Responsibility | Example |
|---|---|---|
| Selector | Querying data from the database | AccountSelector, OpportunitySelector |
| Domain | Business logic, validation, field defaulting | Accounts, Opportunities |
| Service | Orchestration, transaction boundaries, entry points | AccountService, OpportunityService |
This pattern was popularized by Andrew Fawcett in the Salesforce ecosystem and is the foundation of the Apex Enterprise Patterns library (also called the Apex Common Library or fflib).
Here is how the layers relate to each other:
┌─────────────────────────────────────┐
│ Service Layer │ ← Entry point (controllers, triggers, APIs call this)
│ Orchestrates the transaction │
├─────────────────────────────────────┤
│ Domain Layer │ ← Business rules, validation, field logic
│ Operates on a set of records │
├─────────────────────────────────────┤
│ Selector Layer │ ← SOQL queries, data retrieval
│ Returns data to the layers above │
└─────────────────────────────────────┘
The key rule is that dependencies flow downward. The Service layer can call the Domain layer and the Selector layer. The Domain layer can call the Selector layer. The Selector layer calls nothing — it only talks to the database.
No layer should ever reach upward. The Selector layer should never call a Service method. The Domain layer should never call a Service method. This one-way dependency flow is what keeps the architecture clean.
Spaghetti Code vs Layered Code
Before we dive into implementation, let us look at what life looks like without separation of concerns. This is the kind of code that accumulates organically in orgs where there is no architectural plan.
The Spaghetti Approach
Imagine you have a trigger on Opportunity that needs to do the following when an Opportunity is closed-won:
- Validate that the Opportunity has a positive amount and an associated Account
- Query the Account to check if it is a key account (annual revenue above one million)
- Create a follow-up Task for key accounts
- Update a custom field on the Account with the latest closed date
Here is how this typically looks in an org without separation of concerns:
trigger OpportunityTrigger on Opportunity (after update) {
List<Task> tasksToInsert = new List<Task>();
Set<Id> accountIds = new Set<Id>();
List<Opportunity> closedWonOpps = new List<Opportunity>();
for (Opportunity opp : Trigger.new) {
Opportunity oldOpp = Trigger.oldMap.get(opp.Id);
if (opp.StageName == 'Closed Won' && oldOpp.StageName != 'Closed Won') {
if (opp.Amount == null || opp.Amount <= 0) {
opp.addError('Amount must be positive for closed-won opportunities.');
continue;
}
if (opp.AccountId == null) {
opp.addError('Account is required for closed-won opportunities.');
continue;
}
closedWonOpps.add(opp);
accountIds.add(opp.AccountId);
}
}
Map<Id, Account> accountMap = new Map<Id, Account>(
[SELECT Id, Name, AnnualRevenue, Latest_Closed_Won_Date__c
FROM Account
WHERE Id IN :accountIds]
);
List<Account> accountsToUpdate = new List<Account>();
for (Opportunity opp : closedWonOpps) {
Account acc = accountMap.get(opp.AccountId);
if (acc != null) {
acc.Latest_Closed_Won_Date__c = Date.today();
accountsToUpdate.add(acc);
if (acc.AnnualRevenue != null && acc.AnnualRevenue > 1000000) {
tasksToInsert.add(new Task(
Subject = 'Follow up on closed-won deal: ' + opp.Name,
WhatId = opp.Id,
OwnerId = opp.OwnerId,
ActivityDate = Date.today().addDays(7),
Priority = 'High'
));
}
}
}
if (!tasksToInsert.isEmpty()) {
insert tasksToInsert;
}
if (!accountsToUpdate.isEmpty()) {
update accountsToUpdate;
}
}
This works. It will pass a code review in many orgs. But it has serious problems:
- Everything is in the trigger. Business logic, queries, DML — all in one file. If you need the same closed-won logic elsewhere (a batch job, a REST API), you have to duplicate it.
- Testing requires full DML. You cannot test the business rules without inserting real Opportunity and Account records and triggering the actual trigger.
- The query is not reusable. If another part of the system needs to query Accounts with those same fields, someone will write a second query. Then a third. Eventually the fields drift apart and you get inconsistencies.
- Business rules are invisible. The validation rules are mixed in with the iteration logic. A new developer has to read every line of the trigger to find where validation happens.
The Layered Approach (Preview)
With separation of concerns, the same trigger becomes:
trigger OpportunityTrigger on Opportunity (after update) {
OpportunityTriggerHandler.afterUpdate(Trigger.new, Trigger.oldMap);
}
That is it. The trigger delegates to a handler, and the handler delegates to the appropriate layers. We will build out each layer in the next sections.
The Selector Layer
The Selector layer is responsible for all SOQL queries. Every query in your org should live in a Selector class, not scattered across triggers, services, and utility methods.
Why a Separate Layer for Queries?
Three reasons:
-
Consistency. When every query for Accounts lives in
AccountSelector, you guarantee that the same fields are always selected. No more bugs caused by one query selectingAnnualRevenueand another forgetting it. -
Reuse. Multiple callers — service methods, batch jobs, trigger handlers — can all call the same selector method instead of writing their own queries.
-
Testability. In advanced patterns (using mocking frameworks), you can replace the Selector layer with fake data so that your Domain and Service layer tests never touch the database.
Building an AccountSelector
public class AccountSelector {
public static List<Account> selectByIds(Set<Id> accountIds) {
return [
SELECT Id, Name, AnnualRevenue, Latest_Closed_Won_Date__c, OwnerId
FROM Account
WHERE Id IN :accountIds
];
}
public static List<Account> selectKeyAccountsByIds(Set<Id> accountIds) {
return [
SELECT Id, Name, AnnualRevenue, Latest_Closed_Won_Date__c, OwnerId
FROM Account
WHERE Id IN :accountIds
AND AnnualRevenue > 1000000
];
}
public static Map<Id, Account> selectByIdsAsMap(Set<Id> accountIds) {
return new Map<Id, Account>(selectByIds(accountIds));
}
}
Building an OpportunitySelector
public class OpportunitySelector {
public static List<Opportunity> selectByIds(Set<Id> opportunityIds) {
return [
SELECT Id, Name, Amount, StageName, CloseDate,
AccountId, OwnerId
FROM Opportunity
WHERE Id IN :opportunityIds
];
}
public static List<Opportunity> selectClosedWonByAccountIds(Set<Id> accountIds) {
return [
SELECT Id, Name, Amount, StageName, CloseDate,
AccountId, OwnerId
FROM Opportunity
WHERE AccountId IN :accountIds
AND StageName = 'Closed Won'
ORDER BY CloseDate DESC
];
}
}
Selector Conventions
- Name your class after the object:
AccountSelector,ContactSelector,OpportunitySelector. - Every method returns a
List<SObject>or aMap<Id, SObject>. Selectors never return void. - Define a standard set of fields at the top of the class or in every query. Never let callers specify fields — that defeats the purpose of centralization.
- Selectors never perform DML. They only read data.
- Selectors never contain business logic. They do not filter based on business rules. If you need key accounts, create a
selectKeyAccountsByIdsmethod that bakes the filter into the WHERE clause.
The Domain Layer
The Domain layer is where your business logic lives. It operates on a collection of records and encapsulates all the rules, validations, and field defaulting for a specific object.
What Belongs in the Domain Layer?
- Validation rules that are too complex for declarative validation rules
- Field defaulting and calculation logic
- Trigger handler logic (before insert, after update, etc.)
- Any business rule that answers the question: What are the rules for this object?
What Does NOT Belong in the Domain Layer?
- SOQL queries (that is the Selector layer)
- Orchestrating multiple objects (that is the Service layer)
- Direct DML operations on other objects (that is the Service layer)
Building the Opportunities Domain Class
The Domain class receives a list of records and applies business rules to them.
public class Opportunities {
private List<Opportunity> records;
public Opportunities(List<Opportunity> records) {
this.records = records;
}
/**
* Returns only the Opportunities that just transitioned to Closed Won.
*/
public List<Opportunity> getNewlyClosedWon(Map<Id, Opportunity> oldMap) {
List<Opportunity> result = new List<Opportunity>();
for (Opportunity opp : this.records) {
Opportunity oldOpp = oldMap.get(opp.Id);
if (opp.StageName == 'Closed Won' && oldOpp.StageName != 'Closed Won') {
result.add(opp);
}
}
return result;
}
/**
* Validates that closed-won opportunities have a positive amount
* and an associated Account. Adds errors to invalid records.
*/
public List<Opportunity> validateForClosedWon(List<Opportunity> closedWonOpps) {
List<Opportunity> validOpps = new List<Opportunity>();
for (Opportunity opp : closedWonOpps) {
if (opp.Amount == null || opp.Amount <= 0) {
opp.addError('Amount must be positive for closed-won opportunities.');
continue;
}
if (opp.AccountId == null) {
opp.addError('Account is required for closed-won opportunities.');
continue;
}
validOpps.add(opp);
}
return validOpps;
}
/**
* Builds follow-up tasks for key accounts (annual revenue > 1M).
*/
public List<Task> buildFollowUpTasks(
List<Opportunity> opps,
Map<Id, Account> accountMap
) {
List<Task> tasks = new List<Task>();
for (Opportunity opp : opps) {
Account acc = accountMap.get(opp.AccountId);
if (acc != null && acc.AnnualRevenue != null && acc.AnnualRevenue > 1000000) {
tasks.add(new Task(
Subject = 'Follow up on closed-won deal: ' + opp.Name,
WhatId = opp.Id,
OwnerId = opp.OwnerId,
ActivityDate = Date.today().addDays(7),
Priority = 'High'
));
}
}
return tasks;
}
}
Building the Accounts Domain Class
public class Accounts {
private List<Account> records;
public Accounts(List<Account> records) {
this.records = records;
}
/**
* Stamps today's date on the Latest_Closed_Won_Date__c field
* for Accounts associated with the given Opportunities.
*/
public List<Account> stampLatestClosedWonDate(List<Opportunity> closedWonOpps) {
Set<Id> relevantAccountIds = new Set<Id>();
for (Opportunity opp : closedWonOpps) {
relevantAccountIds.add(opp.AccountId);
}
List<Account> accountsToUpdate = new List<Account>();
for (Account acc : this.records) {
if (relevantAccountIds.contains(acc.Id)) {
acc.Latest_Closed_Won_Date__c = Date.today();
accountsToUpdate.add(acc);
}
}
return accountsToUpdate;
}
}
Domain Layer Conventions
- Name your class using the plural form of the object:
Accounts,Opportunities,Contacts. This distinguishes it from the SObject itself. - The constructor accepts a
List<SObject>. The class operates on that collection. - Domain methods return data. They do not perform DML. The caller decides when to commit.
- Domain methods are focused on one object’s rules. If the logic involves coordinating two objects, that belongs in the Service layer.
The Service Layer
The Service layer is the entry point for all business operations. It is where transactions are orchestrated, where multiple Domain and Selector calls are coordinated, and where DML is committed.
What Belongs in the Service Layer?
- Orchestrating a business process that spans multiple objects
- Calling Selectors to retrieve data
- Calling Domain classes to apply business rules
- Performing DML (insert, update, delete)
- Defining the transaction boundary
What Does NOT Belong in the Service Layer?
- SOQL queries (use Selectors)
- Business rules about a specific object (use Domain classes)
- Direct field-level logic (use Domain classes)
Building the OpportunityService
Here is the Service class that orchestrates our closed-won process:
public class OpportunityService {
/**
* Handles all business logic when Opportunities transition to Closed Won.
* Called from the trigger handler.
*/
public static void handleClosedWon(
List<Opportunity> newOpps,
Map<Id, Opportunity> oldMap
) {
// 1. Use the Domain layer to identify newly closed-won opportunities
Opportunities oppDomain = new Opportunities(newOpps);
List<Opportunity> closedWonOpps = oppDomain.getNewlyClosedWon(oldMap);
if (closedWonOpps.isEmpty()) {
return;
}
// 2. Use the Domain layer to validate
List<Opportunity> validOpps = oppDomain.validateForClosedWon(closedWonOpps);
if (validOpps.isEmpty()) {
return;
}
// 3. Use the Selector layer to retrieve Account data
Set<Id> accountIds = new Set<Id>();
for (Opportunity opp : validOpps) {
accountIds.add(opp.AccountId);
}
Map<Id, Account> accountMap = AccountSelector.selectByIdsAsMap(accountIds);
// 4. Use the Domain layer to build follow-up tasks
List<Task> followUpTasks = oppDomain.buildFollowUpTasks(validOpps, accountMap);
// 5. Use the Domain layer to stamp account dates
Accounts accDomain = new Accounts(accountMap.values());
List<Account> accountsToUpdate = accDomain.stampLatestClosedWonDate(validOpps);
// 6. DML — the Service layer owns the transaction
if (!followUpTasks.isEmpty()) {
insert followUpTasks;
}
if (!accountsToUpdate.isEmpty()) {
update accountsToUpdate;
}
}
}
The Trigger Handler
Now the trigger handler becomes a thin routing layer:
public class OpportunityTriggerHandler {
public static void afterUpdate(
List<Opportunity> newOpps,
Map<Id, Opportunity> oldMap
) {
OpportunityService.handleClosedWon(newOpps, oldMap);
}
}
And the trigger itself:
trigger OpportunityTrigger on Opportunity (after update) {
OpportunityTriggerHandler.afterUpdate(Trigger.new, Trigger.oldMap);
}
Service Layer Conventions
- Name your class
[Object]Service:AccountService,OpportunityService,OrderService. - Service methods are
public static. They are the public API of your business logic. - The Service layer is the only layer that performs DML.
- Service methods define the transaction boundary. If something fails, the entire transaction rolls back.
- Controllers, batch classes, REST endpoints, and triggers all call the Service layer. They never call Domain or Selector classes directly (with the exception of trigger handlers calling Domain for before-trigger field defaulting).
All Three Layers Working Together
Let us trace through the complete flow to see how the layers interact:
1. User closes an Opportunity as Won in the UI.
2. OpportunityTrigger fires (after update).
3. OpportunityTrigger delegates to OpportunityTriggerHandler.afterUpdate().
4. OpportunityTriggerHandler calls OpportunityService.handleClosedWon().
5. OpportunityService creates an Opportunities domain instance.
6. OpportunityService calls oppDomain.getNewlyClosedWon() — Domain layer filters records.
7. OpportunityService calls oppDomain.validateForClosedWon() — Domain layer validates.
8. OpportunityService calls AccountSelector.selectByIdsAsMap() — Selector layer queries.
9. OpportunityService calls oppDomain.buildFollowUpTasks() — Domain layer builds Tasks.
10. OpportunityService creates an Accounts domain instance.
11. OpportunityService calls accDomain.stampLatestClosedWonDate() — Domain layer stamps dates.
12. OpportunityService performs DML: insert Tasks, update Accounts.
Every layer does one thing. The Service orchestrates. The Domain applies rules. The Selector queries data. There is no ambiguity about where any piece of logic lives.
What If a Batch Job Needs the Same Logic?
This is where separation of concerns pays its biggest dividend. Suppose you need a nightly batch job that reprocesses closed-won opportunities. Without separation of concerns, you would duplicate the trigger logic in the batch class. With separation of concerns:
public class ClosedWonReprocessBatch implements Database.Batchable<SObject> {
public Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator(
'SELECT Id, Name, Amount, StageName, CloseDate, AccountId, OwnerId ' +
'FROM Opportunity WHERE StageName = \'Closed Won\' ' +
'AND Reprocess__c = true'
);
}
public void execute(Database.BatchableContext bc, List<Opportunity> scope) {
// Reuse the exact same service method
Map<Id, Opportunity> fakeOldMap = new Map<Id, Opportunity>();
for (Opportunity opp : scope) {
// Create a "before" version with a different stage
Opportunity oldVersion = opp.clone(true, true);
oldVersion.StageName = 'Negotiation';
fakeOldMap.put(opp.Id, oldVersion);
}
OpportunityService.handleClosedWon(scope, fakeOldMap);
}
public void finish(Database.BatchableContext bc) {
// Post-processing if needed
}
}
The batch job calls the same OpportunityService.handleClosedWon() method. Zero duplication. If the business rules change, you change them in one place and every caller benefits.
Testing Each Layer Independently
One of the greatest benefits of separation of concerns is testability. Each layer can be tested on its own.
Testing the Selector Layer
Selector tests verify that queries return the right records with the right fields. These tests do require DML to set up data, but they are focused and simple.
@isTest
private class AccountSelectorTest {
@isTest
static void testSelectByIds() {
Account acc = new Account(
Name = 'Test Corp',
AnnualRevenue = 5000000
);
insert acc;
Test.startTest();
List<Account> results = AccountSelector.selectByIds(
new Set<Id>{ acc.Id }
);
Test.stopTest();
System.assertEquals(1, results.size(), 'Should return one account.');
System.assertEquals('Test Corp', results[0].Name);
System.assertEquals(5000000, results[0].AnnualRevenue);
}
@isTest
static void testSelectByIdsReturnsEmptyForNoMatch() {
Test.startTest();
List<Account> results = AccountSelector.selectByIds(
new Set<Id>{ '001000000000000AAA' }
);
Test.stopTest();
System.assertEquals(0, results.size(), 'Should return no accounts.');
}
}
Testing the Domain Layer
Domain tests are the most valuable because they test business rules. Since Domain methods do not perform DML, you can construct records in memory and pass them directly to the Domain class.
@isTest
private class OpportunitiesDomainTest {
@isTest
static void testGetNewlyClosedWon() {
Opportunity newOpp = new Opportunity(
Id = '006000000000001AAA',
Name = 'Big Deal',
StageName = 'Closed Won',
Amount = 100000,
AccountId = '001000000000001AAA'
);
Map<Id, Opportunity> oldMap = new Map<Id, Opportunity>{
'006000000000001AAA' => new Opportunity(
Id = '006000000000001AAA',
Name = 'Big Deal',
StageName = 'Proposal',
Amount = 100000,
AccountId = '001000000000001AAA'
)
};
Opportunities domain = new Opportunities(new List<Opportunity>{ newOpp });
Test.startTest();
List<Opportunity> result = domain.getNewlyClosedWon(oldMap);
Test.stopTest();
System.assertEquals(1, result.size(), 'Should identify one newly closed-won opp.');
}
@isTest
static void testBuildFollowUpTasksForKeyAccount() {
Opportunity opp = new Opportunity(
Id = '006000000000001AAA',
Name = 'Enterprise Deal',
AccountId = '001000000000001AAA',
OwnerId = UserInfo.getUserId()
);
Map<Id, Account> accountMap = new Map<Id, Account>{
'001000000000001AAA' => new Account(
Id = '001000000000001AAA',
Name = 'Big Corp',
AnnualRevenue = 5000000
)
};
Opportunities domain = new Opportunities(new List<Opportunity>{ opp });
Test.startTest();
List<Task> tasks = domain.buildFollowUpTasks(
new List<Opportunity>{ opp },
accountMap
);
Test.stopTest();
System.assertEquals(1, tasks.size(), 'Should create one follow-up task.');
System.assert(tasks[0].Subject.contains('Enterprise Deal'));
System.assertEquals('High', tasks[0].Priority);
}
@isTest
static void testBuildFollowUpTasksSkipsSmallAccounts() {
Opportunity opp = new Opportunity(
Id = '006000000000001AAA',
Name = 'Small Deal',
AccountId = '001000000000001AAA',
OwnerId = UserInfo.getUserId()
);
Map<Id, Account> accountMap = new Map<Id, Account>{
'001000000000001AAA' => new Account(
Id = '001000000000001AAA',
Name = 'Small Shop',
AnnualRevenue = 50000
)
};
Opportunities domain = new Opportunities(new List<Opportunity>{ opp });
Test.startTest();
List<Task> tasks = domain.buildFollowUpTasks(
new List<Opportunity>{ opp },
accountMap
);
Test.stopTest();
System.assertEquals(0, tasks.size(), 'Should not create tasks for small accounts.');
}
}
Notice that the Domain tests create records in memory without inserting them into the database. This makes them extremely fast. A test class with twenty Domain tests can run in under a second.
Testing the Service Layer
Service layer tests are integration tests. They verify that the full orchestration works end to end.
@isTest
private class OpportunityServiceTest {
@TestSetup
static void setupData() {
Account acc = new Account(
Name = 'Key Account Corp',
AnnualRevenue = 2000000
);
insert acc;
Opportunity opp = new Opportunity(
Name = 'Big Enterprise Deal',
StageName = 'Proposal',
Amount = 500000,
CloseDate = Date.today(),
AccountId = acc.Id
);
insert opp;
}
@isTest
static void testHandleClosedWonCreatesTaskAndUpdatesAccount() {
Opportunity opp = [SELECT Id, Name, Amount, StageName, CloseDate,
AccountId, OwnerId
FROM Opportunity LIMIT 1];
// Simulate the old values
Map<Id, Opportunity> oldMap = new Map<Id, Opportunity>{
opp.Id => new Opportunity(
Id = opp.Id,
Name = opp.Name,
StageName = 'Proposal',
Amount = opp.Amount,
CloseDate = opp.CloseDate,
AccountId = opp.AccountId,
OwnerId = opp.OwnerId
)
};
// Change stage to Closed Won
opp.StageName = 'Closed Won';
Test.startTest();
OpportunityService.handleClosedWon(
new List<Opportunity>{ opp },
oldMap
);
Test.stopTest();
// Verify the Task was created
List<Task> tasks = [SELECT Id, Subject, Priority FROM Task
WHERE WhatId = :opp.Id];
System.assertEquals(1, tasks.size(), 'Should have created a follow-up task.');
System.assertEquals('High', tasks[0].Priority);
// Verify the Account was updated
Account updatedAcc = [SELECT Latest_Closed_Won_Date__c FROM Account
WHERE Id = :opp.AccountId];
System.assertEquals(Date.today(), updatedAcc.Latest_Closed_Won_Date__c);
}
}
The Testing Pyramid
With separation of concerns, your test distribution should look like this:
┌──────────┐
/ Service \ ← Few integration tests (slow, hit DB)
/ Tests \
/────────────────\
/ Domain Tests \ ← Many unit tests (fast, in-memory)
/────────────────────\
/ Selector Tests \ ← Moderate tests (verify queries)
/────────────────────────\
The majority of your tests should be Domain layer tests because they are the fastest and test the most valuable thing — your business rules. Selector tests are moderately important. Service tests are the slowest because they hit the database, so you write fewer of them, but they still verify the full integration.
When to Skip Layers
Separation of concerns is a tool, not a religion. There are situations where full three-layer architecture is overkill.
Small Orgs with Minimal Custom Code
If your org has three triggers and five Apex classes, introducing Selector, Domain, and Service layers for every object will create more files than logic. In this case, a simpler two-class approach (trigger plus handler) is perfectly fine.
One-Off Utilities
If you are writing a one-time data migration script or a quick anonymous Apex snippet, layering it is unnecessary. Use your judgment.
The Tipping Point
The tipping point usually comes when:
- You have more than three or four triggers on the same object
- Multiple entry points (triggers, batch jobs, REST APIs, Lightning controllers) need the same business logic
- Your test classes are slow because every test hits the database
- A single change breaks tests in unrelated areas
When you hit that tipping point, the investment in separation of concerns pays for itself many times over.
A Pragmatic Middle Ground
If full three-layer architecture feels like too much, start with just the Service layer. Move all business logic out of triggers and into Service classes. That single change eliminates the most common problem — duplicated logic — and gives you a reusable API for your business processes.
You can add Selector and Domain layers later as the codebase grows. Architecture is not an all-or-nothing decision.
How This Prepares You for the Apex Common Library
The three-layer architecture we built in this post is the manual version of what the Apex Common Library (fflib) provides. The Apex Common Library, created by Andrew Fawcett and maintained by the open-source community, gives you:
- A base
fflib_SObjectSelectorclass that standardizes selector patterns, adds field set support, and enforces CRUD/FLS checks automatically - A base
fflib_SObjectDomainclass that standardizes domain patterns and provides trigger routing (onBeforeInsert,onAfterUpdate, etc.) - A base
fflib_SObjectUnitOfWorkclass that manages DML across multiple objects in a single transaction, handling insert order and relationship mapping - A mocking framework (
fflib_ApexMocks) that lets you mock Selectors and Domains in your Service layer tests so they run without any DML at all
Everything we built manually in this post — the Selector, Domain, and Service classes — maps directly to what the library provides. Understanding the manual approach first makes the library intuitive instead of magical. You know why each base class exists because you have already built the pattern from scratch.
Key Takeaways
-
Separation of concerns means each class has one job. Selectors query. Domains apply rules. Services orchestrate.
-
Dependencies flow downward. Services call Domains and Selectors. Domains call Selectors. Selectors call nothing.
-
The Service layer is the public API. Triggers, batch jobs, REST endpoints, and controllers all call Service methods. They never reach directly into Domain or Selector classes for full operations.
-
Domain tests are your best investment. They are fast (in-memory), focused (one object’s rules), and they test the most valuable thing in your codebase — your business logic.
-
Start with the Service layer if full architecture feels heavy. Moving logic out of triggers into Services is the single biggest improvement you can make.
-
The pattern scales. The manual three-layer architecture maps directly to the Apex Common Library, so when your org grows, you have a clear upgrade path.
What is Next?
In Part 63, we will explore The SOLID Design Principles — the five principles that underpin all good object-oriented design. You will learn what Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion mean in the context of Apex, and how they connect to everything we have covered in separation of concerns and OOP. See you there.