Salesforce · · 31 min read

Object Oriented Concepts in Apex

Mastering OOP in Apex — abstraction, encapsulation, inheritance, polymorphism, and composition. With practical Salesforce examples and a project using polymorphism for runtime behavior changes.

Part 61: Object Oriented Concepts in Apex

Welcome back to the Salesforce series. In the previous posts, we covered Apex fundamentals — syntax, data types, collections, control flow, SOQL, DML, triggers, and testing. All of that gave you the tools to write functional code. This post is about writing well-structured code.

Object-Oriented Programming is not just an academic concept. It is the foundation of how large Salesforce orgs are built and maintained. The difference between a codebase that scales gracefully and one that collapses under its own weight often comes down to how well OOP principles are applied.

By the end of this post, you will understand the four pillars of OOP, know how each one works in Apex with real code, and complete a hands-on project that demonstrates polymorphism in action.


What is Object-Oriented Programming?

Object-Oriented Programming (OOP) is a programming paradigm that organizes code around objects — bundles of data and the methods that operate on that data — rather than around sequences of instructions.

Procedural vs Object-Oriented

In procedural programming, you write a list of instructions that execute from top to bottom. Data and functions are separate. This works fine for small scripts, but it breaks down as complexity grows.

// Procedural approach — everything is a loose function
public class OrderProcessor {
    public static Decimal calculateTotal(List<OrderItem> items) {
        Decimal total = 0;
        for (OrderItem item : items) {
            total += item.UnitPrice * item.Quantity;
        }
        return total;
    }

    public static Decimal applyDiscount(Decimal total, String customerType) {
        if (customerType == 'Gold') {
            return total * 0.85;
        } else if (customerType == 'Silver') {
            return total * 0.90;
        } else {
            return total;
        }
    }

    public static void sendConfirmation(String email, Decimal total) {
        // send email logic
    }
}

This works, but notice the problems. Every new customer type means modifying the applyDiscount method. The OrderProcessor class is responsible for calculation, discount logic, and email sending. If another part of the system needs discount logic, it has to call into this class or duplicate the code.

In the OOP approach, you model the problem domain as objects that encapsulate their own data and behavior:

// Object-oriented approach
public interface IDiscountStrategy {
    Decimal apply(Decimal total);
}

public class GoldDiscount implements IDiscountStrategy {
    public Decimal apply(Decimal total) {
        return total * 0.85;
    }
}

public class SilverDiscount implements IDiscountStrategy {
    public Decimal apply(Decimal total) {
        return total * 0.90;
    }
}

Now each discount strategy is its own class. Adding a new customer tier means adding a new class, not modifying existing code. This is the Open/Closed Principle in action — open for extension, closed for modification.

The Four Pillars of OOP

Every OOP language, including Apex, is built on four foundational concepts:

  1. Abstraction — Hiding complex implementation details behind a simple interface. You expose what something does, not how it does it.
  2. Encapsulation — Bundling data and methods together and restricting direct access to internal state. Other classes interact through controlled interfaces.
  3. Inheritance — Creating new classes that reuse, extend, or modify the behavior of existing classes. A child class inherits from a parent class.
  4. Polymorphism — Treating objects of different classes through a common interface. The same method call can produce different behavior depending on the object’s actual type.

Why OOP Matters in Apex

Apex is an object-oriented language by design. Every piece of Apex code you write lives inside a class. Even triggers delegate to classes. Salesforce’s own platform code — Database.Batchable, Queueable, Schedulable — uses interfaces and abstract classes extensively.

If you want to write Apex that is testable, maintainable, and extensible, you need to understand OOP. There is no way around it.


What is Abstraction?

Abstraction is about showing only what is necessary and hiding everything else. When you call Database.insert(record), you do not need to know how Salesforce handles the underlying storage, indexing, or transaction management. You just call the method and trust that it works. That is abstraction.

In Apex, abstraction is achieved through abstract classes and interfaces.

Abstract Classes

An abstract class is a class that cannot be instantiated directly. It serves as a blueprint for other classes. It can contain both implemented methods (with a body) and abstract methods (without a body).

public abstract class NotificationService {
    // Abstract method — subclasses MUST implement this
    public abstract void send(String recipient, String message);

    // Concrete method — subclasses inherit this as-is
    public void log(String recipient, String message) {
        System.debug('Notification sent to ' + recipient + ': ' + message);
        Notification_Log__c logRecord = new Notification_Log__c(
            Recipient__c = recipient,
            Message__c = message,
            Sent_Date__c = Datetime.now()
        );
        insert logRecord;
    }

    // Template method pattern — defines the algorithm skeleton
    public void sendAndLog(String recipient, String message) {
        send(recipient, message);
        log(recipient, message);
    }
}

The NotificationService class defines what a notification service does (send a message and log it) without specifying how the sending happens. That detail is left to the subclasses.

Abstract Method Rules in Apex

  • Abstract methods can only exist inside abstract classes.
  • Abstract methods have no body — just a signature followed by a semicolon.
  • Any class that extends an abstract class must implement all abstract methods, unless it is also abstract.
  • Abstract classes can have constructors, instance variables, and concrete methods.

Concrete Implementations

