Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Case Study: A System Designed in Rust

Everything in this book so far has been one idea at a time. This chapter takes a small system from a requirements paragraph to working code, so that the ideas appear where they actually arise — which is not in the order a book presents them.

The system is a library’s circulation desk. It is small enough to show completely and has the properties that make design interesting: entities that refer to each other, rules that must not be violated, states that change over time, and money.

The requirements

A library holds books, each with some number of physical copies. Members borrow books and return them. A member may have at most three books out at once, and a book may not be lent when every copy is already out. A loan is due back after twenty-one days, and a late return incurs a fine of twenty-five cents per day, added to the member’s account. The desk staff need a list of overdue loans.

That is the sort of paragraph you actually get. Read it again and notice that it mixes three kinds of statement: things that exist, rules about them, and policy numbers that will change the first time a committee meets.

What owns what

Chapter 2 said the first design question is who is responsible for each thing. Answer it before writing any types, because it decides the shape of everything else.

Books exist whether or not anyone has borrowed them, so something must own the catalog. Members likewise. A loan is a relationship between a book and a member — it has no independent existence, and both of its ends outlive it.

So the ownership tree has one root. A Library owns the books, owns the members, and owns the loans. A loan does not own a book or a member; it refers to them.

That last decision is the one that would have gone differently in Java, where a Loan would hold a Book reference and a Member reference and nobody would think about it. Here, holding references means lifetimes on Loan, lifetimes on anything holding a Loan, and a structure that cannot be stored in the same Library that owns the books it points at. The alternative from chapter 2 — identity instead of pointers — is the right answer, and it brings benefits that have nothing to do with the borrow checker: loans can be written to a file, compared, sent between threads, and reasoned about without chasing pointers.

The nouns become types, and most become newtypes

The requirements name books, copies, members, loans, days, and money. Each one gets a type, and the small ones get newtypes:

#![allow(unused)]
fn main() {
use std::fmt;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct BookId(u32);

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct MemberId(u32);

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct Day(u32);

impl Day {
    pub fn plus(self, days: u32) -> Day {
        Day(self.0 + days)
    }

    pub fn days_since(self, earlier: Day) -> u32 {
        self.0.saturating_sub(earlier.0)
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct Money(i64);

impl Money {
    pub const ZERO: Money = Money(0);

    pub fn cents(amount: i64) -> Money {
        Money(amount)
    }
}

impl fmt::Display for Money {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "${}.{:02}", self.0 / 100, (self.0 % 100).abs())
    }
}
}

Four newtypes, all Copy, all zero cost at runtime. What they buy is that check_out(book, member) cannot be called with the arguments swapped, that a number of days cannot be passed where a day is expected, and that money cannot be accidentally added to an identifier. Every one of those is a bug that a system built on u32 and f64 will eventually have, invisibly.

Money holds an i64 of cents rather than a float, which is not a Rust point but is the most common bug in systems of this kind. Putting it in a type means the decision is made once and cannot be undone by a careless division somewhere else.

Day is a deliberately crude stand-in for a real date type. In a production system this would be chrono::NaiveDate or time::Date — and the point of having a type at all is that swapping it is a change to one file.

The states, and the ones we refuse to represent

A loan is either out or returned. A returned loan has a return date and a fine; a loan that is out has neither, and has a due date. Chapter 6’s test applies: if this were a struct with returned: bool, return_date: Option<Day>, due: Option<Day> and fine: Option<Money>, it would represent sixteen combinations of which three or four are meaningful.

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct BookId(u32);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct MemberId(u32);
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub struct Day(u32);
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub struct Money(i64);
#[derive(Debug)]
pub enum Loan {
    Out { book: BookId, member: MemberId, due: Day },
    Returned { book: BookId, member: MemberId, on: Day, fine: Money },
}

impl Loan {
    pub fn book(&self) -> BookId {
        match self {
            Loan::Out { book, .. } | Loan::Returned { book, .. } => *book,
        }
    }

    pub fn member(&self) -> MemberId {
        match self {
            Loan::Out { member, .. } | Loan::Returned { member, .. } => *member,
        }
    }

    pub fn is_out(&self) -> bool {
        matches!(self, Loan::Out { .. })
    }
}
}

Two states, each carrying exactly the data that state has. A returned loan cannot be missing its fine and an outstanding loan cannot have one. The accessors use an or-pattern to pull the common fields out of either variant, which is the idiomatic way to say “both variants have this”.

