Sagas

A saga is a long-running business process that coordinates commands across multiple aggregations. Unlike an aggregation (which is a single consistency boundary), a saga spans multiple aggregations and manages the workflow between them using the Saga pattern.

What Sagas Are

Sagas are the solution to a fundamental distributed systems problem: how do you maintain consistency across multiple services when atomic transactions aren't available?

Why Use Sagas

In traditional monolithic applications, you could wrap multiple changes in a single database transaction. In distributed systems, this isn't possible — each service owns its data. The Saga pattern provides an alternative:

ApproachDescriptionTrade-off
Distributed TransactionsTwo-phase commit across servicesRequires all services to support it; can block indefinitely
Saga PatternSequential local transactions with compensationEventual consistency; compensation must be designed up-front

Sagas are ideal for:

Declaring a Saga

Use the saga keyword to declare a saga class. Sagas are built on top of aggregations, so they inherit all aggregation capabilities plus additional saga-specific features:

use Sourcing;

saga OrderSaga {
    has Str $.saga-id is projection-id;
    has Str $.state = 'pending';
    has Int $.order-id;
    has Int $.customer-id;

    multi method apply(OrderRequested $e --> 'creating') {
        $!order-id = $e.order-id;
        $!customer-id = $e.customer-id;
    }

    multi method apply(OrderCompleted $e --> 'completed') { }

    method cancel-order() is command {
        self.undo: { self.send-command: Order, { :order-id($!order-id) }, CancelOrder.new };
    }

    method handle-timeout() {
        self.rollback;
        $!state = 'cancelled';
    }
}

State Machine

Sagas use a declarative state machine. Each apply method declares its resulting state using the return type syntax --> 'state-name':

multi method apply(OrderPlaced $e --> 'processing') {
    $!order-id = $e.order-id;
    'processing'  # State transitions to 'processing'
}

multi method apply(PaymentConfirmed $e --> 'ready-to-ship') { } # State transitions to 'ready-to-ship'

The saga automatically tracks the current state in $.state. You can also guard command methods to specific states using the is on-state trait:

method ship-order() is on-state('ready-to-ship') is command {
    $.order-shipped: :tracking(self.generate-tracking);
}

Compensation and Rollback

When a step succeeds, register an undo block that can reverse it if a later step fails:

multi method apply(PaymentProcessed $e --> 'paying') {
    # Store the undo block immediately after the action succeeds
    self.undo: *.payment-refund: $e.amount;

    # Continue to next step
    self.send-command: Inventory, { :order-id($!order-id) }, InventoryReserved.new;
}

If any exception occurs, the saga automatically:

  1. Executes all undo blocks in LIFO order (last-in, first-out)
  2. Clears the undo block stack
  3. Transitions to the 'failed' state
method rollback() is command {
    # Automatically called on any unhandled exception
    # Executes undo blocks in reverse order of registration
}
Undo Block Design

Always register undo blocks immediately after a step succeeds. Don't wait — if an exception happens before you register, there's no way to undo that step!

Undo Blocks

Instead of registering compensation events, you can register undo blocks — callables that execute directly during rollback. This is useful when you need to call methods on aggregates without generating new compensation events.

multi method apply(TransferExecuted $e --> 'depositing') {
    # Withdraw from source account
    $!from-account.withdraw: $!amount;

    # Register an undo block to reverse this action
    # The block receives the saga as $_, enabling method chaining
    $.undo: *.from-account.deposit: $!amount;
}

Undo blocks execute in LIFO order during rollback. They receive the saga instance as the topic, allowing concise method chaining:

method rollback() is command {
    # Executes all undo blocks in reverse order of registration
}

When to use undo blocks:

When to UseExample
Direct aggregate manipulationReversing a withdrawal directly
Simpler workflowsCancelling an order by sending a command
When events aren't neededTemporary state changes

Timeout Handling

