Salesforce · · 26 min read

Exceptions in Apex

Understanding error handling in Apex — exception types, try-catch-finally blocks, creating and throwing custom exceptions, and building resilient Salesforce applications.

Part 40: Exceptions in Apex

Welcome back to the Salesforce series. In the previous posts we covered Apex fundamentals — variables, data types, collections, and control flow. All of that assumed our code would run without problems. In the real world, things go wrong constantly. A query returns zero rows. A DML operation violates a validation rule. A callout to an external service times out. A developer accidentally references a null variable. Every one of these situations produces an exception, and if you do not handle exceptions properly, your entire transaction rolls back, your users see a cryptic error message, and your data ends up in an inconsistent state.

This is Part 40 of the series, and it is dedicated entirely to exceptions in Apex. We will cover what exceptions are, the many types Salesforce provides, how to catch them with try-catch blocks, how to build your own custom exceptions, when to throw them, and the best practices that separate production-ready code from fragile prototypes.


What Is an Exception?

An exception is an event that occurs during the execution of a program that disrupts the normal flow of instructions. In Apex, when something goes wrong at runtime — a division by zero, a null reference, a failed DML operation — the system creates an exception object and “throws” it. If your code does not catch that exception, it propagates up the call stack until Salesforce catches it, rolls back the entire transaction, and displays an error to the user.

The Exception Class Hierarchy

Every exception in Apex extends the base Exception class. This base class provides several useful methods:

  • getMessage() — Returns the error message string.
  • getTypeName() — Returns the fully qualified name of the exception type.
  • getCause() — Returns the exception that caused this exception, if any.
  • getLineNumber() — Returns the line number where the exception was thrown.
  • getStackTraceString() — Returns the full stack trace as a string.

All built-in exceptions like DmlException, QueryException, and NullPointerException extend this base class. When you create custom exceptions, you extend it too.

Stack Traces

When an unhandled exception occurs, Salesforce generates a stack trace. A stack trace is a snapshot of the call stack at the moment the exception was thrown. It tells you the class name, method name, and line number for every frame in the call stack, starting from where the exception occurred and working backward to the entry point. Stack traces are invaluable for debugging because they show you exactly where things went wrong and what path the code took to get there.

How Exceptions Affect Transactions

This is critical to understand: when an unhandled exception occurs in Apex, the entire transaction is rolled back. Every DML operation that executed before the exception — every insert, update, delete, and undelete — is undone. The database returns to the state it was in before the transaction started.

This is by design. Salesforce uses an all-or-nothing transaction model. If something fails partway through, the platform assumes the data is in an inconsistent state and reverses everything. This is safe, but it means that a single unhandled exception deep in your code can undo dozens of successful operations.

There are two important nuances:

  1. Database methods with allOrNone = false allow partial success. If you use Database.insert(records, false), some records can succeed while others fail, and no exception is thrown. You inspect the results to find the failures.
  2. Platform events committed before the exception are not rolled back. Platform event publishes are committed immediately and cannot be undone.

The Different Types of Exceptions

Apex has a large number of built-in exception types. Each one corresponds to a specific category of runtime error. Knowing these types is important because it allows you to catch specific exceptions and handle each category differently.

Exception Type Reference Table

Exception TypeWhen It Occurs
DmlExceptionA DML operation (insert, update, delete, upsert, undelete) fails
QueryExceptionA SOQL query assigned to a single sObject returns zero or more than one row
NullPointerExceptionCode attempts to use a null reference
ListExceptionAn invalid list operation, such as accessing an index out of bounds
MathExceptionAn illegal math operation, such as dividing by zero
TypeExceptionAn invalid type conversion or cast
LimitExceptionA governor limit is exceeded — cannot be caught
CalloutExceptionAn HTTP callout or web service call fails
JSONExceptionMalformed JSON during parsing or deserialization
StringExceptionAn invalid string operation
SObjectExceptionAn invalid sObject operation, such as accessing a field not in the query
SecurityExceptionA CRUD or FLS security violation
System.NoAccessExceptionThe user lacks access to a resource
SerializationExceptionA problem during serialization or deserialization
NoDataFoundExceptionUsed internally for certain data access patterns

