Money transfer
Move money between two accounts without leaving a partial debit or credit when a downstream operation fails.
Product requirements
The transfer accepts a source account, destination account, amount, and note. It must:
- Check that the source account can cover the transfer. Reject the Flow before creating any side effect when the balance is insufficient.
- Create a debit memo, debit the source account, create a credit memo, and credit the destination account in that order.
- Complete only after the destination account is credited.
- Compensate the transfer when a side-effecting Step still fails after its retry policy is exhausted.
The happy path does not wait for approval, a Channel message, or an RPC. It only waits between retries. The Go, Java, Python, and TypeScript examples retry each side-effecting Step for up to one hour, then retry compensation for up to 24 hours. The Rust example uses three attempts so its released-SDK integration test finishes quickly.
The Rust example uses a positive amount as its stubbed balance check and records ledger effects as events. The other examples call an in-memory dependency service with explicit balance and undo methods.
Potential failures fall into three groups:
- An insufficient balance is a business rejection. The Flow fails immediately because no money has moved.
- Memo, debit, and credit calls can fail transiently. Dex retries them with backoff without losing the current Step.
- An operation can keep failing after retries are exhausted. The Flow proceeds to compensation, reverses the transfer effects, and finishes with a failed result. Compensation itself is retried because leaving it incomplete is also a failure.
The dependency calls must be idempotent. A Worker can retry a call after the remote system committed it but before Dex recorded the result.
Flow design
This interactive definition graph is generated from the runnable Python Flow.
The Flow is a sequential saga:
CheckBalance → CreateDebitMemo → Debit → CreateCreditMemo → Credit
It combines these design patterns:
- Fail-fast validation keeps an expected business rejection outside the compensation path.
- Durable retry handles transient dependency failures at the Step that owns the operation.
- Execute Failure Recovery moves an exhausted side-effecting Step to Compensate.
- Saga compensation applies inverse operations and then force-fails the Flow so callers cannot mistake a reversed transfer for success.
Each side effect has its own Step. This makes retry state and execution history visible, and it prevents a retry of Credit from repeating Debit.
Core implementation
The tabs show the complete Flow declaration, required Flow methods, topology, retry policy, and compensation path. Each linked example also includes its request model, dependency service, HTTP controller, and integration test.
def compensated_step_options(
total_duration: timedelta,
) -> StepOptions:
return StepOptions(
execute_retry=RetryPolicy(total_duration=total_duration)
).on_execute_failure_proceed_to(
Compensate,
StepOptions(execute_retry=COMPENSATE_RETRY),
)
class MoneyTransferFlow(Flow[TransferRequest]):
def __init__(self, service: MyDependencyService) -> None:
self.service = service
self.compensate = Compensate(service)
options = compensated_step_options(timedelta(hours=1))
self.credit = Credit(service, options)
self.create_credit_memo = CreateCreditMemo(service, self.credit, options)
self.debit = Debit(service, self.create_credit_memo, options)
self.create_debit_memo = CreateDebitMemo(service, self.debit, options)
self.check_balance = CheckBalance(service, self.create_debit_memo)
def get_steps(self) -> StepList[TransferRequest]:
return StepList.start_step(self.check_balance).other_steps(
self.create_debit_memo,
self.debit,
self.create_credit_memo,
self.credit,
self.compensate,
)
def get_persistence_schema(self) -> PersistenceSchema:
return PersistenceSchema.of()
class Compensate(Step[TransferRequest]):
def __init__(self, service: MyDependencyService) -> None:
self.service = service
def get_step_options(self) -> StepOptions:
return StepOptions(execute_retry=COMPENSATE_RETRY)
def execute(self, context: Context, input: TransferRequest) -> StepDecision:
self.service.undo_credit(input.to_account, input.amount)
self.service.undo_create_credit_memo(
input.to_account,
input.amount,
input.notes,
)
self.service.undo_create_debit_memo(
input.from_account,
input.amount,
input.notes,
)
self.service.undo_debit(input.from_account, input.amount)
return force_fail(
f"transfer has failed from {input.from_account} "
f"to {input.to_account} for amount {input.amount}"
)
Example: examples/python/dex_examples/products/money-transfer/money_transfer_flow.py
Demo
Run Dex and any language example server, then start Money transfer from the examples playground. The screenshot shows one successful transfer with an amount of 42 units. The Flow debited checking-account, credited savings-account, and completed all five operations in order: balance check, debit memo, debit, credit memo, and credit.
