The Itinera bookRust edition

Part 2: Getting started, Chapter 19

Letting policies act on the outside world

What you want to do

The message for each customer is written, but nobody sends it. You want the notify policy to send it, while how it is sent, by email today and perhaps by text message tomorrow, stays the shop's business: the policy should only say "tell this customer this".

The code

What the policy needs from the shop is a trait of its own, with one operation: tell a customer something.

Rust, from the program ch19_letting_policies_act_on_the_outside_world
/// What the shop does for its policies: tell a customer something.
trait Messages {
    fn tell(&self, customer: &str, message: &str);
}

The notify policy's two hooks now ask for it, as a parameter marked #[role], and for the customer's name from the data bag, and tell the customer instead of contributing the message:

Rust, from the program ch19_letting_policies_act_on_the_outside_world
/// Tells the customer how their order went, once its journey has ended.
#[derive(Default)]
struct Notify;

#[workflow_policy(name = "notify")]
impl Notify {
    #[on_workflow_success]
    fn succeeded(
        &self,
        #[from_workflow] customer: String,
        #[from_workflow] order: String,
        #[from_workflow] tracking: Option<String>,
        #[from_workflow] code: Option<String>,
        #[role] messages: &dyn Messages,
    ) -> Result<(), Error> {
        let message = match (tracking, code) {
            (Some(tracking), _) => format!("Order {order} is on its way, tracking {tracking}."),
            (None, Some(code)) => {
                format!("Order {order} is paid: your gift card's code is {code}.")
            }
            (None, None) => format!("Order {order} is complete."),
        };
        messages.tell(&customer, &message);
        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!("Sorry, order {order} could not be completed."),
        );
        Ok(())
    }
}

The workflow implements the trait, in an impl block marked #[role], and sends through the channel it is configured with, a field of the workflow's struct. Here the channel is faked, and prints what it sends.

Rust, from the program ch19_letting_policies_act_on_the_outside_world
/// The shop's workflow: it charges, repairing a charge that fails and auditing one that succeeds,
/// then ships, and shipping may be tried once more. Once a journey has ended, the customer is
/// told, through the channel the shop configures.
#[workflow(name = "orders", reporters = [Log, Desk], policies = [Notify])]
#[with_step(Charge, name = "charge", rescue = RenewCard, policies = [Audit, Outage])]
#[with_step(Ship, name = "ship", retry_budget = 1, policies = [HeavyParcels])]
struct Orders {
    channel: &'static str,
}

#[role]
impl Messages for Orders {
    fn tell(&self, customer: &str, message: &str) {
        println!("{} to {customer}: {message}", self.channel);
    }
}

The steps, policies and reporters it names are as earlier chapters show them: the charge and the audit in chapter 15, RenewCard in chapter 14, the shipping, outage and heavy_parcels in chapter 17, the log in chapter 18 and the desk in chapter 11.

Each order's data now holds its customer's name, and the output no longer holds a message:

Rust, from the program ch19_letting_policies_act_on_the_outside_world
/// What the shop knows about an order when its journey starts.
#[derive(InitialData)]
struct NewOrder {
    order: String,
    customer: String,
    price: i64,
    weight: i64,
    card: String,
    gift_card: bool,
}

/// What the shop reads back once the journey has ended.
#[derive(Output)]
struct Order {
    receipt: Option<String>,
    tracking: Option<String>,
    audit: Option<String>,
}

Each instance is created with the channel, email:

Rust, from the program ch19_letting_policies_act_on_the_outside_world
let instance = Orders::builder()
    .channel("email")
    .initial_data(NewOrder {
        order: order.to_owned(),
        customer: customer.to_owned(),
        price,
        weight,
        card: card.to_owned(),
        gift_card,
    })
    .create()?;

The rest is chapter 18's shop, and it runs the same five orders. It prints:

What ch19_letting_policies_act_on_the_outside_world prints, recorded in ch19_letting_policies_act_on_the_outside_world.txt
Ann's order:
log orders A-1 #1 journey_started card, customer, gift_card, order, price, weight
log orders A-1 #2 attempt_started charge, attempt 1
log orders A-1 #3 service_provided charge gets payments from bank, a ch19_letting_policies_act_on_the_outside_world::Bank
the bank charges 40 for order A-1
log orders A-1 #4 step_succeeded charge
log orders A-1 #5 contribution_committed receipt
log orders A-1 #6 hook_called audit: on step success of charge
log orders A-1 #7 contribution_committed audit
log orders A-1 #8 attempt_started ship, attempt 1
log orders A-1 #9 service_provided ship gets carrier from carriers, a ch19_letting_policies_act_on_the_outside_world::MainCarrier
the main carrier books order A-1
log orders A-1 #10 step_info ship: the parcel leaves with receipt R-A-1
log orders A-1 #11 step_succeeded ship
log orders A-1 #12 contribution_committed tracking
log orders A-1 #13 optional_input_absent code
email to Ann: Order A-1 is on its way, tracking T-A-1.
log orders A-1 #14 hook_called notify: on workflow success
log orders A-1 #15 journey_succeeded
the journey succeeded, with receipt R-A-1 and tracking T-A-1
audit: charge took 40, receipt R-A-1
Gil's order:
log orders A-7 #1 journey_started card, customer, gift_card, order, price, weight
log orders A-7 #2 attempt_started charge, attempt 1
log orders A-7 #3 service_provided charge gets payments from bank, a ch19_letting_policies_act_on_the_outside_world::Bank
the bank charges 45 for order A-7
log orders A-7 #4 step_succeeded charge
log orders A-7 #5 contribution_committed receipt
log orders A-7 #6 hook_called audit: on step success of charge
log orders A-7 #7 contribution_committed audit
log orders A-7 #8 attempt_started ship, attempt 1
log orders A-7 #9 service_provided ship gets carrier from carriers, a ch19_letting_policies_act_on_the_outside_world::MainCarrier
the main carrier books order A-7
log orders A-7 #10 step_failed ship: too-heavy
log orders A-7 #11 hook_called heavy_parcels: on step retry of ship
log orders A-7 #12 step_retrying ship
log orders A-7 #13 attempt_started ship, attempt 2
log orders A-7 #14 service_provided ship gets carrier from carriers, a ch19_letting_policies_act_on_the_outside_world::BackupCarrier
the backup carrier books order A-7
log orders A-7 #15 step_info ship: the parcel leaves with receipt R-A-7
log orders A-7 #16 step_succeeded ship
log orders A-7 #17 contribution_committed tracking
log orders A-7 #18 optional_input_absent code
email to Gil: Order A-7 is on its way, tracking B-A-7.
log orders A-7 #19 hook_called notify: on workflow success
log orders A-7 #20 journey_succeeded
the journey succeeded, with receipt R-A-7 and tracking B-A-7
audit: charge took 45, receipt R-A-7
Jo's order:
log orders A-8 #1 journey_started card, customer, gift_card, order, price, weight
log orders A-8 #2 attempt_started charge, attempt 1
log orders A-8 #3 service_provided charge gets payments from bank, a ch19_letting_policies_act_on_the_outside_world::Bank
the bank declines card jo-2024: it has expired
log orders A-8 #4 step_failed charge: card-expired
log orders A-8 #5 step_rescuing charge: failure, rescued by charge_rescue
log orders A-8 #6 attempt_started charge_rescue, attempt 1
the bank says card jo-2024 was replaced by jo-2029
log orders A-8 #7 step_succeeded charge_rescue
log orders A-8 #8 contribution_committed card
log orders A-8 #9 data_overwritten card
log orders A-8 #10 step_rescued charge by charge_rescue
log orders A-8 #11 attempt_started charge, attempt 2
the bank charges 30 for order A-8
log orders A-8 #12 step_succeeded charge
log orders A-8 #13 contribution_committed receipt
log orders A-8 #14 hook_called audit: on step success of charge
log orders A-8 #15 contribution_committed audit
log orders A-8 #16 attempt_started ship, attempt 1
log orders A-8 #17 service_provided ship gets carrier from carriers, a ch19_letting_policies_act_on_the_outside_world::MainCarrier
the main carrier books order A-8
log orders A-8 #18 step_info ship: the parcel leaves with receipt R-A-8
log orders A-8 #19 step_succeeded ship
log orders A-8 #20 contribution_committed tracking
log orders A-8 #21 optional_input_absent code
email to Jo: Order A-8 is on its way, tracking T-A-8.
log orders A-8 #22 hook_called notify: on workflow success
log orders A-8 #23 journey_succeeded
the journey succeeded, with receipt R-A-8 and tracking T-A-8
audit: charge took 30, receipt R-A-8
Kim's order:
log orders A-9 #1 journey_started card, customer, gift_card, order, price, weight
log orders A-9 #2 attempt_started charge, attempt 1
log orders A-9 #3 service_provided charge gets payments from bank, a ch19_letting_policies_act_on_the_outside_world::Bank
the bank charges 25 for order A-9
log orders A-9 #4 step_succeeded charge
log orders A-9 #5 contribution_committed receipt
log orders A-9 #6 contribution_committed code
log orders A-9 #7 hook_called audit: on step success of charge
log orders A-9 #8 contribution_committed audit
log orders A-9 #9 attempt_started ship, attempt 1
log orders A-9 #10 service_provided ship gets carrier from carriers, a ch19_letting_policies_act_on_the_outside_world::MainCarrier
log orders A-9 #11 step_skipped ship: nothing-to-ship
log orders A-9 #12 contributions_discarded ship
log orders A-9 #13 optional_input_absent tracking
email to Kim: Order A-9 is paid: your gift card's code is G-A-9.
log orders A-9 #14 hook_called notify: on workflow success
log orders A-9 #15 journey_succeeded
the journey succeeded, with receipt R-A-9 and tracking none
audit: charge took 25, receipt R-A-9
Lee's order:
log orders A-10 #1 journey_started card, customer, gift_card, order, price, weight
log orders A-10 #2 attempt_started charge, attempt 1
log orders A-10 #3 service_provided charge gets payments from bank, a ch19_letting_policies_act_on_the_outside_world::Bank
the bank declines card lee-2019: it has expired
log orders A-10 #4 step_failed charge: card-expired
log orders A-10 #5 step_rescuing charge: failure, rescued by charge_rescue
log orders A-10 #6 attempt_started charge_rescue, attempt 1
the bank says nothing replaced card lee-2019
log orders A-10 #7 step_failed charge_rescue: not-replaced
log orders A-10 #8 step_given_up charge_rescue: failure
email to Lee: Sorry, order A-10 could not be completed.
log orders A-10 #9 hook_called notify: on workflow failure
log orders A-10 #10 journey_failed charge_rescue
desk: order A-10 failed at charge_rescue
the journey failed, with receipt none and tracking none
audit: none