Let us walk through each of the major types with code examples.

DmlException

A DmlException occurs when a DML operation fails. Common causes include validation rule violations, required field violations, duplicate rule violations, and trigger exceptions.

// Code that causes a DmlException
Account acc = new Account(); // Name is required
insert acc; // Throws DmlException: REQUIRED_FIELD_MISSING

A DmlException has special methods beyond the standard exception methods:

  • getDmlFields(index) — Returns the fields that caused the error for a given failed row.
  • getDmlId(index) — Returns the ID of the record that failed.
  • getDmlIndex(index) — Returns the original index of the failed row in the DML list.
  • getDmlMessage(index) — Returns the error message for a given failed row.
  • getDmlStatusCode(index) — Returns the status code (like REQUIRED_FIELD_MISSING).
  • getDmlType(index) — Returns the DML operation type.
  • getNumDml() — Returns the number of failed rows.
try {
    List<Account> accounts = new List<Account>{
        new Account(Name = 'Valid Account'),
        new Account() // Missing Name
    };
    insert accounts;
} catch (DmlException e) {
    System.debug('Number of DML errors: ' + e.getNumDml());
    for (Integer i = 0; i < e.getNumDml(); i++) {
        System.debug('Error on row ' + e.getDmlIndex(i));
        System.debug('Status code: ' + e.getDmlStatusCode(i));
        System.debug('Message: ' + e.getDmlMessage(i));
        System.debug('Fields: ' + e.getDmlFields(i));
    }
}

QueryException

A QueryException occurs when you assign a SOQL query result to a single sObject variable and the query returns zero rows or more than one row.

// Zero rows — throws QueryException
Account acc = [SELECT Id, Name FROM Account WHERE Name = 'Does Not Exist'];
// More than one row — throws QueryException
// If there are multiple accounts named 'Acme'
Account acc = [SELECT Id, Name FROM Account WHERE Name = 'Acme'];

The fix is either to use a list, or to add LIMIT 1, or to handle the exception:

// Safe approach: use a list
List<Account> accounts = [SELECT Id, Name FROM Account WHERE Name = 'Acme'];
if (!accounts.isEmpty()) {
    Account acc = accounts[0];
    // Work with acc
}

NullPointerException

This is the most common exception in Apex. It occurs when you try to dereference a null reference — calling a method on null, accessing a property on null, or using a null value where a non-null value is required.

// Accessing a method on a null variable
String s = null;
Integer len = s.length(); // Throws NullPointerException
// Accessing a field on a null sObject
Account acc = null;
String name = acc.Name; // Throws NullPointerException
// Accessing a related object that was not queried
Contact c = [SELECT Id, Name FROM Contact LIMIT 1];
String accountName = c.Account.Name; // Throws NullPointerException
// The Account relationship was not included in the query

Always check for null before dereferencing:

String s = someMethodThatMightReturnNull();
if (s != null) {
    Integer len = s.length();
}

ListException

A ListException occurs when you try to access a list index that does not exist.

List<String> names = new List<String>{'Alice', 'Bob'};
String third = names[2]; // Throws ListException: Index out of bounds
// Also happens with empty lists
List<String> empty = new List<String>();
String first = empty[0]; // Throws ListException

Prevent this by checking the list size:

List<String> names = getNames();
if (names.size() > 2) {
    String third = names[2];
}

MathException

A MathException occurs when you perform an illegal arithmetic operation.

Integer result = 10 / 0; // Throws MathException: Divide by zero
// Prevent it
Integer divisor = getDivisor();
if (divisor != 0) {
    Integer result = 10 / divisor;
} else {
    System.debug('Cannot divide by zero');
}

TypeException

A TypeException occurs when you attempt an invalid type conversion.

