The Itinera bookRust edition

Part 5: Designing with Itinera, Chapter 40

Recognising a decision in the wrong place

What you want to do

A rule put in the wrong place often still runs, and the shop's orders all go through. You want to recognise one when you read a step or a hook, before it hides a decision the shop can no longer see.

The code

Here is a counter-example. This charge renews an expired card itself, tells the customer, and charges the new card:

Rust, from the program ch40_counter_example
/// Charges an order's amount to its card through the payment client, and gives back its receipt,
/// and the gift card's code when the order is a gift card. When the card has expired, it renews
/// it, tells the customer, and charges the new card.
#[derive(Step)]
struct Charge {
    #[input]
    order: String,
    #[input]
    customer: String,
    #[input]
    amount: i64,
    #[input]
    card: String,
    #[input]
    gift_card: bool,
}

#[step]
impl Charge {
    fn run(
        self,
        #[service] payments: &dyn Payments,
        #[service] cards: &dyn Cards,
        #[contributor] mut contributor: Contributor<'_>,
        #[reporter] mut reporter: StepReporter<'_>,
    ) -> Result<Outcome, Error> {
        if self.amount <= 0 {
            return Self::fail("nothing-to-charge");
        }
        let mut receipt = payments.charge(&self.order, &self.card, self.amount)?;
        if receipt.is_none()
            && let Some(renewed) = cards.replacement(&self.card)
        {
            println!(
                "email to {}: card {} has expired, so we charged {renewed}.",
                self.customer, self.card
            );
            receipt = payments.charge(&self.order, &renewed, self.amount)?;
            contributor.contribute("card", renewed);
        }
        let Some(receipt) = receipt else {
            return Self::fail("card-expired");
        };
        reporter.set_global_metadata("receipt", receipt.clone())?;
        contributor.contribute("receipt", receipt);
        if self.gift_card {
            contributor.contribute("code", format!("G-{}", self.order));
        }
        Self::succeed()
    }
}
Rust, the same in its asynchronous form, from the program ch40_counter_example_async
/// Charges an order's amount to its card through the payment client, and gives back its receipt,
/// and the gift card's code when the order is a gift card. When the card has expired, it renews
/// it, tells the customer, and charges the new card.
#[derive(Step)]
struct Charge {
    #[input]
    order: String,
    #[input]
    customer: String,
    #[input]
    amount: i64,
    #[input]
    card: String,
    #[input]
    gift_card: bool,
}

#[step]
impl Charge {
    async fn run(
        self,
        #[service] payments: &dyn Payments,
        #[service] cards: &dyn Cards,
        #[contributor] mut contributor: Contributor<'_>,
        #[reporter] mut reporter: AsyncStepReporter<'_>,
    ) -> Result<Outcome, Error> {
        if self.amount <= 0 {
            return Self::fail("nothing-to-charge");
        }
        let mut receipt = payments
            .charge(&self.order, &self.card, self.amount)
            .await?;
        if receipt.is_none()
            && let Some(renewed) = cards.replacement(&self.card).await
        {
            println!(
                "email to {}: card {} has expired, so we charged {renewed}.",
                self.customer, self.card
            );
            receipt = payments.charge(&self.order, &renewed, self.amount).await?;
            contributor.contribute("card", renewed);
        }
        let Some(receipt) = receipt else {
            return Self::fail("card-expired");
        };
        reporter
            .set_global_metadata("receipt", receipt.clone())
            .await?;
        contributor.contribute("receipt", receipt);
        if self.gift_card {
            contributor.contribute("code", format!("G-{}", self.order));
        }
        Self::succeed()
    }
}

It asks for chapter 12's Payments and chapter 35's Cards. In the asynchronous form, Payments and Cards take #[async_trait], and the charge awaits the bank and the registry, as services became in chapter 26.

Since the charge renews the card itself, the workflow gives it no rescue step:

Rust, from the program ch40_counter_example
#[workflow(name = "orders", reporters = [Log, Desk], policies = [Notify])]
#[with_step(Lookup, name = "lookup", policies = [AlreadyCompleted])]
#[with_step(Price, name = "price")]
#[with_step(Charge, name = "charge", policies = [Audit, Outage])]
#[with_step(Ship, name = "ship", retry_budget = 1, policies = [FailedShipping])]
struct Orders {
    channel: &'static str,
}
Rust, the same in its asynchronous form, from the program ch40_counter_example_async
#[workflow(name = "orders", reporters = [Desk], async_reporters = [Log], policies = [Notify])]
#[with_step(Lookup, name = "lookup", policies = [AlreadyCompleted])]
#[with_step(Price, name = "price")]
#[with_step(Charge, name = "charge", policies = [Audit, Outage])]
#[with_step(Ship, name = "ship", retry_budget = 1, policies = [FailedShipping])]
struct Orders {
    channel: &'static str,
}

