Apex Classes and Objects: A Comprehensive Guide
Introduction to OOP in Apex
If you’ve spent any time building on the Salesforce platform, you already know that Apex is the backbone of custom logic. But here’s something developers often underestimate early on: how you structure your Apex classes directly determines how maintainable, scalable, and testable your org becomes over time.
Apex is a strongly typed, object-oriented programming language developed by Salesforce. It runs on the Salesforce platform and follows Java-like OOP principles — think classes, objects, inheritance, polymorphism, and encapsulation. Whether you’re building a trigger handler, a REST service, or a complex business logic engine, understanding Apex classes thoroughly separates junior developers from architects.
In this guide, we’ll walk through everything: how to define classes, which access modifiers to use, when to choose static over instance methods, how inheritance works, and the real-world patterns that experienced Salesforce architects rely on every day.
Table of Contents
- Class Structure and Syntax
- Access Modifiers
- Static vs Instance Members
- Methods and Properties
- Inheritance and Polymorphism
- Interfaces and Abstract Classes
- Real-World Examples
- Best Practices and Common Pitfalls
- Conclusion
- FAQ
Class Structure and Syntax
Defining Classes
An Apex class is a blueprint. It defines the state (variables/properties) and behavior (methods) that objects created from it will have. Here’s the basic syntax:
Apex Class
public class OrderProcessor {
// Properties
private String orderId;
private Decimal totalAmount;
// Methods
public void processOrder() {
// logic here
}
}
Every Apex class file must have a matching .cls file in your org. The class name must match the filename exactly — Salesforce enforces this strictly.
A few structural rules worth keeping in mind:
- A class can contain inner classes (nested classes), which are useful for grouping related logic.
- Top-level classes must have an access modifier (
public,global, or omit forprivate-equivalent in certain contexts). - Apex does not support multiple inheritance of classes, but it does support multiple interface implementation.