public class EmailNotificationService extends NotificationService {
    public override void send(String recipient, String message) {
        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 SmsNotificationService extends NotificationService {
    private String smsEndpoint;

    public SmsNotificationService(String endpoint) {
        this.smsEndpoint = endpoint;
    }

    public override void send(String recipient, String message) {
        HttpRequest req = new HttpRequest();
        req.setEndpoint(this.smsEndpoint);
        req.setMethod('POST');
        req.setBody(JSON.serialize(new Map<String, String>{
            'phone' => recipient,
            'text' => message
        }));
        Http http = new Http();
        HttpResponse res = http.send(req);

        if (res.getStatusCode() != 200) {
            throw new CalloutException('SMS send failed: ' + res.getBody());
        }
    }
}

public class PushNotificationService extends NotificationService {
    public override void send(String recipient, String message) {
        // Integration with a push notification provider
        Platform_Event__e evt = new Platform_Event__e(
            Recipient__c = recipient,
            Message__c = message,
            Channel__c = 'PUSH'
        );
        EventBus.publish(evt);
    }
}

Now the calling code does not need to know which service it is using:

NotificationService service = new EmailNotificationService();
service.sendAndLog('user@example.com', 'Your order has shipped.');

// Later, swap to SMS without changing the calling code
service = new SmsNotificationService('https://api.sms-provider.com/send');
service.sendAndLog('+1234567890', 'Your order has shipped.');

When to Use Abstract Classes

Use abstract classes when:

  • You want to provide shared implementation that subclasses inherit.
  • You need to define a template for an algorithm where some steps vary.
  • Subclasses share common state (instance variables).

Use interfaces instead when:

  • You only need to define a contract with no shared implementation.
  • A class needs to conform to multiple contracts (Apex does not support multiple inheritance of classes, but it supports multiple interface implementation).

What is Encapsulation?

Encapsulation is about controlling access to the internal state of an object. Instead of letting other classes reach in and modify an object’s variables directly, you expose controlled methods — getters and setters — that enforce rules about how data can be read and changed.

Access Modifiers in Apex

Apex has four access modifiers:

ModifierVisibility
privateOnly within the same class. This is the default if no modifier is specified.
protectedWithin the same class and any class that extends it.
publicAnywhere within the same namespace (your org).
globalAnywhere, including from managed packages and external APIs. Cannot be rolled back to a lower visibility once released in a managed package.

Why Encapsulation Matters

Consider this class with no encapsulation:

public class BankAccount {
    public Decimal balance;
    public String accountNumber;
}

// Anywhere in the codebase:
BankAccount acct = new BankAccount();
acct.balance = -5000; // No validation — this should not be allowed
acct.accountNumber = ''; // Empty account number — bad data

There is nothing preventing invalid state. Now compare this with a properly encapsulated version:

public class BankAccount {
    private Decimal balance;
    private String accountNumber;

    public BankAccount(String accountNumber, Decimal initialDeposit) {
        if (String.isBlank(accountNumber)) {
            throw new IllegalArgumentException('Account number is required.');
        }
        if (initialDeposit < 0) {
            throw new IllegalArgumentException('Initial deposit cannot be negative.');
        }
        this.accountNumber = accountNumber;
        this.balance = initialDeposit;
    }

    public Decimal getBalance() {
        return this.balance;
    }

    public String getAccountNumber() {
        return this.accountNumber;
    }

    public void deposit(Decimal amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException('Deposit amount must be positive.');
        }
        this.balance += amount;
    }

    public void withdraw(Decimal amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException('Withdrawal amount must be positive.');
        }
        if (amount > this.balance) {
            throw new IllegalArgumentException('Insufficient funds.');
        }
        this.balance -= amount;
    }
}

Now the only way to change the balance is through deposit() and withdraw(), both of which enforce business rules. The account number is set once in the constructor and cannot be changed. The internal state is protected.

Getters and Setters — Apex Properties

Apex supports a property syntax that is more concise than writing separate getter and setter methods:

public class Contact_Wrapper {
    // Auto-implemented property
    public String firstName { get; set; }

    // Property with custom logic
    public String email {
        get { return email; }
        set {
            if (value != null && !value.contains('@')) {
                throw new IllegalArgumentException('Invalid email format.');
            }
            email = value;
        }
    }

    // Read-only property
    public String fullName {
        get {
            return this.firstName + ' ' + this.lastName;
        }
        private set;
    }

    public String lastName { get; set; }
}

The private set pattern is especially useful. It means the property can be read from anywhere but only written to from within the class itself.

Encapsulation Best Practices

  • Make all instance variables private by default. Only widen access when there is a clear reason.
  • Use properties or methods to expose data, not raw variables.
  • Validate inputs in setters and constructors.
  • Do not expose mutable collections directly. Return copies or unmodifiable views instead.
public class OrderService {
    private List<Order> orders;

    public OrderService() {
        this.orders = new List<Order>();
    }

    // BAD: Returns the internal list — caller can modify it
    // public List<Order> getOrders() { return this.orders; }

    // GOOD: Returns a copy
    public List<Order> getOrders() {
        return new List<Order>(this.orders);
    }

    public void addOrder(Order ord) {
        if (ord == null) {
            throw new IllegalArgumentException('Order cannot be null.');
        }
        this.orders.add(ord);
    }
}

What is Inheritance?

Inheritance allows a class to derive from another class, reusing its fields and methods while adding or modifying behavior. The parent class is called the base class or superclass. The child class is called the derived class or subclass.

The extends Keyword

In Apex, you use extends to create a subclass:

public virtual class Animal {
    protected String name;
    protected String species;

    public Animal(String name, String species) {
        this.name = name;
        this.species = species;
    }

    public virtual String speak() {
        return this.name + ' makes a sound.';
    }

    public String getName() {
        return this.name;
    }
}

public class Dog extends Animal {
    private String breed;

    public Dog(String name, String breed) {
        super(name, 'Dog'); // Call the parent constructor
        this.breed = breed;
    }

    public override String speak() {
        return this.name + ' barks!';
    }

    public String getBreed() {
        return this.breed;
    }
}

The super Keyword