In the asynchronous form, the workflow lists Log under async_reporters, as the log became in chapter 31.

The card registry goes to the charge itself, not to a rescue step:

Rust, from the program ch40_counter_example
/// The bank's card registry in Europe, for the charge only.
#[provider(steps = ["charge"])]
fn cards(&self) -> Result<Provided<dyn Cards>, Error> {
    Ok(Provided::from_box(card_registry("eu")).with_data("eu".to_owned()))
}

The workflow's other methods are as earlier chapters show them: the ID generator in chapter 11, the bank's provider in chapter 12, the carriers' provider in chapter 13, the input adapter in chapter 21 and the order book's provider in chapter 22.

The other steps, policies and reporters it names are as earlier chapters show them: lookup and already_completed in chapter 22, price in chapter 20, outage and audit in chapter 34, ship in chapter 17, failed_shipping in chapter 23, notify in chapter 19, the log in chapter 33, chapter 35 and chapter 36, and the desk in chapter 11. It runs Jo's order, whose card has expired, and prints:

What ch40_counter_example prints, recorded in ch40_counter_example.txt
log orders A-8 for Jo #1 journey_started card, customer, gift_card, order, parcel, price
log orders A-8 for Jo #2 attempt_started lookup, attempt 1
log orders A-8 for Jo #3 service_provided lookup gets order_book from ledger, a ch40_counter_example::Ledger
the order book has no order A-8 yet
log orders A-8 for Jo #4 step_succeeded lookup
log orders A-8 for Jo #5 contribution_committed completed
log orders A-8 for Jo #6 hook_called already_completed: on step success of lookup
log orders A-8 for Jo #7 attempt_started price, attempt 1
log orders A-8 for Jo #8 optional_input_absent discount
log orders A-8 for Jo #9 step_succeeded price
log orders A-8 for Jo #10 contribution_committed amount
log orders A-8 for Jo #11 attempt_started charge, attempt 1
log orders A-8 for Jo #12 service_provided charge gets payments from bank, a ch40_counter_example::Bank
log orders A-8 for Jo #13 service_provided charge gets cards from cards, a dyn ch40_counter_example::Cards, with eu
the bank declines card jo-2024: it has expired
the card registry in eu says card jo-2024 was replaced by jo-2029
email to Jo: card jo-2024 has expired, so we charged jo-2029.
the bank charges 30 for order A-8
log orders A-8 for Jo, receipt R-A-8 #14 global_metadata_set receipt
log orders A-8 for Jo, receipt R-A-8 #15 step_succeeded charge
log orders A-8 for Jo, receipt R-A-8 #16 contribution_committed card
log orders A-8 for Jo, receipt R-A-8 #17 data_overwritten card
log orders A-8 for Jo, receipt R-A-8 #18 contribution_committed receipt
log orders A-8 for Jo, receipt R-A-8 #19 hook_called audit: on step success of charge
log orders A-8 for Jo, receipt R-A-8 #20 contribution_committed audit
log orders A-8 for Jo, receipt R-A-8 #21 attempt_started ship, attempt 1
log orders A-8 for Jo, receipt R-A-8 #22 input_adapter_supplied ship gets weight from parcels
log orders A-8 for Jo, receipt R-A-8 #23 service_provided ship gets carrier from carriers, a ch40_counter_example::MainCarrier
the main carrier books order A-8
log orders A-8 for Jo, receipt R-A-8 #24 step_info ship: the parcel leaves with receipt R-A-8
log orders A-8 for Jo, receipt R-A-8 #25 step_succeeded ship
log orders A-8 for Jo, receipt R-A-8 #26 contribution_committed tracking
log orders A-8 for Jo, receipt R-A-8 #27 optional_input_absent code
email to Jo: Order A-8 is on its way, tracking T-A-8.
log orders A-8 for Jo, receipt R-A-8 #28 hook_called notify: on workflow success
log orders A-8 for Jo, receipt R-A-8 #29 journey_succeeded
the journey succeeded, with receipt R-A-8 and tracking T-A-8
audit: A-8: charge took 30 on attempt 1, receipt R-A-8
What the asynchronous form ch40_counter_example_async prints, recorded in ch40_counter_example_async.txt
log orders A-8 for Jo #1 journey_started card, customer, gift_card, order, parcel, price
log orders A-8 for Jo #2 attempt_started lookup, attempt 1
log orders A-8 for Jo #3 service_provided lookup gets order_book from ledger, a ch40_counter_example_async::Ledger
the order book has no order A-8 yet
log orders A-8 for Jo #4 step_succeeded lookup
log orders A-8 for Jo #5 contribution_committed completed
log orders A-8 for Jo #6 hook_called already_completed: on step success of lookup
log orders A-8 for Jo #7 attempt_started price, attempt 1
log orders A-8 for Jo #8 optional_input_absent discount
log orders A-8 for Jo #9 step_succeeded price
log orders A-8 for Jo #10 contribution_committed amount
log orders A-8 for Jo #11 attempt_started charge, attempt 1
log orders A-8 for Jo #12 service_provided charge gets payments from bank, a ch40_counter_example_async::Bank
log orders A-8 for Jo #13 service_provided charge gets cards from cards, a dyn ch40_counter_example_async::Cards, with eu
the bank declines card jo-2024: it has expired
the card registry in eu says card jo-2024 was replaced by jo-2029
email to Jo: card jo-2024 has expired, so we charged jo-2029.
the bank charges 30 for order A-8
log orders A-8 for Jo, receipt R-A-8 #14 global_metadata_set receipt
log orders A-8 for Jo, receipt R-A-8 #15 step_succeeded charge
log orders A-8 for Jo, receipt R-A-8 #16 contribution_committed card
log orders A-8 for Jo, receipt R-A-8 #17 data_overwritten card
log orders A-8 for Jo, receipt R-A-8 #18 contribution_committed receipt
log orders A-8 for Jo, receipt R-A-8 #19 hook_called audit: on step success of charge
log orders A-8 for Jo, receipt R-A-8 #20 contribution_committed audit
log orders A-8 for Jo, receipt R-A-8 #21 attempt_started ship, attempt 1
log orders A-8 for Jo, receipt R-A-8 #22 input_adapter_supplied ship gets weight from parcels
log orders A-8 for Jo, receipt R-A-8 #23 service_provided ship gets carrier from carriers, a ch40_counter_example_async::MainCarrier
the main carrier books order A-8
log orders A-8 for Jo, receipt R-A-8 #24 step_info ship: the parcel leaves with receipt R-A-8
log orders A-8 for Jo, receipt R-A-8 #25 step_succeeded ship
log orders A-8 for Jo, receipt R-A-8 #26 contribution_committed tracking
log orders A-8 for Jo, receipt R-A-8 #27 optional_input_absent code
email to Jo: Order A-8 is on its way, tracking T-A-8.
log orders A-8 for Jo, receipt R-A-8 #28 hook_called notify: on workflow success
log orders A-8 for Jo, receipt R-A-8 #29 journey_succeeded
the journey succeeded, with receipt R-A-8 and tracking T-A-8
audit: A-8: charge took 30 on attempt 1, receipt R-A-8