Constructors and Destructors
A constructor is a special method that runs when you instantiate a class. It shares the class name and has no return type:
Apex Class
public class OrderProcessor {
private String orderId;
// Default constructor
public OrderProcessor() {
this.orderId = 'DEFAULT';
}
// Parameterized constructor
public OrderProcessor(String orderId) {
this.orderId = orderId;
}
}
You can overload constructors — meaning you can have multiple constructors with different parameter signatures. Salesforce Apex calls the matching one based on what arguments you pass.
Destructors don’t exist in Apex. Unlike C++, Apex manages memory automatically through the platform’s garbage collector. You don’t need to explicitly release objects. This simplifies development but also means you need to be conscious of heap size limits (currently 6 MB for synchronous transactions).
Access Modifiers
Private, Public, Global, Protected
Access modifiers in Apex control the visibility of your class and its members. Getting these right is critical — especially as your org grows or when you’re building managed packages.
| Modifier | Visibility |
|---|---|
private | Accessible only within the defining class |
public | Accessible within the same namespace (same org or package) |
protected | Accessible within the class and any subclasses (extends support) |
global | Accessible from any Apex code, including outside the namespace |
Here’s how each looks in practice:
Apex Class
public class AccountService {
private String cacheKey; // only this class
protected Integer retryCount; // this class + subclasses
public void fetchAccount() {} // accessible in the same namespace
global void exposeToPackage() {} // accessible everywhere
}
When to Use Each Modifier
Use private for anything that’s an implementation detail — helper variables, internal utility methods, anything the outside world doesn’t need to know about. This is your default choice for most class members.
Use public for methods and properties that other classes in your org need to call. Most service classes and utility methods fall here.
Use protected when you’re building a class hierarchy and want subclasses to access certain members without exposing them to the entire org.
Use global sparingly. It’s required when building managed packages that expose functionality to subscriber orgs — but if you’re not building a managed package, global is almost always unnecessary and creates maintenance overhead you don’t want.
A common mistake in 2026: developers default to
globalout of habit. Reserve it strictly for managed package APIs and @InvocableMethod or @AuraEnabled annotations that require it.
Static vs Instance Members
Static Methods and Variables
Static members belong to the class itself, not to any specific instance. They’re initialized once and shared across all uses within a transaction:
Apex Class
public class TaxCalculator {
public static final Decimal TAX_RATE = 0.18;
public static Decimal calculate(Decimal amount) {
return amount * TAX_RATE;
}
}
// Call without instantiation:
Decimal tax = TaxCalculator.calculate(1000);
Static variables persist for the duration of a single Apex transaction. This makes them useful for caching query results within a trigger or batch job — a pattern called transaction-level memoization.
Instance Methods and Properties
Instance members require you to create an object first:
Apex Class
public class InvoiceGenerator {
private String clientName;
public InvoiceGenerator(String clientName) {
this.clientName = clientName;
}
public String generateInvoice() {
return 'Invoice for: ' + this.clientName;
}
}
// Must instantiate first:
InvoiceGenerator gen = new InvoiceGenerator('Acme Corp');
String invoice = gen.generateInvoice();
The key difference between static and instance methods in Apex: static methods can’t access instance variables or call instance methods, because they don’t operate on a specific object. Instance methods can access both static and instance members.
| Feature | Static | Instance |
|---|---|---|
| Called on | Class name | Object reference |
| Access instance vars | ❌ No | ✅ Yes |
| Persists across calls | For the transaction duration | As long as object exists |
| Best for | Utilities, caching, constants | Stateful business logic |
Methods and Properties
Methods define behavior. Properties store state. In Apex, you can define properties with automatic getters and setters using a clean shorthand:
Apex Class
public class Contact {
// Automatic property
public String FullName { get; set; }
// Property with custom logic
private String email;
public String Email {
get { return email; }
set {
if (value.contains('@')) {
email = value;
} else {
throw new IllegalArgumentException('Invalid email format.');
}
}
}
// Method with return value
public String getFormattedContact() {
return FullName + ' <' + Email + '>';
}
}
Properties with custom getters and setters are a clean way to add validation logic without cluttering your business methods. I’ve found this pattern especially useful when working with data transformation classes that receive input from external APIs.
Inheritance and Polymorphism
Extending Classes
Apex supports single-class inheritance using the extends keyword. A subclass inherits all accessible members from its parent:
Apex Class
public virtual class Animal {
public String name;
public virtual String makeSound() {
return 'Some generic sound';
}
}
public class Dog extends Animal {
public override String makeSound() {
return 'Woof!';
}
}
The override keyword is required when you replace a parent method. Forgetting it causes a compile error — which is actually helpful, as it prevents accidental overrides.
Virtual and Abstract Classes
This is where things get interesting for architects. Apex offers two flavors of extensible classes:
Virtual classes provide a default implementation that subclasses can override:
Apex Class
public virtual class PaymentProcessor {
public virtual void processPayment(Decimal amount) {
// Default payment logic
}
}
Abstract classes define a contract — they declare methods that subclasses must implement:
Apex Class
public abstract class NotificationService {
// Must be implemented by subclass
public abstract void sendNotification(String message);
// Shared concrete method
public void logNotification(String message) {
System.debug('Notification sent: ' + message);
}
}
Abstract classes in Apex are ideal when you want to enforce a consistent interface across multiple implementations while still sharing common logic — a pattern that significantly reduces code duplication in large orgs.
You can’t instantiate an abstract class directly. It exists purely as a template for subclasses.
Interfaces and Abstract Classes
Interfaces take the abstraction one step further. They define only method signatures — no implementation at all:
Apex Class
public interface Discountable {
Decimal applyDiscount(Decimal originalPrice);
Boolean isEligible(Id customerId);
}
public class LoyaltyDiscount implements Discountable {
public Decimal applyDiscount(Decimal originalPrice) {
return originalPrice * 0.90; // 10% off
}
public Boolean isEligible(Id customerId) {
// Check loyalty tier
return true;
}
}
When to use interfaces vs abstract classes:
- Use an interface when you want multiple unrelated classes to share a behavior contract. A class can implement multiple interfaces.
- Use an abstract class when you have shared implementation logic plus a required contract. Since Apex supports only single inheritance, use this when you need to share code between related classes.
This distinction matters a lot when designing frameworks — for example, building a pluggable trigger handler framework where each handler must implement beforeInsert() and afterInsert(), but each has its own logic.
Real-World Examples
Here’s a pattern I’ve used on enterprise Salesforce implementations: a service layer with abstract base and concrete implementations.
Apex Class
// Abstract base service
public abstract class CRMService {
protected String orgId;
public CRMService() {
this.orgId = UserInfo.getOrganizationId();
}
public abstract List<SObject> fetchRecords(Map<String, Object> filters);
public void logActivity(String action) {
System.debug('[' + orgId + '] Action: ' + action);
}
}
// Concrete implementation
public class LeadService extends CRMService {
public override List<SObject> fetchRecords(Map<String, Object> filters) {
String status = (String) filters.get('status');
return [SELECT Id, Name, Status FROM Lead WHERE Status = :status LIMIT 200];
}
}
// Usage
CRMService service = new LeadService();
List<SObject> leads = service.fetchRecords(new Map<String, Object>{'status' => 'Open'});
This pattern gives you type safety, reusability, and a clean separation between what a service does (defined in the abstract class) and how it does it (defined in the concrete class). It’s particularly powerful when combined with dependency injection for unit testing.
Best Practices and Common Pitfalls
Best practices for Apex classes in 2026:
- Follow the Single Responsibility Principle (SRP). Each class should do one thing well. An
AccountServiceshould handle account logic — not email sending, not PDF generation. - Use the trigger handler pattern. Never put business logic directly in a trigger. Move it to a dedicated handler class. This makes your code testable and reusable.
- Prefer dependency injection over hardcoded instantiation. Instead of
new EmailService()inside your logic class, pass the service in through the constructor. This makes unit testing dramatically easier. - Keep governor limits in mind when choosing static variables. Static variables are useful for caching SOQL results within a transaction, but don’t misuse them as a global state store — it makes code harder to reason about.
- Document your access modifiers intentionally. Every
globalmethod is a public API commitment. If you mark somethingglobaltoday, removing it later breaks any code that depends on it.
Common pitfalls to avoid:
- Circular dependencies between classes. Class A calling Class B calling Class A creates maintenance nightmares. Design your dependency graph to flow in one direction.
- Overusing static context. Static methods that depend on static variables create hidden state that’s hard to test and debug.
- Missing
overridekeyword. If your subclass method silently doesn’t override the parent method because you forgot the keyword, you’ll get subtle bugs that take hours to track down. - Ignoring heap size. Instantiating large object graphs inside loops can push you toward heap limits quickly. Profile your transactions during code review.
Conclusion
Apex classes are the foundation of everything you build on Salesforce. Getting comfortable with class structure, access modifiers, static vs instance members, and inheritance patterns isn’t just academic — it directly affects how maintainable, testable, and scalable your org becomes as it grows.
The three takeaways worth remembering:
- Default to
privatefor class members and only widen access when necessary. - Use abstract classes and interfaces to enforce contracts across your codebase.
- Structure your classes around single responsibilities — it’s the difference between code that’s easy to change and code that’s terrifying to touch.
If you’re building a new Salesforce project in 2026, invest time upfront in your class architecture. A well-designed service layer with proper OOP principles pays dividends every time you need to add a feature or fix a bug. And it makes life a lot easier for the next developer who inherits your work.
FAQ
Q1: How do I create an Apex class in Salesforce?
You can create an Apex class through the Salesforce Developer Console, VS Code with the Salesforce Extension Pack, or directly in Setup under Apex Classes. In VS Code, run SFDX: Create Apex Class from the command palette. The class file and a test file are generated automatically. Always pair your class with a test class targeting at least 75% code coverage.
Q2: What is the difference between static and instance methods in Apex?
Static methods belong to the class and can be called without creating an object — for example, MyClass.myStaticMethod(). Instance methods require an object to be instantiated first — MyClass obj = new MyClass(); obj.myInstanceMethod(). Static methods can’t access instance variables, while instance methods can access both. Use static methods for utility functions and caching; use instance methods for logic that depends on object state.
Q3: When should I use an abstract class vs an interface in Apex?
Use an abstract class when you want to share implementation code across related classes while still enforcing certain methods. Use an interface when you want unrelated classes to implement a common contract. Since Apex supports single inheritance but multiple interface implementation, interfaces are more flexible when a class needs to fulfill multiple behavioral contracts.
Q4: What are the access modifiers in Apex and when should I use them?
Apex has four access modifiers: private (class-only access), protected (class and subclasses), public (same namespace), and global (any Apex code, including outside the namespace). Default to private for most members, use public for service methods, protected for inheritance hierarchies, and global only when building managed packages or exposing methods to external systems via @AuraEnabled or @InvocableMethod.
Q5: Can an Apex class extend multiple classes?
No. Apex supports single-class inheritance only — a class can extend just one parent class. However, a class can implement multiple interfaces simultaneously. This is the recommended approach for achieving multiple-contract patterns in Apex without multiple inheritance.
Q6: What are Apex class best practices for 2026?
The top best practices include: following the Single Responsibility Principle, using the trigger handler pattern instead of inline trigger logic, applying dependency injection for testability, documenting access modifiers intentionally (especially global), and designing class hierarchies with abstract classes or interfaces to enforce contracts. Additionally, always write unit tests alongside your classes and keep governor limit awareness built into your design decisions.
Q7: What is the heap size limit for Apex classes?
The Apex heap size limit is 6 MB for synchronous transactions and 12 MB for asynchronous transactions (batch, future, queueable). Instantiating large object collections or deeply nested class hierarchies inside loops can push these limits. Use static variables carefully to cache query results and avoid repeated SOQL within a single transaction.