Skip to main content

Command Palette

Search for a command to run...

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

Updated
โ€ข7 min read
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 isolation

  • OpportunityDomain_Test.cls: Tests domain in isolation

  • OpportunityService_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.

More from this blog

Kingsley Mgbams

19 posts

This publication focuses on practical Salesforce development, sharing real-world experiences, best practices, and lessons learned along the way.