Object obj = 'I am a String';
Integer num = (Integer) obj; // Throws TypeException
// Use instanceof to check before casting
Object obj = getUnknownObject();
if (obj instanceof Integer) {
    Integer num = (Integer) obj;
} else if (obj instanceof String) {
    String str = (String) obj;
}

LimitException

A LimitException occurs when your code exceeds a governor limit — too many SOQL queries, too many DML statements, too much CPU time, too much heap size, and so on.

This is the one exception you cannot catch. Salesforce intentionally prevents you from catching LimitException because governor limits exist to protect shared resources on the multi-tenant platform. If you could catch and suppress a limit exception, you could write code that endlessly consumes shared resources.

// This will throw a LimitException that CANNOT be caught
try {
    for (Integer i = 0; i < 200; i++) {
        Account acc = [SELECT Id FROM Account LIMIT 1]; // SOQL inside a loop
    }
} catch (System.LimitException e) {
    // This catch block will NEVER execute
    // LimitException cannot be caught
    System.debug('This line never runs');
}

The only defense against LimitException is to write code that stays within governor limits. Use Limits.getQueries() and Limits.getLimitQueries() to check your consumption proactively:

if (Limits.getQueries() < Limits.getLimitQueries()) {
    // Safe to run another query
    List<Account> accounts = [SELECT Id FROM Account LIMIT 10];
}

CalloutException

A CalloutException occurs when an HTTP callout or web service call fails — the endpoint is unreachable, the connection times out, or the response is invalid.

try {
    HttpRequest req = new HttpRequest();
    req.setEndpoint('https://api.example.com/data');
    req.setMethod('GET');
    req.setTimeout(5000);

    Http http = new Http();
    HttpResponse res = http.send(req); // May throw CalloutException
} catch (CalloutException e) {
    System.debug('Callout failed: ' + e.getMessage());
    // Log the error, retry, or notify an admin
}

JSONException

A JSONException occurs when you attempt to parse or deserialize malformed JSON.

try {
    String malformed = '{"name": "Acme", "industry":}'; // Invalid JSON
    Map<String, Object> parsed = (Map<String, Object>) JSON.deserializeUntyped(malformed);
} catch (JSONException e) {
    System.debug('Invalid JSON: ' + e.getMessage());
}

StringException

A StringException occurs during invalid string operations.

try {
    String s = 'Hello';
    String sub = s.substring(0, 100); // Throws StringException
} catch (StringException e) {
    System.debug('String error: ' + e.getMessage());
}

SObjectException

An SObjectException occurs when you try to access a field that was not included in your SOQL query.

Account acc = [SELECT Id FROM Account LIMIT 1];
// Name was not queried
try {
    String name = acc.Name; // Throws SObjectException
} catch (SObjectException e) {
    System.debug('Field not queried: ' + e.getMessage());
}

The fix is to always include the fields you need in your SOQL query.

SecurityException

A SecurityException occurs when code enforced with WITH SECURITY_ENFORCED or Security.stripInaccessible() encounters a CRUD or FLS violation.

try {
    List<Account> accounts = [
        SELECT Id, Name, AnnualRevenue
        FROM Account
        WITH SECURITY_ENFORCED
    ];
} catch (System.QueryException e) {
    // WITH SECURITY_ENFORCED throws a QueryException with an
    // "Insufficient permissions" message if the user lacks field access
    System.debug('Security violation: ' + e.getMessage());
}

System.NoAccessException

This exception is thrown when the running user does not have access to a particular resource, such as a Visualforce page or a connected app.

// Typically thrown by platform features, not directly by developers
// Example: accessing a page the user's profile does not allow

Try-Catch Blocks

Now that we know what exceptions exist, let us learn how to handle them. The primary mechanism for handling exceptions in Apex is the try-catch block.

Basic Syntax

try {
    // Code that might throw an exception
} catch (ExceptionType e) {
    // Code that handles the exception
}

The try block contains the code that might fail. The catch block contains the code that runs when a specific type of exception is thrown. The variable e is the exception object, and you can call methods like getMessage() and getStackTraceString() on it.

