Service Layer
By the end of this lesson, you'll be able to:
- Define what belongs in a service class
- Refactor Module 42's validation logic into a dedicated service class
- Explain why a service class is easier to unit test than logic embedded in a trigger
Prerequisites: "Separation of Concerns"
Extracting the business rule from the trigger
public with sharing class TransactionValidationService {
public static void validate(List<Transaction__c> transactions, Map<Id, Bank_Account__c> accountsById) {
for (Transaction__c t : transactions) {
Bank_Account__c acc = accountsById.get(t.Bank_Account__c);
if (acc.Status__c != 'Active') {
t.addError('This account is not active and cannot accept transactions.');
} else if (t.Type__c == 'Withdrawal' && t.Amount__c > acc.Balance__c) {
t.addError('Insufficient funds for this withdrawal.');
}
}
}
}
The exact same business rule from Module 42's TransactionValidation trigger, moved into a class with no dependency on Trigger.new or Trigger.old — it takes plain lists and maps as parameters instead, which is precisely what makes it testable in isolation.
The trigger becomes a thin delegator
trigger TransactionValidation on Transaction__c (before insert) {
Set<Id> accountIds = new Set<Id>();
for (Transaction__c t : Trigger.new) accountIds.add(t.Bank_Account__c);
Map<Id, Bank_Account__c> accounts = new Map<Id, Bank_Account__c>(
[SELECT Id, Balance__c, Status__c FROM Bank_Account__c WHERE Id IN :accountIds]
);
TransactionValidationService.validate(Trigger.new, accounts);
}
The trigger file now only handles trigger mechanics — collecting IDs and calling the service — while the actual business decision lives in TransactionValidationService, satisfying Lesson 2's separation of concerns directly.
Why this is easier to test
@isTest
static void rejectsInactiveAccountTransaction() {
Bank_Account__c acc = new Bank_Account__c(Id = TestHelper.fakeId(Bank_Account__c.SObjectType), Status__c = 'Frozen', Balance__c = 100);
Transaction__c t = new Transaction__c(Bank_Account__c = acc.Id, Type__c = 'Deposit', Amount__c = 10);
TransactionValidationService.validate(new List<Transaction__c>{t}, new Map<Id, Bank_Account__c>{acc.Id => acc});
Assert.isTrue(t.hasErrors());
}
Because TransactionValidationService.validate accepts plain data rather than needing an actual DML insert to trigger it, this test never needs to insert a real Bank_Account__c record — a meaningfully faster and simpler test than Module 42's original approach, which had to insert real records to exercise the trigger.
Exercise
As a comment, explain why TransactionValidationService.validate accepts a List<Transaction__c> and a Map<Id, Bank_Account__c> as parameters, rather than reading Trigger.new and querying accounts itself.
Show hint
Think about what a test would need to set up in each case.
Service Layer Quiz
My Notes
Log in to keep private notes on this lesson.
Questions about this lesson
No questions yet — be the first to ask.
Log in to ask a question about this lesson.
Summary
A service class holds business logic — the rules and decisions a system makes — kept deliberately free of trigger mechanics and, where possible, free of direct SOQL/DML, so it can be tested and reasoned about on its own.