Bringing Itinera into existing code
What you want to do
The shop's first checkout, chapter 1's, still takes some of its orders, and it works. You want to bring it into Itinera without stopping it or rewriting it: one piece at a time, keeping the bank's client, the audit trail and the log it already has, with the checkout behaving as before after each move.
The code
The program is chapter 1's checkout. Its payment, which charged the card, renewed an expired one and audited the charge inside one function, is the piece moved first, into a workflow of its own, payment. The bank's client stays as it was: the steps ask for a trait, and the client implements it by calling its own methods:
/// What the payment's steps ask of the bank.
pub trait Payments: Send + Sync {
fn charge(&self, card: &str, amount: i64) -> Result<String, Declined>;
fn renewed_card(&self, card: &str) -> Option<String>;
}
/// The bank's client, as it was, behind the steps' trait.
impl Payments for Bank {
fn charge(&self, card: &str, amount: i64) -> Result<String, Declined> {
Bank::charge(self, card, amount)
}
fn renewed_card(&self, card: &str) -> Option<String> {
Bank::renewed_card(self, card)
}
}/// What the payment's steps ask of the bank.
#[async_trait]
pub trait Payments: Send + Sync {
async fn charge(&self, card: &str, amount: i64) -> Result<String, Declined>;
async fn renewed_card(&self, card: &str) -> Option<String>;
}
/// The bank's client, as it was, behind the steps' trait.
#[async_trait]
impl Payments for Bank {
async fn charge(&self, card: &str, amount: i64) -> Result<String, Declined> {
Bank::charge(self, card, amount).await
}
async fn renewed_card(&self, card: &str) -> Option<String> {
Bank::renewed_card(self, card).await
}
}The charge becomes a step, and the renewal its rescue step. The charge no longer renews anything: it says why it failed.
/// Charges the amount to the card, and gives back the receipt.
#[derive(Step)]
struct Charge {
#[input]
card: String,
#[input]
amount: i64,
}
#[step]
impl Charge {
fn run(
self,
#[service] payments: &dyn Payments,
#[contributor] mut contributor: Contributor<'_>,
) -> Result<Outcome, Error> {
match payments.charge(&self.card, self.amount) {
Ok(receipt) => {
contributor.contribute("receipt", receipt);
Self::succeed()
}
Err(Declined::ExpiredCard) => Self::fail("card-expired"),
Err(Declined::Refused) => Self::fail("declined"),
}
}
}
/// Repairs a charge whose card had expired: it asks the bank for the card that replaced it.
#[derive(RescueStep)]
struct RenewCard {
#[input]
card: String,
#[reason]
reason: Reason,
}
#[rescue_step]
impl RenewCard {
fn run(
self,
#[service] payments: &dyn Payments,
#[contributor] mut contributor: Contributor<'_>,
) -> Result<RescueOutcome, Error> {
if self.reason.code() != "card-expired" {
return Self::fail("not-repairable");
}
let Some(card) = payments.renewed_card(&self.card) else {
return Self::fail("not-renewed");
};
contributor.contribute("card", card);
Self::succeed()
}
}/// Charges the amount to the card, and gives back the receipt.
#[derive(Step)]
struct Charge {
#[input]
card: String,
#[input]
amount: i64,
}
#[step]
impl Charge {
async fn run(
self,
#[service] payments: &dyn Payments,
#[contributor] mut contributor: Contributor<'_>,
) -> Result<Outcome, Error> {
match payments.charge(&self.card, self.amount).await {
Ok(receipt) => {
contributor.contribute("receipt", receipt);
Self::succeed()
}
Err(Declined::ExpiredCard) => Self::fail("card-expired"),
Err(Declined::Refused) => Self::fail("declined"),
}
}
}
/// Repairs a charge whose card had expired: it asks the bank for the card that replaced it.
#[derive(RescueStep)]
struct RenewCard {
#[input]
card: String,
#[reason]
reason: Reason,
}
#[rescue_step]
impl RenewCard {
async fn run(
self,
#[service] payments: &dyn Payments,
#[contributor] mut contributor: Contributor<'_>,
) -> Result<RescueOutcome, Error> {
if self.reason.code() != "card-expired" {
return Self::fail("not-repairable");
}
let Some(card) = payments.renewed_card(&self.card).await else {
return Self::fail("not-renewed");
};
contributor.contribute("card", card);
Self::succeed()
}
}The audit becomes a policy, which writes to the audit trail with the function the checkout already used:
/// Writes every successful charge to the shop's audit trail.
#[derive(Default)]
struct Audit;
#[step_policy(name = "audit")]
impl Audit {
#[on_step_success]
fn charged(
&self,
#[from_workflow] amount: i64,
#[from_step] receipt: String,
) -> Result<Option<OnSuccess>, Error> {
audit::record(&format!("charged {amount}, receipt {receipt}"));
Self::carry_on()
}
}/// Writes every successful charge to the shop's audit trail.
#[derive(Default)]
struct Audit;
#[step_policy(name = "audit")]
impl Audit {
#[on_step_success]
async fn charged(
&self,
#[from_workflow] amount: i64,
#[from_step] receipt: String,
) -> Result<Option<OnSuccess>, Error> {
audit::record(&format!("charged {amount}, receipt {receipt}"));
Self::carry_on()
}
}The log line the payment wrote becomes a reporter, which writes with the shop's existing log::info:
/// Writes to the shop's log when a step fails, and when a rescue step repairs it.
#[derive(Default)]
struct Log;
impl Reporter for Log {
fn report(&mut self, event: &Event) -> Result<(), Error> {
match &event.body {
EventBody::StepFailed { step, reason, .. } => log::info(&format!(
"payment {}: {} failed, {}",
event.journey_id,
step.step,
reason.code()
)),
EventBody::StepRescued { step, rescue, .. } => log::info(&format!(
"payment {}: {} repaired by {rescue}",
event.journey_id, step.step
)),
_ => {}
}
Ok(())
}
}The workflow has one step, and its provider makes the bank's client as the checkout did:
/// Charges an order, renewing a card that has expired, and audits the charge.
#[workflow(name = "payment", reporters = [Log])]
#[with_step(Charge, name = "charge", rescue = RenewCard, policies = [Audit])]
pub struct Payment;
#[workflow]
impl Payment {
/// Names each journey after its order.
#[id_generator]
fn order_number(&self, data: &DataBag) -> Result<String, Error> {
Ok(data.required("order")?)
}
/// The bank, as it is configured today.
#[provider(key = "payments")]
fn bank(&self) -> Result<Provided<dyn Payments>, Error> {
Ok(provide!(Bank {
this_year: 2026,
limit: 1000,
}))
}
}
/// What a payment starts with.
#[derive(InitialData)]
pub struct Due {
pub order: String,
pub card: String,
pub amount: i64,
}
/// What a payment gives back.
#[derive(Output)]
pub struct Paid {
pub receipt: String,
}The rest of the checkout is chapter 1's, with three changes: Shop no longer holds the bank; CheckoutError gains a variant for a payment that did not run, which not_run makes from any error; and the line that took the payment now calls pay:
/// An order, as the shop's website hands it over.
pub struct Order {
pub order: &'static str,
pub customer: &'static str,
pub card: &'static str,
pub price: i64,
pub discount: Option<i64>,
pub weight: i64,
}
/// Why a checkout did not complete.
#[derive(Debug)]
pub enum CheckoutError {
OutOfStock,
PaymentDeclined(Declined),
NotShipped,
PaymentNotRun(String),
}
impl fmt::Display for CheckoutError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::OutOfStock => write!(f, "out of stock"),
Self::PaymentDeclined(Declined::ExpiredCard) => write!(f, "card expired"),
Self::PaymentDeclined(Declined::Refused) => write!(f, "payment declined"),
Self::NotShipped => write!(f, "no carrier could take it"),
Self::PaymentNotRun(why) => write!(f, "the payment did not run: {why}"),
}
}
}
/// Why the payment workflow could not say how the payment went.
fn not_run(error: impl fmt::Display) -> CheckoutError {
CheckoutError::PaymentNotRun(error.to_string())
}
impl From<Declined> for CheckoutError {
fn from(declined: Declined) -> Self {
Self::PaymentDeclined(declined)
}
}
/// The shop, with everything it talks to.
pub struct Shop {
pub warehouse: Warehouse,
pub main: Carrier,
pub backup: Carrier,
pub messages: Messages,
}
impl Shop {
/// Reserves, charges and ships an order, and tells the customer how it went.
pub fn checkout(&self, order: &Order) -> Result<String, CheckoutError> {
log::info(&format!("checkout {}", order.order));
let shipped = self.reserve_charge_and_ship(order);
match &shipped {
Ok(tracking) => self.messages.tell(
order.customer,
&format!("order {} is on its way: {tracking}", order.order),
),
Err(error) => {
log::warn(&format!("checkout {} failed: {error}", order.order));
self.messages.tell(
order.customer,
&format!("order {} could not be completed", order.order),
);
}
}
shipped
}
fn reserve_charge_and_ship(&self, order: &Order) -> Result<String, CheckoutError> {
self.warehouse
.reserve(order.order)
.map_err(|_| CheckoutError::OutOfStock)?;
let amount = order.price - order.discount.unwrap_or(0);
self.pay(order.order, order.card, amount)?;
shipping::ship(&self.main, &self.backup, order.order, order.weight)
.ok_or(CheckoutError::NotShipped)
}/// An order, as the shop's website hands it over.
pub struct Order {
pub order: &'static str,
pub customer: &'static str,
pub card: &'static str,
pub price: i64,
pub discount: Option<i64>,
pub weight: i64,
}
/// Why a checkout did not complete.
#[derive(Debug)]
pub enum CheckoutError {
OutOfStock,
PaymentDeclined(Declined),
NotShipped,
PaymentNotRun(String),
}
impl fmt::Display for CheckoutError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::OutOfStock => write!(f, "out of stock"),
Self::PaymentDeclined(Declined::ExpiredCard) => write!(f, "card expired"),
Self::PaymentDeclined(Declined::Refused) => write!(f, "payment declined"),
Self::NotShipped => write!(f, "no carrier could take it"),
Self::PaymentNotRun(why) => write!(f, "the payment did not run: {why}"),
}
}
}
/// Why the payment workflow could not say how the payment went.
fn not_run(error: impl fmt::Display) -> CheckoutError {
CheckoutError::PaymentNotRun(error.to_string())
}
impl From<Declined> for CheckoutError {
fn from(declined: Declined) -> Self {
Self::PaymentDeclined(declined)
}
}
/// The shop, with everything it talks to.
pub struct Shop {
pub warehouse: Warehouse,
pub main: Carrier,
pub backup: Carrier,
pub messages: Messages,
}
impl Shop {
/// Reserves, charges and ships an order, and tells the customer how it went.
pub async fn checkout(&self, order: &Order) -> Result<String, CheckoutError> {
log::info(&format!("checkout {}", order.order));
let shipped = self.reserve_charge_and_ship(order).await;
match &shipped {
Ok(tracking) => self.messages.tell(
order.customer,
&format!("order {} is on its way: {tracking}", order.order),
),
Err(error) => {
log::warn(&format!("checkout {} failed: {error}", order.order));
self.messages.tell(
order.customer,
&format!("order {} could not be completed", order.order),
);
}
}
shipped
}
async fn reserve_charge_and_ship(&self, order: &Order) -> Result<String, CheckoutError> {
self.warehouse
.reserve(order.order)
.map_err(|_| CheckoutError::OutOfStock)?;
let amount = order.price - order.discount.unwrap_or(0);
self.pay(order.order, order.card, amount).await?;
shipping::ship(&self.main, &self.backup, order.order, order.weight)
.ok_or(CheckoutError::NotShipped)
}pay runs the workflow, and turns how the journey ended back into the checkout's own errors:
/// Charges an order's card through the payment workflow, and gives back the receipt.
fn pay(&self, order: &str, card: &str, amount: i64) -> Result<String, CheckoutError> {
let instance = Payment::builder()
.initial_data(Due {
order: order.to_owned(),
card: card.to_owned(),
amount,
})
.create()
.map_err(not_run)?;
let result = local_executor_for!(Payment)
.run(instance)
.map_err(not_run)?;
match &result.status {
JourneyStatus::Succeeded { .. } => {
let paid: Paid = result.output().map_err(not_run)?;
Ok(paid.receipt)
}
JourneyStatus::Failed {
failure: Failure::Failure(reason),
..
} if reason.code() == "card-expired" => Err(Declined::ExpiredCard.into()),
JourneyStatus::Failed { .. } => Err(Declined::Refused.into()),
_ => Err(CheckoutError::PaymentNotRun(
result.status.kind().to_string(),
)),
}
}/// Charges an order's card through the payment workflow, and gives back the receipt.
async fn pay(&self, order: &str, card: &str, amount: i64) -> Result<String, CheckoutError> {
let instance = Payment::builder()
.initial_data(Due {
order: order.to_owned(),
card: card.to_owned(),
amount,
})
.create()
.map_err(not_run)?;
let result = local_executor_for!(Payment)
.run(instance)
.await
.map_err(not_run)?;
match &result.status {
JourneyStatus::Succeeded { .. } => {
let paid: Paid = result.output().map_err(not_run)?;
Ok(paid.receipt)
}
JourneyStatus::Failed {
failure: Failure::Failure(reason),
..
} if reason.code() == "card-expired" => Err(Declined::ExpiredCard.into()),
JourneyStatus::Failed { .. } => Err(Declined::Refused.into()),
_ => Err(CheckoutError::PaymentNotRun(
result.status.kind().to_string(),
)),
}
}The asynchronous form starts from a checkout that was already asynchronous, its bank's client, checkout and main awaiting as they go. Payments and the client's implementation of it take #[async_trait], the implementation awaiting the client, the steps' run and the audit's hook are async fn, and pay awaits the journey where the checkout awaited the payment. main is chapter 1's, with the same four orders, and both forms print:
INFO checkout A-1
AUDIT charged 35, receipt R-ann-card-2028-35
to Ann: order A-1 is on its way: T-A-1
A-1: shipped, T-A-1
INFO checkout A-2
INFO payment A-2: charge failed, card-expired
INFO payment A-2: charge repaired by charge_rescue
AUDIT charged 25, receipt R-ben-card-2029-25
to Ben: order A-2 is on its way: T-A-2
A-2: shipped, T-A-2
INFO checkout A-3
AUDIT charged 30, receipt R-cat-card-2027-30
WARN Rapid cannot take A-3: 30 kg is over its 20 kg limit; trying Steady
to Cat: order A-3 is on its way: B-A-3
A-3: shipped, B-A-3
INFO checkout A-4
INFO payment A-4: charge failed, card-expired
INFO payment A-4: charge repaired by charge_rescue
INFO payment A-4: charge failed, declined
WARN checkout A-4 failed: payment declined
to Dan: order A-4 could not be completed
A-4: failed, payment declinedHow it works
- The rest of the program doesn't know.
checkoutstill returns the same result and the same errors, the shipping and the messages are untouched, and each order ends as it did in chapter 1.payis the only seam: it creates an instance, runs its journey and reads how it ended. - Existing code is kept, behind the workflow's parts. The bank's client is a service through the trait it implements, the audit trail is called from a policy, and the log from a reporter. What moves into the workflow is the decisions: when to renew a card, and when to audit. The code that talks to the outside world stays where it was.
- The log now says what happened. Chapter 1's payment logged that it was renewing a card, before it tried. Now the log follows the journey's events: Ben's charge failed with
card-expired, the rescue step repaired it, and the charge succeeded. Dan's was repaired too, then failed again,declined, which chapter 1's log never said. - One piece at a time. The shipping, with its backup carrier, and the messages to the customer are still chapter 1's code, running beside the workflow. Each can move next: the shipping as a step with a retry budget and a provider for each attempt, the messages as a policy acting through a role. Move one piece, run the program, and compare what it prints with what it printed before.
- Where Itinera fits, and where it doesn't. It fits a piece of business with decisions around it: whether to try again, how to repair, what to record and whom to tell. A single call with nothing to decide around it, such as reserving the goods here, gains nothing from a workflow of its own. It stays a call, or becomes a step of a workflow that has decisions to make.
The checkout's payment is now a workflow, and the shop has one way to charge a card. It also wants a second way to sell: customers collecting their orders at the shop's counter, paying at the counter's terminal, with nothing to ship.
Outcomes
We have shown how to bring Itinera into code that already works, one piece at a time, keeping the code that talks to the outside world.
| The usual way | With Itinera |
|---|---|
| Untangling a function often means rewriting it, and its callers, in one go. | One piece becomes a workflow behind a function the callers already call. |
| A client, a log or an audit trail written for the old code is often rewritten for the new. | Each stays, called from a service's implementation, a policy or a reporter. |