Multiple Catch Blocks

You can have multiple catch blocks to handle different exception types differently:

try {
    Account acc = [SELECT Id, Name FROM Account WHERE Name = 'Test'];
    acc.Name = null;
    update acc;
} catch (QueryException e) {
    System.debug('Query problem: ' + e.getMessage());
} catch (DmlException e) {
    System.debug('DML problem: ' + e.getMessage());
} catch (Exception e) {
    System.debug('Something else went wrong: ' + e.getMessage());
}

Catch Ordering: Specific to General

When using multiple catch blocks, you must order them from most specific to most general. If you put catch (Exception e) first, it will catch every exception and the more specific catch blocks below it will never execute. In fact, the Apex compiler will throw an error if you place a parent exception class before a child class.

// CORRECT: specific first, general last
try {
    // code
} catch (DmlException e) {
    // handles only DML exceptions
} catch (QueryException e) {
    // handles only query exceptions
} catch (Exception e) {
    // handles everything else
}
// WRONG: compiler error — Exception catches everything
try {
    // code
} catch (Exception e) {
    // This catches everything, making the blocks below unreachable
} catch (DmlException e) {
    // Unreachable — compiler error
}

The Finally Block

The finally block runs regardless of whether an exception was thrown. It always executes — whether the try block completes normally, a catch block handles an exception, or an exception propagates upward. It is commonly used for cleanup operations.

try {
    // Risky code
    Account acc = [SELECT Id, Name FROM Account WHERE Name = 'Test'];
    update acc;
} catch (Exception e) {
    System.debug('Error: ' + e.getMessage());
} finally {
    System.debug('This always runs, whether or not an exception occurred');
    // Cleanup code goes here
}

Even if there is no catch block, finally still runs:

try {
    Integer result = 10 / 0; // Throws MathException
} finally {
    System.debug('This runs before the exception propagates');
}
// The MathException still propagates after the finally block executes

Nested Try-Catch

You can nest try-catch blocks when different parts of your code require different error handling strategies:

try {
    Account acc = [SELECT Id, Name FROM Account WHERE Name = 'Acme' LIMIT 1];

    try {
        acc.Name = 'Acme Corp Updated';
        update acc;
    } catch (DmlException e) {
        System.debug('Could not update account: ' + e.getMessage());
    }

    // Code continues even if the update failed
    System.debug('Proceeding with remaining logic');
} catch (QueryException e) {
    System.debug('Could not find account: ' + e.getMessage());
}

Using Try-Catch to Catch Exceptions

Let us look at practical, real-world examples of catching exceptions.

Catching DML Errors

public class AccountService {
    public static void createAccounts(List<Account> accounts) {
        try {
            insert accounts;
            System.debug('Successfully inserted ' + accounts.size() + ' accounts');
        } catch (DmlException e) {
            for (Integer i = 0; i < e.getNumDml(); i++) {
                System.debug('Failed record index: ' + e.getDmlIndex(i));
                System.debug('Error message: ' + e.getDmlMessage(i));
                System.debug('Status code: ' + e.getDmlStatusCode(i));
            }
        }
    }
}

Catching Query Errors

public class ContactService {
    public static Contact getContactByEmail(String email) {
        try {
            return [SELECT Id, Name, Email FROM Contact WHERE Email = :email];
        } catch (QueryException e) {
            System.debug('No contact found or multiple contacts found for: ' + email);
            return null;
        }
    }
}

Partial Success with Database Methods

Instead of catching exceptions from DML statements, you can use Database methods with allOrNone = false. This prevents exceptions entirely and gives you granular control over which records succeeded and which failed.

public class BulkInsertService {
    public static void insertAccountsSafely(List<Account> accounts) {
        // allOrNone = false means partial success is allowed
        List<Database.SaveResult> results = Database.insert(accounts, false);

        for (Integer i = 0; i < results.size(); i++) {
            if (results[i].isSuccess()) {
                System.debug('Successfully inserted: ' + results[i].getId());
            } else {
                for (Database.Error err : results[i].getErrors()) {
                    System.debug('Error on record ' + i + ': ' + err.getMessage());
                    System.debug('Status code: ' + err.getStatusCode());
                    System.debug('Fields affected: ' + err.getFields());
                }
            }
        }
    }
}