super refers to the parent class. You use it to:

  1. Call the parent constructor: super(arg1, arg2) — must be the first statement in the child constructor.
  2. Call a parent method: super.methodName() — useful when you want to extend, not replace, the parent’s behavior.
public class ServiceDog extends Dog {
    private String task;

    public ServiceDog(String name, String breed, String task) {
        super(name, breed);
        this.task = task;
    }

    public override String speak() {
        // Extend the parent behavior rather than replacing it
        String base = super.speak();
        return base + ' (Service dog trained for: ' + this.task + ')';
    }
}

virtual vs abstract

Both virtual and abstract allow a method to be overridden, but they differ in a critical way:

Featurevirtualabstract
Has a method bodyYesNo
Can be instantiatedYes (if the class is virtual, not abstract)No
Must be overriddenNo — overriding is optionalYes — subclass must override
Use caseProvide a default behavior that can optionally be changedForce subclasses to provide their own implementation

Practical Example: Trigger Handler Framework

One of the most common uses of inheritance in Salesforce development is the trigger handler pattern:

public abstract class TriggerHandler {
    protected Boolean isExecuting;
    protected Integer size;

    public TriggerHandler() {
        this.isExecuting = Trigger.isExecuting;
        this.size = Trigger.size;
    }

    public void run() {
        if (Trigger.isBefore) {
            if (Trigger.isInsert) {
                beforeInsert(Trigger.new);
            } else if (Trigger.isUpdate) {
                beforeUpdate(Trigger.new, Trigger.oldMap);
            } else if (Trigger.isDelete) {
                beforeDelete(Trigger.old, Trigger.oldMap);
            }
        } else if (Trigger.isAfter) {
            if (Trigger.isInsert) {
                afterInsert(Trigger.new, Trigger.newMap);
            } else if (Trigger.isUpdate) {
                afterUpdate(Trigger.new, Trigger.oldMap, Trigger.newMap);
            } else if (Trigger.isDelete) {
                afterDelete(Trigger.old, Trigger.oldMap);
            } else if (Trigger.isUndelete) {
                afterUndelete(Trigger.new, Trigger.newMap);
            }
        }
    }

    // Virtual methods with empty default implementations
    // Subclasses only override what they need
    protected virtual void beforeInsert(List<SObject> newRecords) {}
    protected virtual void beforeUpdate(List<SObject> newRecords, Map<Id, SObject> oldMap) {}
    protected virtual void beforeDelete(List<SObject> oldRecords, Map<Id, SObject> oldMap) {}
    protected virtual void afterInsert(List<SObject> newRecords, Map<Id, SObject> newMap) {}
    protected virtual void afterUpdate(List<SObject> newRecords, Map<Id, SObject> oldMap, Map<Id, SObject> newMap) {}
    protected virtual void afterDelete(List<SObject> oldRecords, Map<Id, SObject> oldMap) {}
    protected virtual void afterUndelete(List<SObject> newRecords, Map<Id, SObject> newMap) {}
}

Concrete handler for the Account object:

public class AccountTriggerHandler extends TriggerHandler {

    protected override void beforeInsert(List<SObject> newRecords) {
        List<Account> accounts = (List<Account>) newRecords;
        for (Account acct : accounts) {
            if (String.isBlank(acct.Industry)) {
                acct.Industry = 'Other';
            }
        }
    }

    protected override void afterUpdate(
        List<SObject> newRecords,
        Map<Id, SObject> oldMap,
        Map<Id, SObject> newMap
    ) {
        List<Account> accounts = (List<Account>) newRecords;
        List<Task> followUpTasks = new List<Task>();

        for (Account acct : accounts) {
            Account oldAcct = (Account) oldMap.get(acct.Id);
            if (acct.Rating != oldAcct.Rating && acct.Rating == 'Hot') {
                followUpTasks.add(new Task(
                    WhatId = acct.Id,
                    Subject = 'Follow up with hot account',
                    ActivityDate = Date.today().addDays(1),
                    OwnerId = acct.OwnerId
                ));
            }
        }

        if (!followUpTasks.isEmpty()) {
            insert followUpTasks;
        }
    }
}

And the trigger itself is just one line:

trigger AccountTrigger on Account (before insert, before update, after insert, after update, after delete) {
    new AccountTriggerHandler().run();
}

When NOT to Use Inheritance

Inheritance is powerful but overusing it creates problems:

  • Deep hierarchies (more than 2-3 levels) become difficult to understand and maintain.
  • Fragile base class problem — changing the parent class can break all child classes.
  • Tight coupling — subclasses are tightly coupled to their parent. If the parent changes its internal implementation, subclasses that rely on super calls may break.
  • Inappropriate “is-a” relationships — if the relationship between two classes is not truly “is-a,” use composition instead. A User is not a DatabaseConnection even if it needs one.

A good rule of thumb: prefer composition over inheritance unless the relationship is genuinely hierarchical.


What is Polymorphism?

Polymorphism means “many forms.” It allows you to write code that works with objects of different types through a common interface. The same method call produces different behavior depending on the actual type of the object at runtime.

Compile-Time vs Runtime Polymorphism

Compile-time polymorphism (also called static polymorphism) is achieved through method overloading — multiple methods with the same name but different parameter lists:

public class MathUtils {
    public static Integer add(Integer a, Integer b) {
        return a + b;
    }

    public static Decimal add(Decimal a, Decimal b) {
        return a + b;
    }

    public static Integer add(Integer a, Integer b, Integer c) {
        return a + b + c;
    }
}

