# Apex Transaction Finalizers

> Use this skill when you need guaranteed post-Queueable cleanup, retry, or failure-logging logic that must run even when the parent Queueable throws an unhandled exception. Trigger keywords: FinalizerContext, System.attachFinalizer, Queueable cleanup on failure, post-job compensation, guaranteed async cleanup. NOT for Queueable design or chaining — use apex/apex-queueable-patterns. NOT for the same need in Flow — use flow/flow-transaction-finalizer-patterns.

- Skill: `pranavnagrecha/apex-transaction-finalizers` (Agent Skill, multi-file: 7 files)
- Install (CLI): `npx skillmds add pranavnagrecha/apex-transaction-finalizers`
- Raw SKILL.md: https://api.skillmd.com/api/skills/pranavnagrecha/apex-transaction-finalizers/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: PranavNagrecha (https://skillmd.com/u/pranavnagrecha)
- Updated: 2026-09-08
- Page: https://skillmd.com/skills/pranavnagrecha/apex-transaction-finalizers

---


# Apex Transaction Finalizers

This skill activates when a Queueable job needs guaranteed post-execution behavior — cleanup, retry, or failure logging — that must run even if the parent Queueable throws an unhandled exception. Use `System.attachFinalizer()` to bind a `System.Finalizer` implementation to a Queueable; the Finalizer runs in a **separate Apex transaction** with **full governor limits** after the parent job finishes.

---

## Before Starting

Gather this context before working on anything in this domain:

- Confirm the parent job is a `Queueable` (not Batch, Scheduled, or `@future`). Transaction Finalizers are only supported on Queueable jobs.
- Identify the failure scenario: Is this a retry (re-enqueue the same job), a compensation (write a failure record / publish a PE), or silent logging?
- Determine a retry ceiling. Finalizers can enqueue exactly one new Queueable — if that Queueable also has a Finalizer, the chain continues. Without a retry limit you risk infinite loops.
- Check the API version of the Queueable class — `System.attachFinalizer()` requires API v53.0+ (Summer '21).
- The Finalizer does **not** run if the parent job was aborted via `System.abortJob()`. Plan for that case separately.

---

## Core Concepts

### Finalizer Lifecycle

When `System.attachFinalizer(myFinalizer)` is called inside a Queueable's `execute()` method, the platform registers the Finalizer to execute after the parent job's transaction closes — whether it committed successfully or was rolled back due to an unhandled exception. The Finalizer runs in a **completely separate Apex transaction** with fresh governor-limit counters (100 SOQL queries, 150 DML statements, etc.). The parent transaction's state (variable values, uncommitted DML) is not visible to the Finalizer.

### FinalizerContext API

The Finalizer's `execute(FinalizerContext ctx)` method receives a `FinalizerContext` object with three key members:

| Member | Returns | Notes |
|---|---|---|
| `ctx.getJobId()` | `Id` | The `AsyncApexJob` ID of the **parent** Queueable |
| `ctx.getResult()` | `System.ParentJobResult` | `SUCCESS` or `UNHANDLED_EXCEPTION` |
| `ctx.getException()` | `Exception` | Non-null only when `getResult() == UNHANDLED_EXCEPTION` |

Always gate retry / compensation logic on `ctx.getResult()` to avoid double-processing on success.

### Enqueue Constraint

A Finalizer may enqueue **exactly one** new Queueable job via `System.enqueueJob()`. Attempting to enqueue more than one throws a `System.AsyncException`. A Finalizer cannot attach another Finalizer to itself — `System.attachFinalizer()` called from within a Finalizer context throws a `System.AsyncException`.

### Abort Gap

If the parent Queueable is terminated via `System.abortJob()`, the Finalizer is **not invoked**. This is a hard platform constraint with no workaround at the Finalizer layer. If abort-path cleanup is required, model it as a separate Schedulable or monitoring job that polls `AsyncApexJob` for `ABORTED` status.

---

## Common Patterns

### Retry-on-Failure with Backoff Counter

**When to use:** A Queueable makes an external callout or complex DML that can fail transiently. You want automatic retry up to N times without manual re-queuing.

**How it works:**
1. Pass a `retryCount` integer into the Queueable constructor.
2. Inside `execute()`, call `System.attachFinalizer(new MyFinalizer(jobPayload, retryCount))`.
3. In the Finalizer's `execute()`, check `ctx.getResult()`. On `UNHANDLED_EXCEPTION` and `retryCount < MAX_RETRIES`, enqueue a new instance of the Queueable with `retryCount + 1`.
4. On `retryCount >= MAX_RETRIES`, write a failure record instead of re-enqueuing.

**Why not try/catch inside execute():** A `try/catch` inside `execute()` only catches exceptions thrown by code in that block — governor-limit violations and some system exceptions escape it. A Finalizer provides an out-of-band, guaranteed callback even for unhandled exceptions that bypass catch blocks.

### Failure Logging to Custom Object

**When to use:** You need an auditable record of every Queueable failure for operations monitoring, SLA reporting, or manual reprocessing.

**How it works:**
1. Attach a Finalizer that receives the job context (record IDs, batch key, etc.) from the parent Queueable constructor.
2. In `execute(ctx)`, if `ctx.getResult() == UNHANDLED_EXCEPTION`, insert an `Async_Job_Error__c` (or equivalent) record with the job ID, exception message, stack trace, and payload snapshot.
3. Use the single Queueable enqueue slot only if retry is also needed; otherwise leave it unused.

**Why not System.debug:** Debug logs are transient and unavailable to non-admin users. A custom object record survives platform restarts and is queryable by monitoring tools.

---

## Decision Guidance

| Situation | Recommended Approach | Reason |
|---|---|---|
| Parent Queueable fails transiently (callout timeout, lock contention) | Retry Finalizer with counter | Full governor limits in separate transaction; single enqueue slot used for the retry job |
| Failure needs permanent audit record | Logging Finalizer (DML in separate transaction) | Parent transaction is rolled back; Finalizer gets fresh DML budget |
| Both retry AND logging needed | Single Finalizer handles both; log first, then conditionally enqueue retry | Enqueue limit is 1 — combine both behaviors in one Finalizer |
| Parent job was aborted by admin | Schedulable monitor polling `AsyncApexJob` for ABORTED status | Finalizer does not fire on abort; no workaround |
| Batch job completion callback | `Database.Batchable` `finish()` method or apex-batch-chaining skill | Transaction Finalizers are Queueable-only |
| Publishing a Platform Event on failure | PE publish inside Finalizer OR dedicated PE skill | PE publish counts against Finalizer's DML budget; prefer dedicated skill for complex routing |

---

## Recommended Workflow

1. **Confirm Queueable context** — verify the failing async job is a `Queueable`, API v53+, and that abort-path behavior does not need to be covered by this Finalizer.
2. **Choose Finalizer behavior** — decide between retry, compensation DML, or both. If both, plan the single Finalizer class that handles them sequentially.
3. **Design the retry ceiling** — pick `MAX_RETRIES` (typically 3–5) and pass `retryCount` through the Queueable constructor so the Finalizer can increment and re-enqueue safely.
4. **Implement `System.Finalizer`** — create a class that `implements System.Finalizer`, receives the job payload via constructor, implements `execute(FinalizerContext ctx)`, gates on `ctx.getResult()`, and enqueues at most one retry job.
5. **Attach in `execute()`** — call `System.attachFinalizer(new MyFinalizer(...))` near the top of the parent Queueable's `execute()` method so it is registered before any code that might throw.
6. **Test both SUCCESS and UNHANDLED_EXCEPTION paths** — use `Test.startTest()` / `Test.stopTest()` to flush the queue; mock the failure by having the Queueable throw in test context, and assert the Finalizer's DML/enqueue behavior.
7. **Review checklist** — confirm no second `attachFinalizer` call, retry counter bounded, no `attachFinalizer` inside the Finalizer itself.

---

## Review Checklist

- [ ] `System.attachFinalizer()` is called exactly once per Queueable `execute()` invocation
- [ ] Finalizer gates all compensation logic on `ctx.getResult() == System.ParentJobResult.UNHANDLED_EXCEPTION`
- [ ] Retry counter is passed via constructor and incremented before re-enqueuing; `MAX_RETRIES` ceiling is enforced
- [ ] The Finalizer enqueues at most one new Queueable job (throws `AsyncException` if you try more)
- [ ] No call to `System.attachFinalizer()` inside the Finalizer's own `execute()` method
- [ ] Tests cover both SUCCESS and UNHANDLED_EXCEPTION result paths
- [ ] Abort-path (if required) is handled by a separate mechanism — Finalizer does not fire on `System.abortJob()`

---

## Salesforce-Specific Gotchas

1. **Finalizer does not fire on `System.abortJob()`** — If an admin or another job calls `System.abortJob(parentJobId)`, the Finalizer is silently skipped. This is undocumented in some sources but confirmed in the official Apex Developer Guide. Any cleanup that must happen on abort needs a separate polling mechanism.
2. **Parent transaction rollback is total** — When the Queueable throws an unhandled exception, every DML operation in that transaction is rolled back. The Finalizer starts with a clean slate — it cannot read variables set in the parent, and it cannot "see" records that the parent tried but failed to commit.
3. **One enqueue, no exceptions** — Calling `System.enqueueJob()` more than once in a single Finalizer `execute()` call throws `System.AsyncException` immediately. Wrap the retry call in a conditional so it is only reached when retry is actually needed.
4. **Finalizer exception is swallowed** — If the Finalizer itself throws an unhandled exception, the platform logs it to `ApexLog` but does not propagate it anywhere visible. There is no secondary Finalizer. Build explicit logging inside the Finalizer's own `execute()` using a `try/catch` wrapper.

---

## Output Artifacts

| Artifact | Description |
|---|---|
| `System.Finalizer` implementation class | Apex class implementing `System.Finalizer` with retry and/or logging logic |
| Updated Queueable class | Parent Queueable with `System.attachFinalizer()` call and `retryCount` constructor param |
| `Async_Job_Error__c` insert (optional) | Custom object record capturing job ID, exception type, message, and stack trace |

---

## Related Skills

- apex-queueable-patterns — foundational Queueable design; use alongside this skill for the parent job structure
- apex-batch-chaining — for batch-to-batch chaining; Finalizers do not apply to Batch jobs
- apex-limits-monitoring — for monitoring Apex governor limits that might cause the Queueable to fail in the first place

