The Itinera bookRust edition

Part 1: Why Itinera, Chapter 2

The big picture

What you want to do

You want to see chapter 1's checkout written with Itinera, all at once, before learning any of it. Don't try to understand every line. The comments say what each piece is for, and answer chapter 1's questions where the code answers them. The chapters after this one teach each piece, as you need it.

The code

Start with the workflow. Looking at it, you can see at once what happens to every order: which steps run, in what order, how many times, what repairs a failure and who reacts, without reading any of the code it names.

Rust, from the program ch02_big_picture
/// The shop's workflow: reserve, charge and ship, auditing the charge, rescuing it when the
/// card has expired, and telling the customer how the order went.
#[workflow(name = "orders", policies = [Notify], reporters = [Log])]
// The steps run in this order, and nothing reorders them.
#[with_step(Reserve, name = "reserve")]
// The charge runs at most twice: once, and once more after its rescue step succeeds.
// Every successful charge is audited.
#[with_step(Charge, name = "charge", rescue = RenewCard, policies = [Audit])]
// Shipping may be tried once more, after a failure worth trying again.
#[with_step(Ship, name = "ship", retry_budget = 1)]
struct Orders;

The business rules it lists are steps, one for each: reserve the goods, charge the card, renew an expired card, ship the parcel.

Rust, from the program ch02_big_picture
/// Reserves the order's goods in the stock store.
#[derive(Step)]
struct Reserve {
    #[input]
    order: String,
}

#[step]
impl Reserve {
    fn run(self, #[service] stock: &dyn StockStore) -> Result<Outcome, Error> {
        if !stock.reserve(&self.order) {
            return Self::fail("out-of-stock");
        }
        Self::succeed()
    }
}

/// Charges the card the price, less any discount. It only says how it went: what happens after
/// a declined card is not its business.
#[derive(Step)]
struct Charge {
    #[input]
    card: String,
    #[input]
    price: i64,
    #[input]
    discount: Option<i64>,
}

#[step]
impl Charge {
    fn run(
        self,
        #[service] payments: &dyn Payments,
        #[contributor] mut contributor: Contributor<'_>,
    ) -> Result<Outcome, Error> {
        let amount = self.price - self.discount.unwrap_or(0);
        match payments.charge(&self.card, amount) {
            Ok(receipt) => {
                contributor.contribute("charged", amount);
                contributor.contribute("receipt", receipt);
                Self::succeed()
            }
            Err(Declined::ExpiredCard) => Self::fail("expired-card"),
            Err(Declined::Refused) => Self::fail("declined"),
        }
    }
}

/// The charge's rescue step: asks the customer's bank for the card that replaced an expired one.
/// A charge that failed for any other reason, or ended in an abnormal termination, which has no
/// reason, cannot be repaired this way.
#[derive(RescueStep)]
struct RenewCard {
    #[input]
    card: String,
    #[reason]
    reason: Option<Reason>,
}

#[rescue_step]
impl RenewCard {
    fn run(
        self,
        #[service] payments: &dyn Payments,
        #[contributor] mut contributor: Contributor<'_>,
    ) -> Result<RescueOutcome, Error> {
        if self.reason.as_ref().map(Reason::code) != Some("expired-card") {
            return Self::fail("nothing-to-renew");
        }
        let Some(card) = payments.renewed_card(&self.card) else {
            return Self::fail("nothing-to-renew");
        };
        contributor.contribute("card", card);
        Self::succeed()
    }
}

/// Ships the order through the carrier it is given, without knowing which one it is. A carrier
/// that cannot take the parcel might be replaced on another try, so that failure is worth trying
/// again.
#[derive(Step)]
struct Ship {
    #[input]
    order: String,
    #[input]
    weight: i64,
}

#[step]
impl Ship {
    fn run(
        self,
        #[service] carrier: &dyn Carrier,
        #[service] stock: &dyn StockStore,
        #[contributor] mut contributor: Contributor<'_>,
    ) -> Result<Outcome, Error> {
        let Some(tracking) = carrier.send(&self.order, self.weight) else {
            return Self::fail("parcel-refused");
        };
        stock.hand_over(&self.order);
        contributor.contribute("tracking", tracking);
        Self::succeed()
    }
}

The policies it attaches react to how things went: audit a successful charge, tell the customer at the end.

Rust, from the program ch02_big_picture
/// Audits every successful charge, in the journey's events, including one after a rescue.
#[derive(Default)]
struct Audit;