This pattern is extremely common in production code. It lets you insert 200 records, have 195 succeed, and report the 5 failures without rolling back the entire batch. Compare this to a plain insert accounts statement, which fails the entire list if even one record has a problem.

Combining Try-Catch with Callouts

public class ExternalService {
    public static String fetchData(String endpoint) {
        try {
            HttpRequest req = new HttpRequest();
            req.setEndpoint(endpoint);
            req.setMethod('GET');
            req.setTimeout(10000);

            Http http = new Http();
            HttpResponse res = http.send(req);

            if (res.getStatusCode() == 200) {
                return res.getBody();
            } else {
                throw new CalloutException(
                    'Unexpected status code: ' + res.getStatusCode()
                );
            }
        } catch (CalloutException e) {
            System.debug('Callout failed: ' + e.getMessage());
            // Log to a custom object, send an email alert, etc.
            return null;
        }
    }
}

Creating a Custom Exception

Built-in exception types cover most situations, but sometimes you need exceptions specific to your business logic. Creating a custom exception in Apex is simple: extend the Exception class.

Basic Custom Exception

public class InsufficientFundsException extends Exception {}

That is all it takes. By extending Exception, your class automatically inherits all the constructors and methods of the base class. You can throw it, catch it, and call getMessage() on it just like any built-in exception.

Custom Exception with Additional Properties

For more complex scenarios, you can add custom properties and constructors:

public class OrderProcessingException extends Exception {
    public String orderId { get; private set; }
    public String errorCode { get; private set; }

    public OrderProcessingException(String orderId, String errorCode, String message) {
        this(message); // Call the parent constructor that accepts a message
        this.orderId = orderId;
        this.errorCode = errorCode;
    }
}

Naming Convention

The Salesforce convention is to end your exception class names with Exception. Apex actually enforces a rule that classes extending Exception must have names that end with Exception. If you try to name your class InsufficientFundsError and extend Exception, the compiler will reject it.

Exception Class Rules

  • The class name must end with Exception.
  • You cannot create a top-level class named exactly Exception.
  • Custom exceptions automatically inherit four constructors from the base Exception class:
    • new MyException() — no arguments
    • new MyException(String message) — with a message
    • new MyException(Exception cause) — with a cause
    • new MyException(String message, Exception cause) — with both
// All of these work automatically
InsufficientFundsException e1 = new InsufficientFundsException();
InsufficientFundsException e2 = new InsufficientFundsException('Balance too low');
InsufficientFundsException e3 = new InsufficientFundsException(someOtherException);
InsufficientFundsException e4 = new InsufficientFundsException('Balance too low', someOtherException);

How to Throw a Custom Exception

You create exceptions with the new keyword and throw them with the throw keyword.

Basic Throwing

public class PaymentService {
    public static void processPayment(Decimal amount, Decimal balance) {
        if (amount <= 0) {
            throw new IllegalArgumentException('Payment amount must be positive');
        }
        if (amount > balance) {
            throw new InsufficientFundsException(
                'Cannot process payment of ' + amount + '. Balance is only ' + balance
            );
        }
        // Process the payment
    }
}

The calling code then catches the exception:

try {
    PaymentService.processPayment(500, 100);
} catch (InsufficientFundsException e) {
    System.debug('Payment failed: ' + e.getMessage());
    // Show a user-friendly message
} catch (IllegalArgumentException e) {
    System.debug('Invalid input: ' + e.getMessage());
}

When to Throw vs. When to Catch

This is a design decision that matters. Here are the guidelines:

  • Throw when your method encounters a condition it cannot and should not handle. If a method receives invalid input, it should throw. The caller is in a better position to decide what to do.
  • Catch when your method can meaningfully recover from the error. If a callout fails and you can retry, catch the exception and retry. If a query returns no rows and you can return a default, catch the exception.
  • Re-throw when you need to log or transform the exception but still want the caller to handle it.
