Salesforce · · 25 min read

The SOLID Design Principles in Apex

Applying the five SOLID principles to Salesforce Apex — Single Responsibility, Open-Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion, with practical Salesforce examples.

Part 63: The SOLID Design Principles in Apex

Welcome back to the Salesforce series. In Part 58, we talked about writing clean code. In the posts that followed, we explored design patterns, refactoring, and separation of concerns. All of those ideas orbit around a single goal: building software that is easy to change.

This is Part 63, and we are going to study the five SOLID design principles. These principles are the foundation of object-oriented design. They were collected and popularized by Robert C. Martin, often called Uncle Bob, in the early 2000s. The acronym SOLID was later coined by Michael Feathers, but the ideas themselves come directly from decades of software engineering experience.

SOLID stands for:

  • S — Single Responsibility Principle
  • O — Open-Closed Principle
  • L — Liskov Substitution Principle
  • I — Interface Segregation Principle
  • D — Dependency Inversion Principle

Each principle addresses a different dimension of code organization. Together, they guide you toward classes that are small, focused, extensible, and testable. In a Salesforce context, these principles apply directly to trigger handlers, service classes, selectors, domain classes, and any custom Apex you write.

We will walk through each principle one at a time. For every principle, you will see a clear definition, a violation example showing what goes wrong when you ignore it, and a corrected example showing how to fix it. All examples use real Salesforce objects and realistic Apex patterns.


Why SOLID Matters in Salesforce

Salesforce orgs grow over time. A project that starts with ten triggers and a handful of classes can balloon into hundreds of classes, thousands of lines of Apex, and dozens of integrations. When that growth happens without guiding principles, you end up with code that is fragile, tangled, and terrifying to change.

SOLID principles are not academic theory. They are practical guidelines that keep your codebase flexible. When you follow them, you can add new features without rewriting existing code. You can swap out implementations for testing. You can hand a class to another developer and they can understand it without reading the entire org.

The Salesforce platform has some unique constraints. Governor limits, bulkification requirements, and the metadata-driven architecture all shape how you write Apex. But SOLID principles work within those constraints. They do not fight the platform. They make your platform code better.


Single Responsibility Principle (SRP)

Definition

The Single Responsibility Principle states that a class should have one, and only one, reason to change. Another way to say this: a class should have exactly one job. If a class is responsible for two different things, then a change to one responsibility can break the other.

Robert C. Martin phrases it in terms of actors. A class should be responsible to one, and only one, actor. An actor is a group of stakeholders or users who would request a change. If two different groups of people could ask you to change the same class for different reasons, that class has too many responsibilities.

Why It Matters in Salesforce

In Salesforce, SRP violations are extremely common. Developers frequently write trigger handlers that validate data, call external services, send emails, and create related records all in one class. When the business asks you to change the email template, you are editing the same class that handles validation. When the integration team needs to update the callout logic, they are working in the same file as the person fixing a field mapping bug. Merge conflicts multiply. Regression risk increases.

Violation: A Trigger Handler That Does Everything

Here is a trigger handler that violates SRP. It handles validation, discount calculation, task creation, and email notification all in one place.

public class OpportunityTriggerHandler {

    public void handleAfterInsert(List<Opportunity> newOpps) {
        List<String> errors = new List<String>();
        List<Task> tasksToInsert = new List<Task>();
        List<Messaging.SingleEmailMessage> emails = new List<Messaging.SingleEmailMessage>();

        for (Opportunity opp : newOpps) {
            // Validation logic
            if (opp.Amount == null || opp.Amount <= 0) {
                errors.add('Opportunity ' + opp.Name + ' has an invalid amount.');
                continue;
            }
            if (opp.CloseDate == null) {
                errors.add('Opportunity ' + opp.Name + ' has no close date.');
                continue;
            }

            // Discount calculation logic
            Decimal discount = 0;
            if (opp.Amount > 100000) {
                discount = opp.Amount * 0.10;
            } else if (opp.Amount > 50000) {
                discount = opp.Amount * 0.05;
            }

            // Task creation logic
            Task followUp = new Task();
            followUp.Subject = 'Follow up on ' + opp.Name;
            followUp.WhatId = opp.Id;
            followUp.OwnerId = opp.OwnerId;
            followUp.ActivityDate = Date.today().addDays(7);
            followUp.Description = 'Discount offered: ' + discount;
            tasksToInsert.add(followUp);

            // Email notification logic
            Messaging.SingleEmailMessage email = new Messaging.SingleEmailMessage();
            email.setToAddresses(new List<String>{'sales-team@example.com'});
            email.setSubject('New Opportunity: ' + opp.Name);
            email.setPlainTextBody('A new opportunity worth ' + opp.Amount + ' was created.');
            emails.add(email);
        }

        if (!errors.isEmpty()) {
            System.debug('Errors: ' + errors);
        }
        if (!tasksToInsert.isEmpty()) {
            insert tasksToInsert;
        }
        if (!emails.isEmpty()) {
            Messaging.sendEmail(emails);
        }
    }
}