There is a design question hidden here that the requirements do not answer: is a returned loan still a loan, or is it history? Keeping both in one Vec<Loan> means the overdue report has to filter, and it means the loan history is preserved. Moving returned loans to a separate collection would make each collection simpler and would throw away the ability to ask what a member has borrowed before. Keeping them together is the choice here, and it is the kind of choice that should be made deliberately rather than discovered later.

The failures become an enum

Three of the requirements are rules, and a rule is something that can be violated. Chapter 8’s guidance is that each violation a caller might handle differently gets its own variant, carrying what the caller needs to act:

#![allow(unused)]
fn main() {
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct BookId(u32);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct MemberId(u32);
#[derive(Debug)]
pub enum LendingError {
    NoSuchBook(BookId),
    NoSuchMember(MemberId),
    AllCopiesOut { book: BookId, title: String },
    LimitReached { member: MemberId, limit: usize },
    NotOnLoan { book: BookId, member: MemberId },
}

impl fmt::Display for LendingError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            LendingError::NoSuchBook(id) => write!(f, "no book with id {}", id.0),
            LendingError::NoSuchMember(id) => write!(f, "no member with id {}", id.0),
            LendingError::AllCopiesOut { title, .. } => {
                write!(f, "every copy of {title:?} is on loan")
            }
            LendingError::LimitReached { limit, .. } => {
                write!(f, "member already has {limit} books out")
            }
            LendingError::NotOnLoan { book, .. } => {
                write!(f, "book {} is not on loan to that member", book.0)
            }
        }
    }
}

impl std::error::Error for LendingError {}
}

AllCopiesOut carries the title as well as the identifier, because the desk software will want to say which book, and it would otherwise have to look it up again. LimitReached carries the limit, because the message should say what the limit is and the caller does not know the policy. These are small decisions and they are the difference between an error a user can act on and one that sends them to the manual.

The policy becomes a trait

Twenty-one days, three books, twenty-five cents. Those numbers are in the requirements, they will change, and different branches of the same library system will want different ones. They are also the numbers a test needs to control.

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub struct Money(i64);
impl Money { pub fn cents(amount: i64) -> Money { Money(amount) } }
pub trait Policy {
    fn loan_days(&self) -> u32;
    fn borrowing_limit(&self) -> usize;
    fn fine_for(&self, days_late: u32) -> Money;
}

pub struct StandardPolicy;

impl Policy for StandardPolicy {
    fn loan_days(&self) -> u32 {
        21
    }

    fn borrowing_limit(&self) -> usize {
        3
    }

    fn fine_for(&self, days_late: u32) -> Money {
        Money::cents(i64::from(days_late) * 25)
    }
}
}

This trait passes chapter 9’s test: there will be more than one implementation, and one of them is a test fixture. A Policy that lends for one day with no limit makes the overdue path testable without simulating a month. Compare a Storage trait added at the same time — with one implementation and no immediate second — which would be abstraction bought on speculation.

Box<dyn Policy> rather than a generic parameter, because the choice is made once at startup, the calls are not hot, and a generic parameter on Library would propagate into every type and function that mentions it. Chapter 10’s decision, made in the direction chapter 10 recommends for this shape.

The system

use std::collections::HashMap;
use std::fmt;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct BookId(u32);

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct MemberId(u32);

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct Day(u32);

impl Day {
    pub fn plus(self, days: u32) -> Day { Day(self.0 + days) }
    pub fn days_since(self, earlier: Day) -> u32 { self.0.saturating_sub(earlier.0) }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct Money(i64);

impl Money {
    pub const ZERO: Money = Money(0);
    pub fn cents(amount: i64) -> Money { Money(amount) }
}

impl fmt::Display for Money {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "${}.{:02}", self.0 / 100, (self.0 % 100).abs())
    }
}

#[derive(Debug)]
pub struct Book { pub title: String, pub copies: u8 }

#[derive(Debug)]
pub struct Member { pub name: String, pub fines: Money }

#[derive(Debug)]
pub enum Loan {
    Out { book: BookId, member: MemberId, due: Day },
    Returned { book: BookId, member: MemberId, on: Day, fine: Money },
}

impl Loan {
    pub fn book(&self) -> BookId {
        match self { Loan::Out { book, .. } | Loan::Returned { book, .. } => *book }
    }
    pub fn member(&self) -> MemberId {
        match self { Loan::Out { member, .. } | Loan::Returned { member, .. } => *member }
    }
    pub fn is_out(&self) -> bool { matches!(self, Loan::Out { .. }) }
}

