5. Model a payment state
The task
Section titled “The task”A payment is pending, then authorized, then captured. It can fail before capture, and a captured payment can be refunded. Write the type, a capture operation, and a can_refund check.
The versions
Section titled “The versions”WorseThe state is a
String. A typo in one comparison makes refunds impossible, and the compiler cannot see it.pub struct Payment {pub status: String, // "pending", "authorized", "captured", "refunded", "failed"pub auth_code: Option<String>,pub amount_cents: u64,}impl Payment {pub fn capture(&mut self) -> Result<(), String> {if self.status != "authorized" {return Err(format!("cannot capture a {} payment", self.status));}self.status = "captured".to_string();Ok(())}pub fn can_refund(&self) -> bool {self.status == "Captured"}}OKFour
boolflags. No typos, but 16 combinations, and most of them are not real states.pub struct Payment {pub authorized: bool,pub captured: bool,pub refunded: bool,pub failed: bool,pub auth_code: Option<String>,pub amount_cents: u64,}impl Payment {pub fn capture(&mut self) -> Result<(), &'static str> {if !self.authorized || self.captured || self.failed {return Err("payment is not authorized");}self.captured = true;Ok(())}pub fn can_refund(&self) -> bool {self.captured && !self.refunded}}BetterAn enum. Each variant is one state with exactly its own data, and
captureconsumes the old state.#[derive(Debug, Clone, PartialEq, Eq)]pub enum Payment {Pending {amount_cents: u64,},Authorized {amount_cents: u64,auth_code: String,},Captured {amount_cents: u64,auth_code: String,},Refunded {amount_cents: u64,},Failed {reason: String,},}impl Payment {/// Only an authorized payment can be captured. Any other state is returned unchanged.pub fn capture(self) -> Result<Self, Self> {match self {Self::Authorized {amount_cents,auth_code,} => Ok(Self::Captured {amount_cents,auth_code,}),other => Err(other),}}pub fn can_refund(&self) -> bool {matches!(self, Self::Captured { .. })}}
The “Worse” version has a bug that is easy to miss in review: capture writes "captured", and can_refund compares with "Captured". The code compiles, and no payment can ever be refunded. The test worse_typo_makes_refunds_impossible proves it. Strings also allow any other value: "authorised", "CAPTURED", or "". See S15: bool flags or string constants instead of an enum.
The “OK” version removes the typos, because the compiler checks field names. But four flags have 16 combinations, and only five of them are real states. The test ok_allows_impossible_combinations builds a payment that is “captured”, “failed”, and “not authorized” at the same time, and can_refund says yes. Every method must also decide in which order to test the flags. auth_code is an Option, so the type cannot say that a captured payment always has one. See S17: Struct with many Option fields that are really states.
The “Better” version makes the wrong states impossible to build:
- Each variant holds only its own data.
AuthorizedandCapturedhave anauth_code: String, not anOption.Failedhas a reason and nothing else. capture(self)consumes the payment and returns the new state. The caller cannot keep using the oldAuthorizedvalue by mistake. For any other state, it returns the payment unchanged inErr.can_refundis onematches!. There is no order of checks to get wrong.- A new state is a compile error in every exhaustive
matchthat does not handle it.
other => Err(other) in capture is a catch-all, but not a hidden one: it says “every other state cannot be captured”. That stays true when a state is added.
Verdict
Section titled “Verdict”| Criterion | OK | Better |
|---|---|---|
| Correctness | Better | Better |
| "Worse" has a typo bug that the compiler cannot find. "OK" allows impossible combinations. "Better" allows only real states. | ||
| Failure behavior | Better | Better |
"Worse" returns a String error. "OK" returns a fixed message. "Better" returns the unchanged payment, so the caller still has it. | ||
| Performance | Same | Same |
| All three are small values. The difference does not matter here. | ||
| Readability | Same | Better |
| The enum is the list of states. A reader does not need to work out which flag combinations are valid. | ||
| API ergonomics | Same | Better |
Callers match on the state and get its data in the same step. To read amount_cents, they must match, which is one cost of the enum. | ||
Trade-offs
Section titled “Trade-offs”- Shared fields need a
matchto read. Every variant butFailedhasamount_cents. If many callers need it, add a methodamount_cents(&self) -> Option<u64>, or put the shared data in a struct with astate: PaymentStateenum field. - Storage and wire formats are strings. A database column or JSON field is often
"captured". Convert at the boundary (withFromStr,TryFrom, orserde), and use the enum inside the program. - Flags are correct when they are independent.
gift_wrappedandexpresscan each be true or false in any combination. That is not a state machine, so twoboolfields are fine.