// The compiler determines which method to call based on the arguments
Integer result1 = MathUtils.add(1, 2);         // Calls add(Integer, Integer)
Decimal result2 = MathUtils.add(1.5, 2.3);     // Calls add(Decimal, Decimal)
Integer result3 = MathUtils.add(1, 2, 3);      // Calls add(Integer, Integer, Integer)

Runtime polymorphism (also called dynamic polymorphism) is achieved through method overriding and interfaces. The actual method that runs is determined at runtime based on the object’s type:

public interface IShape {
    Decimal calculateArea();
    String describe();
}

public class Circle implements IShape {
    private Decimal radius;

    public Circle(Decimal radius) {
        this.radius = radius;
    }

    public Decimal calculateArea() {
        return Math.PI * this.radius * this.radius;
    }

    public String describe() {
        return 'Circle with radius ' + this.radius;
    }
}

public class Rectangle implements IShape {
    private Decimal width;
    private Decimal height;

    public Rectangle(Decimal width, Decimal height) {
        this.width = width;
        this.height = height;
    }

    public Decimal calculateArea() {
        return this.width * this.height;
    }

    public String describe() {
        return 'Rectangle ' + this.width + 'x' + this.height;
    }
}

Now you can write code that operates on any shape without knowing the specific type:

List<IShape> shapes = new List<IShape>{
    new Circle(5),
    new Rectangle(4, 6),
    new Circle(3)
};

for (IShape shape : shapes) {
    System.debug(shape.describe() + ' — Area: ' + shape.calculateArea());
}

Interface-Based Polymorphism

Interfaces are the most common way to achieve polymorphism in Apex. A class can implement multiple interfaces, which gives you more flexibility than inheritance:

public interface ILoggable {
    String toLogString();
}

public interface IValidatable {
    List<String> validate();
}

public class OpportunityWrapper implements ILoggable, IValidatable {
    private Opportunity opp;

    public OpportunityWrapper(Opportunity opp) {
        this.opp = opp;
    }

    public String toLogString() {
        return 'Opportunity: ' + opp.Name + ' | Stage: ' + opp.StageName
            + ' | Amount: ' + opp.Amount;
    }

    public List<String> validate() {
        List<String> errors = new List<String>();
        if (opp.Amount == null || opp.Amount <= 0) {
            errors.add('Amount must be greater than zero.');
        }
        if (opp.CloseDate < Date.today()) {
            errors.add('Close date cannot be in the past.');
        }
        return errors;
    }
}

Practical Example: Pricing Strategies

This example demonstrates how polymorphism enables different behavior based on customer type without using if-else chains:

public interface IPricingStrategy {
    Decimal calculatePrice(Decimal basePrice, Integer quantity);
    String getStrategyName();
}

public class StandardPricing implements IPricingStrategy {
    public Decimal calculatePrice(Decimal basePrice, Integer quantity) {
        return basePrice * quantity;
    }

    public String getStrategyName() {
        return 'Standard';
    }
}

public class PremiumPricing implements IPricingStrategy {
    private static final Decimal DISCOUNT_RATE = 0.15;

    public Decimal calculatePrice(Decimal basePrice, Integer quantity) {
        Decimal subtotal = basePrice * quantity;
        return subtotal * (1 - DISCOUNT_RATE);
    }

    public String getStrategyName() {
        return 'Premium (15% discount)';
    }
}

public class WholesalePricing implements IPricingStrategy {
    private static final Decimal SMALL_ORDER_DISCOUNT = 0.10;
    private static final Decimal LARGE_ORDER_DISCOUNT = 0.25;
    private static final Integer LARGE_ORDER_THRESHOLD = 100;

    public Decimal calculatePrice(Decimal basePrice, Integer quantity) {
        Decimal discount = quantity >= LARGE_ORDER_THRESHOLD
            ? LARGE_ORDER_DISCOUNT
            : SMALL_ORDER_DISCOUNT;
        return (basePrice * quantity) * (1 - discount);
    }

    public String getStrategyName() {
        return 'Wholesale (tiered discount)';
    }
}

Usage:

IPricingStrategy strategy = PricingStrategyFactory.getStrategy(account.Type);
Decimal finalPrice = strategy.calculatePrice(product.UnitPrice, orderQuantity);

No matter how many pricing tiers you add in the future, the calling code never changes. That is the power of polymorphism.


What is Composition?

Composition is a design principle where a class contains instances of other classes rather than inheriting from them. Instead of an “is-a” relationship (inheritance), you model a “has-a” relationship.

Composition Over Inheritance

Consider a scenario where you need to build a reporting service that can format output in different ways and send it through different channels. With inheritance, you might be tempted to create a deep hierarchy:

ReportService
├── EmailReportService
│   ├── EmailCsvReportService
│   └── EmailPdfReportService
├── SlackReportService
│   ├── SlackCsvReportService
│   └── SlackPdfReportService

This is a combinatorial explosion. Every new format or channel doubles the number of classes. With composition, you solve this cleanly:

public interface IReportFormatter {
    String format(List<Map<String, Object>> data);
    String getFileExtension();
}

public interface IReportDelivery {
    void deliver(String content, String fileName, List<String> recipients);
}

public class CsvFormatter implements IReportFormatter {
    public String format(List<Map<String, Object>> data) {
        if (data == null || data.isEmpty()) {
            return '';
        }
        List<String> headers = new List<String>(data[0].keySet());
        List<String> lines = new List<String>{ String.join(headers, ',') };

        for (Map<String, Object> row : data) {
            List<String> values = new List<String>();
            for (String header : headers) {
                values.add(String.valueOf(row.get(header)));
            }
            lines.add(String.join(values, ','));
        }
        return String.join(lines, '\n');
    }

    public String getFileExtension() {
        return 'csv';
    }
}