// Re-throwing after logging
public class DataService {
    public static Account getAccount(Id accountId) {
        try {
            return [SELECT Id, Name FROM Account WHERE Id = :accountId];
        } catch (QueryException e) {
            // Log the error
            System.debug(LoggingLevel.ERROR, 'Failed to fetch Account ' + accountId);
            System.debug(LoggingLevel.ERROR, e.getStackTraceString());
            // Re-throw so the caller knows something went wrong
            throw e;
        }
    }
}

Wrapping Exceptions

You can catch one exception and throw a different one, using the original as the cause. This is useful for creating meaningful exception hierarchies:

public class AccountServiceException extends Exception {}

public class AccountService {
    public static void updateAccountName(Id accountId, String newName) {
        try {
            Account acc = [SELECT Id, Name FROM Account WHERE Id = :accountId];
            acc.Name = newName;
            update acc;
        } catch (QueryException e) {
            throw new AccountServiceException('Account not found: ' + accountId, e);
        } catch (DmlException e) {
            throw new AccountServiceException('Could not update account: ' + e.getDmlMessage(0), e);
        }
    }
}

The calling code catches AccountServiceException and can still access the original cause via getCause().

Do Not Use Exceptions for Control Flow

Exceptions should represent exceptional conditions — things that are not part of the normal, expected flow of your program. Do not use exceptions as a substitute for if statements.

// BAD: Using exceptions for control flow
public static Boolean accountExists(String name) {
    try {
        Account acc = [SELECT Id FROM Account WHERE Name = :name];
        return true;
    } catch (QueryException e) {
        return false;
    }
}

// GOOD: Use a list and check the result
public static Boolean accountExists(String name) {
    List<Account> accounts = [SELECT Id FROM Account WHERE Name = :name LIMIT 1];
    return !accounts.isEmpty();
}

The second approach is cleaner, faster, and does not rely on exception handling for a perfectly normal scenario (an account not existing).


Best Practices

Here are the rules for professional exception handling in Apex.

1. Catch Specific Exceptions, Not Generic Exception

// BAD: catches everything, hides the real problem
try {
    // complex code
} catch (Exception e) {
    System.debug('Something went wrong');
}

// GOOD: catches only what you expect
try {
    insert accounts;
} catch (DmlException e) {
    // Handle the specific DML failure
}

The only time catching the generic Exception is acceptable is when you are at the top level of your call stack (such as in an Aura controller method or a REST endpoint) and you want to ensure a user-friendly error message is always returned.

2. Never Swallow Exceptions Silently

// TERRIBLE: exception is caught and completely ignored
try {
    insert accounts;
} catch (DmlException e) {
    // empty catch block — the error is silently lost
}

This is one of the worst things you can do. The DML failed, records were not inserted, and nobody knows about it. Always log, re-throw, or report the error in some way.

3. Log Before Re-Throwing

If you catch an exception just to log it and then re-throw, make sure you actually log useful information:

try {
    update accounts;
} catch (DmlException e) {
    System.debug(LoggingLevel.ERROR, 'DML failed in AccountService.updateAccounts');
    System.debug(LoggingLevel.ERROR, 'Message: ' + e.getMessage());
    System.debug(LoggingLevel.ERROR, 'Stack: ' + e.getStackTraceString());
    throw e;
}

4. Use Database Methods for Partial Success

When processing records in bulk, prefer Database.insert(records, false) over insert records. The Database method approach gives you control over partial failures without exceptions.

5. Remember That LimitException Cannot Be Caught

No try-catch block will save you from governor limits. The only solution is to write efficient, bulkified code that stays within limits.

6. Use addError() in Triggers

In trigger context, you often want to mark specific records as failed rather than throwing an exception that rolls back the entire transaction:

