The Itinera bookRust edition

Part 5: Designing with Itinera, Chapter 42

Using a step in another workflow

What you want to do

Customers can now collect their orders at the shop's counter, paying at the counter's card terminal and taking the goods away, so nothing is shipped. You want the counter to look the order up, price it and charge it exactly as delivered orders are, without copying any of that code, and to decide for itself everything else.

The code

The shop is the one chapter 37 lists. The counter's terminal is one more implementation of the Payments trait the charge already asks for, as chapter 12 shows it with its error:

Rust, from the program ch42_using_a_step_in_another_workflow
/// The shop counter's card terminal, faked: it declines a card that expired before this year,
/// and prints what it does. Its receipts start with `C`.
struct Terminal {
    this_year: u32,
}

impl Payments for Terminal {
    fn charge(&self, order: &str, card: &str, amount: i64) -> Result<Option<String>, NetworkDown> {
        if expiry(card).is_some_and(|year| year < self.this_year) {
            println!("the counter's terminal declines card {card}: it has expired");
            return Ok(None);
        }
        println!("the counter's terminal charges {amount} for order {order}");
        Ok(Some(format!("C-{order}")))
    }
}
Rust, the same in its asynchronous form, from the program ch42_using_a_step_in_another_workflow_async
/// The shop counter's card terminal, faked: it declines a card that expired before this year,
/// and prints what it does once it has answered. Its receipts start with `C`.
struct Terminal {
    this_year: u32,
}

#[async_trait]
impl Payments for Terminal {
    async fn charge(
        &self,
        order: &str,
        card: &str,
        amount: i64,
    ) -> Result<Option<String>, NetworkDown> {
        network().await;
        if expiry(card).is_some_and(|year| year < self.this_year) {
            println!("the counter's terminal declines card {card}: it has expired");
            return Ok(None);
        }
        println!("the counter's terminal charges {amount} for order {order}");
        Ok(Some(format!("C-{order}")))
    }
}

The counter's workflow lists three of the shop's steps, Lookup, Price and Charge, under the same names, and provides the order book and the terminal. Its initial data has what those steps read, and no parcel:

Rust, from the program ch42_using_a_step_in_another_workflow
/// The counter's workflow: it looks the order up, and finishes an order already completed.
/// Otherwise it works out what the order costs, then charges it at the counter's terminal and
/// audits the charge. The customer takes the goods away, so nothing is shipped, and a card the
/// terminal declines is handed back rather than repaired.
#[workflow(name = "counter", reporters = [Log])]
#[with_step(Lookup, name = "lookup", policies = [AlreadyCompleted])]
#[with_step(Price, name = "price")]
#[with_step(Charge, name = "charge", policies = [Audit])]
struct Counter;

#[workflow]
impl Counter {
    /// Names each journey after its order.
    #[id_generator]
    fn order_number(&self, data: &DataBag) -> Result<String, Error> {
        Ok(data.required("order")?)
    }

    /// The order book, which has the orders completed before today.
    #[provider(key = "order_book")]
    fn ledger(&self) -> Result<Provided<dyn OrderBook>, Error> {
        Ok(provide!(Ledger {
            completed: vec!["A-13"],
        }))
    }

    /// The counter's terminal, as the payment client of the journey.
    #[provider(key = "payments")]
    fn terminal(&self) -> Result<Provided<dyn Payments>, Error> {
        Ok(provide!(Terminal { this_year: 2026 }))
    }
}

/// What the shop knows about an order collected at the counter when its journey starts.
#[derive(InitialData)]
struct CounterOrder {
    order: String,
    price: i64,
    discount: Option<i64>,
    card: String,
    gift_card: bool,
}
Rust, the same in its asynchronous form, from the program ch42_using_a_step_in_another_workflow_async
/// The counter's workflow: it looks the order up, and finishes an order already completed.
/// Otherwise it works out what the order costs, then charges it at the counter's terminal and
/// audits the charge. The customer takes the goods away, so nothing is shipped, and a card the
/// terminal declines is handed back rather than repaired.
#[workflow(name = "counter", async_reporters = [Log])]
#[with_step(Lookup, name = "lookup", policies = [AlreadyCompleted])]
#[with_step(Price, name = "price")]
#[with_step(Charge, name = "charge", policies = [Audit])]
struct Counter;

#[workflow]
impl Counter {
    /// Names each journey after its order.
    #[id_generator]
    fn order_number(&self, data: &DataBag) -> Result<String, Error> {
        Ok(data.required("order")?)
    }