public class JsonFormatter implements IReportFormatter {
    public String format(List<Map<String, Object>> data) {
        return JSON.serializePretty(data);
    }

    public String getFileExtension() {
        return 'json';
    }
}

public class EmailDelivery implements IReportDelivery {
    public void deliver(String content, String fileName, List<String> recipients) {
        Messaging.SingleEmailMessage email = new Messaging.SingleEmailMessage();
        email.setToAddresses(recipients);
        email.setSubject('Report: ' + fileName);

        Messaging.EmailFileAttachment attachment = new Messaging.EmailFileAttachment();
        attachment.setFileName(fileName);
        attachment.setBody(Blob.valueOf(content));
        email.setFileAttachments(new List<Messaging.EmailFileAttachment>{ attachment });

        Messaging.sendEmail(new List<Messaging.SingleEmailMessage>{ email });
    }
}

public class ChatterDelivery implements IReportDelivery {
    public void deliver(String content, String fileName, List<String> recipients) {
        for (String userId : recipients) {
            FeedItem post = new FeedItem(
                ParentId = userId,
                Body = 'Report generated: ' + fileName + '\n\n' + content.left(5000)
            );
            insert post;
        }
    }
}

The ReportService class composes a formatter and a delivery mechanism:

public class ReportService {
    private IReportFormatter formatter;
    private IReportDelivery delivery;

    public ReportService(IReportFormatter formatter, IReportDelivery delivery) {
        this.formatter = formatter;
        this.delivery = delivery;
    }

    public void generateAndDeliver(
        List<Map<String, Object>> data,
        String reportName,
        List<String> recipients
    ) {
        String content = this.formatter.format(data);
        String fileName = reportName + '.' + this.formatter.getFileExtension();
        this.delivery.deliver(content, fileName, recipients);
    }
}

Usage — mix and match any combination:

// CSV report via email
ReportService csvEmail = new ReportService(new CsvFormatter(), new EmailDelivery());
csvEmail.generateAndDeliver(data, 'monthly-sales', recipientEmails);

// JSON report via Chatter
ReportService jsonChatter = new ReportService(new JsonFormatter(), new ChatterDelivery());
jsonChatter.generateAndDeliver(data, 'monthly-sales', recipientUserIds);

Adding a new format (PDF) or a new channel (Slack) means adding one class — not a whole tree of subclasses.

Dependency Injection Basics in Apex

Composition works best when dependencies are injected rather than hardcoded. Dependency injection means passing an object’s dependencies to it from the outside, typically through the constructor:

// WITHOUT dependency injection — tightly coupled
public class AccountService {
    private EmailService emailService;

    public AccountService() {
        this.emailService = new EmailService(); // Hardcoded dependency
    }
}

// WITH dependency injection — loosely coupled
public class AccountService {
    private INotificationService notificationService;

    public AccountService(INotificationService notificationService) {
        this.notificationService = notificationService;
    }
}

// In production code:
AccountService svc = new AccountService(new EmailNotificationService());

// In test code:
AccountService svc = new AccountService(new MockNotificationService());

Dependency injection makes your code testable. In tests, you inject mock implementations that do not make callouts or DML operations. In production, you inject the real implementations. The AccountService class does not need to change either way.


PROJECT: Polymorphic Discount Calculator

Let us build a complete project that demonstrates polymorphism in action. The scenario: an e-commerce company on Salesforce needs a discount calculator that applies different pricing strategies based on customer tier. The tier can change at runtime, and new tiers can be added without modifying existing code.

Step 1: Define the Interface

/**
 * Interface for discount calculation strategies.
 * Each customer tier implements this interface with its own discount logic.
 */
public interface IDiscountCalculator {
    /**
     * Calculate the discounted price for a line item.
     * @param unitPrice The base unit price of the product.
     * @param quantity The number of units being purchased.
     * @return The total price after discount.
     */
    Decimal calculateTotal(Decimal unitPrice, Integer quantity);

    /**
     * Return a human-readable description of the discount applied.
     * @return Description string.
     */
    String getDescription();

    /**
     * Return the discount percentage as a value between 0 and 1.
     * @param unitPrice The base unit price.
     * @param quantity The quantity ordered.
     * @return The discount rate.
     */
    Decimal getDiscountRate(Decimal unitPrice, Integer quantity);
}

Step 2: Implement the Strategies

/**
 * Standard tier: no discount applied.
 */
public class StandardDiscountCalculator implements IDiscountCalculator {

    public Decimal calculateTotal(Decimal unitPrice, Integer quantity) {
        return unitPrice * quantity;
    }

    public String getDescription() {
        return 'Standard pricing — no discount applied.';
    }

    public Decimal getDiscountRate(Decimal unitPrice, Integer quantity) {
        return 0;
    }
}

/**
 * Silver tier: flat 10% discount on all orders.
 */
public class SilverDiscountCalculator implements IDiscountCalculator {
    private static final Decimal DISCOUNT = 0.10;

    public Decimal calculateTotal(Decimal unitPrice, Integer quantity) {
        return (unitPrice * quantity) * (1 - DISCOUNT);
    }

    public String getDescription() {
        return 'Silver tier — 10% discount on all orders.';
    }

    public Decimal getDiscountRate(Decimal unitPrice, Integer quantity) {
        return DISCOUNT;
    }
}

/**
 * Gold tier: 15% discount, plus an extra 5% if order exceeds 50 units.
 */
public class GoldDiscountCalculator implements IDiscountCalculator {
    private static final Decimal BASE_DISCOUNT = 0.15;
    private static final Decimal VOLUME_BONUS = 0.05;
    private static final Integer VOLUME_THRESHOLD = 50;

