But why Itinera?
What you want to do
Fair question. You already know how to write a checkout, so why would you hand its flow to a framework? Let's not argue about it in the abstract. Let's look at code you have probably written yourself, and see what it costs.
You run a small online shop. When a customer checks out, the shop reserves the goods, charges the card, ships the parcel and tells the customer how it went. You want to answer simple questions about any order: what the shop tried, in what order, what it did when something failed, and who was told.
This chapter looks at a checkout written the usual way, in plain Rust, by a competent developer, and asks it those questions. No Itinera yet: just the usual code, and four orders to try it on.
The code
The checkout is split the way most code bases split it: a module for payments, one for shipping, one for the checkout itself, and small modules for the log and the audit trail. The modules are written inline, as mod payments { ... }, so that the whole program fits in one file; in a real project each would be a file of its own. The bank, the warehouse, the carriers and the customer messages are faked inside the program, so it prints the same thing every time it runs. Errors travel the way they usually do in Rust: as Result values, passed up with the ? operator.
The checkout reserves, charges and ships, then tells the customer how it went:
/// Reserves, charges and ships an order, and tells the customer how it went.
pub fn checkout(&self, order: &Order) -> Result<String, CheckoutError> {
log::info(&format!("checkout {}", order.order));
let shipped = self.reserve_charge_and_ship(order);
match &shipped {
Ok(tracking) => self.messages.tell(
order.customer,
&format!("order {} is on its way: {tracking}", order.order),
),
Err(error) => {
log::warn(&format!("checkout {} failed: {error}", order.order));
self.messages.tell(
order.customer,
&format!("order {} could not be completed", order.order),
);
}
}
shipped
}
fn reserve_charge_and_ship(&self, order: &Order) -> Result<String, CheckoutError> {
self.warehouse
.reserve(order.order)
.map_err(|_| CheckoutError::OutOfStock)?;
let amount = order.price - order.discount.unwrap_or(0);
payments::take_payment(&self.bank, order.card, amount)?;
shipping::ship(&self.main, &self.backup, order.order, order.weight)
.ok_or(CheckoutError::NotShipped)
}Taking the payment charges the card. When the card has expired, it asks the bank for the new card and charges that one. It also records every successful charge in the audit trail:
/// Charges the card. An expired card is renewed with the bank and charged once more.
/// Every successful charge is audited.
pub fn take_payment(bank: &Bank, card: &str, amount: i64) -> Result<String, Declined> {
let receipt = match bank.charge(card, amount) {
Err(Declined::ExpiredCard) => {
log::info(&format!("card {card} expired, renewing it"));
let renewed = bank.renewed_card(card).ok_or(Declined::ExpiredCard)?;
bank.charge(&renewed, amount)?
}
charged => charged?,
};
audit::record(&format!("charged {amount}, receipt {receipt}"));
Ok(receipt)
}Shipping hands the parcel to the main carrier, and to a backup carrier when the main one cannot take it. It returns an Option: the tracking number, or nothing:
/// Ships through the main carrier, or through the backup carrier when the main one
/// cannot take the parcel.
pub fn ship(main: &Carrier, backup: &Carrier, order: &str, weight: i64) -> Option<String> {
match main.ship(order, weight) {
Ok(tracking) => Some(tracking),
Err(error) => {
log::warn(&format!(
"{} cannot take {order}: {error}; trying {}",
main.name, backup.name
));
backup.ship(order, weight).ok()
}
}
}The program sets up the fakes and checks out four orders. Each card ends with the year it expires, and the bank refuses a card that expired before this year, and any charge above 1,000. The main carrier, Rapid, takes parcels up to 20 kg; the backup carrier, Steady, up to 50 kg. So the orders' own data decides what happens to them: Ann's goes through without trouble. Ben's card expired in 2024, and the bank's new card for him expires in 2029. Cat's parcel weighs 30 kg, too heavy for the main carrier. Dan's card has expired too, and his order of 1,200 is above the bank's limit, so the bank refuses the new card as well.
fn main() {
let shop = Shop {
bank: Bank {
this_year: 2026,
limit: 1000,
},
warehouse: Warehouse {
out_of_stock: vec![],
},
main: Carrier {
name: "Rapid",
tracking_prefix: "T",
max_weight: 20,
},
backup: Carrier {
name: "Steady",
tracking_prefix: "B",
max_weight: 50,
},
messages: Messages,
};
let orders = [
Order {
order: "A-1",
customer: "Ann",
card: "ann-card-2028",
price: 40,
discount: Some(5),
weight: 2,
},
Order {
order: "A-2",
customer: "Ben",
card: "ben-card-2024",
price: 25,
discount: None,
weight: 1,
},
Order {
order: "A-3",
customer: "Cat",
card: "cat-card-2027",
price: 30,
discount: None,
weight: 30,
},
Order {
order: "A-4",
customer: "Dan",
card: "dan-card-2025",
price: 1200,
discount: None,
weight: 3,
},
];
for order in &orders {
match shop.checkout(order) {
Ok(tracking) => println!("{}: shipped, {tracking}", order.order),
Err(error) => println!("{}: failed, {error}", order.order),
}
println!();
}
}It prints:
INFO checkout A-1
AUDIT charged 35, receipt R-ann-card-2028-35
to Ann: order A-1 is on its way: T-A-1
A-1: shipped, T-A-1
INFO checkout A-2
INFO card ben-card-2024 expired, renewing it
AUDIT charged 25, receipt R-ben-card-2029-25
to Ben: order A-2 is on its way: T-A-2
A-2: shipped, T-A-2
INFO checkout A-3
AUDIT charged 30, receipt R-cat-card-2027-30
WARN Rapid cannot take A-3: 30 kg is over its 20 kg limit; trying Steady
to Cat: order A-3 is on its way: B-A-3
A-3: shipped, B-A-3
INFO checkout A-4
INFO card dan-card-2025 expired, renewing it
WARN checkout A-4 failed: payment declined
to Dan: order A-4 could not be completed
A-4: failed, payment declinedHow it works
Every piece of this code is reasonable. We would have written it this way too. Now try to answer some questions about it.
- In what order does a checkout do its work? This one is easy: the function that reserves, charges and ships says it in three lines. But each of those lines hides decisions of its own, and nothing in the checkout shows them.
- What happens to an order when the card is declined twice? The checkout does not say. You follow the charge into the payments module. There,
take_paymentmatches on the bank's answer: a card declined because it has expired is renewed with the bank and charged once more. If the bank refuses that second charge, the refusal travels silently back up to the checkout, and a single character carries it: the?at the end of the secondbank.charge(...)returns it fromtake_payment, and the?aftertake_paymentinreserve_charge_and_shippasses it on, turned into aCheckoutErrorby itsFromimplementation. The checkout then logs a warning and tells the customer. A card refused for any other reason is never tried again: the last arm of thematchreturns the refusal at once. To learn this you read three functions in two modules, and the bank'sDeclinedenum, its list of reasons for refusing a charge. Dan's order shows the answer, but its log does not say that it was the new card the bank refused. - What happens when the main carrier cannot take the parcel? The shipping module tries the backup carrier. If the backup cannot take the parcel either, its error is thrown away:
.ok()turns the backup'sResultinto anOption, and the error goes with it. The log shows only the main carrier's trouble, and the checkout reports "no carrier could take it", without saying why. - Is every successful charge audited? Only the payments module tells you. The audit happens at the end of the payment function, after the renewal. Reading the checkout, you would never know that charges are audited at all.
- When is the customer told, and what? At the end of the checkout, whether the order went through or not. On a failure, the customer is told only that the order could not be completed. Why it failed is in the log, where each module writes what its author thought worth writing, in its own way: the checkout logs failures, the payments module logs renewals, the shipping module logs the switch to the backup carrier.
The code mixes two kinds of decision. Business rules decide what the shop does: an order is charged its price less any discount, goods are reserved before they are shipped, a parcel goes to a carrier. Flow control decides what happens around that work: renew the card and try once more, fall back to another carrier, give up, audit, tell the customer. Logging is woven through both. Every flow decision lives inside a business function, so the only way to find it is to read that function, and the one it calls, and the one after.
That is the answer to "why Itinera?": the hardest part of understanding a system like this is knowing what is going on. Answering one question means reading every module, and a decision is easy to miss: the audit tucked into the payment function, the backup carrier's error dropped, the second refusal that travels up without a word. Changing a decision is harder still, because you first have to find every place that makes it.
The shop is small, and you can still read all of it. A system that has grown for years, across teams and repositories, has the same problem with no one left who has read everything, and agents changing it faster than anyone can follow. The Itinera site tells that larger story on Why Itinera.
The next chapter writes the same checkout with Itinera, where every flow decision has one place to live, and answers each of these questions by reading one declaration.
Outcomes
We have shown how flow decisions hide inside business code, so that answering a simple question about one order means reading every module it passes through.
| The usual way | With Itinera |
|---|---|
| Each function usually decides, inside its own code, what happens when something fails, so the flow is spread across every module it calls. | The flow is declared in one workflow, and chapter 2 answers every question of this chapter from it. |
| Each module often logs what its author thought worth logging, in its own way, so the log rarely tells the whole story. | One reporter attached to the workflow writes the log of every journey, in one format. |