This class has at least four reasons to change: validation rules might change, discount logic might change, task creation rules might change, and email content might change. Four actors, four responsibilities, one class.

Corrected: Separate Classes for Each Responsibility

Break the handler into focused classes, each with a single job.

public class OpportunityValidator {

    public List<Opportunity> getValidOpportunities(List<Opportunity> opportunities) {
        List<Opportunity> valid = new List<Opportunity>();
        for (Opportunity opp : opportunities) {
            if (opp.Amount != null && opp.Amount > 0 && opp.CloseDate != null) {
                valid.add(opp);
            }
        }
        return valid;
    }
}
public class OpportunityDiscountCalculator {

    public Decimal calculateDiscount(Decimal amount) {
        if (amount > 100000) {
            return amount * 0.10;
        } else if (amount > 50000) {
            return amount * 0.05;
        }
        return 0;
    }
}
public class OpportunityTaskCreator {

    public List<Task> createFollowUpTasks(List<Opportunity> opportunities) {
        List<Task> tasks = new List<Task>();
        OpportunityDiscountCalculator calculator = new OpportunityDiscountCalculator();

        for (Opportunity opp : opportunities) {
            Decimal discount = calculator.calculateDiscount(opp.Amount);
            Task followUp = new Task();
            followUp.Subject = 'Follow up on ' + opp.Name;
            followUp.WhatId = opp.Id;
            followUp.OwnerId = opp.OwnerId;
            followUp.ActivityDate = Date.today().addDays(7);
            followUp.Description = 'Discount offered: ' + discount;
            tasks.add(followUp);
        }
        return tasks;
    }
}
public class OpportunityNotifier {

    public List<Messaging.SingleEmailMessage> buildNotifications(List<Opportunity> opportunities) {
        List<Messaging.SingleEmailMessage> emails = new List<Messaging.SingleEmailMessage>();
        for (Opportunity opp : opportunities) {
            Messaging.SingleEmailMessage email = new Messaging.SingleEmailMessage();
            email.setToAddresses(new List<String>{'sales-team@example.com'});
            email.setSubject('New Opportunity: ' + opp.Name);
            email.setPlainTextBody('A new opportunity worth ' + opp.Amount + ' was created.');
            emails.add(email);
        }
        return emails;
    }
}

Now the trigger handler becomes a thin orchestrator.

public class OpportunityTriggerHandler {

    public void handleAfterInsert(List<Opportunity> newOpps) {
        OpportunityValidator validator = new OpportunityValidator();
        List<Opportunity> validOpps = validator.getValidOpportunities(newOpps);

        OpportunityTaskCreator taskCreator = new OpportunityTaskCreator();
        List<Task> tasks = taskCreator.createFollowUpTasks(validOpps);
        if (!tasks.isEmpty()) {
            insert tasks;
        }

        OpportunityNotifier notifier = new OpportunityNotifier();
        List<Messaging.SingleEmailMessage> emails = notifier.buildNotifications(validOpps);
        if (!emails.isEmpty()) {
            Messaging.sendEmail(emails);
        }
    }
}

Each class now has one reason to change. The validator changes only when validation rules change. The discount calculator changes only when pricing logic changes. The task creator changes only when follow-up requirements change. The notifier changes only when notification content changes. The handler itself only changes when the orchestration order changes.

SRP and Trigger Frameworks

If you are using a trigger framework like the one we built in earlier posts, SRP naturally maps to the service layer pattern. Your trigger delegates to a handler, the handler delegates to service classes, and each service class owns one responsibility. This is not a coincidence. Trigger frameworks were designed with SRP in mind.


Open-Closed Principle (OCP)

Definition

