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.');
            }
        }
    }
}

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.

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.