    /// The order book, which has the orders completed before today.
    #[provider(key = "order_book")]
    fn ledger(&self) -> Result<Provided<dyn OrderBook>, Error> {
        Ok(provide!(Ledger {
            completed: vec!["A-13"],
        }))
    }

    /// The counter's terminal, as the payment client of the journey.
    #[provider(key = "payments")]
    fn terminal(&self) -> Result<Provided<dyn Payments>, Error> {
        Ok(provide!(Terminal { this_year: 2026 }))
    }
}

/// What the shop knows about an order collected at the counter when its journey starts.
#[derive(InitialData)]
struct CounterOrder {
    order: String,
    price: i64,
    discount: Option<i64>,
    card: String,
    gift_card: bool,
}

What it names is as earlier chapters show it: the lookup, already_completed and OrderBook in chapter 22, the pricing in chapter 20, the charge in chapter 33, the audit in chapter 34, and the log in chapter 33, chapter 35 and chapter 36.

main prints the listing of each workflow with entry, as chapter 37 did:

Rust, from the program ch42_using_a_step_in_another_workflow
println!("orders:");
for listed in Orders::descriptor().listing() {
    println!("{}", entry(&listed));
}
println!("counter:");
for listed in Counter::descriptor().listing() {
    println!("{}", entry(&listed));
}

Then it runs two new orders at the counter: Nia's, with a discount, and Omar's, whose card has expired:

Rust, from the program ch42_using_a_step_in_another_workflow
let mut executor = local_executor_for!(Counter);
// The order, the customer, the price, the discount and the card.
let orders = [
    ("A-17", "Nia", 18, Some(3), "nia-2028"),
    ("A-18", "Omar", 12, None, "omar-2023"),
];
for (order, customer, price, discount, card) in orders {
    println!("{customer}'s order {order}, at the counter:");
    let instance = Counter::builder()
        .reporters_global_metadata("customer", customer.to_owned())
        .initial_data(CounterOrder {
            order: order.to_owned(),
            price,
            discount,
            card: card.to_owned(),
            gift_card: false,
        })
        .create()?;
    follow_up(&executor.run(instance)?)?;
}
Rust, the same in its asynchronous form, from the program ch42_using_a_step_in_another_workflow_async
// The order, the customer, the price, the discount and the card.
let orders = [
    ("A-17", "Nia", 18, Some(3), "nia-2028"),
    ("A-18", "Omar", 12, None, "omar-2023"),
];
for (order, customer, price, discount, card) in orders {
    println!("{customer}'s order {order}, at the counter:");
    let instance = Counter::builder()
        .reporters_global_metadata("customer", customer.to_owned())
        .initial_data(CounterOrder {
            order: order.to_owned(),
            price,
            discount,
            card: card.to_owned(),
            gift_card: false,
        })
        .create()?;
    follow_up(&journey(instance).await?)?;
}

follow_up now reads the counter's output, and says to hand back a card the terminal declined:

Rust, from the program ch42_using_a_step_in_another_workflow
/// What the counter reads back once the journey has ended.
#[derive(Output)]
struct Collected {
    receipt: Option<String>,
    audit: Option<String>,
}

/// Says how a journey at the counter ended.
fn follow_up(result: &JourneyResult) -> Result<(), Box<dyn std::error::Error>> {
    let data: Collected = result.output()?;
    match &result.status {
        JourneyStatus::Succeeded { .. } => {
            let receipt = data.receipt.as_deref().unwrap_or("none");
            println!("the journey succeeded, with receipt {receipt}");
            println!("audit: {}", data.audit.as_deref().unwrap_or("none"));
        }
        JourneyStatus::Failed {
            failure: Failure::Failure(reason),
            ..
        } => println!("the journey failed: {}, hand the card back", reason.code()),
        _ => println!("the journey ended as {}", result.status.kind()),
    }
    Ok(())
}
Rust, the same in its asynchronous form, from the program ch42_using_a_step_in_another_workflow_async
/// What the counter reads back once the journey has ended.
#[derive(Output)]
struct Collected {
    receipt: Option<String>,
    audit: Option<String>,
}

/// Says how a journey at the counter ended.
fn follow_up(result: &JourneyResult) -> Result<(), Box<dyn std::error::Error>> {
    let data: Collected = result.output()?;
    match &result.status {
        JourneyStatus::Succeeded { .. } => {
            let receipt = data.receipt.as_deref().unwrap_or("none");
            println!("the journey succeeded, with receipt {receipt}");
            println!("audit: {}", data.audit.as_deref().unwrap_or("none"));
        }
        JourneyStatus::Failed {
            failure: Failure::Failure(reason),
            ..
        } => println!("the journey failed: {}, hand the card back", reason.code()),
        _ => println!("the journey ended as {}", result.status.kind()),
    }
    Ok(())
}