Long-running processes need timeouts to handle failure scenarios like a service going down. Sagas support scheduled timeouts. By default, timeouts will call the rollback method to automatically undo any completed steps:

multi method apply(OrderPlaced $e --> 'waiting-payment') {
    # Schedule a timeout to cancel if payment isn't received
    # This will automatically call rollback() when it fires
    self.timeout-in: 'rollback', :5minutes;
}

You can also specify a custom timeout handler:

multi method apply(OrderPlaced $e --> 'waiting-payment') {
    # Schedule a custom timeout handler
    self.timeout-in: 'cancel-if-no-payment', :5minutes;
}

method cancel-if-no-payment() {
    self.rollback;
    $!state = 'cancelled';
}

The timeout methods are:

MethodPurpose
timeout-in($name, :5minutes)Schedule a timeout from within apply()
timeout-in('rollback', :5minutes)Schedule a timeout that defaults to calling rollback
cancel-timeout($name)Cancel a previously scheduled timeout
verify-timeouts()Check and fire expired timeouts (call periodically)
External Scheduler Required

Sagas don't run their own timers. Your application must periodically call verify-timeouts() on active sagas (e.g., every 10 seconds via a cron job or background worker).

Saga-Aggregate Coordination

Sagas can send commands to and bind aggregations for coordinated workflows:

send-command

Send a command directly to an aggregate:

method apply(OrderPlaced $e --> 'authorizing') {
    # Send payment command to the Payment aggregate
    self.send-command: Payment, { :order-id($!order-id) }, AuthorizePayment.new(:amount($e.amount));
}

bind-aggregate

Bind an aggregation attribute to the saga for lazy loading:

saga AccountTransfer {
    has Account $.from-account;
    has Account $.to-account;

    multi method apply(TransferStarted $e --> 'withdrawing') {
        $!from-account = self.bind-aggregate: 'from-account', Account, :id($e.from-id);
        $!to-account   = self.bind-aggregate: 'to-account', Account, :id($e.to-id);
    }
}

Saga Design Rules

Example: Order Creation Saga

use Sourcing;

# Events
class OrderRequested    { has $.saga-id; has $.customer-id; has $.total }
class PaymentAuthorized { has $.saga-id; has $.payment-id }
class InventoryReserved { has $.saga-id; has $.reservation-id }
class OrderShipped      { has $.saga-id; has $.tracking-number }
class OrderCompleted    { has $.saga-id }

# Sagas
saga CreateOrder {
    has Str     $.saga-id is projection-id;
    has Str     $.state = 'pending';

    multi method apply(OrderRequested $e --> 'authorizing') {
        self.send-command: Payment, { :saga-id($!saga-id) }, Authorize.new(:amount($e.total));
        self.timeout-in: 'payment-timeout', :1minute;
    }

    multi method apply(PaymentAuthorized $e --> 'reserving') {
        self.cancel-timeout: 'payment-timeout';
        self.send-command: Inventory, { :saga-id($!saga-id) }, Reserve.new(:items($e.items));
        self.undo: { self.send-command: Payment, { :saga-id($!saga-id) }, Refund.new(:payment-id($e.payment-id)) };
        self.timeout-in: 'inventory-timeout', :2minutes;
    }

    multi method apply(InventoryReserved $e --> 'shipping') {
        self.cancel-timeout: 'inventory-timeout';
        self.send-command: Shipping, { :saga-id($!saga-id) }, Ship.new(:address($e.address));
        self.undo: { self.send-command: Inventory, { :saga-id($!saga-id) }, Release.new(:reservation-id($e.reservation-id)) };
    }

    multi method apply(OrderShipped $e --> 'completed') {
        self.undo: { self.send-command: Shipping, { :saga-id($!saga-id) }, Cancel.new(:tracking($e.tracking-number)) };
    }

    method payment-timeout {
        self.rollback;
        $!state = 'payment-failed';
    }

    method inventory-timeout {
        self.rollback;
        $!state = 'inventory-failed';
    }
}

Next Steps