Skip to main content

Command Palette

Search for a command to run...

Replace @future with Queueable Apex: What Salesforce Actually Wants You to Do

Updated
9 min read
Replace @future with Queueable Apex: What Salesforce Actually Wants You to Do

There’s a good chance your org still has @future methods quietly running in production right now — maybe dozens of them, scattered across service classes and trigger handlers. They work. They’ve always worked. Nobody’s touched them in years.

That’s exactly the problem.

@future was never designed for the kinds of async workflows modern Salesforce orgs demand. It was a useful stopgap, but deliberately limited. Queueable Apex arrived to address those limits, and yet many teams don’t migrate until something breaks.

This article walks through why @future eventually becomes a liability, how Queueable Apex solves those limits, and what a practical migration looks like — from basic replacements to chaining, payloads, finalizers, and retry logic.


The Problem With @future

Most developers know @future can’t accept sObjects. That’s usually the first limitation they hit. But the full picture matters before you commit to an async pattern.

1. No Complex Parameters

The Apex Developer Guide is explicit:

"Methods with the future annotation cannot take sObjects or objects as arguments."

Only primitives or collections of primitives are supported. This means every @future method either accepts a Set<Id> and re-queries everything it needs, or serializes data into strings and reconstructs it later.

In a large org with high-volume processing, that re-querying adds up quickly — both in SOQL usage and in code that becomes harder to reason about and test.

2. No Chaining

"You cannot call a method annotated with @future from a method that also has the @future annotation."

If you need to orchestrate multiple async steps — update records, then make a callout, then log the result — @future gives you no native sequencing model. Each step has to be triggered externally.

That usually leads to workarounds: scheduled jobs, custom status fields, or event-based handoffs that exist only because the original async primitive was too limited.

3. No Job Identity

@future methods return void. There’s no job ID, no direct status handle, and no practical way to correlate execution with AsyncApexJob.

In monitored enterprise orgs and CI/CD pipelines, that’s a real operational gap. Failures often stay invisible until a record is wrong or a downstream system starts complaining.

What This Looks Like at Scale

In larger orgs, these limitations compound:

  • SOQL usage rises because every async step re-queries data it already had

  • Workflows fragment into disconnected pieces that are hard to trace

  • Debugging becomes reactive instead of observable

@future isn’t broken. It’s just the wrong tool once the workflow becomes more than simple fire-and-forget logic.


What Queueable Apex Gives You

Queueable Apex was introduced to address these exact limitations.

It gives you:

  • Complex type support — pass sObjects, custom classes, and structured payloads directly

  • Job chaining — enqueue the next step from the current job

  • Job IDsSystem.enqueueJob() returns an ID you can monitor

  • Finalizers — post-execution hooks for retries and failure handling

  • A class-based design — explicit, testable, and easier to reason about

This is more than a feature upgrade. It’s a better mental model for asynchronous Apex.


Prerequisites

Before diving in, confirm your environment is ready:

  • API Version: 32.0 or later

  • Permissions: Execute Apex, Async Apex visibility

  • Tooling: Salesforce CLI (sf), VS Code with Salesforce Extensions

Queueable and Finalizers are both Generally Available features.


Step 1: The Legacy Pattern You’re Replacing

Here’s a typical @future method:

public class AccountService {
    @future
    public static void updateAccounts(Set<Id> accountIds) {
        List<Account> accounts = [
            SELECT Id, Name FROM Account WHERE Id IN :accountIds
        ];
        for (Account acc : accounts) {
            acc.Name += ' - Updated';
        }
        update accounts;
    }
}

This is the standard @future compromise: pass IDs, then re-query. It works, but every call pays for that design choice.


Step 2: The Queueable Replacement

Here’s the same logic rebuilt as a Queueable:

public class AccountQueueable implements Queueable {

    private List<Account> accounts;

    public AccountQueueable(List<Account> accounts) {
        this.accounts = accounts;
    }

    public void execute(QueueableContext context) {
        for (Account acc : accounts) {
            acc.Name += ' - Updated';
        }
        update accounts;
    }
}

Enqueuing it is straightforward:

List<Account> accs = [SELECT Id, Name FROM Account LIMIT 10];
Id jobId = System.enqueueJob(new AccountQueueable(accs));

Now the data is passed directly, and you immediately get a job ID back.


Step 3: Monitoring Jobs

Once you have a job ID, monitoring becomes explicit:

AsyncApexJob job = [
    SELECT Id, Status, NumberOfErrors, ExtendedStatus
    FROM AsyncApexJob
    WHERE Id = :jobId
];

This is one of Queueable’s biggest operational advantages over @future: async work becomes observable instead of opaque.


Step 4: Passing Complex Objects With a Payload Pattern

Queueable doesn’t force everything into primitives. You can define a structured payload and pass it directly into the job:

public class SyncPayload {
    public List<Account> accounts;
    public String source;
    public Boolean notify;
    public Integer retryCount; // Used by Finalizer retry logic (see Step 6)

    public SyncPayload(List<Account> accounts, String source, Boolean notify) {
        this.accounts = accounts;
        this.source = source;
        this.notify = notify;
        this.retryCount = 0;
    }
}

This keeps the constructor clean and makes it easy to carry execution context:

  • what records are being processed

  • where the request came from

  • whether downstream actions should happen

  • how many retries have already occurred

That’s far easier to extend than passing IDs and reconstructing everything later.


Step 5: Chaining Jobs for Multi-Step Workflows

Queueable supports chaining by enqueuing a new job from inside execute().

public class Step1Job implements Queueable {

    private List<Account> accounts;

    public Step1Job(List<Account> accounts) {
        this.accounts = accounts;
    }