/// Runs a journey at the counter with an executor of its own.
async fn journey(instance: Instance<Counter, Asynchronous>) -> Result<JourneyResult, Refusal> {
    local_executor_for!(Counter).run(instance).await
}

In the asynchronous form, journey runs a counter's instance with an executor of its own, as chapter 26's does for the shop, Payments and the terminal take #[async_trait], the counter lists Log under async_reporters, and each journey is awaited. It prints:

What ch42_using_a_step_in_another_workflow prints, recorded in ch42_using_a_step_in_another_workflow.txt
orders:
1. lookup, with already_completed; services: order_book from ledger
2. price
3. charge, with audit, outage; services: payments from bank; rescued by charge_rescue, services: cards from cards
4. ship, with failed_shipping; adapted by parcels; services: carrier from carriers
counter:
1. lookup, with already_completed; services: order_book from ledger
2. price
3. charge, with audit; services: payments from terminal
Nia's order A-17, at the counter:
log counter A-17 for Nia #1 journey_started card, discount, gift_card, order, price
log counter A-17 for Nia #2 attempt_started lookup, attempt 1
log counter A-17 for Nia #3 service_provided lookup gets order_book from ledger, a ch42_using_a_step_in_another_workflow::Ledger
the order book has no order A-17 yet
log counter A-17 for Nia #4 step_succeeded lookup
log counter A-17 for Nia #5 contribution_committed completed
log counter A-17 for Nia #6 hook_called already_completed: on step success of lookup
log counter A-17 for Nia #7 attempt_started price, attempt 1
log counter A-17 for Nia #8 step_succeeded price
log counter A-17 for Nia #9 contribution_committed amount
log counter A-17 for Nia #10 attempt_started charge, attempt 1
log counter A-17 for Nia #11 service_provided charge gets payments from terminal, a ch42_using_a_step_in_another_workflow::Terminal
the counter's terminal charges 15 for order A-17
log counter A-17 for Nia, receipt C-A-17 #12 global_metadata_set receipt
log counter A-17 for Nia, receipt C-A-17 #13 step_succeeded charge
log counter A-17 for Nia, receipt C-A-17 #14 contribution_committed receipt
log counter A-17 for Nia, receipt C-A-17 #15 hook_called audit: on step success of charge
log counter A-17 for Nia, receipt C-A-17 #16 contribution_committed audit
log counter A-17 for Nia, receipt C-A-17 #17 journey_succeeded
the journey succeeded, with receipt C-A-17
audit: A-17: charge took 15 on attempt 1, receipt C-A-17
Omar's order A-18, at the counter:
log counter A-18 for Omar #1 journey_started card, gift_card, order, price
log counter A-18 for Omar #2 attempt_started lookup, attempt 1
log counter A-18 for Omar #3 service_provided lookup gets order_book from ledger, a ch42_using_a_step_in_another_workflow::Ledger
the order book has no order A-18 yet
log counter A-18 for Omar #4 step_succeeded lookup
log counter A-18 for Omar #5 contribution_committed completed
log counter A-18 for Omar #6 hook_called already_completed: on step success of lookup
log counter A-18 for Omar #7 attempt_started price, attempt 1
log counter A-18 for Omar #8 optional_input_absent discount
log counter A-18 for Omar #9 step_succeeded price
log counter A-18 for Omar #10 contribution_committed amount
log counter A-18 for Omar #11 attempt_started charge, attempt 1
log counter A-18 for Omar #12 service_provided charge gets payments from terminal, a ch42_using_a_step_in_another_workflow::Terminal
the counter's terminal declines card omar-2023: it has expired
log counter A-18 for Omar #13 step_failed charge: card-expired
log counter A-18 for Omar #14 step_given_up charge: failure
log counter A-18 for Omar #15 journey_failed charge
the journey failed: card-expired, hand the card back
What the asynchronous form ch42_using_a_step_in_another_workflow_async prints, recorded in ch42_using_a_step_in_another_workflow_async.txt
orders:
1. lookup, with already_completed; services: order_book from ledger
2. price
3. charge, with audit, outage; services: payments from bank; rescued by charge_rescue, services: cards from cards
4. ship, with failed_shipping; adapted by parcels; services: carrier from carriers
counter:
1. lookup, with already_completed; services: order_book from ledger
2. price
3. charge, with audit; services: payments from terminal
Nia's order A-17, at the counter:
log counter A-17 for Nia #1 journey_started card, discount, gift_card, order, price
log counter A-17 for Nia #2 attempt_started lookup, attempt 1
log counter A-17 for Nia #3 service_provided lookup gets order_book from ledger, a ch42_using_a_step_in_another_workflow_async::Ledger
the order book has no order A-17 yet
log counter A-17 for Nia #4 step_succeeded lookup
log counter A-17 for Nia #5 contribution_committed completed
log counter A-17 for Nia #6 hook_called already_completed: on step success of lookup
log counter A-17 for Nia #7 attempt_started price, attempt 1
log counter A-17 for Nia #8 step_succeeded price
log counter A-17 for Nia #9 contribution_committed amount
log counter A-17 for Nia #10 attempt_started charge, attempt 1
log counter A-17 for Nia #11 service_provided charge gets payments from terminal, a ch42_using_a_step_in_another_workflow_async::Terminal
the counter's terminal charges 15 for order A-17
log counter A-17 for Nia, receipt C-A-17 #12 global_metadata_set receipt
log counter A-17 for Nia, receipt C-A-17 #13 step_succeeded charge
log counter A-17 for Nia, receipt C-A-17 #14 contribution_committed receipt
log counter A-17 for Nia, receipt C-A-17 #15 hook_called audit: on step success of charge
log counter A-17 for Nia, receipt C-A-17 #16 contribution_committed audit
log counter A-17 for Nia, receipt C-A-17 #17 journey_succeeded
the journey succeeded, with receipt C-A-17
audit: A-17: charge took 15 on attempt 1, receipt C-A-17
Omar's order A-18, at the counter:
log counter A-18 for Omar #1 journey_started card, gift_card, order, price
log counter A-18 for Omar #2 attempt_started lookup, attempt 1
log counter A-18 for Omar #3 service_provided lookup gets order_book from ledger, a ch42_using_a_step_in_another_workflow_async::Ledger
the order book has no order A-18 yet
log counter A-18 for Omar #4 step_succeeded lookup
log counter A-18 for Omar #5 contribution_committed completed
log counter A-18 for Omar #6 hook_called already_completed: on step success of lookup
log counter A-18 for Omar #7 attempt_started price, attempt 1
log counter A-18 for Omar #8 optional_input_absent discount
log counter A-18 for Omar #9 step_succeeded price
log counter A-18 for Omar #10 contribution_committed amount
log counter A-18 for Omar #11 attempt_started charge, attempt 1
log counter A-18 for Omar #12 service_provided charge gets payments from terminal, a ch42_using_a_step_in_another_workflow_async::Terminal
the counter's terminal declines card omar-2023: it has expired
log counter A-18 for Omar #13 step_failed charge: card-expired
log counter A-18 for Omar #14 step_given_up charge: failure
log counter A-18 for Omar #15 journey_failed charge
the journey failed: card-expired, hand the card back