#[derive(Debug)]
pub enum LendingError {
    NoSuchBook(BookId),
    NoSuchMember(MemberId),
    AllCopiesOut { book: BookId, title: String },
    LimitReached { member: MemberId, limit: usize },
    NotOnLoan { book: BookId, member: MemberId },
}

impl fmt::Display for LendingError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            LendingError::NoSuchBook(id) => write!(f, "no book with id {}", id.0),
            LendingError::NoSuchMember(id) => write!(f, "no member with id {}", id.0),
            LendingError::AllCopiesOut { title, .. } => write!(f, "every copy of {title:?} is on loan"),
            LendingError::LimitReached { limit, .. } => write!(f, "member already has {limit} books out"),
            LendingError::NotOnLoan { book, .. } => write!(f, "book {} is not on loan to that member", book.0),
        }
    }
}

impl std::error::Error for LendingError {}

pub trait Policy {
    fn loan_days(&self) -> u32;
    fn borrowing_limit(&self) -> usize;
    fn fine_for(&self, days_late: u32) -> Money;
}

pub struct StandardPolicy;

impl Policy for StandardPolicy {
    fn loan_days(&self) -> u32 { 21 }
    fn borrowing_limit(&self) -> usize { 3 }
    fn fine_for(&self, days_late: u32) -> Money { Money::cents(i64::from(days_late) * 25) }
}

pub struct Library {
    books: HashMap<BookId, Book>,
    members: HashMap<MemberId, Member>,
    loans: Vec<Loan>,
    policy: Box<dyn Policy>,
}

impl Library {
    pub fn new(policy: Box<dyn Policy>) -> Library {
        Library {
            books: HashMap::new(),
            members: HashMap::new(),
            loans: Vec::new(),
            policy,
        }
    }

    pub fn add_book(&mut self, id: u32, title: &str, copies: u8) -> BookId {
        let id = BookId(id);
        self.books.insert(id, Book { title: String::from(title), copies });
        id
    }

    pub fn enroll(&mut self, id: u32, name: &str) -> MemberId {
        let id = MemberId(id);
        self.members.insert(id, Member { name: String::from(name), fines: Money::ZERO });
        id
    }

    fn copies_out(&self, book: BookId) -> usize {
        self.loans.iter().filter(|loan| loan.is_out() && loan.book() == book).count()
    }

    fn books_held_by(&self, member: MemberId) -> usize {
        self.loans.iter().filter(|loan| loan.is_out() && loan.member() == member).count()
    }

    pub fn check_out(&mut self, book: BookId, member: MemberId, today: Day)
        -> Result<Day, LendingError>
    {
        let record = self.books.get(&book).ok_or(LendingError::NoSuchBook(book))?;
        if !self.members.contains_key(&member) {
            return Err(LendingError::NoSuchMember(member));
        }
        if self.copies_out(book) >= usize::from(record.copies) {
            return Err(LendingError::AllCopiesOut { book, title: record.title.clone() });
        }
        let limit = self.policy.borrowing_limit();
        if self.books_held_by(member) >= limit {
            return Err(LendingError::LimitReached { member, limit });
        }

        let due = today.plus(self.policy.loan_days());
        self.loans.push(Loan::Out { book, member, due });
        Ok(due)
    }

    pub fn check_in(&mut self, book: BookId, member: MemberId, today: Day)
        -> Result<Money, LendingError>
    {
        let index = self
            .loans
            .iter()
            .position(|loan| loan.is_out() && loan.book() == book && loan.member() == member)
            .ok_or(LendingError::NotOnLoan { book, member })?;

        let due = match &self.loans[index] {
            Loan::Out { due, .. } => *due,
            Loan::Returned { .. } => unreachable!("position matched an outstanding loan"),
        };

        let fine = self.policy.fine_for(today.days_since(due));
        self.loans[index] = Loan::Returned { book, member, on: today, fine };

        if let Some(record) = self.members.get_mut(&member) {
            record.fines = Money(record.fines.0 + fine.0);
        }
        Ok(fine)
    }

    pub fn overdue(&self, today: Day) -> impl Iterator<Item = (&str, &str, u32)> {
        self.loans.iter().filter_map(move |loan| match loan {
            Loan::Out { book, member, due } if *due < today => {
                let title = self.books.get(book)?.title.as_str();
                let name = self.members.get(member)?.name.as_str();
                Some((name, title, today.days_since(*due)))
            }
            _ => None,
        })
    }
}