#[step_policy(name = "audit")]
impl Audit {
    #[on_step_success]
    fn audited(
        &self,
        #[from_step] charged: i64,
        #[from_step] receipt: String,
        #[reporter] mut reporter: HookReporter<'_>,
    ) -> Result<Option<OnSuccess>, Error> {
        reporter.info(format!("charged {charged}, receipt {receipt}"))?;
        Self::carry_on()
    }
}

/// A role the shop provides: telling a customer something.
trait Messages {
    fn tell(&self, customer: &str, message: &str);
}

/// Tells the customer how the order went, once, when the journey ends.
#[derive(Default)]
struct Notify;

#[workflow_policy(name = "notify")]
impl Notify {
    #[on_workflow_success]
    fn shipped(
        &self,
        #[from_workflow] customer: String,
        #[from_workflow] order: String,
        #[from_workflow] tracking: String,
        #[role] messages: &dyn Messages,
    ) -> Result<(), Error> {
        messages.tell(
            &customer,
            &format!("order {order} is on its way: {tracking}"),
        );
        Ok(())
    }

    #[on_workflow_failure]
    fn failed(
        &self,
        #[from_workflow] customer: String,
        #[from_workflow] order: String,
        #[role] messages: &dyn Messages,
    ) -> Result<(), Error> {
        messages.tell(&customer, &format!("order {order} could not be completed"));
        Ok(())
    }
}

Its impl blocks supply what the steps use: the bank, the warehouse and a carrier for every attempt, and the way the shop tells a customer something.

Rust, from the program ch02_big_picture
#[workflow]
impl Orders {
    /// One payment client for the journey, used by the charge and its rescue step.
    #[provider(key = "payments")]
    fn bank(&self) -> Result<Provided<dyn Payments>, Error> {
        Ok(provide!(BANK))
    }

    /// One stock store for the journey, shared by reserve and ship.
    #[provider(key = "stock")]
    fn warehouse(&self) -> Result<Provided<dyn StockStore>, Error> {
        Ok(provide!(WAREHOUSE))
    }

    /// A carrier for every attempt: the main carrier first, the backup carrier after.
    #[provider(per_attempt)]
    fn carrier(&self, #[attempt] attempt: NonZeroU32) -> Result<Provided<dyn Carrier>, Error> {
        if attempt.get() == 1 {
            Ok(provide!(MAIN_CARRIER))
        } else {
            Ok(provide!(BACKUP_CARRIER))
        }
    }
}

/// How the shop tells a customer something, for the notify policy: here, by printing it.
#[role]
impl Messages for Orders {
    fn tell(&self, customer: &str, message: &str) {
        println!("  to {customer}: {message}");
    }
}

The bank, the warehouse and the carriers are faked, as in chapter 1, so the program prints the same thing every time. Before it runs anything, it prints the workflow's listing:

Rust, from the program ch02_big_picture
// The workflow lists its steps, their policies, services and rescue step, without running
// anything.
for listed in Orders::descriptor().listing() {
    println!("{}", entry(&listed));
}

Then it runs seven orders. Ann, Ben, Cat and Dan come from chapter 1, with the same data. Three more customers take the paths none of them takes: Eve's goods are out of stock, Fin's card is valid but the order is above the bank's limit of 1,000, and Gil's parcel weighs 60 kg, too heavy for both carriers.

Rust, from the program ch02_big_picture
// The order, the customer, the card, the price, the discount and the parcel's weight in kg.
let orders = [
    ("A-1", "Ann", "ann-card-2028", 40, Some(5), 2),
    ("A-2", "Ben", "ben-card-2024", 25, None, 1),
    ("A-3", "Cat", "cat-card-2027", 30, None, 30),
    ("A-4", "Dan", "dan-card-2025", 1200, None, 3),
    ("A-5", "Eve", "eve-card-2027", 20, None, 2),
    ("A-6", "Fin", "fin-card-2028", 1500, None, 4),
    ("A-7", "Gil", "gil-card-2029", 45, None, 60),
];

It prints the listing, then, under each order, what the shop's log and the customer were told, and how it ended:

What ch02_big_picture prints, recorded in ch02_big_picture.txt
1. reserve; services: stock from warehouse
2. charge, with audit; services: payments from bank; rescued by charge_rescue, services: payments from bank
3. ship; services: carrier from carrier, stock from warehouse
A-1:
  INFO audit: charged 35, receipt R-ann-card-2028-35
  to Ann: order A-1 is on its way: T-A-1