    public Decimal calculateTotal(Decimal unitPrice, Integer quantity) {
        Decimal rate = getDiscountRate(unitPrice, quantity);
        return (unitPrice * quantity) * (1 - rate);
    }

    public String getDescription() {
        return 'Gold tier — 15% base discount, +5% for orders over 50 units.';
    }

    public Decimal getDiscountRate(Decimal unitPrice, Integer quantity) {
        Decimal rate = BASE_DISCOUNT;
        if (quantity > VOLUME_THRESHOLD) {
            rate += VOLUME_BONUS;
        }
        return rate;
    }
}

/**
 * Platinum tier: 20% discount, free shipping threshold at 25 units,
 * and additional 3% loyalty bonus for orders over $10,000.
 */
public class PlatinumDiscountCalculator implements IDiscountCalculator {
    private static final Decimal BASE_DISCOUNT = 0.20;
    private static final Decimal LOYALTY_BONUS = 0.03;
    private static final Decimal LOYALTY_THRESHOLD = 10000;

    public Decimal calculateTotal(Decimal unitPrice, Integer quantity) {
        Decimal rate = getDiscountRate(unitPrice, quantity);
        return (unitPrice * quantity) * (1 - rate);
    }

    public String getDescription() {
        return 'Platinum tier — 20% base discount, +3% loyalty bonus for orders over $10,000.';
    }

    public Decimal getDiscountRate(Decimal unitPrice, Integer quantity) {
        Decimal subtotal = unitPrice * quantity;
        Decimal rate = BASE_DISCOUNT;
        if (subtotal > LOYALTY_THRESHOLD) {
            rate += LOYALTY_BONUS;
        }
        return rate;
    }
}

Step 3: Build the Factory

The factory encapsulates the logic for mapping a customer tier string to the correct strategy instance. This is the only place in the codebase that knows about the mapping.

/**
 * Factory class that returns the appropriate IDiscountCalculator
 * based on the customer's tier.
 */
public class DiscountCalculatorFactory {

    private static final Map<String, Type> STRATEGY_MAP = new Map<String, Type>{
        'Standard'  => StandardDiscountCalculator.class,
        'Silver'    => SilverDiscountCalculator.class,
        'Gold'      => GoldDiscountCalculator.class,
        'Platinum'  => PlatinumDiscountCalculator.class
    };

    /**
     * Get a discount calculator for the given customer tier.
     * @param tier The customer tier (Standard, Silver, Gold, Platinum).
     * @return An instance of IDiscountCalculator for that tier.
     * @throws IllegalArgumentException if the tier is not recognized.
     */
    public static IDiscountCalculator getCalculator(String tier) {
        if (String.isBlank(tier)) {
            return new StandardDiscountCalculator();
        }

        Type calculatorType = STRATEGY_MAP.get(tier);
        if (calculatorType == null) {
            throw new IllegalArgumentException(
                'Unknown customer tier: ' + tier
                + '. Valid tiers: ' + STRATEGY_MAP.keySet()
            );
        }

        return (IDiscountCalculator) calculatorType.newInstance();
    }

    /**
     * Get all available tier names.
     * @return Set of valid tier strings.
     */
    public static Set<String> getAvailableTiers() {
        return STRATEGY_MAP.keySet();
    }
}

Step 4: Create the Order Pricing Service

This service ties everything together. It takes an Account (which has a tier) and a list of line items, then calculates the total using the appropriate strategy.

/**
 * Service class that uses the discount calculator to price an order.
 */
public class OrderPricingService {

    /**
     * Wrapper to hold pricing results for a single line item.
     */
    public class LineItemResult {
        public String productName { get; private set; }
        public Decimal unitPrice { get; private set; }
        public Integer quantity { get; private set; }
        public Decimal subtotal { get; private set; }
        public Decimal discountRate { get; private set; }
        public Decimal discountedTotal { get; private set; }

        public LineItemResult(
            String productName,
            Decimal unitPrice,
            Integer quantity,
            Decimal subtotal,
            Decimal discountRate,
            Decimal discountedTotal
        ) {
            this.productName = productName;
            this.unitPrice = unitPrice;
            this.quantity = quantity;
            this.subtotal = subtotal;
            this.discountRate = discountRate;
            this.discountedTotal = discountedTotal;
        }
    }

    /**
     * Wrapper to hold the full order pricing result.
     */
    public class OrderResult {
        public String customerTier { get; private set; }
        public String discountDescription { get; private set; }
        public List<LineItemResult> lineItems { get; private set; }
        public Decimal orderTotal { get; private set; }
        public Decimal totalSavings { get; private set; }

        public OrderResult(String tier, String description) {
            this.customerTier = tier;
            this.discountDescription = description;
            this.lineItems = new List<LineItemResult>();
            this.orderTotal = 0;
            this.totalSavings = 0;
        }

        public void addLineItem(LineItemResult item) {
            this.lineItems.add(item);
            this.orderTotal += item.discountedTotal;
            this.totalSavings += (item.subtotal - item.discountedTotal);
        }
    }

    /**
     * Calculate pricing for an entire order.
     * @param customerTier The tier of the customer placing the order.
     * @param lineItems List of maps with keys: productName, unitPrice, quantity.
     * @return An OrderResult with complete pricing details.
     */
    public OrderResult calculateOrder(
        String customerTier,
        List<Map<String, Object>> lineItems
    ) {
        IDiscountCalculator calculator = DiscountCalculatorFactory.getCalculator(customerTier);
        OrderResult result = new OrderResult(customerTier, calculator.getDescription());

        for (Map<String, Object> item : lineItems) {
            String productName = (String) item.get('productName');
            Decimal unitPrice = (Decimal) item.get('unitPrice');
            Integer quantity = (Integer) item.get('quantity');

            Decimal subtotal = unitPrice * quantity;
            Decimal discountRate = calculator.getDiscountRate(unitPrice, quantity);
            Decimal discountedTotal = calculator.calculateTotal(unitPrice, quantity);

            result.addLineItem(new LineItemResult(
                productName, unitPrice, quantity,
                subtotal, discountRate, discountedTotal
            ));
        }

        return result;
    }
}

