Salesforce Apex Best Practices: Avoid God Classes with the Service Layer Pattern

You inherit a Salesforce org. You open the main Apex class.
It's 1,400 lines long.
Validation logic, DML, integration calls, business rules, utility methods โ all in one file. One change to the wrong method breaks three unrelated tests. Nobody wants to touch it. Nobody fully understands it anymore.
If your Apex class is 1000+ lines, it's not powerful โ it's a liability.
This is what a God Class looks like in production. And many Salesforce orgs end up with at least one.
๐งฉ Problem Statement
God Classes emerge gradually. A developer adds a method. Then another. Then a new requirement lands and the easiest path is to add it to the existing class. Six months later, one class owns everything.
A typical God Class:
Handles validation, DML, business logic, and integrations in one place
Is tightly coupled to multiple objects and processes
Cannot be tested in isolation โ you test everything or nothing
Can introduce unintended side effects when modified
Becomes a bottleneck when multiple developers work in the same org
At scale, the cost compounds:
| Problem | Impact |
|---|---|
| Fragile tests | CI/CD pipelines slow down or fail |
| Tight coupling | One change breaks unrelated functionality |
| No clear ownership | Multiple teams collide on the same file |
| Poor reusability | Hard to reuse across Batch, Flow, or API contexts |
This is not just a code smell. It's an architectural problem that gets more expensive with every passing sprint.
๐ก The Solution
A common way to address this is Separation of Concerns, implemented through the Service Layer pattern, implemented through the Service Layer pattern.
Instead of one large class doing everything, logic is split into focused layers:
Trigger โ Service Layer โ Specialized Classes โ Database
Each layer has one job:
Trigger โ entry point only, no logic
Service โ orchestrates the flow
Validator โ enforces data integrity
Domain โ applies business rules
Integration โ handles external calls
This project demonstrates how to refactor a monolithic Apex class into this structure โ and how the resulting code is easier to test, maintain, and extend.
๐ Deep Dive โ Refactoring a God Class
โ Before: The God Class
Here's a condensed version of what a God Class looks like. Imagine this at 1,000+ lines โ validation mixed with DML, integration calls scattered throughout, no clear boundaries.
// โ God Class โ do not model production code on this
public class OpportunityManager {
public static void process(List<Opportunity> opps) {
for (Opportunity opp : opps) {
// Validation mixed in with everything else
if (opp.Amount <= 0) {
opp.addError('Amount must be greater than zero');
}
// Business logic buried mid-method
if (opp.StageName == 'Closed Won') {
opp.Description = 'Won deal โ processed by OpportunityManager';
}
// Integration logic with no separation
if (opp.StageName == 'Prospecting') {
System.debug('Calling external system...');
// Imagine 200 lines of HTTP callout logic here
}
// DML inside a loop โ governor limit violation waiting to happen
update opp;
}
}
// Utility methods, unrelated helpers, and more mixed in below...
// ... 900 more lines
}
๐จ Why This Fails
DML inside a loop โ risks hitting governor limits at scale
No single responsibility โ you can't test validation without triggering integration logic
Limited reusability โ difficult to safely call from Batch Apex or services
Hard to assign clear ownership โ which team owns which part?
โ After: The Service Layer Pattern
We refactor into five focused layers. Each class focuses on a single responsibility.
1๏ธโฃ Trigger Layer โ Entry Point Only
// OpportunityTrigger.trigger
trigger OpportunityTrigger on Opportunity (before update) {
OpportunityService.processOpportunities(Trigger.new);
}
Why: The trigger routes. It should not contain business logic. This keeps the entry point thin and the logic independently testable.
2๏ธโฃ Service Layer โ Orchestration
// OpportunityService.cls
public with sharing class OpportunityService {
public static void processOpportunities(List<Opportunity> opps) {
OpportunityValidator.validate(opps);
OpportunityDomain.applyBusinessLogic(opps);
OpportunityIntegration.handleIntegration(opps);
}
}
Why: The Service Layer is the conductor. It defines the sequence of operations without owning any of them. Swap, add, or remove a step here without touching the individual layers.
3๏ธโฃ Validator โ Data Integrity
// OpportunityValidator.cls
public with sharing class OpportunityValidator {
public static void validate(List<Opportunity> opps) {
for (Opportunity opp : opps) {
if (opp.Amount == null || opp.Amount <= 0) {
opp.addError('Amount must be greater than zero');
}
if (String.isBlank(opp.StageName)) {
opp.addError('Stage Name is required');
}
}
}
}
Why: Validation logic lives in one place. When rules change, you change one class โ not hunt through 1,000 lines.
4๏ธโฃ Domain Layer โ Business Rules
// OpportunityDomain.cls
public with sharing class OpportunityDomain {
public static void applyBusinessLogic(List<Opportunity> opps) {
for (Opportunity opp : opps) {
if (opp.StageName == 'Closed Won') {
opp.Description = 'Won deal โ auto-tagged';
opp.Probability = 100;
}
if (opp.StageName == 'Closed Lost') {
opp.Probability = 0;
}
}
}
}
Why: Business rules are isolated from validation and integration. A product change updates one file, not the whole system.
5๏ธโฃ Integration Layer โ External Calls
// OpportunityIntegration.cls
public with sharing class OpportunityIntegration {
public static void handleIntegration(List<Opportunity> opps) {
for (Opportunity opp : opps) {
if (opp.StageName == 'Prospecting') {
// External callout logic isolated here
System.debug('Notifying external system for: ' + opp.Name);
}
}
}
}
Why: Integration logic changes independently from business rules. Isolating it here prevents a callout change from destabilizing your validation or domain logic.
๐งช Testing Each Layer in Isolation
This is where the Service Layer pattern pays off most. Each class can be tested in isolation, reducing unintended test coupling.
@isTest
private class OpportunityValidator_Test {
@isTest
static void testValidation_AmountZero_ShouldFail() {
Opportunity opp = new Opportunity(
Name = 'Test Opp',
StageName = 'Prospecting',
Amount = 0,
CloseDate = Date.today().addDays(30)
);
Test.startTest();
// Call validator directly โ no trigger, no service, no integration
OpportunityValidator.validate(new List<Opportunity>{ opp });
Test.stopTest();
// Verify the error was added
System.assertEquals(1, opp.getErrors().size(),
'Should flag Amount = 0 as invalid');
}
@isTest
static void testValidation_ValidAmount_ShouldPass() {
Opportunity opp = new Opportunity(
Name = 'Valid Opp',
StageName = 'Prospecting',
Amount = 5000,
CloseDate = Date.today().addDays(30)
);
Test.startTest();
OpportunityValidator.validate(new List<Opportunity>{ opp });
Test.stopTest();
System.assertEquals(0, opp.getErrors().size(),
'Valid opportunity should produce no errors');
}
}
Why this matters: With a God Class, testing validation means running the entire method โ including DML, integrations, and side effects. With a Service Layer, You test more targeted behavior with fewer unintended side effects.
๐ Architecture Overview
Trigger
OpportunityTrigger.trigger: Entry point โ routes only
Service Layer
OpportunityService.cls: Orchestrates all layers
Validation
OpportunityValidator.cls: Validation rules
Domain Logic
OpportunityDomain.cls: Business logic
Integration
OpportunityIntegration.cls: External call logic
Tests
OpportunityValidator_Test.cls: Tests validator in isolationOpportunityDomain_Test.cls: Tests domain in isolationOpportunityService_Test.cls: Tests full orchestration
๐ Real-World Applications
CI/CD Pipelines
Smaller classes โ faster, more stable tests with clearer failure points.
Hotfixes
Update a single layer (e.g., OpportunityDomain) without impacting unrelated logic.
Enterprise Orgs
Clear ownership across teams reduces conflicts and improves collaboration.
Reuse Across Contexts
The same logic can be reused across Batch Apex, Queueables, REST APIs, and Flows with minimal adaptation.
โ Key Takeaways
A God Class is not a sign of power โ it's a sign of accumulated technical debt
Separation of Concerns is the principle; the Service Layer pattern is the implementation
The trigger routes, the service orchestrates, and the specialized classes execute
Isolated layers mean isolated tests โ test exactly what you intend, nothing more
Smaller classes reduce merge conflicts, deployment risk, and cognitive overhead
This pattern scales from small teams to large enterprise orgs.
๐ Resources
๐ฌ Let's Talk
Open your most-used Apex class right now.
How many responsibilities does it have?
Drop the number in the comments โ and tell us whether you'd refactor it or start fresh.