The Open-Closed Principle states that software entities should be open for extension but closed for modification. You should be able to add new behavior to a system without changing existing, tested, working code.

This sounds paradoxical at first. How can you add behavior without changing anything? The answer is abstraction. You define interfaces or abstract classes that represent a contract. Then you add new behavior by creating new implementations of that contract. The existing code that depends on the abstraction never changes.

Why It Matters in Salesforce

Salesforce orgs constantly receive new requirements. A client asks for Slack notifications in addition to email notifications. A new approval process needs a different discount strategy. A third-party integration needs an alternative authentication method. If your code is not designed for extension, every new requirement means editing existing classes, which means retesting everything those classes touch.

Violation: Hardcoded Notification Logic

Here is a class that sends notifications. Every time the business wants a new notification channel, you have to modify this class.

public class NotificationService {

    public void sendNotification(String channel, String message, String recipient) {
        if (channel == 'Email') {
            Messaging.SingleEmailMessage email = new Messaging.SingleEmailMessage();
            email.setToAddresses(new List<String>{recipient});
            email.setSubject('Notification');
            email.setPlainTextBody(message);
            Messaging.sendEmail(new List<Messaging.SingleEmailMessage>{email});

        } else if (channel == 'Chatter') {
            FeedItem post = new FeedItem();
            post.ParentId = UserInfo.getUserId();
            post.Body = message;
            insert post;

        } else if (channel == 'SMS') {
            // Callout to SMS provider
            HttpRequest req = new HttpRequest();
            req.setEndpoint('callout:SMS_Provider/send');
            req.setMethod('POST');
            req.setBody('{"to":"' + recipient + '","message":"' + message + '"}');
            new Http().send(req);
        }
        // Every new channel means adding another else-if here
    }
}

This class is closed for extension and open for modification, which is the exact opposite of what OCP asks for. Adding a Slack channel means editing this class. Adding a Teams channel means editing it again. Each edit risks breaking the existing channels.

Corrected: Interface-Based Extension

Define an interface that represents the notification contract.

public interface INotificationSender {
    void send(String message, String recipient);
}

Now create one implementation per channel.

public class EmailNotificationSender implements INotificationSender {

    public void send(String message, String recipient) {
        Messaging.SingleEmailMessage email = new Messaging.SingleEmailMessage();
        email.setToAddresses(new List<String>{recipient});
        email.setSubject('Notification');
        email.setPlainTextBody(message);
        Messaging.sendEmail(new List<Messaging.SingleEmailMessage>{email});
    }
}
public class ChatterNotificationSender implements INotificationSender {

    public void send(String message, String recipient) {
        FeedItem post = new FeedItem();
        post.ParentId = UserInfo.getUserId();
        post.Body = message;
        insert post;
    }
}
public class SmsNotificationSender implements INotificationSender {

    public void send(String message, String recipient) {
        HttpRequest req = new HttpRequest();
        req.setEndpoint('callout:SMS_Provider/send');
        req.setMethod('POST');
        req.setBody('{"to":"' + recipient + '","message":"' + message + '"}');
        new Http().send(req);
    }
}

The service class now works with the interface, not with concrete implementations.

public class NotificationService {

    private List<INotificationSender> senders;

    public NotificationService(List<INotificationSender> senders) {
        this.senders = senders;
    }

    public void sendNotification(String message, String recipient) {
        for (INotificationSender sender : this.senders) {
            sender.send(message, recipient);
        }
    }
}

When the business asks for Slack notifications, you create a new class.

public class SlackNotificationSender implements INotificationSender {

    public void send(String message, String recipient) {
        HttpRequest req = new HttpRequest();
        req.setEndpoint('callout:Slack_Webhook');
        req.setMethod('POST');
        req.setBody('{"text":"' + message + '","channel":"' + recipient + '"}');
        new Http().send(req);
    }
}

You never touch NotificationService, EmailNotificationSender, or any other existing class. The system is open for extension and closed for modification.

OCP and Custom Metadata

In Salesforce, you can take OCP even further by using Custom Metadata Types to drive which implementations are active. Store the class names in a custom metadata record and use Type.forName() to instantiate them at runtime. This lets administrators enable or disable notification channels without deploying new code.


Liskov Substitution Principle (LSP)

Definition

The Liskov Substitution Principle states that objects of a superclass should be replaceable with objects of a subclass without altering the correctness of the program. If class B extends class A, then anywhere you use an instance of A, you should be able to use an instance of B and everything should still work.

