The canonical shape of transaction control in Apex is savepoint → try → DML → catch → rollback. Three lines of API surface, one straightforward pattern.
The minimal example
public class OrderService {
public void createOrderWithLines(Order__c header, List<Line_Item__c> lines) {
Savepoint sp = Database.setSavepoint();
try {
insert header;
for (Line_Item__c li : lines) {
li.Order__c = header.Id;
}
insert lines;
} catch (Exception e) {
Database.rollback(sp);
throw new OrderException('Order creation failed: ' + e.getMessage(), e);
}
}
public class OrderException extends Exception {}
}
What happens here:
Database.setSavepoint()snapshots the database state.- We insert the order header and the line items.
- If anything inside the
trythrows, weDatabase.rollback(sp)— both inserts are undone. - We re-throw a typed exception so callers know exactly what failed.
A more realistic multi-step example
public class CustomerOnboarding {
public void onboard(String name, String email, Decimal initialDeposit) {
Savepoint sp = Database.setSavepoint();
try {
// Step 1: create the Account
Account a = new Account(Name = name);
insert a;
// Step 2: create the primary Contact
Contact c = new Contact(
LastName = name,
Email = email,
AccountId = a.Id
);
insert c;
// Step 3: create an opening balance record
Customer_Balance__c bal = new Customer_Balance__c(
Account__c = a.Id,
Balance__c = initialDeposit
);
insert bal;
// Step 4: post a welcome platform event
EventBus.publish(new Welcome_Event__e(AccountId__c = a.Id));
} catch (DmlException dmle) {
Database.rollback(sp);
System.debug('DML failure during onboarding: ' + dmle.getMessage());
throw new OnboardingException(
'Onboarding failed for ' + name + ': ' + dmle.getDmlMessage(0), dmle
);
} catch (Exception e) {
Database.rollback(sp);
throw new OnboardingException('Unexpected error: ' + e.getMessage(), e);
}
}
public class OnboardingException extends Exception {}
}
Notice the two catch blocks — a specific DmlException handler for save failures, and a generic Exception handler as the fallback. Both roll back; both re-throw with context.
Multiple savepoints — partial rollback
When the unit of failure is one step (not the whole operation), use multiple savepoints:
public void importBatch(List<Account> accounts, List<Contact> contacts) {
Savepoint spStart = Database.setSavepoint();
insert accounts;
Savepoint spAfterAccounts = Database.setSavepoint();
try {
for (Contact c : contacts) {
// assume c.Account.Name was used to find the right new Account
}
insert contacts;
} catch (Exception e) {
// Keep the Accounts, drop the Contacts
Database.rollback(spAfterAccounts);
System.debug('Contacts failed but accounts retained: ' + e.getMessage());
}
}
Rolling back to spAfterAccounts undoes the insert contacts only — the accounts stay. Rolling back to spStart would undo everything.
Test class for the savepoint logic
@isTest
private class OrderServiceTest {
@isTest
static void rollsBackOnFailure() {
Order__c order = new Order__c(Name = 'Test');
List<Line_Item__c> bad = new List<Line_Item__c>{
new Line_Item__c(Quantity__c = -1) // fails a validation rule
};
Test.startTest();
try {
new OrderService().createOrderWithLines(order, bad);
System.assert(false, 'Should have thrown');
} catch (OrderService.OrderException e) {
// expected
}
Test.stopTest();
System.assertEquals(0, [SELECT COUNT() FROM Order__c]);
System.assertEquals(0, [SELECT COUNT() FROM Line_Item__c]);
}
}
The assertEquals(0, ...) lines prove the savepoint rolled back the partial insert.
Common mistakes the test usually catches
- Reusing record Ids after rollback —
header.Idis reset to null when its insert is rolled back. Any field that already stored the value is left pointing to nothing. - Forgetting to re-throw — silently swallowing the exception means the caller thinks the operation succeeded.
- Doing DML in the catch block without a fresh savepoint — if it fails, you’re back to default all-or-nothing rollback.
What interviewers are really looking for
The three-line pattern is the minimum. The senior signal is: (1) typed exception classes (OrderException) instead of re-throwing the raw cause, (2) separating DmlException from generic Exception for better diagnostics, (3) acknowledging that callouts and Publish-Immediately events don’t roll back, (4) discarding rolled-back Ids instead of reusing them, (5) a test that asserts zero rows after a forced failure to prove the savepoint worked.
Verified against: Apex Developer Guide — Transaction Control. Last reviewed 2026-05-17.