Advanced 30 min read

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

APEX

Service Layer Quiz

1. What is the defining trait of a service class in this layered architecture?

Log in to submit the quiz and save your score.

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.