This principle was introduced by Barbara Liskov in 1987. It is about behavioral compatibility. A subclass must honor the contract established by its parent. It must not weaken preconditions, strengthen postconditions unexpectedly, or throw new exceptions that the parent did not throw.

Why It Matters in Salesforce

In Apex, you use inheritance and interfaces frequently. Trigger handlers extend base handler classes. Service classes implement shared interfaces. If a subclass violates the expectations set by its parent, calling code breaks in subtle and hard-to-debug ways.

Violation: A Subclass That Breaks the Contract

Imagine a base class for processing discounts.

public virtual class DiscountProcessor {

    public virtual Decimal calculateDiscount(Opportunity opp) {
        if (opp.Amount == null) {
            return 0;
        }
        return opp.Amount * 0.05;
    }
}

The contract is straightforward: pass in an Opportunity, get back a discount amount. A null Amount returns zero. Now a developer creates a subclass for premium accounts.

public class PremiumDiscountProcessor extends DiscountProcessor {

    public override Decimal calculateDiscount(Opportunity opp) {
        // Violation: throws an exception that the parent never threw
        if (opp.Amount == null) {
            throw new DiscountException('Amount is required for premium discounts.');
        }
        // Violation: requires a field the parent did not require
        if (opp.Account == null || opp.Account.AnnualRevenue == null) {
            throw new DiscountException('Account with AnnualRevenue is required.');
        }
        return opp.Amount * 0.15;
    }
}

This violates LSP in two ways. First, the parent handles a null Amount gracefully by returning zero. The subclass throws an exception instead, strengthening the precondition. Second, the subclass requires the Account relationship to be populated, which is a new precondition the parent never imposed. Code that works with a DiscountProcessor variable will break when a PremiumDiscountProcessor is substituted in.

// This code works fine with the parent class
DiscountProcessor processor = new DiscountProcessor();
Decimal discount = processor.calculateDiscount(someOppWithNullAmount);
// discount is 0, no crash

// Substitute the subclass and it breaks
DiscountProcessor processor = new PremiumDiscountProcessor();
Decimal discount = processor.calculateDiscount(someOppWithNullAmount);
// Throws DiscountException — LSP violation

Corrected: Honoring the Parent Contract

The subclass must accept the same inputs and handle edge cases the same way the parent does.

public class PremiumDiscountProcessor extends DiscountProcessor {

    public override Decimal calculateDiscount(Opportunity opp) {
        if (opp.Amount == null) {
            return 0; // Same behavior as the parent
        }

        Decimal baseRate = 0.15;

        // Enhance behavior without breaking the contract
        if (opp.Account != null
            && opp.Account.AnnualRevenue != null
            && opp.Account.AnnualRevenue > 5000000) {
            baseRate = 0.20;
        }

        return opp.Amount * baseRate;
    }
}

Now the subclass handles null Amount the same way the parent does. It enhances the discount calculation when additional data is available, but it does not crash when that data is absent. Any code that works with a DiscountProcessor will also work with a PremiumDiscountProcessor.

LSP Guidelines for Apex

When you override a method in Apex, follow these rules:

  1. Do not throw exceptions that the parent method does not throw.
  2. Do not require inputs that the parent method does not require.
  3. Accept at least the same range of inputs as the parent.
  4. Return a result that is consistent with the parent’s documented behavior.
  5. Do not silently skip work that the parent performs. If the parent saves a record, the override should also save a record.

If you find yourself needing fundamentally different behavior, that is a signal that inheritance is the wrong tool. Consider composition or a separate interface instead.


Interface Segregation Principle (ISP)

Definition

The Interface Segregation Principle states that no client should be forced to depend on methods it does not use. If an interface has ten methods but a particular implementing class only needs three, that interface is too large. It should be split into smaller, more focused interfaces.

Why It Matters in Salesforce

Fat interfaces are a common problem in Apex codebases. A developer creates one large interface for all record operations, and then every implementing class is forced to provide stub implementations for methods it does not care about. This creates noise, confusion, and fragile code.

Violation: A Fat Interface

Here is an interface that tries to cover every possible operation on a record.

