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.');
}
}
}
}
public with sharing class TransactionValidationService {— a plain class, no trigger dependency at all.public static void validate(List<Transaction__c> transactions, Map<Id, Bank_Account__c> accountsById) {— plain parameters, notTrigger.new/Trigger.old.for (Transaction__c t : transactions) {— iterates the passed-in list.Bank_Account__c acc = accountsById.get(t.Bank_Account__c);— looks up from the passed-in map — no query here.if (acc.Status__c != 'Active') {— the status check.t.addError('This account is not active and cannot accept transactions.');— unchanged from Module 42.} else if (t.Type__c == 'Withdrawal' && t.Amount__c > acc.Balance__c) {— the overdraft check.t.addError('Insufficient funds for this withdrawal.');— unchanged.}— closes theif/else if.}— closes theforloop.}— closes the method.}— closes the class.
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);
}
trigger TransactionValidation on Transaction__c (before insert) {— the trigger, now much thinner.Set<Id> accountIds = new Set<Id>();— collects the distinct account Ids.for (Transaction__c t : Trigger.new) accountIds.add(t.Bank_Account__c);— trigger mechanics only.- (blank line)
Map<Id, Bank_Account__c> accounts = new Map<Id, Bank_Account__c>(— aMapbuilt from a query.[SELECT Id, Balance__c, Status__c FROM Bank_Account__c WHERE Id IN :accountIds]— one query for every involved account.);— closes the map constructor.- (blank line)
TransactionValidationService.validate(Trigger.new, accounts);— delegates the actual decision to the service.}— closes the trigger.
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());
}
@isTest— marks the test method.static void rejectsInactiveAccountTransaction() {— states the exact scenario under test.Bank_Account__c acc = new Bank_Account__c(Id = TestHelper.fakeId(Bank_Account__c.SObjectType), Status__c = 'Frozen', Balance__c = 100);— a fake, unsaved Id — noinsertneeded.Transaction__c t = new Transaction__c(Bank_Account__c = acc.Id, Type__c = 'Deposit', Amount__c = 10);— an in-memory transaction, also never inserted.- (blank line)
TransactionValidationService.validate(new List<Transaction__c>{t}, new Map<Id, Bank_Account__c>{acc.Id => acc});— calls the service directly with hand-built, in-memory data.- (blank line)
Assert.isTrue(t.hasErrors());— confirmsaddError()actually fired.}— closes the test method.
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.