Step 5: Write Comprehensive Tests

@isTest
private class DiscountCalculatorTest {

    // -------------------------------------------------------
    // Standard tier tests
    // -------------------------------------------------------

    @isTest
    static void standardTier_noDiscount() {
        IDiscountCalculator calc = DiscountCalculatorFactory.getCalculator('Standard');
        Decimal total = calc.calculateTotal(100, 5);
        System.assertEquals(500, total, 'Standard tier should apply no discount.');
        System.assertEquals(0, calc.getDiscountRate(100, 5), 'Standard discount rate should be 0.');
    }

    // -------------------------------------------------------
    // Silver tier tests
    // -------------------------------------------------------

    @isTest
    static void silverTier_tenPercentDiscount() {
        IDiscountCalculator calc = DiscountCalculatorFactory.getCalculator('Silver');
        Decimal total = calc.calculateTotal(100, 10);
        System.assertEquals(900, total, 'Silver tier should apply 10% discount. 100*10*0.9 = 900');
    }

    @isTest
    static void silverTier_singleUnit() {
        IDiscountCalculator calc = DiscountCalculatorFactory.getCalculator('Silver');
        Decimal total = calc.calculateTotal(49.99, 1);
        Decimal expected = 49.99 * 0.90;
        System.assertEquals(expected, total, 'Silver should discount a single unit at 10%.');
    }

    // -------------------------------------------------------
    // Gold tier tests
    // -------------------------------------------------------

    @isTest
    static void goldTier_baseDiscount() {
        IDiscountCalculator calc = DiscountCalculatorFactory.getCalculator('Gold');
        Decimal total = calc.calculateTotal(100, 10);
        // 10 units < 50 threshold, so 15% discount
        Decimal expected = 1000 * 0.85;
        System.assertEquals(expected, total, 'Gold tier under 50 units should get 15% discount.');
    }

    @isTest
    static void goldTier_volumeBonus() {
        IDiscountCalculator calc = DiscountCalculatorFactory.getCalculator('Gold');
        Decimal total = calc.calculateTotal(100, 51);
        // 51 units > 50 threshold, so 15% + 5% = 20% discount
        Decimal expected = 5100 * 0.80;
        System.assertEquals(expected, total, 'Gold tier over 50 units should get 20% discount.');
    }

    @isTest
    static void goldTier_exactThreshold() {
        IDiscountCalculator calc = DiscountCalculatorFactory.getCalculator('Gold');
        // Exactly 50 units — does NOT exceed threshold
        Decimal total = calc.calculateTotal(100, 50);
        Decimal expected = 5000 * 0.85;
        System.assertEquals(expected, total, 'Gold at exactly 50 units should get base 15% only.');
    }

    // -------------------------------------------------------
    // Platinum tier tests
    // -------------------------------------------------------

    @isTest
    static void platinumTier_baseDiscount() {
        IDiscountCalculator calc = DiscountCalculatorFactory.getCalculator('Platinum');
        Decimal total = calc.calculateTotal(50, 10);
        // Subtotal = 500, under $10K threshold. 20% discount.
        Decimal expected = 500 * 0.80;
        System.assertEquals(expected, total, 'Platinum under $10K should get 20% discount.');
    }

    @isTest
    static void platinumTier_loyaltyBonus() {
        IDiscountCalculator calc = DiscountCalculatorFactory.getCalculator('Platinum');
        Decimal total = calc.calculateTotal(200, 100);
        // Subtotal = 20000, over $10K. 20% + 3% = 23% discount.
        Decimal expected = 20000 * 0.77;
        System.assertEquals(expected, total, 'Platinum over $10K should get 23% discount.');
    }

    // -------------------------------------------------------
    // Factory tests
    // -------------------------------------------------------

    @isTest
    static void factory_nullTierReturnsStandard() {
        IDiscountCalculator calc = DiscountCalculatorFactory.getCalculator(null);
        System.assert(calc instanceof StandardDiscountCalculator,
            'Null tier should return StandardDiscountCalculator.');
    }

    @isTest
    static void factory_blankTierReturnsStandard() {
        IDiscountCalculator calc = DiscountCalculatorFactory.getCalculator('');
        System.assert(calc instanceof StandardDiscountCalculator,
            'Blank tier should return StandardDiscountCalculator.');
    }

    @isTest
    static void factory_invalidTierThrowsException() {
        try {
            DiscountCalculatorFactory.getCalculator('Diamond');
            System.assert(false, 'Should have thrown IllegalArgumentException.');
        } catch (IllegalArgumentException e) {
            System.assert(e.getMessage().contains('Diamond'),
                'Exception message should include the invalid tier name.');
        }
    }

    @isTest
    static void factory_availableTiers() {
        Set<String> tiers = DiscountCalculatorFactory.getAvailableTiers();
        System.assertEquals(4, tiers.size(), 'Should have 4 tiers.');
        System.assert(tiers.contains('Standard'), 'Should contain Standard.');
        System.assert(tiers.contains('Platinum'), 'Should contain Platinum.');
    }

    // -------------------------------------------------------
    // OrderPricingService integration tests
    // -------------------------------------------------------

