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:
/// 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()
}
}/// 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:
#[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,
}#[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:
/// 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:
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-8log 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-8How it works
What the counter-example shows:
- 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. - 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_rescuingandstep_rescuedin 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. - 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 reasoncard-expiredwhen the card has expired, the one failure the rescue step knows how to repair, and the journey records the repair asstep_rescuingandstep_rescued. With the message in a policy acting through a role, asnotifydoes in chapter 19, the journey records the hook it ran ashook_called. - 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. |