How it works

What the counter-example shows:

  1. It works, and its journey records none of it. Jo's order is charged and shipped. But the log says the charge succeeded on its first attempt, events 11 and 15, and that the card was overwritten, event 17. Nothing says the card had expired, that the registry renewed it, or that Jo was told: the email came from inside the step, as a println! the journey never sees.
  2. It holds three decisions where one belongs. Charging is the step's. Renewing the card is a repair, which the rescue step made visible as step_rescuing and step_rescued in chapter 14. Telling the customer is a reaction, which belongs to a policy, through a role, as chapter 19 showed. A step does one thing of the business, and says how it went.
  3. Back in their places, the journey records them. With the renewal in the rescue step charge_rescue, as chapter 35 gave it its own card registry, the charge fails with the reason card-expired when the card has expired, the one failure the rescue step knows how to repair, and the journey records the repair as step_rescuing and step_rescued. With the message in a policy acting through a role, as notify does in chapter 19, the journey records the hook it ran as hook_called.
  4. The places it doesn't belong are recognisable. A step that tries its work again in a loop, calls a second service when the first fails, or tells anyone anything has taken a decision that isn't its own. A hook that works out a price, or judges whether a card is good enough, has taken one of the business's. When a step or a hook takes a decision the journey doesn't record, such as a repair, a retry or a message, that decision is out of its place.

Now you can tell a decision in its place from one out of it. But the shop's first checkout, chapter 1's, still runs beside the workflow, and moving a system like it is rarely done in one go.

Outcomes

We have shown how to recognise a decision in the wrong place, and where to move it.

The usual way With Itinera
A rule put in the wrong place often goes unnoticed, since the code still works. A rule in the wrong place is spotted from the journey, which records none of what it decided.