trigger AccountTrigger on Account (before insert) {
    for (Account acc : Trigger.new) {
        if (acc.AnnualRevenue != null && acc.AnnualRevenue < 0) {
            acc.AnnualRevenue.addError('Annual Revenue cannot be negative');
        }
    }
}

Using addError() only fails the offending record. The other records in the batch still proceed.

7. Provide Meaningful Error Messages

// BAD
throw new AccountServiceException('Error');

// GOOD
throw new AccountServiceException(
    'Cannot deactivate Account ' + acc.Id +
    ' because it has ' + openCases.size() + ' open Cases'
);

8. Create a Centralized Error Logging Utility

In production orgs, consider creating a utility class that logs exceptions to a custom object, sends email alerts, or publishes platform events for monitoring:

public class ErrorLogger {
    public static void log(String source, Exception e) {
        Error_Log__c logEntry = new Error_Log__c(
            Source__c = source,
            Message__c = e.getMessage(),
            Stack_Trace__c = e.getStackTraceString(),
            Type__c = e.getTypeName(),
            Timestamp__c = Datetime.now()
        );
        // Use Database.insert to avoid throwing another exception
        Database.insert(logEntry, false);
    }
}

PROJECT: The Custom Exception Extravaganza

Time to put everything together. In this project you will build a small Order Processing service that uses custom exceptions, try-catch handling, and proper error reporting.

Step 1: Create the Custom Exceptions

Create three custom exception classes:

public class OrderNotFoundException extends Exception {}
public class InvalidOrderException extends Exception {
    public String orderId { get; private set; }

    public InvalidOrderException(String orderId, String message) {
        this(message);
        this.orderId = orderId;
    }
}
public class OrderProcessingException extends Exception {
    public String errorCode { get; private set; }

    public OrderProcessingException(String errorCode, String message) {
        this(message);
        this.errorCode = errorCode;
    }

    public OrderProcessingException(String errorCode, String message, Exception cause) {
        this(message, cause);
        this.errorCode = errorCode;
    }
}

Step 2: Create the OrderService Class

public class OrderService {

    public static Order__c getOrder(String orderId) {
        List<Order__c> orders = [
            SELECT Id, Name, Status__c, Total_Amount__c, Account__c
            FROM Order__c
            WHERE Name = :orderId
            LIMIT 1
        ];

        if (orders.isEmpty()) {
            throw new OrderNotFoundException('No order found with ID: ' + orderId);
        }

        return orders[0];
    }

    public static void validateOrder(Order__c order) {
        if (order.Total_Amount__c == null || order.Total_Amount__c <= 0) {
            throw new InvalidOrderException(
                order.Name,
                'Order total must be greater than zero. Current total: ' + order.Total_Amount__c
            );
        }

        if (order.Account__c == null) {
            throw new InvalidOrderException(
                order.Name,
                'Order must be associated with an Account'
            );
        }

        if (order.Status__c == 'Cancelled') {
            throw new InvalidOrderException(
                order.Name,
                'Cannot process a cancelled order'
            );
        }
    }

    public static void processOrder(String orderId) {
        Order__c order;

        // Step 1: Retrieve the order
        try {
            order = getOrder(orderId);
        } catch (OrderNotFoundException e) {
            ErrorLogger.log('OrderService.processOrder', e);
            throw e; // Re-throw — caller needs to know
        }

        // Step 2: Validate the order
        try {
            validateOrder(order);
        } catch (InvalidOrderException e) {
            ErrorLogger.log('OrderService.processOrder', e);
            throw new OrderProcessingException(
                'VALIDATION_FAILED',
                'Order validation failed for ' + orderId + ': ' + e.getMessage(),
                e
            );
        }

        // Step 3: Update the order status
        try {
            order.Status__c = 'Processing';
            update order;
        } catch (DmlException e) {
            ErrorLogger.log('OrderService.processOrder', e);
            throw new OrderProcessingException(
                'DML_FAILURE',
                'Could not update order status: ' + e.getDmlMessage(0),
                e
            );
        }

        System.debug('Order ' + orderId + ' processed successfully');
    }
}

