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:
- Database methods with
allOrNone = falseallow partial success. If you useDatabase.insert(records, false), some records can succeed while others fail, and no exception is thrown. You inspect the results to find the failures. - 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 Type | When It Occurs |
|---|---|
DmlException | A DML operation (insert, update, delete, upsert, undelete) fails |
QueryException | A SOQL query assigned to a single sObject returns zero or more than one row |
NullPointerException | Code attempts to use a null reference |
ListException | An invalid list operation, such as accessing an index out of bounds |
MathException | An illegal math operation, such as dividing by zero |
TypeException | An invalid type conversion or cast |
LimitException | A governor limit is exceeded — cannot be caught |
CalloutException | An HTTP callout or web service call fails |
JSONException | Malformed JSON during parsing or deserialization |
StringException | An invalid string operation |
SObjectException | An invalid sObject operation, such as accessing a field not in the query |
SecurityException | A CRUD or FLS security violation |
System.NoAccessException | The user lacks access to a resource |
SerializationException | A problem during serialization or deserialization |
NoDataFoundException | Used 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 (likeREQUIRED_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
Exceptionclass:new MyException()— no argumentsnew MyException(String message)— with a messagenew MyException(Exception cause)— with a causenew 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
orderIdanderrorCode. - 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.
LimitExceptioncannot be caught — write efficient code to avoid it.- Use
try-catch-finallyblocks to handle exceptions gracefully. - Catch specific exceptions before catching the general
Exceptionclass. - Never swallow exceptions silently — always log, report, or re-throw.
- Create custom exceptions for your business logic.
- Use
Databasemethods withallOrNone = falsefor 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.