How it works

  1. A step belongs to no workflow. Lookup, Price and Charge say what they read and which services they ask for, by key, and how their work went. Nothing in them names orders, the next step or a policy, so the counter lists them as they are, and they run as they do for a delivered order: Nia's price is 18 less 3, and the charge takes 15.
  2. Each workflow provides its own services. The charge asks for payments, and gets the bank in orders and the terminal in counter: the listings say so, and so does service_provided, event 11. The counter's receipts start with C, and the charge never knew which client it had.
  3. Each workflow decides its own flow. In orders, a charge that fails because the card has expired is rescued, as chapter 14 showed. In counter, it has no rescue step, so the same failure, card-expired, gives the charge up and fails Omar's journey: the customer is standing there, and can hand over another card. counter has no ship, so none of its retry budget or policies, and no outage policy, since the counter's terminal has no network to lose.
  4. Each workflow decides its own policies and reporters. Audit and AlreadyCompleted are attached in both, and Audit writes the same audit line. counter lists the Log reporter alone, and no workflow policy, so nobody is told by email: the log's lines start with the workflow's name, counter.
  5. Both workflows live in one program. Each is declared once and checked as it compiles, as chapter 37 showed: had counter listed Charge without providing payments, it would not compile, with the violation service not provided.

A step can now be changed once for every workflow that lists it, which is how the shop wants its coding agents to work: they change its business rules, often in steps. The shop wants to know that such a change leaves every order's flow as a person agreed.

Outcomes

We have shown how one step runs in two workflows, each deciding its own services, flow, policies and reporters.

The usual way With Itinera
A second sales channel often copies the first one's code, or adds flags to it for the cases that differ. The second workflow lists the same steps, and declares only what differs.
A function reused by a second channel often has to learn which channel called it. The charge runs unchanged in both workflows, with whichever client each provides.