Step 3: Create the Caller

public class OrderController {

    @AuraEnabled
    public static String submitOrder(String orderId) {
        try {
            OrderService.processOrder(orderId);
            return 'Order processed successfully';
        } catch (OrderNotFoundException e) {
            return 'Error: ' + e.getMessage();
        } catch (OrderProcessingException e) {
            return 'Processing Error [' + e.errorCode + ']: ' + e.getMessage();
        } catch (Exception e) {
            // Catch-all at the top level — ensure the user always gets a message
            ErrorLogger.log('OrderController.submitOrder', e);
            return 'An unexpected error occurred. Please contact your administrator.';
        }
    }
}

Step 4: Test It

@IsTest
private class OrderServiceTest {

    @IsTest
    static void testOrderNotFound() {
        try {
            OrderService.getOrder('FAKE-ORDER-999');
            System.assert(false, 'Expected OrderNotFoundException');
        } catch (OrderNotFoundException e) {
            System.assert(e.getMessage().contains('FAKE-ORDER-999'));
        }
    }

    @IsTest
    static void testInvalidOrderNullAmount() {
        Order__c testOrder = new Order__c(
            Name = 'TEST-001',
            Total_Amount__c = 0,
            Status__c = 'New'
        );

        try {
            OrderService.validateOrder(testOrder);
            System.assert(false, 'Expected InvalidOrderException');
        } catch (InvalidOrderException e) {
            System.assert(e.getMessage().contains('greater than zero'));
            System.assertEquals('TEST-001', e.orderId);
        }
    }

    @IsTest
    static void testInvalidOrderCancelled() {
        Order__c testOrder = new Order__c(
            Name = 'TEST-002',
            Total_Amount__c = 100,
            Account__c = null,
            Status__c = 'Cancelled'
        );

        try {
            OrderService.validateOrder(testOrder);
            System.assert(false, 'Expected InvalidOrderException');
        } catch (InvalidOrderException e) {
            System.assert(
                e.getMessage().contains('Account') ||
                e.getMessage().contains('cancelled')
            );
        }
    }

    @IsTest
    static void testExceptionWrapping() {
        OrderNotFoundException original = new OrderNotFoundException('test');
        OrderProcessingException wrapped = new OrderProcessingException(
            'NOT_FOUND', 'Wrapped error', original
        );

        System.assertEquals('NOT_FOUND', wrapped.errorCode);
        System.assertEquals('Wrapped error', wrapped.getMessage());
        System.assertNotEquals(null, wrapped.getCause());
        System.assertEquals('test', wrapped.getCause().getMessage());
    }
}

What You Practiced

This project brought together every concept from this post:

  • Custom exceptions with additional properties like orderId and errorCode.
  • Throwing exceptions when business validation fails or data is not found.
  • Catching specific exceptions at different layers of the code.
  • Re-throwing and wrapping exceptions to add context as errors propagate.
  • A catch-all at the top level to ensure users always get a readable message.
  • Error logging to a custom object so that failures are tracked.
  • Unit tests that verify exceptions are thrown correctly.

Summary

Exceptions are not something you bolt on at the end. They are a core part of your application design. Every method you write should consider what can go wrong and how it should communicate failure to its callers. The key takeaways from this post:

  • An unhandled exception rolls back the entire transaction.
  • Apex has many built-in exception types, each for a specific category of error.
  • LimitException cannot be caught — write efficient code to avoid it.
  • Use try-catch-finally blocks to handle exceptions gracefully.
  • Catch specific exceptions before catching the general Exception class.
  • Never swallow exceptions silently — always log, report, or re-throw.
  • Create custom exceptions for your business logic.
  • Use Database methods with allOrNone = false for partial success.
  • Use addError() in triggers to fail individual records instead of the whole batch.

Next up — Part 41: Casting in Apex. We will explore how type casting works in Apex, the difference between implicit and explicit casts, how to safely cast with instanceof, casting collections, and the common pitfalls that trip up developers. See you there.