    @isTest
    static void orderPricingService_goldCustomerMultipleItems() {
        OrderPricingService service = new OrderPricingService();

        List<Map<String, Object>> items = new List<Map<String, Object>>{
            new Map<String, Object>{
                'productName' => 'Widget A',
                'unitPrice' => (Decimal) 25.00,
                'quantity' => (Integer) 10
            },
            new Map<String, Object>{
                'productName' => 'Widget B',
                'unitPrice' => (Decimal) 75.00,
                'quantity' => (Integer) 5
            }
        };

        OrderPricingService.OrderResult result = service.calculateOrder('Gold', items);

        System.assertEquals('Gold', result.customerTier);
        System.assertEquals(2, result.lineItems.size());

        // Widget A: 25 * 10 = 250. Gold base 15% discount (10 < 50). 250 * 0.85 = 212.50
        System.assertEquals(212.50, result.lineItems[0].discountedTotal);
        // Widget B: 75 * 5 = 375. Gold base 15% discount (5 < 50). 375 * 0.85 = 318.75
        System.assertEquals(318.75, result.lineItems[1].discountedTotal);

        System.assertEquals(531.25, result.orderTotal);
        System.assert(result.totalSavings > 0, 'Total savings should be positive.');
    }

    @isTest
    static void orderPricingService_runtimeStrategySwap() {
        // This test demonstrates that the same service can use different
        // strategies at runtime based on the customer tier string.
        OrderPricingService service = new OrderPricingService();

        List<Map<String, Object>> items = new List<Map<String, Object>>{
            new Map<String, Object>{
                'productName' => 'Gadget',
                'unitPrice' => (Decimal) 100.00,
                'quantity' => (Integer) 10
            }
        };

        // Standard customer
        OrderPricingService.OrderResult standardResult =
            service.calculateOrder('Standard', items);
        System.assertEquals(1000, standardResult.orderTotal);

        // Silver customer — same items, different price
        OrderPricingService.OrderResult silverResult =
            service.calculateOrder('Silver', items);
        System.assertEquals(900, silverResult.orderTotal);

        // Gold customer — same items, different price again
        OrderPricingService.OrderResult goldResult =
            service.calculateOrder('Gold', items);
        System.assertEquals(850, goldResult.orderTotal);

        // Platinum customer
        OrderPricingService.OrderResult platinumResult =
            service.calculateOrder('Platinum', items);
        System.assertEquals(800, platinumResult.orderTotal);

        // Verify ordering: Standard > Silver > Gold > Platinum
        System.assert(standardResult.orderTotal > silverResult.orderTotal);
        System.assert(silverResult.orderTotal > goldResult.orderTotal);
        System.assert(goldResult.orderTotal > platinumResult.orderTotal);
    }

    // -------------------------------------------------------
    // Description tests
    // -------------------------------------------------------

    @isTest
    static void allStrategiesHaveDescriptions() {
        for (String tier : DiscountCalculatorFactory.getAvailableTiers()) {
            IDiscountCalculator calc = DiscountCalculatorFactory.getCalculator(tier);
            System.assert(
                String.isNotBlank(calc.getDescription()),
                tier + ' calculator should have a non-blank description.'
            );
        }
    }
}

How It All Works Together

Here is the flow at runtime:

  1. A customer places an order. Their Account record has a Customer_Tier__c field set to “Gold.”
  2. The OrderPricingService calls DiscountCalculatorFactory.getCalculator('Gold').
  3. The factory uses the Type class to instantiate GoldDiscountCalculator and returns it as an IDiscountCalculator.
  4. The service calls calculateTotal() on the interface reference. At runtime, the JVM dispatches to GoldDiscountCalculator.calculateTotal().
  5. If the customer gets upgraded to Platinum next month, the same code path runs, but now the factory returns PlatinumDiscountCalculator and the discount logic changes automatically.

No if-else chains. No switch statements. No modification to existing classes. Just a new class that implements the interface, a new entry in the factory map, and the system works.

Extending the System

To add a new “Enterprise” tier in the future, you only need to:

  1. Create EnterpriseDiscountCalculator implements IDiscountCalculator.
  2. Add 'Enterprise' => EnterpriseDiscountCalculator.class to the factory map.

No existing class is modified. No existing tests break. This is the Open/Closed Principle working hand-in-hand with polymorphism.


Key Takeaways

  • Abstraction lets you define what a class does without specifying how. Use abstract classes for shared behavior and interfaces for pure contracts.
  • Encapsulation protects internal state and enforces business rules. Make fields private, expose controlled access through methods and properties.
  • Inheritance enables code reuse through hierarchical relationships. Use it when the relationship is genuinely “is-a,” but keep hierarchies shallow.
  • Polymorphism allows the same code to work with objects of different types. It eliminates if-else chains and makes your code extensible without modification.
  • Composition models “has-a” relationships and avoids the rigidity of deep inheritance trees. Prefer composition over inheritance as your default approach.
  • The Factory pattern pairs naturally with polymorphism — it isolates the object creation logic so the rest of your code works only with interfaces.
  • Dependency injection makes composition testable by allowing you to swap real implementations with mocks.

These are not just theoretical concepts. Every well-architected Salesforce org uses these patterns. Trigger handler frameworks use inheritance. Service layers use composition. Strategy patterns use polymorphism. The Salesforce platform itself is built on these principles — Database.Batchable, Queueable, Schedulable, and Messaging.InboundEmailHandler are all interfaces that you implement polymorphically.


What is Next?

In Part 62, we will cover Separation of Concerns — how to organize your Apex codebase into distinct layers (service, domain, selector, and unit of work) so that each class has a single responsibility. We will look at how separation of concerns builds on the OOP principles covered in this post and apply it to a real Salesforce project structure.

See you in the next one.