Skip to content

5. Model a payment state

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.

  1. 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"
    }
    }
  2. OKFour bool flags. 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
    }
    }
  3. BetterAn enum. Each variant is one state with exactly its own data, and capture consumes 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. Authorized and Captured have an auth_code: String, not an Option. Failed has a reason and nothing else.
  • capture(self) consumes the payment and returns the new state. The caller cannot keep using the old Authorized value by mistake. For any other state, it returns the payment unchanged in Err.
  • can_refund is one matches!. There is no order of checks to get wrong.
  • A new state is a compile error in every exhaustive match that 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.

Each version compared with the Worse version
CriterionOKBetter
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.
  • Shared fields need a match to read. Every variant but Failed has amount_cents. If many callers need it, add a method amount_cents(&self) -> Option<u64>, or put the shared data in a struct with a state: PaymentState enum field.
  • Storage and wire formats are strings. A database column or JSON field is often "captured". Convert at the boundary (with FromStr, TryFrom, or serde), and use the enum inside the program.
  • Flags are correct when they are independent. gift_wrapped and express can each be true or false in any combination. That is not a state machine, so two bool fields are fine.