AK

Menu

Back to Blog

Why Most Apex Exception Handling Is Lying to You

ApexSalesforceException HandlingArchitectureBest Practices

A concise look at common Apex exception handling anti-patterns, why they exist, and how to handle exceptions correctly across triggers, services, controllers, and async Apex.


Disclaimer:
This post reflects my personal experience working with Apex code across different teams and projects. These are my own views and not official Salesforce guidance.


Exception handling in Apex is one of those topics that looks solved.

Most codebases today follow a familiar pattern:

  • Triggers wrapped in try–catch
  • Service methods wrapped in try–catch
  • Controllers wrapped in try–catch
  • Catch blocks logging to Nebula
  • Execution either continues quietly or the exception is rethrown

On the surface, this feels like defensive, mature code.

In reality, much of this exception handling is misplaced.
It looks correct, passes reviews, and still quietly works against you.

This post is not about syntax.
It’s about where exception handling actually belongs in Apex — and why so much of it ends up lying to us.


The Core Misunderstanding

The most common misconception is simple:

If I catch an exception, I’ve handled it.

In Apex, catching an exception only gives you a decision point.

If the layer catching the exception cannot decide:

  • whether execution should stop,
  • how the failure affects the operation,
  • or how the caller should be informed,

then the exception is not being handled — it’s just being intercepted.

This misunderstanding drives most Apex exception anti-patterns.


Common Apex Exception Handling Anti-Patterns

1. Catching Exceptions Without Ownership

A very common pattern:

catch (Exception e) {
    Logger.log(e);
}

This often appears in trigger handlers, utility classes, or deep service helpers.

These layers usually don’t know:

  • the business intent,
  • the caller,
  • whether the failure was expected,
  • or how it should surface.

Catching exceptions without this context removes intent from the system.


2. Handling Exceptions Too Deep in the Call Stack

Handling exceptions deep in the stack causes loss of causality.

In Apex, this is amplified because:

  • Exception chaining is often neglected,
  • stack traces are easily lost during translation,
  • execution contexts vary (trigger, UI, async).

The earlier an exception is handled, the harder it becomes to understand why it happened.


3. Using Exceptions for Expected Outcomes

Exceptions are often used for:

  • validation failures,
  • missing optional data,
  • “no records found” scenarios.

These are not exceptional states.

Exceptions should represent broken invariants, not normal branching logic.
Using them as control flow leads to unnecessary rollbacks and noisy code.


4. Throwing Exceptions from Triggers

Triggers run in bulk.
Uncaught exceptions shatter that bulk context.

Throwing exceptions from triggers:

  • rolls back all records,
  • breaks partial success,
  • turns record-level validation into transaction failure.

This is not an exception problem — it’s a layering problem.


5. Controllers Acting as Catch-All Safety Nets

Catching Exception in controllers to “protect the UI” is another common habit.

This often results in:

  • silent failures,
  • false success states,
  • data inconsistencies discovered later.

Controllers are not correctness layers.
They are translation layers.


What Correct Apex Exception Handling Looks Like

Once the anti-patterns are clear, the correct patterns are surprisingly consistent.

Triggers: Report, Don’t Handle

Triggers should report record-level issues and preserve bulk behavior.

if (isActivating(acc) && acc.Name == null) {
    acc.addError('Name is required for activation.');
}

Triggers report problems — they do not decide how the system recovers.


Service / Domain Layers: Enforce Business Rules

This is where exceptions should be thrown.

Service layers own business invariants and correctness.

if (hasOpenComplianceIssues(accountId)) {
    throw new AccountComplianceException();
}

Key points:

  • The rule is non-negotiable
  • Continuing execution would corrupt state
  • The exception is intentional, not defensive

Services generally shouldn’t catch exceptions unless they add real value.


Controllers: Translate for the Caller

Controllers sit at the boundary and translate known failures.

try {
    AccountActivationService.activate(accountId);
} catch (AccountComplianceException e) {
    throw new AuraHandledException(
        'Resolve compliance issues before activation.'
    );
}

Controllers should:

  • catch specific business exceptions,
  • translate them appropriately,
  • let unexpected failures surface.

Async Apex: Observe and Escalate

Async contexts are different:

  • no user
  • no UI
  • failures won’t surface automatically.
try {
    AccountActivationService.activate(accountId);
} catch (Exception e) {
    ErrorLogger.log('Activation failed', e);
}

Async exception handling is about visibility, not recovery.


Where Logging Fits

Logging is not exception handling — it’s a side effect.

Logging makes sense:

  • at system boundaries,
  • when failure leaves your control,
  • when no further handling is possible.

Logging everywhere usually means exception ownership is unclear.


The One Principle That Ties This Together

If there’s one rule that simplifies most Apex exception decisions, it’s this:

Throw exceptions where correctness is enforced.
Handle exceptions where context is known.
Log exceptions only when failure leaves your control.

Most problematic Apex exception handling violates one of these three ideas.


Final Thought

Most Apex exception handling isn’t wrong — it’s just misplaced.

Too many try–catch blocks.
Too little intent.
Too much interception.

Good exception handling is quieter:

  • fewer catch blocks,
  • clear ownership,
  • meaningful failures.

And when exception handling is honest, the system stops lying —
to developers, to logs, and to itself.