    public void execute(QueueableContext context) {
        for (Account acc : accounts) {
            acc.Description = 'Step 1 complete';
        }
        update accounts;

        System.enqueueJob(new Step2Job(accounts));
    }
}

Step 2 can handle a different concern entirely, such as a callout:

public with sharing class Step2Job implements Queueable, Database.AllowsCallouts {

    private List<Account> accounts;

    public Step2Job(List<Account> accounts) {
        this.accounts = accounts;
    }

    public void execute(QueueableContext context) {
        Http http = new Http();

        for (Account acc : accounts) {
            HttpRequest req = new HttpRequest();
            req.setEndpoint('https://api.example.com/accounts/' + acc.Id);
            req.setMethod('POST');

            HttpResponse res = http.send(req);
            System.debug('Response: ' + res.getStatusCode());
        }
    }
}

This gives you a clean, sequential async workflow.

One important platform constraint still applies:

Each Queueable job can enqueue only one child job.

So Queueable chaining is ideal for linear workflows. If you need fan-out or parallel branching, you need a different pattern.

Chaining and Finalizers are independent features. You can use either one on its own, or combine them when a workflow needs both orchestration and controlled failure handling.


Step 6: Finalizers for Controlled Retry Logic

Finalizers are one of the most useful parts of Queueable Apex. A Finalizer runs after the Queueable completes, regardless of whether the job succeeded or failed.

That makes it the right place for retry logic.

public with sharing class AccountJobWithFinalizer implements Queueable, Finalizer {

    private SyncPayload payload;
    private static final Integer MAX_RETRIES = 3;

    @TestVisible
    private static Boolean simulateFailure = false;

    // Used only for deterministic test assertions
    @TestVisible
    private static Integer retryInvocationCount = 0;

    public AccountJobWithFinalizer(SyncPayload payload) {
        this.payload = payload;
    }

    public void execute(QueueableContext context) {
        System.attachFinalizer(this);

        if (simulateFailure) {
            throw new CalloutException('Simulated failure for testing');
        }

        for (Account acc : payload.accounts) {
            acc.Description = 'Processed via: ' + payload.source;
        }
        update payload.accounts;
    }

    public void execute(FinalizerContext context) {

        if (context.getResult() == ParentJobResult.SUCCESS) {
            System.debug('Job completed successfully.');
        } else {
            System.debug('Job failed: ' + context.getException().getMessage());

            if (payload.retryCount < MAX_RETRIES) {
                payload.retryCount++;
                retryInvocationCount++;

                System.enqueueJob(new AccountJobWithFinalizer(payload));
            } else {
                System.debug('Max retries reached. Escalating.');
            }
        }
    }
}

Two points matter here.

First, the retry guard is mandatory. Without retryCount < MAX_RETRIES, a consistently failing job can re-enqueue itself indefinitely.

Second, System.attachFinalizer(this) needs to happen before the main business logic so the Finalizer is still attached even when the job throws.


Step 7: Testing Queueable and Finalizers

Async testing is where teams often cut corners. Queueable and Finalizers are testable, but the strategy has to match the implementation.

Happy Path

Test.startTest();
System.enqueueJob(new AccountJobWithFinalizer(payload));
Test.stopTest();

Account result = [SELECT Description FROM Account WHERE Id = :acc.Id];
Assert.areEqual(
    'Processed via: Test',
    result.Description,
    'Expected job execution to update description.'
);

Test.stopTest() forces the queued work to run synchronously in the test context, so assertions can validate the final state.

Retry Logic

Forcing a real DML failure is possible, but it makes the test harder to control and less deterministic. A cleaner approach is to inject a known failure using the @TestVisible flag:

AccountJobWithFinalizer.simulateFailure = true;
AccountJobWithFinalizer.retryInvocationCount = 0;

Test.startTest();
System.enqueueJob(new AccountJobWithFinalizer(payload));

try {
    Test.stopTest();
} catch (CalloutException e) {
    Assert.isTrue(
        e.getMessage().contains('Simulated failure for testing'),
        'Expected simulated failure exception.'
    );
}

The Finalizer runs before the exception surfaces from Test.stopTest(), so the retry path still executes.

After that, assert the retry actually happened:

Assert.isTrue(
    AccountJobWithFinalizer.retryInvocationCount > 0,
    'Expected retry logic to enqueue a new job.'
);

Finally, reset static state so it does not leak into other tests:

AccountJobWithFinalizer.simulateFailure = false;

Architecture at a Glance

Here’s the execution flow for the Finalizer-based retry pattern:

Trigger / Service Layer
    |
    v
System.enqueueJob(AccountJobWithFinalizer)
    |
    v
AccountJobWithFinalizer.execute()
    |
    +--> attachFinalizer()
    +--> business logic runs
    |
    v
Finalizer.execute()
    |
    +--> SUCCESS → log / proceed
    +--> FAILURE → retry (guarded by retryCount < MAX_RETRIES)

Key Takeaways

  • @future cannot accept sObjects — that is a platform constraint

  • Queueable supports complex types, structured payloads, and job IDs

  • Chaining enables sequential async workflows without external orchestration

  • Finalizers run after execution regardless of outcome and are the right place for retry logic

  • Retry logic must be guarded to prevent infinite loops

  • Queueable is the modern async standard in Apex


Project Repository

The complete working code for everything covered in this article, including tests, is available here:

🔗 github.com/Mgbams/salesforce-spring-26 — replace-future-method-with-queueable


Further Reading


Most teams don’t migrate off @future until something forces their hand — a SOQL limit, a failed integration, or a workflow that simply cannot be orchestrated cleanly.

The platform already gives you the better pattern.

Where in your org is @future still hiding, and what would it take to move to a Queueable-first architecture?