Not verified hands-on. This article was written from primary documentation rather than reproduced in an org, so it carries no tested-release badge. If you run it and something differs, say so in the comments and it gets tested and corrected.

The short answer: a custom exception is a class whose name ends in Exception and which extends Exception. That naming rule is enforced by the compiler and catches everyone once. The reason to define them is not tidiness — it is that catch (Exception e) cannot tell your business rule from a null pointer, and that distinction decides whether you retry, report, or fail.

public class InsufficientStockException extends Exception {}

if (available < requested) {
    throw new InsufficientStockException(
        'Only ' + available + ' of ' + requested + ' available for ' + sku);
}

Apex gives you four constructors free: no arguments, a message, a cause, and both. You do not write them, and writing one is a compile error unless you write all of them.

Design the taxonomy around what the caller does

The useful question is not “what went wrong” but “what should happen next”. Three categories cover most systems, and the names should say which:

  • The caller sent something wrong. ValidationException. Never retry, report to the user, do not page anyone.
  • Something downstream is temporarily unavailable. TransientException. Retry with backoff, report only if it keeps failing.
  • A rule of the business was violated. InsufficientStockException, AccountOnHoldException. Not retryable, and the message goes to a person who can act on it.

Anything that does not fit one of those is probably a bug, and a bug should surface as the exception it already is rather than being wrapped into something that looks handled.

Carry the data, not just the sentence

A string message is fine for a log and useless for code. Add fields for the things a caller might branch on:

public class ErpRejectedException extends Exception {
    public String erpCode;
    public Id orderId;
    public Boolean retryable = false;
}

ErpRejectedException e = new ErpRejectedException('ERP rejected order');
e.erpCode = code;
e.orderId = orderId;
e.retryable = (code == 'TEMP_UNAVAILABLE');
throw e;

Now the retry decision is data rather than a string comparison, and nobody has to parse a message that someone will eventually reword.

Wrapping, and keeping the cause

When you catch a low-level exception and throw your own, pass the original as the cause. Otherwise the stack trace stops at your throw and the actual failure is gone:

catch (DmlException e) {
    throw new OrderSaveException('Could not save order ' + ref, e);
}

getCause() retrieves it, and the debug log shows the chain. A wrapped exception without a cause is strictly worse than no wrapping at all.

What Apex will not let you do

  • No checked exceptions. Nothing forces a caller to handle anything, so the contract lives in documentation and in tests.
  • You cannot catch by cause type. Catch your exception and inspect getCause() yourself.
  • An uncaught exception rolls back the transaction, taking any record you wrote to document the failure with it, which is why a retry design creates its task record first. Which is correct and also means you cannot throw and expect a partial save to survive. Use Database.insert(records, false) if partial success is the requirement.
  • addError() on a record is not an exception. In a trigger it marks one record as failed and lets the rest through, which is usually the behaviour you want in bulk. Throwing kills all 200.

In a trigger, prefer addError

This is the most practically useful part. A thrown exception in a trigger fails the entire DML operation, including the 199 records that were fine. record.addError('message') fails only that record when the caller used partial-success DML, which is the behaviour a bulk load needs, and the message appears against the right row in a Data Loader error file.

Frequently asked

Why does my class not compile? The name must end in Exception and it must extend Exception or another exception class. Both, exactly.

Can I add a constructor? Only if you define all of them. It is rarely worth it. Use fields and a factory method instead.

Should I catch and log everything? No. Catching an exception you cannot do anything about converts a visible failure into a silent one, and the log expires in days.