public interface IRecordService {
    List<SObject> getAll();
    SObject getById(Id recordId);
    List<SObject> getByIds(Set<Id> recordIds);
    SObject create(SObject record);
    List<SObject> createBulk(List<SObject> records);
    SObject updateRecord(SObject record);
    List<SObject> updateBulk(List<SObject> records);
    void deleteRecord(Id recordId);
    void deleteBulk(Set<Id> recordIds);
    List<SObject> search(String searchTerm);
    Integer countAll();
    Map<Id, SObject> getMapByIds(Set<Id> recordIds);
}

Now imagine a reporting class that only needs to read data. It must implement this entire interface.

public class OpportunityReportService implements IRecordService {

    public List<SObject> getAll() {
        return [SELECT Id, Name, Amount, StageName FROM Opportunity];
    }

    public SObject getById(Id recordId) {
        return [SELECT Id, Name, Amount, StageName FROM Opportunity WHERE Id = :recordId];
    }

    public List<SObject> getByIds(Set<Id> recordIds) {
        return [SELECT Id, Name, Amount, StageName FROM Opportunity WHERE Id IN :recordIds];
    }

    // All of these are meaningless for a report service
    public SObject create(SObject record) {
        throw new UnsupportedOperationException('Reports do not create records.');
    }

    public List<SObject> createBulk(List<SObject> records) {
        throw new UnsupportedOperationException('Reports do not create records.');
    }

    public SObject updateRecord(SObject record) {
        throw new UnsupportedOperationException('Reports do not update records.');
    }

    public List<SObject> updateBulk(List<SObject> records) {
        throw new UnsupportedOperationException('Reports do not update records.');
    }

    public void deleteRecord(Id recordId) {
        throw new UnsupportedOperationException('Reports do not delete records.');
    }

    public void deleteBulk(Set<Id> recordIds) {
        throw new UnsupportedOperationException('Reports do not delete records.');
    }

    public List<SObject> search(String searchTerm) {
        throw new UnsupportedOperationException('Reports do not support search.');
    }

    public Integer countAll() {
        return [SELECT COUNT() FROM Opportunity];
    }

    public Map<Id, SObject> getMapByIds(Set<Id> recordIds) {
        return new Map<Id, SObject>(
            [SELECT Id, Name, Amount, StageName FROM Opportunity WHERE Id IN :recordIds]
        );
    }
}

Half of this class is nothing but exceptions. That is a clear signal that the interface is too broad.

Corrected: Segregated Interfaces

Split the fat interface into smaller, role-based interfaces.

public interface IRecordReader {
    List<SObject> getAll();
    SObject getById(Id recordId);
    List<SObject> getByIds(Set<Id> recordIds);
    Map<Id, SObject> getMapByIds(Set<Id> recordIds);
    Integer countAll();
}
public interface IRecordWriter {
    SObject create(SObject record);
    List<SObject> createBulk(List<SObject> records);
    SObject updateRecord(SObject record);
    List<SObject> updateBulk(List<SObject> records);
}
public interface IRecordDeleter {
    void deleteRecord(Id recordId);
    void deleteBulk(Set<Id> recordIds);
}
public interface IRecordSearcher {
    List<SObject> search(String searchTerm);
}

Now the report service only implements what it needs.

public class OpportunityReportService implements IRecordReader {

    public List<SObject> getAll() {
        return [SELECT Id, Name, Amount, StageName FROM Opportunity];
    }

    public SObject getById(Id recordId) {
        return [SELECT Id, Name, Amount, StageName FROM Opportunity WHERE Id = :recordId];
    }

    public List<SObject> getByIds(Set<Id> recordIds) {
        return [SELECT Id, Name, Amount, StageName FROM Opportunity WHERE Id IN :recordIds];
    }

    public Map<Id, SObject> getMapByIds(Set<Id> recordIds) {
        return new Map<Id, SObject>(
            [SELECT Id, Name, Amount, StageName FROM Opportunity WHERE Id IN :recordIds]
        );
    }

    public Integer countAll() {
        return [SELECT COUNT() FROM Opportunity];
    }
}

No more throwing exceptions for methods that do not belong. The class is clean, focused, and honest about its capabilities.

A full CRUD service class can implement multiple interfaces.

public class OpportunityCrudService implements IRecordReader, IRecordWriter, IRecordDeleter {
    // Implements all methods from all three interfaces
    // Each method has a real implementation
    // ...
}

