Tax / Fiscal · Architecture

Acumatica Saga Pattern for Transactions

Acumatica Saga Pattern for Transactions is the Acumatica operations topic that you only get good at by doing it badly a few times.

John Kihiu12 min read

The moment a business process spans Acumatica and an external system — a payment gateway, a 3PL warehouse, a marketplace — the neat guarantee of a database transaction disappears. Acumatica can commit a Sales Order in a single BQL transaction; it cannot also commit "charge the card" and "reserve the pallet" atomically, because those live in someone else's database behind someone else's API. The saga pattern is how you get correctness back without a distributed transaction coordinator, which nobody sane runs across SaaS boundaries anyway.

Why ACID stops at the tenant boundary

A two-phase commit needs every participant to expose a prepare/commit protocol and to hold locks until the coordinator says go. Acumatica's API doesn't offer that, and neither does Stripe, nor a third-party WMS. So the instant a workflow touches more than one system, you've left ACID territory whether you planned to or not. The failure mode people underestimate isn't "the whole thing fails" — it's the partial failure: the Sales Order is created, the payment gateway charges the customer, and then the warehouse reservation call times out. Nothing rolled back, because nothing was ever in the same transaction to begin with.

The saga pattern in plain terms

A saga replaces one big transaction with a sequence of small local transactions, each of which is paired with a compensating action that undoes it if a later step fails. Step 1 creates the Sales Order. Step 2 reserves inventory. Step 3 charges the payment gateway. If step 3 fails, you don't roll back the database — you run the compensations for steps 1 and 2 in reverse: release the inventory reservation, then void or cancel the Sales Order. The guarantee a saga gives you is weaker than ACID (the system passes through visibly inconsistent intermediate states) but it's the only guarantee available once a workflow crosses process boundaries, and it's enough as long as every step has a real compensating action.

Orchestration vs. choreography

There are two ways to coordinate the steps. In an orchestrated saga, a central coordinator — typically a middleware service or an integration layer sitting outside Acumatica — calls each participant in order, waits for success or failure, and decides which compensations to fire. This is easier to reason about and debug because the whole state machine lives in one place. In a choreographed saga, there's no central brain: each system reacts to events published by the previous one and publishes its own event when done, including failure events that downstream (or upstream) systems listen for to trigger their own compensation. Choreography scales better with more participants but gets hard to trace once you have more than three or four steps — you end up reconstructing the flow from logs across systems you don't all own. For an Acumatica integration with two or three external dependents, orchestration is almost always the less painful choice.

Pick orchestration until it hurts

Choreography looks more "event-driven" and elegant on a whiteboard, but debugging a stuck saga across four independently-owned event logs at 2am is not where you want to be. Start with a single orchestrator service that owns the saga state; only decompose into choreography if the orchestrator becomes an actual bottleneck.

A concrete Acumatica example

Take an order flow: create a Sales Order in Acumatica, reserve stock, then charge the customer through an external payment gateway. The saga looks like this: Step 1 creates the SO via the Acumatica REST API (contract-based endpoint) and leaves it in an Open, unconfirmed state. Step 2 calls the gateway's authorization endpoint to hold funds — not capture yet. Step 3, once authorization succeeds, captures the payment and only then does the orchestrator flip the Sales Order to a confirmed status (or trigger shipment confirmation). If the capture step fails, the compensating actions run in reverse: void the authorization at the gateway, and either cancel the Sales Order or move it to a "payment failed" status rather than leaving it looking like a valid confirmed order. The key design decision is to authorize before you touch inventory in any way that's hard to undo, and to capture last, since capture is the step with the worst compensating action (a refund, which has its own delay and failure modes).

Where business events and the API fit

Acumatica's business events are a natural trigger point for saga steps, but they work best as the notifier, not the orchestrator. A business event on the Sales Order (fired on a status change, for example) can push a webhook or message to your external orchestrator saying "SO created, proceed to inventory reservation" — the actual sequencing and compensation logic should live in that external service, not in generic inquiry-driven business event chains inside Acumatica itself. Trying to build a multi-step saga entirely out of chained business events tends to produce a fragile, hard-to-debug web of triggers with no single place to see saga state. Use business events to emit "this local transaction completed" signals, and keep the compensating-action logic and step tracking in code you control outside Acumatica.

C# · SAGA STEP WITH COMPENSATION
public class ChargePaymentStep : ISagaStep
{
    public async Task<StepResult> ExecuteAsync(SagaContext ctx)
    {
        var auth = await _gateway.Authorize(ctx.OrderId, ctx.Amount);
        if (!auth.Success)
            return StepResult.Failed(auth.ErrorMessage);

        ctx.Set("authorizationId", auth.AuthorizationId);
        return StepResult.Succeeded();
    }

    public async Task CompensateAsync(SagaContext ctx)
    {
        // Undo: void the hold, do not touch the SO here —
        // the orchestrator calls the SO compensation step separately
        var authId = ctx.Get<string>("authorizationId");
        if (authId != null)
            await _gateway.VoidAuthorization(authId);
    }
}

// Orchestrator, simplified
foreach (var step in steps)
{
    var result = await step.ExecuteAsync(context);
    if (!result.Success)
    {
        foreach (var completed in executedSteps.Reverse())
            await completed.CompensateAsync(context);
        break;
    }
    executedSteps.Add(step);
}

Wrapping up

Sagas aren't a framework you install — they're a discipline of pairing every cross-system action with a compensating one and tracking saga state somewhere durable, outside of Acumatica's own transaction log. Get the compensations right (especially for payment capture, the hardest one to undo cleanly) before you worry about orchestration vs. choreography, and lean on Acumatica business events as triggers rather than as the coordination engine itself.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.