succeeded
A-2:
  WARN charge, attempt 1: expired-card
  INFO charge to be rescued by charge_rescue
  INFO audit: charged 25, receipt R-ben-card-2029-25
  to Ben: order A-2 is on its way: T-A-2
succeeded
A-3:
  INFO audit: charged 30, receipt R-cat-card-2027-30
  WARN ship, attempt 1: parcel-refused
  INFO ship tried again
  to Cat: order A-3 is on its way: B-A-3
succeeded
A-4:
  WARN charge, attempt 1: expired-card
  INFO charge to be rescued by charge_rescue
  WARN charge, attempt 2: declined
  WARN charge given up: failure
  to Dan: order A-4 could not be completed
failed: failure, declined
A-5:
  WARN reserve, attempt 1: out-of-stock
  WARN reserve given up: failure
  to Eve: order A-5 could not be completed
failed: failure, out-of-stock
A-6:
  WARN charge, attempt 1: declined
  INFO charge to be rescued by charge_rescue
  WARN charge_rescue, attempt 1: nothing-to-renew
  WARN charge_rescue given up: failure
  to Fin: order A-6 could not be completed
failed: failure, nothing-to-renew
A-7:
  INFO audit: charged 45, receipt R-gil-card-2029-45
  WARN ship, attempt 1: parcel-refused
  INFO ship tried again
  WARN ship, attempt 2: parcel-refused
  WARN ship given up: retries exhausted
  to Gil: order A-7 could not be completed
failed: retries exhausted, parcel-refused

How it works

Every order follows the path the diagram draws, and the output shows which.

flowchart TD
  accTitle: A journey through the shop
  accDescr: The journey reserves the goods, then charges the card. A successful charge is audited, then the order is shipped. A failed charge is rescued by charge_rescue; when the rescue succeeds the charge runs once more, and when the rescue or the second charge fails the journey fails. Shipping is tried with the main carrier; when it cannot take the parcel, shipping is tried once more with the backup carrier. When shipping succeeds, notify tells the customer and the journey succeeds. Any failure that is not repaired leads notify to tell the customer, and the journey fails.
  start([The journey starts with the order]) --> reserve[reserve]
  reserve -- success --> charge[charge]
  reserve -- failure --> told_failed[notify tells the customer the order could not be completed]
  charge -- success --> audit[audit logs the charge]
  charge -- failure --> rescue[charge_rescue asks the bank for the new card]
  rescue -- success --> again[charge runs once more]
  rescue -- failure --> told_failed
  again -- success --> audit
  again -- failure --> told_failed
  audit --> ship[ship, attempt 1, main carrier]
  ship -- success --> told_shipped[notify tells the customer the order is on its way]
  ship -- failure, retry budget left --> ship2[ship, attempt 2, backup carrier]
  ship2 -- success --> told_shipped
  ship2 -- failure --> told_failed
  told_shipped --> succeeded([The journey succeeded])
  told_failed --> failed([The journey failed])
Diagram: A journey through the shop

The diagram shows:

  1. The journey starts with the order's data and runs reserve. If reserving fails, as Eve's does, the journey goes to its end as failed.
  2. Then charge runs. When it succeeds, as Ann's does, the audit policy logs the charge.
  3. When the charge fails, the rescue step, which the listing names charge_rescue, runs and asks the bank for the new card. If the rescue fails, as Fin's does, the journey goes to its end as failed.
  4. When the rescue succeeds, the charge runs once more. If it succeeds, as Ben's does, audit logs it; if it fails, as Dan's does, the journey goes to its end as failed.
  5. After the audit, ship makes its first attempt, with the main carrier. If it succeeds, as Ann's does, the journey goes to its end as succeeded.
  6. If the first attempt fails, ship has a retry left, so it makes a second attempt, with the backup carrier. If that succeeds, as Cat's does, the journey goes to its end as succeeded; if it fails, as Gil's does, the journey goes to its end as failed.
  7. At a successful end, notify tells the customer the order is on its way, and the journey succeeded.
  8. At a failed end, notify tells the customer the order could not be completed, and the journey failed.

Outcomes

We have shown the whole checkout as one workflow, where every question of chapter 1 is answered in one place.

The usual way With Itinera
Retries, repairs and fallbacks usually live in the functions that meet the failure, so finding them means reading those functions. Every retry budget, rescue step and policy is in the workflow's declaration.
Which services a function uses is often known only from its body or its constructor. The listing shows every step's services and where each comes from, before anything runs.