Apex supports implementing multiple interfaces, so this works naturally with the language. Clients that only need to read records depend on IRecordReader. Clients that need to write depend on IRecordWriter. No one is forced to depend on methods they do not use.

ISP and the Selector Pattern

If you follow the Selector pattern from the Apex Enterprise Patterns, ISP helps you keep selectors focused. A selector for Opportunity queries should not include methods for querying Accounts. Keep each selector interface aligned with one SObject or one bounded context.


Dependency Inversion Principle (DIP)

Definition

The Dependency Inversion Principle states two things:

  1. High-level modules should not depend on low-level modules. Both should depend on abstractions.
  2. Abstractions should not depend on details. Details should depend on abstractions.

In practical terms, this means your business logic classes should not directly instantiate the classes they depend on. Instead, they should receive those dependencies through their constructors or methods, and those dependencies should be typed as interfaces or abstract classes rather than concrete classes.

Why It Matters in Salesforce

Without DIP, your Apex classes are tightly coupled. A service class that directly instantiates an HTTP callout class cannot be unit tested without actually making the callout. A handler that directly instantiates a selector class cannot be tested without inserting real records. DIP breaks these hard dependencies and makes unit testing with mocks possible.

Violation: Direct Dependency on Concrete Classes

Here is a service class that is tightly coupled to its dependencies.

public class LeadConversionService {

    public void convertLead(Id leadId) {
        // Direct dependency on a concrete selector
        LeadSelector selector = new LeadSelector();
        Lead ld = selector.getLeadById(leadId);

        if (ld == null) {
            throw new LeadConversionException('Lead not found.');
        }

        // Direct dependency on a concrete validator
        LeadValidator validator = new LeadValidator();
        if (!validator.isReadyForConversion(ld)) {
            throw new LeadConversionException('Lead is not ready for conversion.');
        }

        // Direct dependency on a concrete callout class
        EnrichmentService enrichment = new EnrichmentService();
        Map<String, String> enrichedData = enrichment.enrichLead(ld);

        // Perform conversion
        Database.LeadConvert lc = new Database.LeadConvert();
        lc.setLeadId(leadId);
        lc.setConvertedStatus('Closed - Converted');
        Database.convertLead(lc);

        // Direct dependency on a concrete notifier
        ConversionNotifier notifier = new ConversionNotifier();
        notifier.notifyOwner(ld);
    }
}

This class directly creates LeadSelector, LeadValidator, EnrichmentService, and ConversionNotifier. To test the conversion logic, you would need real Lead records in the database, a working callout endpoint, and an email-ready environment. That is a fragile and slow test.

Corrected: Depending on Abstractions with Constructor Injection

First, define interfaces for each dependency.

public interface ILeadSelector {
    Lead getLeadById(Id leadId);
}
public interface ILeadValidator {
    Boolean isReadyForConversion(Lead ld);
}
public interface IEnrichmentService {
    Map<String, String> enrichLead(Lead ld);
}
public interface IConversionNotifier {
    void notifyOwner(Lead ld);
}

Now rewrite the service to depend on the interfaces, injected through the constructor.

public class LeadConversionService {

    private ILeadSelector selector;
    private ILeadValidator validator;
    private IEnrichmentService enrichment;
    private IConversionNotifier notifier;

    // Constructor injection: dependencies come from outside
    public LeadConversionService(
        ILeadSelector selector,
        ILeadValidator validator,
        IEnrichmentService enrichment,
        IConversionNotifier notifier
    ) {
        this.selector = selector;
        this.validator = validator;
        this.enrichment = enrichment;
        this.notifier = notifier;
    }

    // Convenience constructor for production use
    public LeadConversionService() {
        this(
            new LeadSelector(),
            new LeadValidator(),
            new EnrichmentService(),
            new ConversionNotifier()
        );
    }

    public void convertLead(Id leadId) {
        Lead ld = this.selector.getLeadById(leadId);

        if (ld == null) {
            throw new LeadConversionException('Lead not found.');
        }

        if (!this.validator.isReadyForConversion(ld)) {
            throw new LeadConversionException('Lead is not ready for conversion.');
        }

        Map<String, String> enrichedData = this.enrichment.enrichLead(ld);

        Database.LeadConvert lc = new Database.LeadConvert();
        lc.setLeadId(leadId);
        lc.setConvertedStatus('Closed - Converted');
        Database.convertLead(lc);

        this.notifier.notifyOwner(ld);
    }
}