fn main() {
    let mut library = Library::new(Box::new(StandardPolicy));

    let dune = library.add_book(1, "Dune", 1);
    let sicp = library.add_book(2, "Structure and Interpretation", 2);
    let ada = library.enroll(10, "Ada");
    let grace = library.enroll(11, "Grace");

    let opening_day = Day(1);

    match library.check_out(dune, ada, opening_day) {
        Ok(due) => println!("Dune due on day {}", due.0),
        Err(error) => println!("error: {error}"),
    }

    match library.check_out(dune, grace, opening_day) {
        Ok(due) => println!("Dune due on day {}", due.0),
        Err(error) => println!("error: {error}"),
    }

    library.check_out(sicp, grace, opening_day).unwrap();

    let today = Day(30);
    for (name, title, days) in library.overdue(today) {
        println!("{name} has {title} {days} days overdue");
    }

    match library.check_in(dune, ada, today) {
        Ok(fine) => println!("returned Dune, fine {fine}"),
        Err(error) => println!("error: {error}"),
    }

    match library.check_in(dune, ada, today) {
        Ok(fine) => println!("returned Dune, fine {fine}"),
        Err(error) => println!("error: {error}"),
    }
}

Running it prints:

Dune due on day 22
error: every copy of "Dune" is on loan
Ada has Dune 8 days overdue
Grace has Structure and Interpretation 8 days overdue
returned Dune, fine $2.00
error: book 1 is not on loan to that member

The last two lines are the ones to look at. Returning the same book twice is rejected without any explicit check for double-return, because the first check_in replaced the Loan::Out with a Loan::Returned and position no longer finds an outstanding loan. The rule is enforced by the state model rather than by a guard.

What the compiler made us decide

Four decisions in that design were forced, and all four are decisions the same system needs in any language.

Loans refer to books by identifier. The alternative would have required lifetimes and would have made loans impossible to store alongside the books. In Java this decision is made by accident, and the consequence — that a Loan object is not serializable, comparable or movable without dragging half the object graph with it — is discovered in the second year.

check_in reads the due date out before mutating. Writing self.loans[index] = Loan::Returned { ..., fine: self.policy.fine_for(...) } in one expression fails to compile, because it borrows self immutably for the policy call while borrowing it mutably for the assignment. Chapter 3’s advice — do one thing, then the other — applies, and the version that compiles is easier to read.

copies_out and books_held_by are separate &self methods called before the mutation. Trying to inline them into check_out after taking a mutable borrow of self.loans fails. Small read-only helpers that finish before the write begins are a shape the borrow checker teaches you, and it happens to be good structure anyway.

overdue returns an iterator borrowing the library. Its signature says the returned iterator cannot outlive the Library, which is a fact about the data that no other language would tell the caller. If the desk software needed to store that report, the fix is to collect it into owned values — and the compiler is the thing that raises the question.

What would change at scale

The design above is right for the system described and would need three changes under real load.

A linear scan over loans is fine for thousands and wrong for millions. The shape that replaces it keeps the Vec as the owner of the data and adds indices beside it: a HashMap<MemberId, Vec<usize>> of outstanding loans per member. That is chapter 12’s arena pattern in miniature, and the ownership story does not change.

Persistence would be added by making the Library the in-memory projection of a store rather than the store itself, with a Repository trait — which at that point has two implementations and a reason to exist, where at the start it would not have.

Day would become a real date type, and Money would either stay as it is or gain a currency field. Because both are newtypes with a small surface, that change touches their own impl blocks and almost nothing else, which is what introducing them bought.

Conclusion

The design process here ran in the order the book has been teaching. Decide what owns what, and prefer identity to pointers when the relationship is a reference rather than containment. Give the nouns types, and give the small ones newtypes, so that the compiler polices the distinctions your prose was already making. Model states as variants carrying their own data, so that the combinations you never want have no spelling. Name the failures in an enum, with the context a caller needs. Put policy behind a trait when there will really be more than one implementation, and let the test fixture be the second.

None of that is a Rust technique. It is ordinary design, and its only unusual property is that the compiler checks whether you did it.

The last chapter collects the recurring shapes — the ones worth recognizing on sight, and the ones worth recognizing so you can stop writing them.

Questions to think through

The design keeps returned loans in the same collection as outstanding ones. Write down what becomes harder and what becomes possible. Which would you choose for a system that has to answer “what has this member borrowed in the last five years”?

check_out clones the title into the AllCopiesOut error. What would it cost to avoid the clone, and is that a trade you would make?

The Policy trait has one real implementation and one test implementation. Chapter 9 said a trait with one implementor is usually not earning anything. Does a test fixture count as a second implementor? Argue both sides.