How it works

  1. A role is what a workflow offers its policies. It is a set of operations the workflow's own code carries out, such as "tell a customer". A policy that needs to act on the outside world asks for a role, never for the workflow itself. In Rust, #[role] on impl Messages for Orders makes the workflow lend itself, as a &dyn Messages, to every hook that asks for that role.
  2. A role carries operations, never data. The customer's name and the order come to the hook as data from the workflow, from the data bag. The role only does something with them.
  3. A workflow's fields are its configuration. channel is set on the builder of each instance, with .channel("email"), and is never in the data bag, so no step or hook can read it as data. create() doesn't compile until it is set.
  4. The policy knows only the role. Notify tells a customer through whatever Messages it is lent, so sending by text message would change the workflow's role, and no policy. A policy acts on the outside world only through the roles its workflow offers.
  5. Only policies ask for roles. Steps never do: a step does its business with its inputs and its services, and never reaches the workflow.
  6. A role's work is the workflow's business. The executor doesn't record what a role does: the log shows hook_called once the hook has returned, after the email was sent, and nothing in between.

So every customer is told how their order went. The charge, though, still charges the full price of every order, while the shop gives some customers a discount that nothing in the shop applies.

Outcomes

We have shown how a policy acts on the outside world through a role its workflow offers.

The usual way With Itinera
Code that sends a message usually holds the mail client itself, so changing how customers are told changes that code. The policy asks for a role, and only the workflow, which implements it, knows how the message is sent.