The business logic is identical. But now the dependencies flow in from outside. Production code uses the no-argument constructor, which wires up the real implementations. Test code uses the parameterized constructor and passes in mocks.

Unit Testing with Mocks

Here is what a test looks like when DIP is in place.

@IsTest
private class LeadConversionServiceTest {

    // Mock implementations
    private class MockLeadSelector implements ILeadSelector {
        private Lead mockLead;

        public MockLeadSelector(Lead ld) {
            this.mockLead = ld;
        }

        public Lead getLeadById(Id leadId) {
            return this.mockLead;
        }
    }

    private class MockLeadValidator implements ILeadValidator {
        private Boolean isReady;

        public MockLeadValidator(Boolean isReady) {
            this.isReady = isReady;
        }

        public Boolean isReadyForConversion(Lead ld) {
            return this.isReady;
        }
    }

    private class MockEnrichmentService implements IEnrichmentService {
        public Map<String, String> enrichLead(Lead ld) {
            return new Map<String, String>{'industry' => 'Technology'};
        }
    }

    private class MockConversionNotifier implements IConversionNotifier {
        public Boolean wasCalled = false;

        public void notifyOwner(Lead ld) {
            this.wasCalled = true;
        }
    }

    @IsTest
    static void testConvertLead_ValidLead_ConvertsSuccessfully() {
        // Arrange
        Lead testLead = new Lead(
            Id = '00Q000000000001',
            FirstName = 'Test',
            LastName = 'Lead',
            Company = 'Test Corp',
            Status = 'Qualified'
        );

        MockLeadSelector mockSelector = new MockLeadSelector(testLead);
        MockLeadValidator mockValidator = new MockLeadValidator(true);
        MockEnrichmentService mockEnrichment = new MockEnrichmentService();
        MockConversionNotifier mockNotifier = new MockConversionNotifier();

        LeadConversionService service = new LeadConversionService(
            mockSelector,
            mockValidator,
            mockEnrichment,
            mockNotifier
        );

        // Act
        Test.startTest();
        service.convertLead(testLead.Id);
        Test.stopTest();

        // Assert
        System.assert(mockNotifier.wasCalled, 'Owner should be notified after conversion.');
    }

    @IsTest
    static void testConvertLead_NullLead_ThrowsException() {
        MockLeadSelector mockSelector = new MockLeadSelector(null);
        MockLeadValidator mockValidator = new MockLeadValidator(true);
        MockEnrichmentService mockEnrichment = new MockEnrichmentService();
        MockConversionNotifier mockNotifier = new MockConversionNotifier();

        LeadConversionService service = new LeadConversionService(
            mockSelector,
            mockValidator,
            mockEnrichment,
            mockNotifier
        );

        Boolean exceptionThrown = false;
        try {
            service.convertLead('00Q000000000002');
        } catch (LeadConversionException e) {
            exceptionThrown = true;
        }

        System.assert(exceptionThrown, 'Should throw exception when lead is not found.');
    }

    @IsTest
    static void testConvertLead_NotReady_ThrowsException() {
        Lead testLead = new Lead(
            Id = '00Q000000000003',
            FirstName = 'Test',
            LastName = 'Lead',
            Company = 'Test Corp',
            Status = 'Open'
        );

        MockLeadSelector mockSelector = new MockLeadSelector(testLead);
        MockLeadValidator mockValidator = new MockLeadValidator(false);
        MockEnrichmentService mockEnrichment = new MockEnrichmentService();
        MockConversionNotifier mockNotifier = new MockConversionNotifier();

        LeadConversionService service = new LeadConversionService(
            mockSelector,
            mockValidator,
            mockEnrichment,
            mockNotifier
        );

        Boolean exceptionThrown = false;
        try {
            service.convertLead(testLead.Id);
        } catch (LeadConversionException e) {
            exceptionThrown = true;
        }

        System.assert(exceptionThrown, 'Should throw exception when lead is not ready.');
        System.assert(!mockNotifier.wasCalled, 'Owner should not be notified if conversion fails.');
    }
}

These tests are fast. They do not insert records into the database. They do not make HTTP callouts. They do not send emails. Each test isolates exactly one behavior of the LeadConversionService. That is the power of Dependency Inversion.

The Two-Constructor Pattern

