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?
- They coordinate multiple aggregations in a single business workflow
- They maintain a state machine that tracks progress through the process
- They implement compensation — if any step fails, previously completed steps are rolled back
- They support timeouts for long-running processes that might stall
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:
| Approach | Description | Trade-off |
|---|---|---|
| Distributed Transactions | Two-phase commit across services | Requires all services to support it; can block indefinitely |
| Saga Pattern | Sequential local transactions with compensation | Eventual consistency; compensation must be designed up-front |
Sagas are ideal for:
- Order processing — Validate payment, reserve inventory, schedule shipping
- Account transfers — Withdraw from source, deposit to destination
- Onboarding flows — Create user, send welcome email, provision resources
- Any multi-step business process that spans service boundaries
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:
- Executes all undo blocks in LIFO order (last-in, first-out)
- Clears the undo block stack
- Transitions to the
'failed'state
method rollback() is command {
# Automatically called on any unhandled exception
# Executes undo blocks in reverse order of registration
}
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 Use | Example |
|---|---|
| Direct aggregate manipulation | Reversing a withdrawal directly |
| Simpler workflows | Cancelling an order by sending a command |
| When events aren't needed | Temporary 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:
| Method | Purpose |
|---|---|
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) |
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
- Design undo blocks first. For every step, ask: "if this fails later, how do we undo it?"
- Register undo blocks immediately. After each successful step, register its undo block before proceeding.
- Keep sagas idempotent. Target aggregations should handle repeated commands gracefully.
- Use timeouts for critical paths. If a step takes too long, assume it failed and undo.
- Name states after user-facing outcomes.
'completed','cancelled','failed'
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
- See the Sourcing::Saga API Reference for all available methods
- Learn about the is on-state trait for state-guarded commands
- Read about Metaclasses — how the saga keyword works
- Explore Aggregations — sagas build on aggregation functionality