Notice the pattern in the corrected example. There is one constructor that accepts all dependencies as parameters, and a second no-argument constructor that provides the default production implementations. This is a pragmatic Apex pattern because Salesforce does not have a built-in dependency injection container. The two-constructor approach gives you the best of both worlds: clean dependency injection in tests and zero configuration in production.

// Production code — uses the default constructor
LeadConversionService service = new LeadConversionService();
service.convertLead(leadId);

// Test code — uses the parameterized constructor with mocks
LeadConversionService service = new LeadConversionService(
    mockSelector, mockValidator, mockEnrichment, mockNotifier
);
service.convertLead(leadId);

This pattern works well until your dependency graph gets deep. If class A depends on class B, which depends on class C, which depends on class D, the no-argument constructors cascade and wire everything up automatically for production. In tests, you only mock the level you are testing.


How the Five Principles Work Together

SOLID is not five separate rules that you apply in isolation. The principles reinforce each other.

SRP and OCP: When each class has one responsibility, it is much easier to extend behavior by adding new classes rather than modifying existing ones. A notification system built with SRP naturally lends itself to OCP because each notification channel is its own class.

OCP and LSP: When you extend behavior through interfaces and subclasses, LSP ensures that the new implementations do not break existing consumers. OCP tells you to extend rather than modify. LSP tells you to extend correctly.

ISP and DIP: When interfaces are small and focused, it is easier to create mock implementations for testing. DIP tells you to depend on abstractions. ISP tells you to keep those abstractions lean. Together, they make your code testable.

SRP and DIP: When a class has a single responsibility, it tends to have fewer dependencies. Fewer dependencies means simpler constructors and simpler mock setups. SRP reduces coupling naturally, and DIP formalizes that reduction.

A Practical Checklist

When you write or review Apex code, ask these five questions:

  1. SRP: Does this class have more than one reason to change? If yes, split it.
  2. OCP: Will I need to modify this class when a new variation is added? If yes, introduce an interface.
  3. LSP: Can I substitute any subclass without breaking the caller? If no, fix the subclass or remove the inheritance.
  4. ISP: Does any implementing class throw UnsupportedOperationException or leave methods empty? If yes, split the interface.
  5. DIP: Does this class create its own dependencies using the new keyword? If yes, inject them through the constructor.

You do not need to apply every principle to every class. A simple utility method that formats a date does not need dependency injection. A straightforward data class does not need an interface. Use SOLID where complexity demands it, and keep things simple where simplicity suffices.


Common Objections

”This creates too many classes.”

Yes, SOLID produces more classes. But each class is small, focused, and easy to understand. You trade a few large, complex files for many small, simple ones. The total complexity does not increase. It gets redistributed into manageable pieces. Modern IDEs and Salesforce DX make navigating many files straightforward.

”Apex does not support real dependency injection.”

True, Apex does not have a framework like Spring or Guice. But the two-constructor pattern gives you manual dependency injection that is perfectly adequate. For larger orgs, libraries like the Apex Commons (fflib) provide a full Application Factory pattern that handles injection at scale.

”We do not have time for this.”

You do not have time to not do this. The time you invest in applying SOLID pays for itself many times over. Every bug that does not happen because your classes are isolated, every feature that deploys without regression because you extended instead of modified, every test that runs in milliseconds because you inject mocks instead of inserting records — that is time saved.


Summary

The SOLID principles provide a reliable framework for writing Apex that scales.

The Single Responsibility Principle keeps classes focused on one job, so changes are isolated and merge conflicts are rare. The Open-Closed Principle lets you add behavior through new classes instead of editing old ones, so existing features stay stable. The Liskov Substitution Principle ensures that subclasses honor their parent’s contract, so polymorphism works predictably. The Interface Segregation Principle keeps interfaces lean, so implementing classes are not burdened with methods they do not need. The Dependency Inversion Principle decouples business logic from infrastructure, so unit tests are fast and reliable.

Apply these principles gradually. Start with SRP by breaking up your largest handler. Move to OCP by introducing an interface the next time a new variation appears. Use DIP when you want to unit test a service without hitting the database. Over time, these principles become second nature, and your Salesforce codebase becomes a place where change is safe and development is fast.

In Part 64, we will explore The Apex Common Library — the open-source fflib framework that provides a full enterprise architecture for Salesforce, including the Unit of Work, Selector, Domain, and Service patterns that build directly on the SOLID principles we covered today. See you there.